Skip to content

Add thv skill sync: restore project skills from the lock file#5895

Merged
samuv merged 4 commits into
mainfrom
skills-lock/04-sync
Jul 24, 2026
Merged

Add thv skill sync: restore project skills from the lock file#5895
samuv merged 4 commits into
mainfrom
skills-lock/04-sync

Conversation

@samuv

@samuv samuv commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Why: RFC THV-0080 needs a way to restore a project's pinned skill set on any machine — a fresh clone, CI, a teammate's laptop — and to verify on-disk content still matches the lock file without installing anything (a CI integrity gate). This ships the full sync vertical slice in one PR since the CLI command can't exist without its API endpoint.
  • What:
    • Sync reconciles installed skills against toolhive.lock.yaml: missing/drifted entries reinstall at the pinned reference (never re-resolving source — see buildPinnedReference), --check reports drift read-only, --adopt records lock entries for unmanaged installs, --prune removes installs no longer in the lock.
    • Found and fixed a real bug while building this: reinstalling at an unchanged pinned digest hit both the OCI and git install paths' "same digest means content is already correct" fast path, silently skipping re-extraction — exactly the drift sync exists to repair (a tampered file doesn't change the pinned commit/digest). A new internal SyncRestore option on InstallOptions bypasses that fast path in both install_extraction.go and install_git.go.
    • POST /api/v1beta/skills/sync, wired through a narrow skillSyncer interface in pkg/api/v1 so this PR doesn't need to ship an Upgrade stub (widens to skills.SkillLockService in PR5).
    • thv skill sync CLI: --check, --adopt, --prune, --clients, --project-root (auto-detected by walking up for .git when omitted).

Part of the production stack for #5715 (RFC THV-0080). Stack: 4/6 — lockfile → lock-service → install-hooks → sync → upgrade → cli-exitcodes. Based on #5894 (PR3).

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e, scoped: ginkgo --label-filter=skills-lock)
  • Linting (task lint-fix)

Table-driven drift-matrix tests cover up-to-date / drifted-and-reinstalled / missing-and-restored / check-mode-no-write / adopt / prune / feature-disabled-403; golden tests for buildPinnedReference (OCI digest-pin, git commit-pin with and without a subdir path) and isImmutableSource; API handler tests including the 501 "not supported" path when the underlying service lacks Sync. New E2E spec: install → delete files on disk → sync --check reports drift without touching anything → sync restores the files.

Does this introduce a user-facing change?

Not yet — same TOOLHIVE_SKILLS_LOCK_ENABLED gate as the rest of the stack (see PR3). Once the full stack lands: thv skill sync restores a project's skills to match its committed lock file.

Special notes for reviewers

  • The SyncRestore fix (install_extraction.go, install_git.go) is the part I'd like the most scrutiny on — it's a small diff but changes behavior on the existing install fast paths, gated entirely behind an internal-only option only Sync sets.
  • pkg/skills/client.Client gains Sync but does not yet assert skills.SkillLockService conformance — that lands in PR5 once Upgrade exists too.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Jul 21, 2026
@samuv samuv self-assigned this Jul 21, 2026
@samuv
samuv force-pushed the skills-lock/04-sync branch from 784a7ce to 9c2d5f5 Compare July 21, 2026 15:31
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 21, 2026
@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.59903% with 65 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.88%. Comparing base (669265e) to head (d8f23e4).

Files with missing lines Patch % Lines
pkg/skills/skillsvc/sync.go 60.97% 34 Missing and 14 partials ⚠️
pkg/skills/client/client.go 0.00% 11 Missing ⚠️
pkg/skills/skillsvc/pin.go 82.35% 3 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5895      +/-   ##
==========================================
- Coverage   71.93%   71.88%   -0.06%     
==========================================
  Files         711      713       +2     
  Lines       73029    73228     +199     
==========================================
+ Hits        52534    52638     +104     
- Misses      16726    16822      +96     
+ Partials     3769     3768       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@samuv
samuv force-pushed the skills-lock/03-install-hooks branch from 7824f3f to 1514a9d Compare July 21, 2026 16:58
@samuv
samuv force-pushed the skills-lock/04-sync branch from 9c2d5f5 to 06a66f8 Compare July 21, 2026 16:58
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 21, 2026
@samuv
samuv force-pushed the skills-lock/03-install-hooks branch from 1514a9d to 3dd34f4 Compare July 21, 2026 17:08
@samuv
samuv force-pushed the skills-lock/04-sync branch from 06a66f8 to 42a03de Compare July 21, 2026 17:08
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 21, 2026
@samuv
samuv force-pushed the skills-lock/04-sync branch from 42a03de to 4604083 Compare July 21, 2026 17:14
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 21, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 23, 2026
@samuv
samuv force-pushed the skills-lock/04-sync branch from 3b4e60f to f8fe482 Compare July 23, 2026 11:54
@samuv
samuv force-pushed the skills-lock/03-install-hooks branch from 773d3ff to 78b4c7d Compare July 23, 2026 11:54
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 23, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Panel review (spec / standards / security / library-reuse / duplication / golang-conventions / UX). The sync core is correct — content re-hash verifies every client dir, --check is read-only, --adopt/--prune behave, and restore installs exactly the pinned resolvedReference@digest. A few real findings; also two items that look broken here but are completed by #5897 (noted so they aren't double-counted). Commenting rather than approving.

Real findings:

  1. --clients "all" is passed through literallyskill_sync.go:81 help promises "all" expands to every detected client, but parseSkillInstallClients (skill_install.go:67) has no such handling, so Sync receives ["all"] verbatim. Implement the expansion or fix the help text.

  2. Git restore doesn't verify the digest is a pinned commit (Medium, security)pinGitReference (pin.go:67) builds git://…@<entry.Digest> and passes it to CloneConfigForRef, which only treats it as a commit when it's 40-char hex. A tampered/hand-edited lock entry with digest: main (or a tag) silently pulls the branch tip instead of the pinned content — the restore invariant is violated. Reject non-40-char-hex digests in pinGitReference (mirror what pinOCIReference gets free from a digest ref).

  3. pinGitReference assumes an https?:// prefixpin.go:69 TrimPrefix(TrimPrefix(url, "https://"), "http://") silently no-ops on any other form, producing a corrupt pinned reference rather than an error. Return an error when neither prefix matches.

Stacking artifacts (fixed by #5897 — no action here):

  • sync --check always exits 0 on drift — resolved by #5897's typed exit codes (exit 2 on check failure).
  • isImmutableSource/isFullCommitHash look like dead code — #5896 is their caller.

UX (should-fix): --check output is indistinguishable from a real sync (no "no changes made" banner); successfully-adopted skills still appear under "Never managed (use --adopt to record)"; "the project matches its lock file" prints even when no lock file exists. Also the TOOLHIVE_SKILLS_LOCK_ENABLED gate is undocumented in --help/docs — a user following the help hits a dead-end 403.

Security (verified clean): OCI restore is content-addressed, projectRoot is validated (absolute/canonical/git-rooted, CWE-22), prune delegates to the scoped Uninstall, --adopt is explicit opt-in, and POST /skills/sync sits behind auth middleware.

@samuv
samuv force-pushed the skills-lock/04-sync branch from f8fe482 to 8bca3c7 Compare July 24, 2026 08:04
@samuv
samuv force-pushed the skills-lock/03-install-hooks branch from 78b4c7d to bad7ade Compare July 24, 2026 08:05
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
@samuv
samuv force-pushed the skills-lock/03-install-hooks branch from bad7ade to 187a35d Compare July 24, 2026 08:35
@samuv
samuv force-pushed the skills-lock/04-sync branch from 8bca3c7 to 1712886 Compare July 24, 2026 08:35
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
Base automatically changed from skills-lock/03-install-hooks to main July 24, 2026 09:10
samuv added 4 commits July 24, 2026 11:10
RFC THV-0080 needs a way to restore a project's pinned skill set on
any machine (a fresh clone, CI, a teammate's laptop) and to verify
on-disk content still matches the lock file without installing
anything. This adds the full sync vertical slice: service logic,
HTTP endpoint, client, and CLI command.

- Sync reconciles installed skills against toolhive.lock.yaml:
  missing/drifted entries reinstall at the pinned reference (never
  re-resolving source, see buildPinnedReference), --check reports
  drift read-only, --adopt/--prune handle unlocked installs
- Fixes a real bug this surfaced: reinstalling at an unchanged
  pinned digest hit both the OCI and git install paths' "same
  digest means content is already correct" fast path, silently
  skipping re-extraction — exactly the drift sync exists to repair.
  A new internal SyncRestore option bypasses that fast path.
- POST /api/v1beta/skills/sync via a narrow skillSyncer interface
  (pkg/api/v1), so this PR doesn't need to ship an Upgrade stub
- `thv skill sync` CLI (--check/--adopt/--prune/--clients/
  --project-root, auto-detected via .git when omitted)

Part of the production stack for #5715 (RFC THV-0080). Stack: 4/6.
reinstallPinned reinstalls at a pinned reference derived from the
lock entry, and that pinned string was flowing straight into
ResolvedReference via the install path — a drift repair overwrote
it with an internal restore form instead of preserving what Source
had actually resolved to, which later corrupts upgrade's ref-change
detection. Add a LockResolvedReference override on InstallOptions,
mirroring the existing LockSource one.
Picks up the AlreadyCurrent JSON tag rename and Drifted doc comment
fix from the PR2 review pass.
The panel review's blocker: drift was only recorded for entries with
an install record, so a lock entry with none — every entry on a fresh
clone or CI runner, since SQLite state is per-machine — landed in no
result bucket and sync --check green-lit a checkout missing all of
its locked skills. Add a Missing bucket, recorded before the check
return.

Restores had three related correctness gaps: restoring a parent
re-materialized its toolhive.requires from mutable source strings
(silently upgrading and re-pinning a dependency whose upstream tag
moved), a restore promoted non-explicit dependencies to explicit
(permanently exempting them from cascade removal), and verification
hashed only the first client's directory (tampering with any other
client's copy was invisible, install-order-dependently). Skip
requires recursion on SyncRestore, preserve Explicit verbatim, and
verify every client directory.

Also classify lock-write failures via a sentinel instead of mapping
every HTTP 500 to lock-write-failed, and fix the E2E decode struct
reading AlreadyCurrent from a stale JSON key — a silently-passing
assertion.
@samuv
samuv force-pushed the skills-lock/04-sync branch from 1712886 to d8f23e4 Compare July 24, 2026 09:10
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
@samuv
samuv merged commit 52bc063 into main Jul 24, 2026
48 checks passed
@samuv
samuv deleted the skills-lock/04-sync branch July 24, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants