Expose duoCreatedSessionId on the note GraphQL payload

What does this MR do and why?

backend MR to unblock !250722 (merged), which adds a "View session" button to agent replies in work items

  • Adds duoCreatedSessionId to the GraphQL Note type, resolving the Duo Agent Platform session that created the note through a batched lookup of created workflow-note links
  • Returns the session id only to users with read_duo_workflow, and declares the matching granular token permission
  • Triggers the note-updated GraphQL subscription when a session is linked to a note
    • Work item notes are pushed to clients by subscription the moment they are created, which is before the created link is written, so the field would resolve to null and the button (added in the next MR in the stack) would only appear after a page reload. The extra push lets it re-resolve the field once the link exists

Reasons we couldn't use the existing fields:

  • duoWorkflowLinks is capped at one call per query so it cannot resolve a list of notes
  • duoTriggeredSession resolves triggered links while agent replies have created links

Example query and payload

query {
  note(id: "gid://gitlab/Note/1597") {
    id
    duoCreatedSessionId
  }
}
{
  "data": {
    "note": {
      "id": "gid://gitlab/Note/1597",
      "duoCreatedSessionId": "gid://gitlab/Ai::DuoWorkflows::Workflow/288"
    }
  }
}

duoCreatedSessionId is null when the note has no created session, or when the current user cannot read it.

Database

All queries below run once per page of notes when duoCreatedSession is selected, driven by the new created_for_notes scope.

  1. The scope finds the created links for the page of notes:
SELECT "duo_workflows_workflow_notes".*
FROM "duo_workflows_workflow_notes"
WHERE "duo_workflows_workflow_notes"."link_type" = 1
  AND "duo_workflows_workflow_notes"."note_id" IN (3584429628, 3584429682, 3584429724, 3584429783, 3612707137, 3612707788, 3668099973)
ORDER BY "duo_workflows_workflow_notes"."id" ASC

Execution plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/55078/commands/158502 (thank you @brytannia - !249882 (comment 3706893441))

Index scan using index_duo_wf_wf_notes_on_note_id returning 7 rows, 15 buffers (~120 KiB). The IN list is bounded by the notes page size.

  1. The scope's preload(workflow: [:project, :namespace, :user]) loads the linked sessions:
SELECT "duo_workflows_workflows".*
FROM "duo_workflows_workflows"
WHERE "duo_workflows_workflows"."id" IN (284, 285, 286, 288, 289)

Execution plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/55049/commands/158380

Index scan using duo_workflows_workflows_pkey, 5 buffers (~40 KiB).

  1. The nested preload loads the sessions' projects, used by the read_duo_workflow policy check:
SELECT "projects".*
FROM "projects"
WHERE "projects"."id" IN (1000000)
  1. The nested preload loads the session owners, also used by the policy check:
SELECT "users".*
FROM "users"
WHERE "users"."id" IN (1, 105)
  1. When the batch contains namespace-level sessions, the nested preload loads their namespaces, also used by the policy check (no query is issued when all sessions are project-level):
SELECT "namespaces".*
FROM "namespaces"
WHERE "namespaces"."id" IN (1, 145, 146)

Queries 2-5 are batched primary-key lookups served by each table's primary-key index.

References

Screenshots or screen recordings

No UI changes in this MR - button is introduced in !250722 (merged)

How to set up and validate locally

  1. Prerequisite: Have GitLab Duo set up in your GDK, with a Duo-enabled group and project

  2. Prerequisite: Have GitLab Runner working

  3. @mention an agent on an issue and ask it a question

  4. In a Rails console, confirm the session owner receives the session as a global ID:

    note = Ai::DuoWorkflows::WorkflowNote.link_type_created.last.note
    owner = note.duo_created_workflow_link.workflow.user
    
    query = "query { note(id: \"#{note.to_gid}\") { id duoCreatedSessionId } }"
    GitlabSchema.execute(query, context: { current_user: owner }).dig('data', 'note', 'duoCreatedSessionId')
    #=> "gid://gitlab/Ai::DuoWorkflows::Workflow/<id>"
  5. Confirm a user who can read the note but not the session gets null. Mention-triggered sessions run in the web environment, which any Duo-enabled project member can read, so temporarily make the session owner-only:

    workflow = note.duo_created_workflow_link.workflow
    original_env = workflow.environment
    workflow.update!(environment: :ide)
    
    member = User.human.active.where.not(id: note.project.team.members.map(&:id)).first
    note.project.add_developer(member)
    
    result = GitlabSchema.execute(query, context: { current_user: member })
    result.dig('data', 'note', 'id')                    #=> the note gid (note is readable)
    result.dig('data', 'note', 'duoCreatedSessionId')   #=> nil
    
    workflow.update!(environment: original_env)
    note.project.members.find_by(user_id: member.id)&.destroy!
  6. Confirm an ordinary comment resolves the field as null:

    plain = note.noteable.notes.user.where.not(id: Ai::DuoWorkflows::WorkflowNote.select(:note_id)).last
    plain_query = "query { note(id: \"#{plain.to_gid}\") { duoCreatedSessionId } }"
    GitlabSchema.execute(plain_query, context: { current_user: owner }).dig('data', 'note', 'duoCreatedSessionId')
    #=> nil
  7. Confirm the lookup batches. Tail the log, then in GraphiQL run a notes-list query for the issue selecting duoCreatedSessionId on each note:

    tail -f log/development.log | grep duo_workflows_workflow_notes

    You should see one query with note_id IN (...), not one per note.

The no-reload behavior from the subscription trigger is validated end-to-end in the frontend MR.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Allison Villa

Merge request reports

Loading
Loading