Summary
SkillService._adopt_legacy_clone() decides whether a legacy skills_library_url names an already-configured skills source using raw string equality, while the rest of the platform stores and produces that URL normalized. The two sides therefore never match for the same repository written two ways, and the install takes the ent#346 "already has sources" refusal branch on every sync, forever — for a repo it is already syncing.
This is the root cause behind the alert flood in #2744. That issue makes the flood idempotent (correct, and independently wanted); this one stops the branch being entered spuriously at all.
Mechanism
validate_skills_library_url is a normalizer, and the write route uses it as one:
# src/backend/routers/skills.py:649 — ASSIGNS the return
url = validate_skills_library_url(body.url)
The adoption path calls the same function and throws the return away, then compares the raw setting value:
# src/backend/services/skill_service.py:734 — return DISCARDED
validate_skills_library_url(url)
...
# src/backend/services/skill_service.py:754
existing = [s for s in db.list_skill_sources() if s.url == url]
Measured on origin/dev@2c5cfe0e3:
| input |
validate_skills_library_url returns |
github.com/abilityai/trinity-skills |
https://github.com/abilityai/trinity-skills |
https://github.com/abilityai/trinity-skills |
https://github.com/abilityai/trinity-skills |
And the bundled default source is seeded from a literal that never passes through the validator:
# src/backend/config.py:522
"TRINITY_DEFAULT_SKILL_SOURCE", "github.com/abilityai/trinity-skills"
So the two representations the platform itself produces — seeded (no scheme) and API-created (normalized) — cannot compare equal to a legacy key written in the other form.
Reproduction
Driving the real _adopt_legacy_clone with a stubbed db:
| source row |
legacy key |
result |
github.com/abilityai/trinity-skills (seeded default) |
https://github.com/abilityai/trinity-skills |
refusal branch + alert |
https://github.com/abilityai/trinity-skills (via POST /api/skills/sources) |
github.com/abilityai/trinity-skills |
refusal branch + alert |
| identical strings |
identical strings |
adopted silently, no alert |
In both alerting cases validate_skills_library_url(source_url) == validate_skills_library_url(legacy_key) — the two sides name the same repository, and the alert was never warranted.
Why the fix cannot weaken ent#346
A match returns the existing source id and creates no row — it is the no-op branch. The grant branch (count_skill_sources() == 0 → create_skill_source) is untouched, and a key that genuinely names a different repo still reaches the refusal exactly as ent#346 left it.
Normalizing removes false positives from the detector. It does not widen what may be granted.
Acceptance criteria
Out of scope
Normalization collapses the scheme/no-scheme pair — the split the platform creates. These remain distinct after normalization and are a smaller, separate tail: …/trinity-skills.git, a trailing /, and the bare owner/repo shorthand.
Context
Found while investigating the private follow-up to ent#346. Related: #2744 (the flood this causes — assigned, in progress, complementary and independently wanted).
Summary
SkillService._adopt_legacy_clone()decides whether a legacyskills_library_urlnames an already-configured skills source using raw string equality, while the rest of the platform stores and produces that URL normalized. The two sides therefore never match for the same repository written two ways, and the install takes the ent#346 "already has sources" refusal branch on every sync, forever — for a repo it is already syncing.This is the root cause behind the alert flood in #2744. That issue makes the flood idempotent (correct, and independently wanted); this one stops the branch being entered spuriously at all.
Mechanism
validate_skills_library_urlis a normalizer, and the write route uses it as one:The adoption path calls the same function and throws the return away, then compares the raw setting value:
Measured on
origin/dev@2c5cfe0e3:validate_skills_library_urlreturnsgithub.com/abilityai/trinity-skillshttps://github.com/abilityai/trinity-skillshttps://github.com/abilityai/trinity-skillshttps://github.com/abilityai/trinity-skillsAnd the bundled default source is seeded from a literal that never passes through the validator:
So the two representations the platform itself produces — seeded (no scheme) and API-created (normalized) — cannot compare equal to a legacy key written in the other form.
Reproduction
Driving the real
_adopt_legacy_clonewith a stubbeddb:github.com/abilityai/trinity-skills(seeded default)https://github.com/abilityai/trinity-skillshttps://github.com/abilityai/trinity-skills(viaPOST /api/skills/sources)github.com/abilityai/trinity-skillsIn both alerting cases
validate_skills_library_url(source_url) == validate_skills_library_url(legacy_key)— the two sides name the same repository, and the alert was never warranted.Why the fix cannot weaken ent#346
A match returns the existing source id and creates no row — it is the no-op branch. The grant branch (
count_skill_sources() == 0→create_skill_source) is untouched, and a key that genuinely names a different repo still reaches the refusal exactly as ent#346 left it.Normalizing removes false positives from the detector. It does not widen what may be granted.
Acceptance criteria
_adopt_legacy_clonecompares repo identity, not raw strings — assign the validator's return, and normalize the stored source url too (sources exist in both forms on live installs).count_skill_sources() == 0path are demonstrably unchanged.Out of scope
Normalization collapses the scheme/no-scheme pair — the split the platform creates. These remain distinct after normalization and are a smaller, separate tail:
…/trinity-skills.git, a trailing/, and the bareowner/reposhorthand.Context
Found while investigating the private follow-up to ent#346. Related: #2744 (the flood this causes — assigned, in progress, complementary and independently wanted).