Record project-scope skill installs in the lock file#5894
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## skills-lock/02-lock-service #5894 +/- ##
===============================================================
+ Coverage 71.78% 71.87% +0.08%
===============================================================
Files 705 708 +3
Lines 72176 72375 +199
===============================================================
+ Hits 51815 52017 +202
+ Misses 16657 16623 -34
- Partials 3704 3735 +31 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
e124faf to
e07cfea
Compare
7824f3f to
1514a9d
Compare
1514a9d to
3dd34f4
Compare
|
Tracked in #5899 (RFC THV-0080 stack). |
bdf19aa to
176f367
Compare
dcca019 to
ef4a99e
Compare
JAORMX
left a comment
There was a problem hiding this comment.
🤖 Automated panel review — PR 3/6 (install hooks + toolhive.requires materialization)
Multi-agent review, each finding adversarially verified against the diff. Advisory. Whole stack gated behind TOOLHIVE_SKILLS_LOCK_ENABLED (off by default). This is the PR you flagged for extra review time — the panel agrees the rollback path is where the risk concentrates.
🟠 Major — dependency-tree rollback is not transactional
- A later dependency failure leaves already-installed siblings orphaned (
lock.go:~504→install.go:~230/353).materializeDependenciesinstalls eachtoolhive.requiresdep via a full recursives.Installthat fully commits DB record + files + its own lock entry before advancing. When a later dep fails,installAndRegister'srollback()deletes only the top-level skill's record + entry. Any earlier-succeeding sibling stays installed on disk,Managedin the DB, and pinned withRequiredBy=[parent]pointing at a parent that no longer exists — violating the RFC's "every non-explicit entry has a live parent" invariant and contradicting the hard-fail philosophy. Scenario: parent requires[depA (ok), depB (missing)]→ depA leaks orphaned. The only rollback test uses a single first-failing dep, so this partial-success case is uncovered. - Destructive rollback on
--force/ pre-existing entries (install.go:~353).rollback()unconditionallystore.Delete+lockfile.RemoveEntry, with no guard distinguishing a record newly created by this call from a pre-existing one. On a--forcere-install of an already-locked skill that just gained arequiresedge, a transient dep-resolution error deletes the skill's DB record and its previously-valid lock entry — includingRequiredByrelationships other parents merged onto it. A working install is destroyed by a transient network error. Consider deleting only records this call created, or a tree-wide compensation/transaction.
🟠 Major — cross-PR (uninstall side)
- Install/uninstall disagree on whether a lock-write failure is fatal → resurrection (
uninstall.go:~62). Install hard-fails + rolls back; uninstall appends theremoveLockEntryAndCascadeerror tocleanupErrsand returns after DB+files are already gone. Result: a stale lock entry for a skill absent from DB/disk → the nextthv skill syncseesentry present + dbOK=falseand reinstalls the "uninstalled" skill, reintroducing AI-executed instructions the user thought they removed.
🟡 Minor
- Materialized deps drop the parent's
--group(lock.go:~496).depOptsomitsGroup, so every transitively materialized dep lands ingroups.DefaultGroupeven when the parent was installed into a non-default group — splitting a parent and its deps across groups. contentDigestis computed from onlyClients[0]'s directory (content_digest.go:~20). Tampering with any other client's materialized copy (e.g..cursor/vs.claude/) leaves the pin matching, and which dir is protected is install-order-dependent.
🟢 Verified praise
- Cascade-uninstall edge cases are covered well (orphan cascades, shared dep survives one parent, explicit never cascaded, install+uninstall cycles proven to terminate), and the cascade is gated on the persisted
Managedflag as well as the env var, so behavior stays inert and self-consistent across toggles.
(Note: ~marginally over the 400-LOC guideline but cohesive and disclosed — not a concern.)
ef4a99e to
611f480
Compare
176f367 to
1c30244
Compare
|
The panel was right that the rollback path is where the risk concentrates — all three majors were real and are fixed in the latest push:
|
JAORMX
left a comment
There was a problem hiding this comment.
🤖 Automated panel re-review on 1c30244 → approving
All three majors are genuinely fixed — each traced end-to-end in code and independently re-verified by an adversarial check:
- ✅ Non-transactional rollback: a fresh install that fails on a later dep now runs
removeLockEntry+cascadeUninstall(the same path uninstall uses), so orphaned siblings are removed from lock + DB + disk. Covered byTestInstallProjectScope_SiblingDependencyRolledBackWithParent. - ✅ Destructive
--forcerollback:InstallResult.PreExisting+ aprevEntrysnapshot mean rollback now restores (not deletes) a pre-existing DB record and its lock entry, preserving other parents'RequiredBy. Covered byTestInstallProjectScope_RollbackRestoresPreExistingState. - ✅ Uninstall resurrection:
uninstallOneremoves the lock entry first and aborts intact on failure, before any DB/disk destruction — the resurrection window is gone. Covered byTestUninstall_LockWriteFailureAbortsBeforeDestruction.
Minors: materialized deps now join the parent's group (✅ tested); the single-Clients[0] contentDigest is reasonably deferred to sync-side verification (#5895 hashes every client dir).
One new minor to consider (non-blocking): in rollbackInstall, the pre-existing-managed-parent branch (prevEntry != nil) restores the parent and returns without cascading — so if a --force reinstall newly declared several deps and an earlier sibling succeeded before a later one failed, that sibling is left installed/pinned. This does not violate the RFC "live parent" invariant (the restored parent still exists) and sync --prune reconciles it, so it's a soft leak — worth an optional follow-up. LGTM.
1c30244 to
773d3ff
Compare
611f480 to
2e75912
Compare
RFC THV-0080 requires every project-scoped skill install to be pinned in toolhive.lock.yaml, including transitively materialized toolhive.requires dependencies, with a fail-hard guarantee so a lock-write failure never leaves an install silently unpinned. The whole feature stays inert on main until the full RFC v1 (this lock-file stack plus the later Sigstore stack) has landed — gated behind TOOLHIVE_SKILLS_LOCK_ENABLED, following the existing TOOLHIVE_DEV precedent for staged rollouts. Each PR in the stack is independently mergeable without exposing partial behavior. - Install hooks into the single choke point (installAndRegister): compute contentDigest, upsert the lock entry, roll back the DB record if the lock write fails - toolhive.requires dependencies materialize recursively (visited set guards cycles, skills.MaxDependencies bounds the whole tree), with RequiredBy merged across shared dependencies - Uninstall cascades to orphaned, non-explicit dependencies via Lockfile.RemoveParentFromRequiredBy, itself cycle-safe - E2E coverage for the real HTTP API + thv serve subprocess path Part of the production stack for #5715 (RFC THV-0080). Stack: 3/6.
gitresolver.ParseGitReference only uses "http://" when TOOLHIVE_DEV=true; the correct default (and what GitHub Actions CI runners use, since they don't set that var) is "https://". The gitRef test helper hardcoded "http://" for the fixture registration key, so every test using it failed in CI with "no fixture registered for https://...". Compute the scheme the same way the resolver does instead of hardcoding either one.
installAndRegister's rollback only deleted the DB record, so a dependency-materialization failure after the parent's own lock entry had already been written left toolhive.lock.yaml claiming a pin that Info() couldn't find. Also key materializeDependencies' cycle-check visited set by the reference a skill was installed with instead of its resolved name, matching how dependency edges reference it — the mismatch let a cycle through unresolved names re-materialize a skill as its own dependency and corrupt its RequiredBy list.
Three panel-review findings on the install/uninstall lifecycle: rollback deleted only the top-level skill, leaking earlier-succeeded sibling dependencies as managed orphans whose RequiredBy pointed at a rolled-back parent; rollback was also unconditionally destructive, so a --force reinstall of a valid, lock-managed skill that failed on a new dependency destroyed its pre-existing DB record and lock entry; and uninstall removed the lock entry last and best-effort, so a lock-write failure after files and DB were gone left a stale entry the next sync silently reinstalled. Rollback now snapshots pre-install state (DB record via InstallResult.PreExisting, lock entry before any write) and restores it when the record pre-existed; when this call created it, removal runs the same dependency cascade as uninstall so fresh orphans are cleaned while shared or explicit deps survive. Uninstall removes the lock entry first and aborts intact on failure. Materialized dependencies also now join the parent's --group instead of falling back to the default group.
2e75912 to
a72e34a
Compare
773d3ff to
78b4c7d
Compare
Summary
toolhive.lock.yaml— including transitively materializedtoolhive.requiresdependencies — with lock-write failure hard-failing the install (never a silently-unpinned skill). This is the largest PR in the stack; flagging it up front for extra review time.installAndRegister): computecontentDigest, upsert the lock entry, roll back the DB record (matching the existing group-registration rollback pattern) if the lock write fails.toolhive.requiresdependencies materialize recursively — aVisitedset guards cycles,skills.MaxDependenciesbounds the whole tree (not just one skill's declared list) — withRequiredBymerged (not overwritten) when a dependency is shared by multiple parents.Lockfile.RemoveParentFromRequiredBy, itself cycle-safe.mainbehindTOOLHIVE_SKILLS_LOCK_ENABLEDuntil the full RFC v1 (this stack + the later Sigstore stack) lands — following the existingTOOLHIVE_DEVprecedent (pkg/skills/gitresolver/reference.go) for staged rollouts. Every PR in the stack stays independently mergeable without exposing partial behavior to users.Part of the production stack for #5715 (RFC THV-0080). Stack: 3/6 — lockfile → lock-service → install-hooks → sync → upgrade → cli-exitcodes. Based on #5893 (PR2).
Type of change
Test plan
task test)task test-e2e, scoped:ginkgo --label-filter=skills-lock)task lint-fix)Unit coverage (86.3% for
pkg/skills/skillsvc) targets the risk areas directly: contentDigest/source recorded correctly on install, dependency materialization, a dependency shared by two parents mergingRequiredBy, a requires cycle terminating, theMaxDependenciestree-wide cap, lock-write failure rolling back the DB record, cascade uninstall (orphaned dependency removed, explicit dependency never cascaded, shared dependency survives one parent's removal, cascade cycle terminating). Two new E2E specs exercise the real HTTP API +thv servesubprocess + a real OCI push/install round-trip, confirming the lock file is written/removed correctly outside the unit-test harness.Does this introduce a user-facing change?
Not yet — gated behind
TOOLHIVE_SKILLS_LOCK_ENABLED, off by default. Once the full stack lands: project-scopethv skill installwill write/updatetoolhive.lock.yamland materializetoolhive.requiresdependencies;thv skill uninstallwill cascade-remove orphaned dependencies.Special notes for reviewers
toolhive.requiresis read back from the extractedSKILL.mdon disk (not from the OCI/git resolver's own parse) — this works uniformly across OCI and git sources without needing to plumb requires through every install path's return type.test/e2e/api_helpers.go'sServerConfiggains anExtraEnv []stringfield (empty/no-op for every existing E2E test) so this PR's tests can opt into the feature flag on their ownthv servesubprocess without turning it on for the whole E2E suite.🤖 Generated with Claude Code