1.1 Add GET catalog_items endpoint for Duo Workflow runs

What does this MR do and why?

Adds GET /api/v4/ai/duo_workflows/workflows/:id/catalog_items. It returns the saved AI Catalog flow a session runs and its subagents, resolved from the dependency records for the current user with the same checks the save path uses. A refused subagent comes back with an error instead of a definition. The error code is the rule that refused it, one of unknown_item, location, visibility, unknown_version. An unknown_item subagent, which the user cannot read, is returned with only its id, version and error. The flow's own include entry already shows those two fields. The checks move to ReferenceChecks, and refusal_code is the single entry point, so save and execution share one definition and one sentence per rule.

The flow itself is checked again at request time, the same as its subagents. A flow the user can no longer read, or that was soft-deleted, returns 404. A workflow whose saved item is not a flow returns 422.

DWS calls this endpoint before it builds the flow, through Workhorse with the run's token (ai-assist!7529). Child sessions and mid-run claims can use it too. Related to #628315 (closed). Epic: gitlab-org#24006.

Database

No migration. One SELECT on ai_catalog_item_version_dependencies by ai_catalog_item_version_id, which is indexed, then five preloads. All of them are by primary key or by an indexed column.

Queries and plans

The table is empty on production, so the clone was seeded with 20 dependency records on a released flow version, pointing at 20 real agents in 20 different projects. Plans are for that one flow.

SELECT "ai_catalog_item_version_dependencies".* FROM "ai_catalog_item_version_dependencies"
WHERE "ai_catalog_item_version_dependencies"."ai_catalog_item_version_id" = 1035917

Plan: 20 records, 0.5 ms, 5 buffers.

SELECT "ai_catalog_item_versions".* FROM "ai_catalog_item_versions" WHERE "ai_catalog_item_versions"."id" IN (664, 1658, ...)

Plan: 20 records, 10.9 ms, 47 buffers.

SELECT "ai_catalog_items".* FROM "ai_catalog_items" WHERE "ai_catalog_items"."id" IN (664, 1329, ...)

Plan: 20 records, 1.6 ms, 50 buffers.

SELECT "projects".* FROM "projects" WHERE "projects"."id" IN (5191294, 1083469, ...)

Plan: 20 records, 9.6 ms, 100 buffers.

SELECT "namespaces".* FROM "namespaces" WHERE "namespaces"."id" IN (6543, 9970, ...)

Plan: 16 records, 2.8 ms, 80 buffers. The 20 projects are in 16 namespaces.

SELECT namespaces.*, root_query.id as source_id FROM "namespaces"
INNER JOIN (
  SELECT id, traversal_ids[1] as root_id FROM "namespaces" WHERE "namespaces"."id" IN (6543, 9970, ...)
) as root_query ON root_query.root_id = namespaces.id

Plan: 16 records, 4.1 ms, 160 buffers. This is NamespaceRootAncestorPreloader.

SELECT "namespace_ai_settings".* FROM "namespace_ai_settings" WHERE "namespace_ai_settings"."namespace_id" = 9970 LIMIT 1

Plan: 1 record, 4.9 ms, 7 buffers. The flow project's root ancestor.

How to set up and validate locally

Behind ai_catalog_flow_agent_references, with beta features on for the group.

One-time setup instructions
  1. In the UI, make sure the project has one custom agent with a released version. Note its id and version.

  2. In the UI, create a custom flow in the same project with this definition, your agent's id and version in both include and subagents, and save it.

    version: "v1"
    environment: ambient
    include:
      - source: ai-catalog
        item_type: agent
        item_id: "53"
        version: "1.0.0"
    components:
      - name: "main"
        type: AgentComponent
        prompt_id: "main_prompt"
        inputs:
          - from: "context:goal"
            as: "goal"
        toolset: []
        subagents:
          - source: ai-catalog
            item_type: agent
            item_id: "53"
            version: "1.0.0"
    prompts:
      - prompt_id: "main_prompt"
        name: "Main prompt"
        prompt_template:
          system: You coordinate one subagent.
          user: "{{goal}}"
          placeholder: history
        unit_primitives: []
        params:
          timeout: 180
    routers:
      - from: "main"
        to: "end"
    flow:
      entry_point: "main"
  3. In a rails console:

    user = project.owners.first
    flow = Ai::Catalog::Item.for_project(project).where(item_type: :flow).order(:id).last
    agent = flow.latest_version.dependencies.first.dependency
    agent_version = flow.latest_version.dependencies.first.dependency_version
    workflow = Ai::DuoWorkflows::Workflow.create!(
      user: user, project: project, workflow_definition: 'ai_catalog_flow',
      ai_catalog_item: flow, ai_catalog_item_version: flow.latest_version
    )
    token = Ai::DuoWorkflows::CreateOauthAccessTokenService.new(
      current_user: user, organization: project.organization
    ).execute.payload[:oauth_access_token].plaintext_token
    puts "export WORKFLOW_ID=#{workflow.id}"
    puts "export TOKEN=#{token}"
  4. In a shell, paste the two export lines, then:

    export HOST=http://gdk.test:3000
    call() {
      curl --silent --header "Authorization: Bearer $TOKEN" \
        "$HOST/api/v4/ai/duo_workflows/workflows/$1/catalog_items" | jq
    }

Workhorse adds the two internal headers the route needs, so the curl does not set them.

Scenarios

Each scenario starts from the common setup. Console steps run in rails console with the variables from step 4 still in scope.

1. Success: flow and one agent with a definition
  1. Run call "$WORKFLOW_ID".
  2. Expect 200. item.item_type is flow, item.definition is the flow config, and item.sub_items has one entry with definition.prompt, definition.toolset and definition.mcp_tools: []. No error key.
2. Refused: agent moved to a private project
  1. In the console, move the agent to another project and make it private:

    agent.update!(visibility: :private, project: Project.find_by_full_path('your-group/other-project'))
  2. Run call "$WORKFLOW_ID".

  3. Expect 200. The sub item has error.code: "location", its name, and no definition key.

  4. Revert: agent.update!(visibility: :public, project: project).

3. Refused: agent soft-deleted after the flow was saved
  1. In the console: agent.update_column(:deleted_at, Time.current).
  2. Run call "$WORKFLOW_ID".
  3. Expect 200 with error.code: "unknown_item" on the sub item, and no name or description key.
  4. Revert: agent.update_column(:deleted_at, nil).
4. Refused: pinned version unreleased
  1. In the console: agent_version.update_column(:release_date, nil).
  2. Run call "$WORKFLOW_ID".
  3. Expect 200 with error.code: "unknown_version".
  4. Revert: agent_version.update_column(:release_date, Time.current).
5. Mixed: one good agent and one refused
  1. In the UI, add a second released agent to the flow's include and subagents, and save.
  2. In the console, recreate workflow against the new flow version (step 4 of the common setup).
  3. Apply scenario 3 to the second agent only.
  4. Run call "$WORKFLOW_ID".
  5. Expect 200 with two sub items: one with definition, one with error.
6. Gate off: feature flag
  1. In the console: Feature.disable(:ai_catalog_flow_agent_references).
  2. Run call "$WORKFLOW_ID".
  3. Expect 200 with item.sub_items: [].
  4. Revert: Feature.enable(:ai_catalog_flow_agent_references).
7. Gate off: Feature preview setting
  1. In the console: project.root_ancestor.namespace_settings.update!(experiment_features_enabled: false).
  2. Run call "$WORKFLOW_ID".
  3. Expect 200 with item.sub_items: [].
  4. Revert with true.
8. 422: workflow with no saved catalog flow
  1. In the console:

    plain = Ai::DuoWorkflows::Workflow.create!(user: user, project: project, workflow_definition: 'software_development')
    puts plain.id
  2. Run call <that id>.

  3. Expect 422.

9. 404: flow soft-deleted after the session started
  1. In the console: flow.update_column(:deleted_at, Time.current).
  2. Run call "$WORKFLOW_ID".
  3. Expect 404.
  4. Revert: flow.update_column(:deleted_at, nil).
10. 404: workflow owned by another user
  1. In the console:

    other = project.team.members.where.not(id: user.id).first
    theirs = Ai::DuoWorkflows::Workflow.create!(user: other, project: project, workflow_definition: 'ai_catalog_flow',
      ai_catalog_item: flow, ai_catalog_item_version: flow.latest_version)
    puts theirs.id
  2. Run call <that id> with the original user's token.

  3. Expect 404.

11. 401: no token
  1. In the shell:

    curl --silent "$HOST/api/v4/ai/duo_workflows/workflows/$WORKFLOW_ID/catalog_items"
  2. Expect 401.

MR acceptance checklist

Edited by Angus Ryer

Merge request reports

Loading
Loading