Add thv skill upgrade: re-resolve pinned skills to newer content#5896
Add thv skill upgrade: re-resolve pinned skills to newer content#5896samuv wants to merge 3 commits into
Conversation
dd81407 to
a7e9269
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## skills-lock/04-sync #5896 +/- ##
=======================================================
- Coverage 71.80% 71.79% -0.02%
=======================================================
Files 710 711 +1
Lines 72574 72746 +172
=======================================================
+ Hits 52113 52228 +115
- Misses 16727 16776 +49
- Partials 3734 3742 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
9c2d5f5 to
06a66f8
Compare
a7e9269 to
9deff64
Compare
06a66f8 to
42a03de
Compare
9deff64 to
3e750f4
Compare
42a03de to
4604083
Compare
3e750f4 to
cf3c890
Compare
cf3c890 to
aecb406
Compare
|
Tracked in #5899 (RFC THV-0080 stack). |
d94ef78 to
e4b12ca
Compare
7aa5c69 to
16a5271
Compare
JAORMX
left a comment
There was a problem hiding this comment.
🤖 Automated panel review — PR 5/6 (thv skill upgrade)
Multi-agent review, each finding adversarially verified against the diff. Advisory. Whole stack gated behind TOOLHIVE_SKILLS_LOCK_ENABLED (off by default). Headline finding: the OCI upgrade path is broken and untested — the two bugs below ship green precisely because coverage is git-only.
🟠 Major — OCI upgrade is broken
resolveOCILatestreturns the un-qualifiedref.String()(upgrade.go:~262) while install storesqualifiedOCIRef(ref)inResolvedReference(via #5894'srecordLockState). go-containerregistry'sref.String()omits the implicit:latest;qualifiedOCIRefappends it (oci.go:22-36). So for a tag-less source likeghcr.io/org/skill, the lock pins…:latestbut upgrade computes…(no tag) → the guardnewRef != entry.ResolvedReference && !AllowRefChangeis always true whenever the digest moved → the skill is reportedRefChangeBlockedand refused. That's the single most common upgrade case (implicit:latestre-pushed). Fix: returnqualifiedOCIRef(ref)to match the install side.resolveLatestState's OCI branch omits the registry fallbackInstallhas (upgrade.go:~216).Installtreats a parsed OCI ref asisOCI, triesinstallFromOCI, and on failure for an ambiguousnamespace/namefalls back toresolveFromRegistry.resolveLatestStatereproduces only the first half — so a skill installed via the (real, tested) registry-fallback path parses asisOCI, goes straight toregistry.Pull, fails exactly as it did at install, and reportsFailed. A skill that installs cleanly can never be upgraded. This is the "faithful duplication dropped a branch" risk you flagged for a second opinion — the panel confirms it dropped a branch.- The entire OCI resolve/compare/pin path +
RefChangeBlocked/--allow-ref-changelogic is untested (upgrade_test.go). Every upgrade test uses git fixtures whereResolvedReference == Source, so the ref-change branch never fires — which is why the two bugs above weren't caught. A table test that installs an OCI/registry-resolved skill, republishes a new manifest digest, and assertsUpgraded(plus a repoint case forRefChangeBlockedwith/without--allow-ref-change) would close all three.
🟡 Minor
--previewisn't read-only for OCI (skill_upgrade.go:53help text). It's documented "without installing anything," butresolveOCILatestdoes a liveregistry.Pullinto the local blob store (no digest-only primitive). The PR body acknowledges this; the user-facing help doesn't — disk-fill / cache-poison / metadata-leak surface for a flag promising nothing installs. Say it still fetches to compare digests.isImmutableSourceonly recognizes lowercase commit hashes (pin.go:34) —[0-9a-f]while the sibling git resolver accepts[0-9a-fA-F]. An uppercase 40-hex commit source is classified mutable and needlessly re-cloned on upgrade, defeating "immutable pins are never re-resolved."- The 409 handler discards the partial
UpgradeResult(skills.go:~431, author-noted). On--fail-on-changesthe API returns only the error, so neither API clients nor the CLI can see which skills are stale — degrades to "N skill(s) would change" with no names, and is inconsistent with the sync path (which returns its result before the exit-code error). Confirming this is a real gap, not acceptable. (This also feeds the exit-code precedence bug — see #5897.) resolveLatestStateduplicatesInstall's git/OCI/registry dispatch with no shared source of truth (upgrade.go:~205), so the two can silently drift — which is exactly what produced the two majors above. Worth extracting a shared resolve-only phase rather than maintaining two copies.
🟢 Verified praise
- The plan-all-then-apply split makes
--fail-on-changesa genuine read-only gate (evaluated before any mutation), and content-addressed pinning defeats plan-vs-apply TOCTOU. Solid supply-chain hygiene, with a dedicated multi-entry regression test.
16a5271 to
7c01f04
Compare
e4b12ca to
a954095
Compare
|
The panel's headline was correct — the OCI path was broken and shipping green only because coverage was git-only. All fixed in the latest push:
|
JAORMX
left a comment
There was a problem hiding this comment.
🤖 Automated panel re-review on 7c01f04 → approving
All three majors are fixed and each was independently re-verified end-to-end against the real code:
- ✅ Tag-less OCI spurious
RefChangeBlocked:resolveOCILatestnow returnsqualifiedOCIRef(ref), matching what install records (recordLockState←qualifiedOCIRefviainstall_oci.go), so a moved digest on a tag-less source reportsUpgraded.TestPlanUpgrade_OCITagLessSourceUpgradesfails on the old code and passes on the fix. - ✅ Missing registry fallback:
resolveLatestStatenow mirrors Install'sisUnambiguousOCIRef→resolveFromRegistry→resolveRegistryLatestdispatch, so an ambiguousnamespace/nameinstall can upgrade. Traced identical toinstall.go;TestPlanUpgrade_RegistryFallbackSourceUpgradescovers it. - ✅ Untested OCI path: new
upgrade_oci_test.go(mockedRegistryClient+ real store) exercises tag-less upgrade, up-to-date, ref-change blocked/allowed, and registry fallback — the branches git fixtures never touched.
Minors fixed: --preview help now discloses the OCI fetch-to-compare; isFullCommitHash accepts uppercase hex; the --fail-on-changes 409 was replaced by a 200 returning the full outcome set. Dispatch-duplication refactor reasonably deferred to #5899 (now covered by tests).
Important cross-stack note (why this is still approvable): removing the 409 means --fail-on-changes is inert in this PR in isolation — it returns outcomes with no error, so thv skill upgrade --fail-on-changes would exit 0. The non-zero exit is delivered by the CLI's upgradeExitError in stacked PR #5897, which I've re-reviewed and verified is present and correct (failed > wouldChange precedence, inversion unit test, exit-3/exit-2 E2E). So the CI gate is functional in the merged stack; it just doesn't live in this PR. Flagging so it isn't lost: the flag must not be relied on until #5897 is in the branch (and the whole feature is gated off by default regardless). LGTM.
7c01f04 to
9817f7e
Compare
a954095 to
3b4e60f
Compare
RFC THV-0080 needs a controlled way to pull newer content for skills pinned by source, without silently drifting the lock file on every install. This adds the full upgrade vertical slice: service logic, HTTP endpoint, client, and CLI command. - Upgrade re-resolves each targeted entry's Source (via the same git/OCI/registry dispatch order Install uses, stopping short of extraction — resolveLatestState) and installs newer content when the digest changed; Source itself is never rewritten - Entries pinned to an immutable reference (an OCI digest or full git commit hash) report not-upgradable — there's nothing newer to resolve to - --preview reports what would change without persisting it; --allow-ref-change permits the resolved reference itself changing; --fail-on-changes gives CI a freshness gate - var _ skills.SkillLockService = (*service)(nil) lands here now that both Sync and Upgrade exist; SkillsRouter widens from the narrow skillSyncer interface (PR4) to the full interface - `thv skill upgrade [name...]` CLI Part of the production stack for #5715 (RFC THV-0080). Stack: 5/6.
Upgrade installed each target in sequence and only checked FailOnChanges after each one returned, so a multi-skill upgrade would install the first changed skill for real before reporting the conflict — a partial mutation a read-only CI gate must never produce. Split into a resolve-only planning pass (also reused to avoid re-resolving Source a second time when applying) and an apply pass that only runs once every target has been checked. Upgrade also now preserves a skill's existing clients instead of expanding to every detected client, matching Sync's behavior.
Two panel-review majors left the OCI upgrade path broken (and invisible, since coverage was git-only): resolveOCILatest returned the unqualified ref.String() while install records qualifiedOCIRef, so every digest change on a tag-less source tripped the ref-change guard instead of upgrading — the single most common upgrade case; and resolveLatestState dropped Install's registry-catalogue fallback for ambiguous names, so a skill installed through that fallback could never be upgraded. Fix both and add an OCI test matrix (tag-less upgrade, tagged up-to-date, ref-change blocked/allowed, registry-fallback source). fail_on_changes previously returned a 409 that discarded the partial result — hiding which skills were stale and conflating a genuine resolution failure with "would change". It now evaluates the plan and returns the full outcomes with a 200, mirroring sync's check mode; exit-code mapping moves entirely to the CLI. Also accept uppercase hex in isFullCommitHash, state honestly in the help text that OCI previews still fetch artifacts, and return a non-nil Outcomes slice.
3b4e60f to
f8fe482
Compare
9817f7e to
f764fed
Compare
Summary
upgradevertical slice in one PR, matching PR4's precedent (the CLI command can't exist without its API endpoint).Upgradere-resolves each targeted entry'sSource— via the same git/OCI/registry dispatch orderInstalluses (resolveLatestState), stopping short of extraction/DB/lock writes — and installs newer content only when the digest actually changed.Sourceitself is never rewritten.isImmutableSource, added in PR4'spin.go) reportnot-upgradable: there's nothing newer to resolve to.--previewreports what would change without persisting it (still fetches to compare digests — an OCI pull has no lighter "digest only" primitive, matching the RFC's own "preview is not side-effect-free" note; git sources resolve without touching disk).--allow-ref-changepermits the resolved reference itself changing.--fail-on-changesgives CI a freshness gate.var _ skills.SkillLockService = (*service)(nil)lands here now that bothSync(PR4) andUpgradeexist;SkillsRouterwidens from PR4's narrowskillSyncerinterface to the fullskills.SkillLockService.thv skill upgrade [name...]CLI.Part of the production stack for #5715 (RFC THV-0080). Stack: 5/6 — lockfile → lock-service → install-hooks → sync → upgrade → cli-exitcodes. Based on #5895 (PR4).
Type of change
Test plan
task test)task test-e2e, scoped:ginkgo --label-filter=skills-lock)task lint-fix)Unit tests cover: up-to-date (source unchanged), installs newer content and preserves
Source, preview reports without persisting, not-upgradable for an immutable source, unknown name returns 404,--fail-on-changesstops at the first change. API handler tests including the 501 path and a--fail-on-changes409 propagation. New E2E spec: build+push v1 → install → republish newer content at the same OCI reference →upgrade --previewreports the change without touching the lock file →upgradeapplies it and the lock file records the new digest withSourceunchanged.Does this introduce a user-facing change?
Not yet — same
TOOLHIVE_SKILLS_LOCK_ENABLEDgate as the rest of the stack. Once the full stack lands:thv skill upgradere-resolves and updates a project's pinned skills.Special notes for reviewers
resolveLatestState/resolveOCILatest/resolveGitLatestintentionally duplicate a slice ofInstall's dispatch logic rather than refactoringInstallto expose a resolve-only phase — a deeper split felt like more surgery than this PR should carry; flagging for a second opinion on whether that tradeoff is right.UpgradeResultoutcomes when--fail-on-changestrips (only the error is returned) — a caller doesn't currently see which earlier entries already succeeded before the stop. Noting as a known simplification, not fixed here.🤖 Generated with Claude Code