Add restore version as new latest for AI Catalog items

What does this MR do and why?

This MR adds a GraphQL mutation and the service to restore an item_version as the latest item_version. So the idea is: sometimes a user updates their AI catalog item (agent/flow) and then realizes the new version isn't perfect and the previous version was actually better. So they want to restore that particular version as the latest version again, that's why this MR adds the mutation and service to do that. There are some cases where we want to block the user from restoring if those cases are met. For example: the source version is a draft version, or the source version is already the latest version.

These changes are behind a WIP feature flag, which defaults to false.

In the system, the latest version is treated as the best version of the AI catalog item, which is why a user would want to restore a previous version back to latest. And right now, when project enables an item from a different project, it's the latest version that gets used, so that's another reason a user would want to keep their best version as the latest.

References

#601095 (closed)

How to set up and validate locally

Click to expand

Prerequisites

  • GDK running
  • Access to Rails console (bin/rails console)
  • Access to GraphiQL (http://gdk.test:3000/-/graphql-explorer)

Setup

1. Enable feature flag

Feature.enable(:ai_catalog_version_restore)

2. Create test data

user = User.find_by(username: 'root')
project = Project.first

item = Ai::Catalog::Item.new(
  name: 'Test Restore Agent',
  description: 'Agent for testing version restore',
  item_type: :agent,
  public: true,
  organization: project.organization,
  project: project
)

v1_0 = item.versions.build(
  version: '1.0.0',
  schema_version: 1,
  definition: { 'system_prompt' => 'Version 1.0 prompt', 'tools' => [], 'user_prompt' => '' },
  release_date: Time.zone.now,
  created_by: user
)
item.latest_version = v1_0

Ai::Catalog::Item.transaction do
  item.save!
  item.update!(latest_released_version: v1_0)
end

v1_1 = item.build_new_version(
  version: '1.1.0',
  schema_version: 1,
  definition: { 'system_prompt' => 'Version 1.1 prompt - BAD VERSION', 'tools' => [], 'user_prompt' => '' },
  release_date: Time.zone.now,
  created_by: user
)
item.save!
item.update!(latest_released_version: v1_1)

puts "v1.0.0 GlobalID: #{v1_0.to_global_id}"
puts "v1.1.0 GlobalID: #{v1_1.to_global_id}"
puts "Item ID: #{item.id}"

Note down the GlobalIDs printed.

Test Cases

TC1: Restore without deprecation

mutation {
  aiCatalogItemVersionRestore(input: {
    id: "<v1_0_global_id>"
    deprecateCurrent: false
  }) {
    itemVersion {
      versionName
      released
      deprecated
      createdBy { username }
    }
    errors
  }
}

Expected:

  • errors: empty
  • versionName: "1.2.0"
  • released: true
  • deprecated: false

Verify in console:

item.reload
item.latest_version.version           # => "1.2.0"
item.latest_released_version.version  # => "1.2.0"
v1_1.reload.deprecated                # => false
item.latest_version.definition        # => matches v1.0 definition

TC2: Restore with deprecation

Create another version first:

v1_3 = item.build_new_version(
  version: '1.3.0',
  schema_version: 1,
  definition: { 'system_prompt' => 'Version 1.3 - ANOTHER BAD', 'tools' => [], 'user_prompt' => '' },
  release_date: Time.zone.now,
  created_by: user
)
item.save!
item.update!(latest_released_version: v1_3)
puts "v1.3.0 GlobalID: #{v1_3.to_global_id}"
mutation {
  aiCatalogItemVersionRestore(input: {
    id: "<v1_0_global_id>"
    deprecateCurrent: true
  }) {
    itemVersion {
      versionName
      released
      deprecated
    }
    errors
  }
}

Expected:

  • errors: empty
  • versionName: "1.4.0"

Verify in console:

item.reload
item.latest_version.version  # => "1.4.0"
v1_3.reload.deprecated       # => true

TC3: Restore a deprecated version

Use v1.3 GlobalID from TC2 (which was deprecated).

mutation {
  aiCatalogItemVersionRestore(input: {
    id: "<v1_3_global_id>"
  }) {
    itemVersion { versionName }
    errors
  }
}

Expected:

  • errors: ["Cannot restore a deprecated version"]
  • itemVersion: null

TC4: Restore the current latest version

puts "Current latest GlobalID: #{item.reload.latest_released_version.to_global_id}"
mutation {
  aiCatalogItemVersionRestore(input: {
    id: "<current_latest_global_id>"
  }) {
    itemVersion { versionName }
    errors
  }
}

Expected:

  • errors: ["Source version is already the current latest version"]

TC5: Restore a draft version

draft = item.build_new_version(
  version: '2.0.0',
  schema_version: 1,
  definition: { 'system_prompt' => 'Draft', 'tools' => [], 'user_prompt' => '' },
  release_date: nil,
  created_by: user
)
item.save!
puts "Draft GlobalID: #{draft.to_global_id}"
mutation {
  aiCatalogItemVersionRestore(input: {
    id: "<draft_global_id>"
  }) {
    itemVersion { versionName }
    errors
  }
}

Expected:

  • errors: ["Source version must be a released version"]

TC6: Non-existent version

mutation {
  aiCatalogItemVersionRestore(input: {
    id: "gid://gitlab/Ai::Catalog::ItemVersion/999999999"
  }) {
    itemVersion { versionName }
    errors
  }
}

Expected:

  • Top-level error: resource not available

TC7: Developer user (unauthorized)

Sign in as a developer user and run TC1 mutation.

Expected:

  • Top-level error: permission denied

TC8: Feature flag disabled

Feature.disable(:ai_catalog_version_restore)

Run TC1 mutation as maintainer.

Expected:

  • errors: ["This feature is not available"]

Re-enable after test:

Feature.enable(:ai_catalog_version_restore)

TC9: excludeDeprecated filter

query {
  aiCatalogItemVersions(excludeDeprecated: true) {
    nodes {
      versionName
      deprecated
    }
  }
}

Expected: No deprecated versions in results.

query {
  aiCatalogItemVersions(excludeDeprecated: false) {
    nodes {
      versionName
      deprecated
    }
  }
}

Expected: Deprecated versions included with deprecated: true.

Database queries

next_version_number query (used by RestoreService and BaseUpdateService):

Query plan - https://postgres.ai/console/gitlab/gitlab-production-main/sessions/52135/commands/153577

SELECT "ai_catalog_item_versions".*
  FROM "ai_catalog_item_versions"
  WHERE "ai_catalog_item_versions"."ai_catalog_item_id" = 1009920
    AND "ai_catalog_item_versions"."release_date" IS NOT NULL
  ORDER BY "ai_catalog_item_versions"."id" DESC
  LIMIT 1

About the released and not_deprecated scopes: both come from ai_catalog_version and are always used with a given item (agent/flow), so the number of version records per item should stay quite small (around 10 or so per agent). Because of that, I didn't add an index on release_date here, and the same reasoning applies to the deprecated scope. Happy to add one if you think it would be worthwhile, though!

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.

Related to #601095 (closed)

Edited by Jaydip Pansuriya

Merge request reports

Loading