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.
Relevant links
- Epic: gitlab-org#22961
- Proof of concept merge request: !257276 (closed)
- Depends on slice D (Publish): #630878 (closed)
- Depends on slice F: #630880
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_itemswithdisable_ddl_transaction!andwith_lock_retries; no new tables and no new indexes.PublishService#mark_staleassigns 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. Apreload_latest_versionscope 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, withdisable_ddl_transaction!andwith_lock_retries. Addsync_status(integer,limit: 2,default: 0,null: false) andsync_metadata(jsonb,default: {},null: false) toai_catalog_items. Both columns have defaults, so no post-deploy migration is needed. - backend Add
enum :sync_status, { synced: 0, stale: 10 }, prefix: :synctoee/app/models/ai/catalog/item.rb. The gap between values is deliberate. - backend Add
jsonb_accessor :sync_metadataforsync_failure_reason,sync_failed_commit_shaandsync_failed_at, aSYNC_METADATA_SIZE_LIMITof 64 kilobytes, and json schema validation against the new schema. - backend Add the
preload_latest_versionscope toAi::Catalog::Item. - backend Add
app/validators/json_schemas/ai_catalog/skill_sync_metadata.json:sync_failure_reasoncapped at 2048 characters,sync_failed_commit_shaaccepting SHA-1 or SHA-256, andsync_failed_atleft unconstrained becausejsonb_accessorserialises 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::SkillSyncTypewithskip_reason: :parent_authorizesand fieldsstatus,failureReasonandfailedCommitSha. - backend Add
SkillSyncStatusEnum, generated fromAi::Catalog::Item.sync_statuses. - backend Add a
syncfield toSkillTypeusingmethod: :itself. - backend Add the request spec
skill_sync_spec.rb. - backend Extend
ee/spec/models/ai/catalog/item_spec.rbto cover the new enum, thejsonb_accessorattributes 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.vueand theSKILL_SYNC_STATUS_STALEconstant. - frontend Add a Jest spec for the badge in
ai_catalog_list_item.vue.