Record project-scope skill installs in the lock file - #5894
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5894 +/- ##
==========================================
+ Coverage 71.84% 71.88% +0.03%
==========================================
Files 708 711 +3
Lines 72811 73022 +211
==========================================
+ Hits 52311 52489 +178
- Misses 16765 16784 +19
- Partials 3735 3749 +14 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
7824f3f to
1514a9d
Compare
1514a9d to
3dd34f4
Compare
|
Tracked in #5899 (RFC THV-0080 stack). |
bdf19aa to
176f367
Compare
773d3ff to
78b4c7d
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Panel review (spec / standards / security / library-reuse / duplication / golang-conventions / UX). Phase-2 mechanics are all correct — hard-fail on lock write, transitive requiredBy, dirhash contentDigest, source never rewritten, managed set. But there are two real correctness findings plus some standards/UX items. Commenting rather than approving.
Correctness (worth addressing before merge):
-
Dependency-cycle deadlock via the OCI path —
pkg/skills/skillsvc/lock.go(materializeDependencies) re-enterss.Install, but theVisitedcycle check only runs insiderecordLockState, after the artifact install. The git path works becauseinstallFromGitlocks on the resolved skill name; a requires reference that dispatches throughinstallByName/installFromOCIlocks onopts.Namebefore resolution (install.go:100). A cycleA → oci:.../Are-locks a key the outer install already holds and deadlocks, with theVisitedset never consulted. Suggest a pre-install cycle check keyed on the requested reference, or locking only on resolved names everywhere. -
Store/lock write ordering inverts the durable-first rule —
recordLockState(lock.go) writes the lock entry before the storeUpdatethat marks the skillManaged. If theUpdatefails, rollback deletes the lock entry but the store record survives unmanaged with files still on disk — a stateUninstallwon't cascade (it only cascades whenexisting.Managed). MarkManagedin the store first, or extend rollback to clear files/record on this failure.
Standards (mechanical): three go-style.md immutable-variable violations — the if x == "" { x = fallback } reassignment at lock.go:42, lock.go:91, uninstall.go:60. (library-reuse notes cmp.Or collapses the first to one line.)
UX: lock-write failures surface as generic HTTP 500s (install.go:267, uninstall.go:61) with no cause or recovery hint — a dead-end for the operator. Worth an actionable message. Also Managed isn't visible in skill list/skill info, so an operator can't tell why one uninstall touches the lock and another doesn't.
Security: verified clean on the main path (os.Root containment, atomic temp+rename, digest hardening, CWE-22). One Low TOCTOU: prevEntry snapshot is read outside the file lock (install.go:246) and could reinstate a stale entry on rollback under concurrent same-skill installs — take the snapshot inside Update's callback.
Deferred (confirm, not defects): Sigstore provenance recording and --allow-unsigned are Phase 5, correctly absent — acknowledged in feature_gate.go.
78b4c7d to
bad7ade
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.
bad7ade to
187a35d
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