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" = 1035917Plan: 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.idPlan: 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 1Plan: 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
-
In the UI, make sure the project has one custom agent with a released version. Note its id and version.
-
In the UI, create a custom flow in the same project with this definition, your agent's id and version in both
includeandsubagents, 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" -
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}" -
In a shell, paste the two
exportlines, 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
- Run
call "$WORKFLOW_ID". - Expect 200.
item.item_typeisflow,item.definitionis the flow config, anditem.sub_itemshas one entry withdefinition.prompt,definition.toolsetanddefinition.mcp_tools: []. Noerrorkey.
2. Refused: agent moved to a private project
-
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')) -
Run
call "$WORKFLOW_ID". -
Expect 200. The sub item has
error.code: "location", itsname, and nodefinitionkey. -
Revert:
agent.update!(visibility: :public, project: project).
3. Refused: agent soft-deleted after the flow was saved
- In the console:
agent.update_column(:deleted_at, Time.current). - Run
call "$WORKFLOW_ID". - Expect 200 with
error.code: "unknown_item"on the sub item, and nonameordescriptionkey. - Revert:
agent.update_column(:deleted_at, nil).
4. Refused: pinned version unreleased
- In the console:
agent_version.update_column(:release_date, nil). - Run
call "$WORKFLOW_ID". - Expect 200 with
error.code: "unknown_version". - Revert:
agent_version.update_column(:release_date, Time.current).
5. Mixed: one good agent and one refused
- In the UI, add a second released agent to the flow's
includeandsubagents, and save. - In the console, recreate
workflowagainst the new flow version (step 4 of the common setup). - Apply scenario 3 to the second agent only.
- Run
call "$WORKFLOW_ID". - Expect 200 with two sub items: one with
definition, one witherror.
6. Gate off: feature flag
- In the console:
Feature.disable(:ai_catalog_flow_agent_references). - Run
call "$WORKFLOW_ID". - Expect 200 with
item.sub_items: []. - Revert:
Feature.enable(:ai_catalog_flow_agent_references).
7. Gate off: Feature preview setting
- In the console:
project.root_ancestor.namespace_settings.update!(experiment_features_enabled: false). - Run
call "$WORKFLOW_ID". - Expect 200 with
item.sub_items: []. - Revert with
true.
8. 422: workflow with no saved catalog flow
-
In the console:
plain = Ai::DuoWorkflows::Workflow.create!(user: user, project: project, workflow_definition: 'software_development') puts plain.id -
Run
call <that id>. -
Expect 422.
9. 404: flow soft-deleted after the session started
- In the console:
flow.update_column(:deleted_at, Time.current). - Run
call "$WORKFLOW_ID". - Expect 404.
- Revert:
flow.update_column(:deleted_at, nil).
10. 404: workflow owned by another user
-
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 -
Run
call <that id>with the original user's token. -
Expect 404.
11. 401: no token
-
In the shell:
curl --silent "$HOST/api/v4/ai/duo_workflows/workflows/$WORKFLOW_ID/catalog_items" -
Expect 401.
MR acceptance checklist
- I have evaluated the MR acceptance checklist for this MR.