Surface skills that have stopped publishing

Why are we doing this work

When a Publish rejects a SKILL.md, the Catalog Item is left untouched by design — see docs/adr/0001-invalid-skills-go-stale-rather-than-being-deleted.md. That is the right behaviour and it has a bad side effect: nothing anywhere records that the rejection happened. The catalog keeps serving the last good Item Version while the author believes their change took effect.

The failure mode is someone pushing a fix, seeing the catalog not change, pushing again, and having no way to find out why short of reading Sidekiq logs. This slice records the outcome of the last Publish on the Catalog Item and shows it: a warning on the skill details page carrying the reason and a link into the Web IDE, and an "Out of date" badge in the list.

Three points that shape the model. There is no pending Sync Status — a Catalog Item exists only because a Publish succeeded, so the only two states are synced and stale. sync_failed_at is recorded but deliberately not exposed over GraphQL, because nothing reads it yet and adding it later costs nothing. And a Rejection whose path has no Catalog Item — a Skill that never published successfully at all — still has nowhere to persist, because Ai::Catalog::Item validates latest_version and cannot exist without a valid Item Version. The Publish collects these into an unpublishable payload bucket and drops them. That remains an open question and is out of scope here.

On naming: the details page shows the published commit and the rejected commit side by side. Two commits, not two things called a version. "Version" already means an Item Version and reusing it here would be ambiguous.

The proof of concept merge request is where to read the whole thing end to end. The domain glossary (CONTEXT.md) and the two architecture decision records under docs/adr/ live on that branch, not on master.

Non-functional requirements

  • Documentation: Not required. The page written in slice F already covers what Stale means.
  • Feature flag: No new check. This slice only decorates surfaces that slice F already gates behind ai_catalog_skills.
  • Performance: one migration adding two defaulted columns to ai_catalog_items with disable_ddl_transaction! and with_lock_retries; no new tables and no new indexes. PublishService#mark_stale assigns the failure reason and checks for a change before the Gitaly commit lookup, so a permanently broken Skill does not pay an RPC on every push. A preload_latest_version scope avoids an N+1 on the list.
  • Testing: request spec skill_sync_spec.rb, plus the model and Jest specs listed below.

Implementation plan

  • backend Add db/migrate/20260922100000_add_sync_state_to_ai_catalog_items.rb, milestone 19.5, with disable_ddl_transaction! and with_lock_retries. Add sync_status (integer, limit: 2, default: 0, null: false) and sync_metadata (jsonb, default: {}, null: false) to ai_catalog_items. Both columns have defaults, so no post-deploy migration is needed.
  • backend Add enum :sync_status, { synced: 0, stale: 10 }, prefix: :sync to ee/app/models/ai/catalog/item.rb. The gap between values is deliberate.
  • backend Add jsonb_accessor :sync_metadata for sync_failure_reason, sync_failed_commit_sha and sync_failed_at, a SYNC_METADATA_SIZE_LIMIT of 64 kilobytes, and json schema validation against the new schema.
  • backend Add the preload_latest_version scope to Ai::Catalog::Item.
  • backend Add app/validators/json_schemas/ai_catalog/skill_sync_metadata.json: sync_failure_reason capped at 2048 characters, sync_failed_commit_sha accepting SHA-1 or SHA-256, and sync_failed_at left unconstrained because jsonb_accessor serialises datetimes space-separated rather than as RFC 3339. Every property accepts null.
  • backend Add PublishService#mark_stale, keeping the Catalog Item, its last valid Item Version and every Item Consumer, and setting the status and metadata. Assign and change-check the failure reason before the Gitaly commit lookup.
  • backend Add Types::Ai::Catalog::SkillSyncType with skip_reason: :parent_authorizes and fields status, failureReason and failedCommitSha.
  • backend Add SkillSyncStatusEnum, generated from Ai::Catalog::Item.sync_statuses.
  • backend Add a sync field to SkillType using method: :itself.
  • backend Add the request spec skill_sync_spec.rb.
  • backend Extend ee/spec/models/ai/catalog/item_spec.rb to cover the new enum, the jsonb_accessor attributes and the metadata schema validation.
  • frontend Add a non-dismissible warning alert to the skill details page carrying the failure reason and an Edit in Web IDE link, and show the published commit and the rejected commit side by side.
  • frontend Add an "Out of date" badge to ai_catalog_list_item.vue and the SKILL_SYNC_STATUS_STALE constant.
  • frontend Add a Jest spec for the badge in ai_catalog_list_item.vue.
Edited by 🤖 GitLab Bot 🤖