Link restarted Duo sessions to their source workflow

What does this MR do and why?

Adds workflow-to-workflow linking for Duo Workflow sessions, mirroring the existing artifact linking in Ai::DuoWorkflows::LinkArtifactService (work items, merge requests, notes, pipelines) - see #600655 (closed)

  • New duo_workflows_workflow_workflows join table + Ai::DuoWorkflows::WorkflowWorkflow join model (via the shared WorkflowLinkable concern), with a single link_type for now: source — the workflow whose restart produced this run.
  • Ai::DuoWorkflows::RestartWorkflowService now creates the link after a workflow is restarted
  • Workflow gains workflow_links / linked_workflows associations alongside the existing artifact link associations.

The schema mirrors the other join tables: unique index on (workflow_id, linked_workflow_id, link_type) (which also makes ensure_link's upsert race-safe), cascading foreign keys on workflow_id, linked_workflow_id, project_id, and namespace_id, and the project-xor-namespace check constraint.

References

How to set up and validate locally

Database

Up:

for v in 20260917120000 20260917120001 20260917120002 20260917120003 20260917120004; do
  bundle exec rails db:migrate:up:main VERSION=$v
done
Local Output
main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 3516
main: == 20260917120000 CreateDuoWorkflowsWorkflowWorkflows: migrating ==============
main: -- create_table(:duo_workflows_workflow_workflows, {:if_not_exists=>true})
main:    -> 0.0157s
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- execute("ALTER TABLE duo_workflows_workflow_workflows\nADD CONSTRAINT check_c8fe093d47\nCHECK ( num_nonnulls(namespace_id, project_id) = 1 )\nNOT VALID;\n")
main:    -> 0.0008s
main: -- execute("SET statement_timeout TO 0")
main:    -> 0.0461s
main: -- execute("ALTER TABLE duo_workflows_workflow_workflows VALIDATE CONSTRAINT check_c8fe093d47;")
main:    -> 0.0011s
main: -- execute("RESET statement_timeout")
main:    -> 0.0005s
main: == 20260917120000 CreateDuoWorkflowsWorkflowWorkflows: migrated (0.1320s) =====

main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 3516

Pro tip: try out scripts/database/migrate.rb for better migration management.
main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 3796
main: == 20260917120001 AddFkDuoWorkflowsWorkflowWorkflowsToWorkflows: migrating ====
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- execute("LOCK TABLE duo_workflows_workflows, duo_workflows_workflow_workflows IN SHARE ROW EXCLUSIVE MODE")
main:    -> 0.0005s
main: -- execute("ALTER TABLE duo_workflows_workflow_workflows ADD CONSTRAINT fk_6ab757d7b9 FOREIGN KEY (workflow_id) REFERENCES duo_workflows_workflows (id) ON DELETE CASCADE NOT VALID;")
main:    -> 0.0040s
main: -- execute("SET statement_timeout TO 0")
main:    -> 0.0003s
main: -- execute("ALTER TABLE duo_workflows_workflow_workflows VALIDATE CONSTRAINT fk_6ab757d7b9;")
main:    -> 0.0060s
main: -- execute("RESET statement_timeout")
main:    -> 0.0397s
main: == 20260917120001 AddFkDuoWorkflowsWorkflowWorkflowsToWorkflows: migrated (0.1241s) 

main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 3796

Pro tip: try out scripts/database/migrate.rb for better migration management.
main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 4016
main: == 20260917120002 AddFkDuoWorkflowsWorkflowWorkflowsToLinkedWorkflows: migrating 
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- execute("LOCK TABLE duo_workflows_workflows, duo_workflows_workflow_workflows IN SHARE ROW EXCLUSIVE MODE")
main:    -> 0.0006s
main: -- execute("ALTER TABLE duo_workflows_workflow_workflows ADD CONSTRAINT fk_ce0416f6b6 FOREIGN KEY (linked_workflow_id) REFERENCES duo_workflows_workflows (id) ON DELETE CASCADE NOT VALID;")
main:    -> 0.0020s
main: -- execute("SET statement_timeout TO 0")
main:    -> 0.0003s
main: -- execute("ALTER TABLE duo_workflows_workflow_workflows VALIDATE CONSTRAINT fk_ce0416f6b6;")
main:    -> 0.0037s
main: -- execute("RESET statement_timeout")
main:    -> 0.0473s
main: == 20260917120002 AddFkDuoWorkflowsWorkflowWorkflowsToLinkedWorkflows: migrated (0.1463s) 

main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 4016

Pro tip: try out scripts/database/migrate.rb for better migration management.
main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 4202
main: == 20260917120003 AddFkDuoWorkflowsWorkflowWorkflowsToProjects: migrating =====
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- execute("LOCK TABLE projects, duo_workflows_workflow_workflows IN SHARE ROW EXCLUSIVE MODE")
main:    -> 0.0005s
main: -- execute("ALTER TABLE duo_workflows_workflow_workflows ADD CONSTRAINT fk_ca85eb899a FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE NOT VALID;")
main:    -> 0.0026s
main: -- execute("SET statement_timeout TO 0")
main:    -> 0.0003s
main: -- execute("ALTER TABLE duo_workflows_workflow_workflows VALIDATE CONSTRAINT fk_ca85eb899a;")
main:    -> 0.0091s
main: -- execute("RESET statement_timeout")
main:    -> 0.0005s
main: == 20260917120003 AddFkDuoWorkflowsWorkflowWorkflowsToProjects: migrated (0.0966s) 

main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 4202

Pro tip: try out scripts/database/migrate.rb for better migration management.
main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 4412
main: == 20260917120004 AddFkDuoWorkflowsWorkflowWorkflowsToNamespaces: migrating ===
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- execute("LOCK TABLE namespaces, duo_workflows_workflow_workflows IN SHARE ROW EXCLUSIVE MODE")
main:    -> 0.0006s
main: -- execute("ALTER TABLE duo_workflows_workflow_workflows ADD CONSTRAINT fk_b57557c3fa FOREIGN KEY (namespace_id) REFERENCES namespaces (id) ON DELETE CASCADE NOT VALID;")
main:    -> 0.0025s
main: -- execute("SET statement_timeout TO 0")
main:    -> 0.0003s
main: -- execute("ALTER TABLE duo_workflows_workflow_workflows VALIDATE CONSTRAINT fk_b57557c3fa;")
main:    -> 0.0079s
main: -- execute("RESET statement_timeout")
main:    -> 0.0005s
main: == 20260917120004 AddFkDuoWorkflowsWorkflowWorkflowsToNamespaces: migrated (0.0982s) 

main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 4412

Pro tip: try out scripts/database/migrate.rb for better migration management.

Down:

for v in 20260917120004 20260917120003 20260917120002 20260917120001 20260917120000; do
  bundle exec rails db:migrate:down:main VERSION=$v
done
Local Output
main: == [advisory_lock_connection] object_id: 165040, pg_backend_pid: 70185
main: == 20260917120004 AddFkDuoWorkflowsWorkflowWorkflowsToNamespaces: reverting ===
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- remove_foreign_key(:duo_workflows_workflow_workflows, {:column=>:namespace_id})
main:    -> 0.0057s
main: == 20260917120004 AddFkDuoWorkflowsWorkflowWorkflowsToNamespaces: reverted (0.1174s) 

main: == [advisory_lock_connection] object_id: 165040, pg_backend_pid: 70185

Pro tip: try out scripts/database/migrate.rb for better migration management.
main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 70402
main: == 20260917120003 AddFkDuoWorkflowsWorkflowWorkflowsToProjects: reverting =====
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- remove_foreign_key(:duo_workflows_workflow_workflows, {:column=>:project_id})
main:    -> 0.0031s
main: == 20260917120003 AddFkDuoWorkflowsWorkflowWorkflowsToProjects: reverted (0.0946s) 

main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 70402

Pro tip: try out scripts/database/migrate.rb for better migration management.
main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 70620
main: == 20260917120002 AddFkDuoWorkflowsWorkflowWorkflowsToLinkedWorkflows: reverting 
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- remove_foreign_key(:duo_workflows_workflow_workflows, {:column=>:linked_workflow_id})
main:    -> 0.0027s
main: == 20260917120002 AddFkDuoWorkflowsWorkflowWorkflowsToLinkedWorkflows: reverted (0.0891s) 

main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 70620

Pro tip: try out scripts/database/migrate.rb for better migration management.
main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 70846
main: == 20260917120001 AddFkDuoWorkflowsWorkflowWorkflowsToWorkflows: reverting ====
main: -- transaction_open?(nil)
main:    -> 0.0000s
main: -- remove_foreign_key(:duo_workflows_workflow_workflows, {:column=>:workflow_id})
main:    -> 0.0031s
main: == 20260917120001 AddFkDuoWorkflowsWorkflowWorkflowsToWorkflows: reverted (0.0810s) 

main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 70846

Pro tip: try out scripts/database/migrate.rb for better migration management.
main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 71028
main: == 20260917120000 CreateDuoWorkflowsWorkflowWorkflows: reverting ==============
main: -- drop_table(:duo_workflows_workflow_workflows)
main:    -> 0.0089s
main: == 20260917120000 CreateDuoWorkflowsWorkflowWorkflows: reverted (0.0175s) =====

main: == [advisory_lock_connection] object_id: 165060, pg_backend_pid: 71028

Pro tip: try out scripts/database/migrate.rb for better migration management.

Testing Instructions:

  1. Run a developer flow locally that takes a while (giving you enough time to cancel it). e.g. @duo-developer-gitlab-duo - Wait 2 min and then check the time and report back.

  2. On the right sidebar, open the session and cancel it. Note the workflow ID.

  3. From the command line, restart the session:

curl --request POST \
--header "PRIVATE-TOKEN: <GDK_PAT_TOKEN>" \
--url "http://gdk.test:3000/api/v4/ai/duo_workflows/workflows/<WORKFLOW_ID>/restart"
  1. Confirm the link from this new workflow to the previous workflow exists in the console, with link_type of source
pry(main)> w = Ai::DuoWorkflows::Workflow.last

=> #<Ai::DuoWorkflows::Workflow:0x0000000147410d00
 id: 4883, ...

pry(main)> w.linked_workflows

=> [#<Ai::DuoWorkflows::Workflow:0x00000001378d4388
  id: 4882, ...

pry(main)> w.workflow_links

=> [#<Ai::DuoWorkflows::WorkflowWorkflow:0x0000000146712478
  id: 1,
  workflow_id: 4883,
  linked_workflow_id: 4882,
  project_id: 22,
  namespace_id: nil,
  created_at: "2026-09-23 19:38:39.324582000 +0000",
  updated_at: "2026-09-23 19:38:39.324582000 +0000",
  link_type: "source">]

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 Roman Eisner

Merge request reports

Loading
Loading