feat(skillhub): add install queue with rate-limit handling and file watcher#445
feat(skillhub): add install queue with rate-limit handling and file watcher#445alchemistklk merged 25 commits intomainfrom
Conversation
FIFO queue for ClawHub CLI skill installations with max 2 concurrent downloads, rate-limit detection/retry with queue-wide pause, dedup, and auto-cleanup of completed items.
…ue from CatalogManager
…ing uninstalled When skills are missing from disk, curated records must be fully removed (not marked uninstalled) so getCuratedSlugsToEnqueue can re-enqueue them. Marking as uninstalled would cause isRemovedByUser to return true, permanently blocking re-installation.
fs.cp() with dereference follows node_modules/.bin symlinks which causes EINVAL when the resolved target overlaps the copy destination. Filter out .bin directories to prevent the crash.
Three fixes: - Executor now receives (slug, source) and records in DB after successful install - getCuratedSlugsToEnqueue checks ALL sources, not just 'curated' - Prevents re-enqueue on restart when skills were recorded as 'managed' by syncNow
…llsDir fallback - Check ledger existence before SkillDb.create() to detect first launch - Only run initialization (copy static skills + enqueue curated) when ledger doesn't exist (fresh install or reinstall) - On subsequent starts, skip initialization entirely — just start watcher - Add dev fallback for staticSkillsDir pointing to apps/desktop/static/bundled-skills - All skills (static + curated) go into the same skills/ directory - Increase fs.watch test timeout to 5s for CI stability
SkillDirWatcher now checks isSlugInFlight() before recording new skills from disk. Slugs currently in the install queue (queued/downloading) are skipped — the queue records them with the correct source when complete. This prevents duplicate records (managed + curated) for the same slug.
- SkillSource type reduced to 'managed' | 'custom' - All static and ClawHub-installed skills recorded as 'managed' - Zod schema accepts legacy 'curated' records and transforms to 'managed' - isRemovedByUser deprecated (always returns false) - Watcher treats all missing skills the same (markUninstalledBySlugs)
The old bundled-skills destination directory is no longer used. All skills now go to the unified openclawSkillsDir. The static bundled-skills source folder (apps/desktop/static/bundled-skills/) is kept for first-launch copy.
… slug at route Three fixes from code review: 1. syncNow() runs on every startup (not just first launch) to catch skills added/removed while controller was stopped 2. InstallQueue.cancel() prevents uninstall from being overridden by a completing queue executor 3. Install route validates slug format before enqueuing
…etry Three fixes from second Codex review: 1. DB record moved from executor to onComplete callback, called only after cancel check passes. Cancelled installs never record in DB. 2. syncNow() runs BEFORE initialize() so on-disk skills are recorded before curated enqueue checks the ledger. 3. Failed items excluded from dedup so users can retry immediately.
Add errorCode field to QueueItem to classify failures: - skill_not_found: skill doesn't exist on ClawHub (removed or renamed) - rate_limit: ClawHub rate limit exceeded - unknown: other errors Frontend shows a toast when a queued skill fails with skill_not_found: 'X is not available on ClawHub. It may have been removed or renamed.'
Add CATALOG_BLOCKLIST to hide skills that exist in the ClawHub catalog data but are no longer available for install (removed or renamed). Currently blocks: self-improving-agent.
Deploying nexu-docs with
|
| Latest commit: |
73fc5f0
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://ed86a54d.nexu-docs.pages.dev |
| Branch Preview URL: | https://feat-skillhub-install-queue.nexu-docs.pages.dev |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds an async InstallQueue and SkillDirWatcher, removes Changes
Sequence DiagramsequenceDiagram
participant Client as Client/Web UI
participant Routes as skillhub-routes
participant Queue as InstallQueue
participant Executor as CatalogManager.executeInstall
participant Watcher as SkillDirWatcher
participant DB as SkillDb
Client->>Routes: POST /api/v1/skillhub/install?slug=foo
Routes->>Routes: Validate slug (regex)
Routes->>Queue: enqueue("foo","managed")
Queue-->>Routes: QueueItem (queued, position)
Routes->>Client: { queued: true, slug, status: "queued", position }
Note over Queue,Executor: async drain/execute loop
Queue->>Executor: executeInstall("foo")
Executor-->>Queue: success / error
alt success
Queue->>DB: onComplete -> recordInstall("foo","managed")
Queue->>Queue: mark done, schedule cleanup
else rate_limit
Queue->>Queue: classify rate_limit, increment retries, pause (3–60s), requeue
else skill_not_found
Queue->>Queue: mark failed, set errorCode "skill_not_found"
end
par filesystem sync
Watcher->>Watcher: fs.watch event (SKILL.md)
Watcher->>Watcher: debounced syncNow()
Watcher->>DB: recordBulkInstall(newOnDisk, "managed")
Watcher->>DB: markUninstalledBySlugs(missingFromDisk, source)
end
Client->>Routes: GET /api/v1/skillhub/catalog
Routes->>DB: fetch catalog
Routes->>Queue: collect queue items
Routes->>Client: { catalog..., queue: [...] }
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/controller/src/services/skillhub/curated-skills.ts (1)
74-77:⚠️ Potential issue | 🟠 Major
isRemovedByUseralways returnsfalse— re-installation bug affects both deprecated and new flows.Per
apps/controller/src/services/skillhub/skill-db.ts:141-142,isRemovedByUseris hardcoded to returnfalse. The guards at lines 74 and 121 incurated-skills.tsare ineffective.The new replacement mechanism in
getCuratedSlugsToEnqueue()(catalog-manager.ts:316-318) has the same issue: it usesgetAllInstalled(), which filters only for status === "installed" records. When a user uninstalls a skill, it transitions to status "uninstalled" (skill-db.ts:125) and is no longer returned bygetAllInstalled(). Therefore, uninstalled curated skills will be re-enqueued instead of being skipped.To preserve user uninstall preferences, update
getCuratedSlugsToEnqueue()to also exclude slugs with status "uninstalled" from the ledger.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/controller/src/services/skillhub/curated-skills.ts` around lines 74 - 77, The guard using skillDb.isRemovedByUser(slug) is ineffective because isRemovedByUser currently always returns false; instead, update getCuratedSlugsToEnqueue (and the curated-skills guards) to consult the ledger and explicitly exclude slugs whose ledger entry status is "uninstalled" (in addition to "installed" handling). Concretely, modify getCuratedSlugsToEnqueue to fetch ledger entries (use getAll or the ledger accessor used in skill-db), filter out any entries where entry.status === "uninstalled" before building the enqueue list, and/or implement isRemovedByUser to return true when the ledger has an "uninstalled" record for the slug so existing checks in curated-skills.ts and catalog-manager.ts work as intended.
🧹 Nitpick comments (5)
docs/plans/2026-03-23-skillhub-install-queue.md (2)
167-169: Plan references"curated"source which is being removed.The test snippets in this plan document use
"curated"as a source value (e.g., lines 167, 291-293), but the PR removes"curated"as a validSkillSource. If this plan is still being used as a reference, consider updating the examples to use"managed"for consistency with the final implementation.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/plans/2026-03-23-skillhub-install-queue.md` around lines 167 - 169, Update the plan examples to stop using the removed "curated" SkillSource: change calls like queue.enqueue("a", "curated") to use the current valid source (e.g., "managed") so examples match the implementation; search for occurrences of queue.enqueue(...) and any other examples referencing "curated" in this document and replace them with the canonical SkillSource value used in the codebase.
1306-1311: Add language specifier to fenced code block.Static analysis flags this block as missing a language identifier (MD040). Since it shows state transitions, consider using
textorplaintext.Suggested fix
-``` +```text queued → downloading → done (happy path) queued → downloading → failed (non-rate-limit error) queued → downloading → queued (rate-limited, retry) queued → downloading → queued → ... → failed (max retries exceeded) -``` +```🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/plans/2026-03-23-skillhub-install-queue.md` around lines 1306 - 1311, Add a language identifier to the fenced code block that shows state transitions so the markdown linter (MD040) is satisfied; change the opening fence from ``` to ```text (or ```plaintext) for the block containing "queued → downloading → done ..." and ensure the closing fence remains ``` so the block is properly fenced.tests/desktop/skill-dir-watcher.test.ts (1)
60-190: Please cover the new watcher race guards directly.The suite exercises happy-path syncs, but it still doesn’t assert either
isSlugInFlightsuppression or thatstop()cancels a pending debounce. Those are the branches that prevent duplicate ledger writes after queue/install churn, so they deserve direct coverage.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/desktop/skill-dir-watcher.test.ts` around lines 60 - 190, Add tests that assert the watcher’s race guards: create a SkillDirWatcher with a small debounceMs and a fake/real skillDb, then trigger a sync and while that sync is running verify that the watcher reports the slug as in-flight (isSlugInFlight for the skill) and that a concurrent sync attempt does not record a second install; also add a test that starts the watcher, writes a SKILL.md to schedule a debounced sync, then immediately calls stop() and assert the pending sync never runs (the skill is not recorded). Reference SkillDirWatcher, start(), stop(), syncNow(), debounceMs and isSlugInFlight when locating where to add the assertions.apps/web/src/hooks/use-community-catalog.ts (1)
30-35: Use generated SDK types directly; stop casting away their contract.The casts on lines 35 and 59–62 hide type drift between the generated API (
apps/web/lib/api/types.gen.ts) and local DTOs (apps/web/src/types/desktop.d.ts). The generatedqueueitems include aretriesfield that your localQueueItemtype is missing; thesourceenum also differs ("curated" | "managed" | "custom"locally vs.'managed' | 'custom'in the SDK). Any code that accessesretrieswill compile but fail at runtime. Remove theas unknown asandas { ... }casts; use or properly map the generated response types instead.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/hooks/use-community-catalog.ts` around lines 30 - 35, The code is casting the SDK response away from its generated types (removing type safety) in the queryFn of use-community-catalog (the useQuery block using getApiV1SkillhubCatalog and SkillhubCatalogData); remove the "as unknown as SkillhubCatalogData" and any manual "as { ... }" casts and instead use the generated SDK types from apps/web/lib/api/types.gen.ts for the API response, or explicitly map the SDK shape to your local DTO (e.g., convert SDK queue items that include retries and the SDK source enum into your local QueueItem shape) inside the queryFn before returning; update the return type of the queryFn (and any downstream consumers) to the proper SDK type or the mapped local type so you no longer hide schema drift between getApiV1SkillhubCatalog and your local types.apps/controller/tests/skillhub-service.test.ts (1)
210-227: The restart branch is still untested.Every
start()test builds a fresh temp root, soexistsSync(env.skillDbPath)never becomes true and the suite only exercises first-launch behavior. Please add one case with a preexisting ledger file so the “skip initialization on subsequent restarts” path is locked down.Also applies to: 271-434
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/controller/tests/skillhub-service.test.ts` around lines 210 - 227, Add a test in apps/controller/tests/skillhub-service.test.ts that creates a preexisting ledger file at env.skillDbPath inside the temp root (use the same mkdtemp/rootDir setup) before calling start() on the SkillHub service so the existsSync(env.skillDbPath) branch is exercised; ensure mocks like mockSkillDbCreate and mockCopyStaticSkills reflect a restart (e.g., mockSkillDbCreate not called or skipped) and assert the initialization path was skipped (check relevant calls/flags on SkillHubService.start, ledger initialization code, and any "skip initialization" behavior). Also add the same preexisting-ledger scenario to the other start() tests referenced in the 271-434 range so subsequent restarts are covered consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/controller/openapi.json`:
- Around line 5436-5448: Update the JSON schema for the response properties
"error" and "errorCode" to OpenAPI 3.1 null-preserving syntax: replace the
"nullable": true usage with a type array that includes "null" (i.e., set "type"
to ["string","null"] for "error" and set "type" to ["string","null"] plus
preserve the "enum" for "errorCode"); then regenerate the client types so
apps/web/lib/api/types.gen.ts reflects the nullable types.
In `@apps/controller/src/routes/skillhub-routes.ts`:
- Around line 195-197: The uninstall currently races with active executors
because container.skillhubService.queue.cancel(slug) only marks cancellation;
you must not call container.skillhubService.catalog.uninstallSkill(slug) until
running jobs have stopped. Change the flow to either (a) after
queue.cancel(slug) await an existing wait method (e.g., queue.waitForStop(slug)
/ queue.awaitIdle(slug) / queue.abortAndWait(slug)) to perform an abortable
shutdown, or (b) if no wait API exists, check a running flag (e.g.,
queue.isActive(slug) / queue.hasRunningJobs(slug)) and reject the uninstall with
a 409/appropriate error until the slug is inactive; ensure you reference and use
container.skillhubService.queue.cancel, the chosen wait/check method, and
container.skillhubService.catalog.uninstallSkill so uninstall only runs after
active jobs have fully stopped.
- Around line 152-154: Move the slug pattern from the ad-hoc SLUG_REGEX check
into the shared Zod schema so all routes use the same validation: update
skillhubSlugSchema (the shared schema in packages/shared/src/schemas/) to
include .regex(SLUG_REGEX or the equivalent pattern) instead of only min(1),
remove the inline SLUG_REGEX.test(slug) guard in the route handler
(apps/controller/src/routes/skillhub-routes.ts) and rely on the validated
path/body param schema for POST /install, DELETE /uninstall and GET
/skills/{slug} so OpenAPI/SDK and all routes enforce the same slug rules.
In `@apps/controller/src/services/skillhub-service.ts`:
- Around line 54-59: InstallQueue's callbacks must not violate its error
contract: make onComplete non-throwing by catching any errors from
skillDb.recordInstall(slug, source) (log the failure but do not rethrow), and
change onCancelled to await catalogManager.uninstallSkill(slug) and inspect its
return value; if the result is { ok: false } then throw an error (or return a
rejected promise) so the queue sees the failure. Reference: onComplete,
skillDb.recordInstall, onCancelled, catalogManager.uninstallSkill, and
InstallQueue.
In `@apps/controller/src/services/skillhub/catalog-manager.ts`:
- Around line 282-309: The code canonicalizes the slug inside executeInstall
(slug = SLUG_CORRECTIONS[rawSlug] ?? rawSlug) but the InstallQueue bookkeeping
still uses the original raw slug, causing ledger drift; fix by normalizing
before queue operations or returning/plumbing the canonical slug into the
completion path: either canonicalize rawSlug earlier (where items are enqueued)
so enqueue uses the normalized value, or modify executeInstall/onComplete to
pass the resolved slug back to InstallQueue.complete (or whichever method
records completion) so the ledger records the canonical slug (reference:
executeInstall, SLUG_CORRECTIONS, InstallQueue, onComplete).
- Around line 316-319: The current getCuratedSlugsToEnqueue() uses only
this.db.getAllInstalled() so migrated-but-uninstalled ledger entries are treated
as never-seen; change the logic to exclude any CURATED_SKILL_SLUGS that have any
ledger record instead of only installed rows. Update getCuratedSlugsToEnqueue()
to call the ledger-history API on the DB (e.g. this.db.getLedgerHistory() /
this.db.getLedgerEntriesForSlug(slug) or a new this.db.hasLedgerRecord(slug))
and filter out slugs with any ledger record before returning the list from
CURATED_SKILL_SLUGS. Ensure you reference getCuratedSlugsToEnqueue,
this.db.getAllInstalled, and CURATED_SKILL_SLUGS when making the change.
In `@apps/controller/src/services/skillhub/skill-dir-watcher.ts`:
- Around line 38-43: The syncNow() implementation treats errors from
scanDiskSlugs() as an empty scan which can incorrectly mark installed skills as
uninstalled or stop reconciliation; wrap the call to this.scanDiskSlugs() in a
try/catch inside syncNow(), log or capture the error, and return early on
failure (do not proceed to diff/marking logic). Ensure the catch targets
failures when reading this.skillsDir so syncNow() aborts safely if the directory
disappears or becomes unreadable.
In `@apps/web/src/pages/skills.tsx`:
- Around line 60-64: The current isBusy calculation treats queue activity as a
local pending action and disables the cancel/uninstall UI; update the logic so
queued/downloading/installing-deps do not block the cancel path: either remove
isQueueActive from isBusy (only use pendingAction !== null) or keep
isQueueActive but explicitly enable the uninstall/cancel controls when
queueStatus is queued/downloading/installing-deps so handleToggle(false) /
queue.cancel(slug) remains reachable; apply the same change in the companion
component CommunitySkillCard (look for isQueueActive, isBusy, handleToggle and
queue.cancel references) so queued installs can always be cancelled.
In `@tests/desktop/skill-db.test.ts`:
- Around line 164-165: The guard using the deprecated isRemovedByUser
(referenced in curated-skills.ts around the logic in copyStaticSkills and
resolveCuratedSkillsToInstall) is ineffective and should be removed or replaced:
either delete the conditional checks that rely on isRemovedByUser so the
existing disk-existence fallbacks handle copies/enqueues, or implement a proper
persisted removal-tracking mechanism (e.g., a removal registry or config) and
consult that registry in copyStaticSkills and resolveCuratedSkillsToInstall
instead of isRemovedByUser to honor user-deletions across restarts.
---
Outside diff comments:
In `@apps/controller/src/services/skillhub/curated-skills.ts`:
- Around line 74-77: The guard using skillDb.isRemovedByUser(slug) is
ineffective because isRemovedByUser currently always returns false; instead,
update getCuratedSlugsToEnqueue (and the curated-skills guards) to consult the
ledger and explicitly exclude slugs whose ledger entry status is "uninstalled"
(in addition to "installed" handling). Concretely, modify
getCuratedSlugsToEnqueue to fetch ledger entries (use getAll or the ledger
accessor used in skill-db), filter out any entries where entry.status ===
"uninstalled" before building the enqueue list, and/or implement isRemovedByUser
to return true when the ledger has an "uninstalled" record for the slug so
existing checks in curated-skills.ts and catalog-manager.ts work as intended.
---
Nitpick comments:
In `@apps/controller/tests/skillhub-service.test.ts`:
- Around line 210-227: Add a test in
apps/controller/tests/skillhub-service.test.ts that creates a preexisting ledger
file at env.skillDbPath inside the temp root (use the same mkdtemp/rootDir
setup) before calling start() on the SkillHub service so the
existsSync(env.skillDbPath) branch is exercised; ensure mocks like
mockSkillDbCreate and mockCopyStaticSkills reflect a restart (e.g.,
mockSkillDbCreate not called or skipped) and assert the initialization path was
skipped (check relevant calls/flags on SkillHubService.start, ledger
initialization code, and any "skip initialization" behavior). Also add the same
preexisting-ledger scenario to the other start() tests referenced in the 271-434
range so subsequent restarts are covered consistently.
In `@apps/web/src/hooks/use-community-catalog.ts`:
- Around line 30-35: The code is casting the SDK response away from its
generated types (removing type safety) in the queryFn of use-community-catalog
(the useQuery block using getApiV1SkillhubCatalog and SkillhubCatalogData);
remove the "as unknown as SkillhubCatalogData" and any manual "as { ... }" casts
and instead use the generated SDK types from apps/web/lib/api/types.gen.ts for
the API response, or explicitly map the SDK shape to your local DTO (e.g.,
convert SDK queue items that include retries and the SDK source enum into your
local QueueItem shape) inside the queryFn before returning; update the return
type of the queryFn (and any downstream consumers) to the proper SDK type or the
mapped local type so you no longer hide schema drift between
getApiV1SkillhubCatalog and your local types.
In `@docs/plans/2026-03-23-skillhub-install-queue.md`:
- Around line 167-169: Update the plan examples to stop using the removed
"curated" SkillSource: change calls like queue.enqueue("a", "curated") to use
the current valid source (e.g., "managed") so examples match the implementation;
search for occurrences of queue.enqueue(...) and any other examples referencing
"curated" in this document and replace them with the canonical SkillSource value
used in the codebase.
- Around line 1306-1311: Add a language identifier to the fenced code block that
shows state transitions so the markdown linter (MD040) is satisfied; change the
opening fence from ``` to ```text (or ```plaintext) for the block containing
"queued → downloading → done ..." and ensure the closing fence remains ``` so
the block is properly fenced.
In `@tests/desktop/skill-dir-watcher.test.ts`:
- Around line 60-190: Add tests that assert the watcher’s race guards: create a
SkillDirWatcher with a small debounceMs and a fake/real skillDb, then trigger a
sync and while that sync is running verify that the watcher reports the slug as
in-flight (isSlugInFlight for the skill) and that a concurrent sync attempt does
not record a second install; also add a test that starts the watcher, writes a
SKILL.md to schedule a debounced sync, then immediately calls stop() and assert
the pending sync never runs (the skill is not recorded). Reference
SkillDirWatcher, start(), stop(), syncNow(), debounceMs and isSlugInFlight when
locating where to add the assertions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a7460088-1c62-48ca-81b4-bcac03be24ff
📒 Files selected for processing (31)
apps/controller/openapi.jsonapps/controller/src/app/env.tsapps/controller/src/routes/skillhub-routes.tsapps/controller/src/runtime/openclaw-runtime-plugin-writer.tsapps/controller/src/services/skillhub-service.tsapps/controller/src/services/skillhub/catalog-manager.tsapps/controller/src/services/skillhub/curated-skills.tsapps/controller/src/services/skillhub/index.tsapps/controller/src/services/skillhub/install-queue.tsapps/controller/src/services/skillhub/skill-db.tsapps/controller/src/services/skillhub/skill-dir-watcher.tsapps/controller/src/services/skillhub/types.tsapps/controller/tests/sessions-runtime.test.tsapps/controller/tests/skillhub-service.test.tsapps/desktop/main/runtime/manifests.tsapps/desktop/shared/desktop-paths.tsapps/web/lib/api/types.gen.tsapps/web/src/components/skills/community-skill-card.tsxapps/web/src/hooks/use-community-catalog.tsapps/web/src/i18n/locales/en.tsapps/web/src/i18n/locales/zh-CN.tsapps/web/src/pages/skills.tsxapps/web/src/types/desktop.d.tsdocs/plans/2026-03-23-skillhub-install-queue.mdtests/controller/skillhub-service.test.tstests/desktop/install-queue.test.tstests/desktop/model-provider-service.test.tstests/desktop/openclaw-auth-profiles-writer.test.tstests/desktop/openclaw-config-compiler.test.tstests/desktop/skill-db.test.tstests/desktop/skill-dir-watcher.test.ts
💤 Files with no reviewable changes (5)
- apps/desktop/shared/desktop-paths.ts
- tests/desktop/openclaw-config-compiler.test.ts
- tests/desktop/model-provider-service.test.ts
- tests/desktop/openclaw-auth-profiles-writer.test.ts
- apps/controller/tests/sessions-runtime.test.ts
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
apps/controller/openapi.json (1)
6660-6671:⚠️ Potential issue | 🟠 MajorModel queue error nullability with valid OpenAPI 3.1 unions.
These new fields still use
nullable: true, which is the 3.0 form. In a 3.1 schema that can leave generated client types non-nullable, anderrorCodestill needs an explicitnullbranch to make returnednullvalues valid.🛠️ Suggested schema change
"error": { - "type": "string", - "nullable": true + "type": ["string", "null"] }, "errorCode": { - "type": "string", - "nullable": true, - "enum": [ - "skill_not_found", - "rate_limit", - "unknown" - ] + "oneOf": [ + { + "type": "string", + "enum": [ + "skill_not_found", + "rate_limit", + "unknown" + ] + }, + { + "type": "null" + } + ] },In OpenAPI 3.1 / JSON Schema 2020-12, what is the correct schema shape for (1) a nullable string field and (2) a nullable string enum field so both schema validation and generated TypeScript clients preserve `null`?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/controller/openapi.json` around lines 6660 - 6671, The OpenAPI 3.1 schema is still using the 3.0 `nullable: true` form for the "error" and "errorCode" properties; replace that with JSON Schema 2020-12 style nullability so generated clients preserve null: for the simple nullable string property "error" change its schema to allow both string and null (e.g., type as ["string","null"] or an anyOf/oneOf of string and {"type":"null"}), and for the nullable enum "errorCode" either include null as one of the enum values (e.g., enum: ["skill_not_found","rate_limit","unknown", null]) or use a oneOf/anyOf combining the enum and {"type":"null"} so null is a valid branch.apps/web/src/pages/skills.tsx (1)
63-67:⚠️ Potential issue | 🟠 MajorKeep queue-active installs cancellable from the card.
isQueueActivenow feedsisBusy, so the switch is disabled and the uninstall control disappears exactly when the backend supports uninstall-as-cancel. That removes the only UI path to stop a queued/download/install-deps job.🛠️ Suggested fix
const isQueueActive = queueStatus === "queued" || queueStatus === "downloading" || queueStatus === "installing-deps"; - const isBusy = pendingAction !== null || isQueueActive; + const isBusy = pendingAction !== null; ... <Switch size="xs" checked={isInstalled || isQueueActive} disabled={isBusy} - loading={isBusy} + loading={isBusy || isQueueActive} onCheckedChange={handleToggle} /> - {isInstalled && !isQueueActive ? ( - <button - type="button" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleToggle(false); - }} - disabled={isBusy} - className="text-[12px] font-medium text-text-muted hover:text-[var(--color-danger)] transition-colors" - > - Uninstall - </button> - ) : ( - <span className="text-[11px] text-text-muted">Installing…</span> - )} + <button + type="button" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleToggle(false); + }} + disabled={isBusy} + className="text-[12px] font-medium text-text-muted hover:text-[var(--color-danger)] transition-colors" + > + {isQueueActive ? "Cancel" : "Uninstall"} + </button>Also applies to: 151-175
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/pages/skills.tsx` around lines 63 - 67, The current logic sets isQueueActive (queueStatus === "queued" || "downloading" || "installing-deps") and then uses it to compute isBusy, which hides/disables the uninstall/cancel control when a job is queued/downloads/installing-deps; change the logic so queued/download/install-deps remain cancellable: keep isQueueActive for UI status but compute isBusy without treating those cancellable queue states as busy (e.g., create isCancellableQueue = queueStatus === "queued" || queueStatus === "downloading" || queueStatus === "installing-deps" and set isBusy = pendingAction !== null || (queueStatus not in cancellable states && other long-running states)), ensuring the switch/uninstall control renders/enabled when isCancellableQueue is true while preserving busy state for truly blocking statuses.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/src/pages/skills.tsx`:
- Around line 267-279: The current de-duplication Set shownErrorSlugs in the
useEffect keys only by item.slug so a repeat failure for the same slug in a
later attempt is suppressed; update the logic to deduplicate by attempt (e.g.,
combine item.slug + item.enqueuedAt or item.attemptId) or remove the slug from
shownErrorSlugs when the item leaves the "failed" state. Locate the useEffect
that iterates data?.queue and change the key used for shownErrorSlugs.current
(or add logic to clear entries when item.status !== "failed") so each new
enqueue attempt will surface its toast.
---
Duplicate comments:
In `@apps/controller/openapi.json`:
- Around line 6660-6671: The OpenAPI 3.1 schema is still using the 3.0
`nullable: true` form for the "error" and "errorCode" properties; replace that
with JSON Schema 2020-12 style nullability so generated clients preserve null:
for the simple nullable string property "error" change its schema to allow both
string and null (e.g., type as ["string","null"] or an anyOf/oneOf of string and
{"type":"null"}), and for the nullable enum "errorCode" either include null as
one of the enum values (e.g., enum: ["skill_not_found","rate_limit","unknown",
null]) or use a oneOf/anyOf combining the enum and {"type":"null"} so null is a
valid branch.
In `@apps/web/src/pages/skills.tsx`:
- Around line 63-67: The current logic sets isQueueActive (queueStatus ===
"queued" || "downloading" || "installing-deps") and then uses it to compute
isBusy, which hides/disables the uninstall/cancel control when a job is
queued/downloads/installing-deps; change the logic so
queued/download/install-deps remain cancellable: keep isQueueActive for UI
status but compute isBusy without treating those cancellable queue states as
busy (e.g., create isCancellableQueue = queueStatus === "queued" || queueStatus
=== "downloading" || queueStatus === "installing-deps" and set isBusy =
pendingAction !== null || (queueStatus not in cancellable states && other
long-running states)), ensuring the switch/uninstall control renders/enabled
when isCancellableQueue is true while preserving busy state for truly blocking
statuses.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 7c56db03-3d4e-4861-b35d-85d231236bb1
📒 Files selected for processing (8)
apps/controller/openapi.jsonapps/controller/src/app/env.tsapps/controller/tests/sessions-runtime.test.tsapps/desktop/main/runtime/manifests.tsapps/web/lib/api/types.gen.tsapps/web/src/i18n/locales/en.tsapps/web/src/i18n/locales/zh-CN.tsapps/web/src/pages/skills.tsx
💤 Files with no reviewable changes (1)
- apps/controller/tests/sessions-runtime.test.ts
✅ Files skipped from review due to trivial changes (1)
- apps/web/src/i18n/locales/zh-CN.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- apps/web/src/i18n/locales/en.ts
- apps/controller/src/app/env.ts
- apps/web/lib/api/types.gen.ts
getCuratedSlugsToEnqueue now uses getAllKnownSlugs() which checks both installed AND uninstalled records. Previously it only checked installed records, so user-uninstalled curated skills would be re-enqueued on next first-launch startup.
…canonicalization Three fixes from CodeRabbit review: 1. Move slug regex to skillhubSlugSchema so all routes validate consistently 2. Wrap onComplete callback in try/catch to prevent queue crash on DB failure 3. Canonicalize slug before enqueuing to prevent ledger/disk mismatch
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
apps/controller/src/routes/skillhub-routes.ts (1)
191-193:⚠️ Potential issue | 🔴 CriticalDon't uninstall while an active install is still writing.
queue.cancel(slug)only marks active work as cancelled; it does not wait forexecuteInstall()/npm installto stop. Callingcatalog.uninstallSkill(slug)immediately can delete the directory mid-write and let late files reappear. Either wait until the queue item is inactive, or throwHTTPException(409, ...)here and letonCancelledfinish cleanup.As per coding guidelines: Errors must throw
HTTPExceptionwith status + contextual message.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/controller/src/routes/skillhub-routes.ts` around lines 191 - 193, The current code calls container.skillhubService.queue.cancel(slug) then immediately container.skillhubService.catalog.uninstallSkill(slug), which can delete files while executeInstall() / npm install is still running; change this to detect an active install and either (A) wait for the queue item to become inactive/settled (use the queue API to poll or await the item’s completion) before calling catalog.uninstallSkill(slug), or (B) immediately throw new HTTPException(409, "Skill is currently installing; cancel requested and uninstall deferred") so onCancelled can finish cleanup; ensure you reference and use container.skillhubService.queue.cancel, the queue item status check, executeInstall/onCancelled semantics, and throw HTTPException with status and contextual message if choosing the 409 path.
🧹 Nitpick comments (1)
apps/controller/tests/skillhub-service.test.ts (1)
366-381: Add a canonicalization regression case.These tests only use identity slugs, so they will still pass if
SkillhubServicestops callingcatalog.canonicalizeSlug()before enqueueing. Making the mock rewrite a slug in both the startup andenqueueInstall()paths would pin the ledger/disk drift fix.Also applies to: 438-449
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/controller/tests/skillhub-service.test.ts` around lines 366 - 381, The test should assert that SkillhubService canonicalizes slugs before enqueueing: update the mock catalog to return ["alpha","beta"] from getCuratedSlugsToEnqueue and also mock catalog.canonicalizeSlug to rewrite those values (e.g., "alpha" -> "ALPHA", "beta" -> "BETA"), then call SkillhubService.create(...) and start() and assert that installQueue.enqueue was called with the canonicalized slugs (e.g., "ALPHA","BETA") and "managed"; also apply the same canonicalization rewrite/assertion to the other test block around lines 438-449 that covers enqueueInstall()/related behavior so the regression is pinned.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/controller/src/services/skillhub/catalog-manager.ts`:
- Around line 754-759: refreshCatalog() currently persists meta.skillCount from
the raw skills list before the post-processing that applies CATALOG_BLOCKLIST
and SLUG_CORRECTIONS, causing meta.skillCount to disagree with the filtered
result returned by the reader; update refreshCatalog() to apply the same
filtering/canonicalization (use CATALOG_BLOCKLIST and SLUG_CORRECTIONS) to the
skills array before computing/persisting meta.skillCount, or else compute
meta.skillCount from the final filtered array you intend to cache/return so the
persisted meta.skillCount equals the length of the filtered skills.
- Around line 292-307: The code currently logs raw child-process output from
execFileAsync (stdout/stderr) when invoking clawHubBin which can leak sensitive
URLs or tokens; change the logging in the block that calls execFileAsync so that
instead of this.log("info", `install stdout ${slug}: ${stdout.trim()}`) and
this.log("warn", `install stderr ${slug}: ${stderr.trim()}`) you produce a
sanitized, structured summary: log success/failure for the slug, include a
truncated length or redacted indicator (e.g., "<redacted-output>" or first N
chars only), remove or mask any URLs or tokens, and preserve the existing log
levels via this.log; keep exec errors intact but ensure any attached output is
sanitized before logging.
In `@apps/controller/src/services/skillhub/install-queue.ts`:
- Around line 255-295: When the executor rejects, first check and handle
cancelled installs before any retry/failure logic: if item.cancelled is true,
clear the cancelled flag, call item.onCancelled(), set item.status to
"cancelled" (or appropriate state), remove it from this.active, push it to
this.completed, call this.scheduleCleanup(item) and this.drain(), then return —
do this at the top of the (err: unknown) => { ... } handler so rate-limit
branches (parseRateLimitPauseMs/classifyError/retries/pauseQueue) never run for
cancelled jobs.
---
Duplicate comments:
In `@apps/controller/src/routes/skillhub-routes.ts`:
- Around line 191-193: The current code calls
container.skillhubService.queue.cancel(slug) then immediately
container.skillhubService.catalog.uninstallSkill(slug), which can delete files
while executeInstall() / npm install is still running; change this to detect an
active install and either (A) wait for the queue item to become inactive/settled
(use the queue API to poll or await the item’s completion) before calling
catalog.uninstallSkill(slug), or (B) immediately throw new HTTPException(409,
"Skill is currently installing; cancel requested and uninstall deferred") so
onCancelled can finish cleanup; ensure you reference and use
container.skillhubService.queue.cancel, the queue item status check,
executeInstall/onCancelled semantics, and throw HTTPException with status and
contextual message if choosing the 409 path.
---
Nitpick comments:
In `@apps/controller/tests/skillhub-service.test.ts`:
- Around line 366-381: The test should assert that SkillhubService canonicalizes
slugs before enqueueing: update the mock catalog to return ["alpha","beta"] from
getCuratedSlugsToEnqueue and also mock catalog.canonicalizeSlug to rewrite those
values (e.g., "alpha" -> "ALPHA", "beta" -> "BETA"), then call
SkillhubService.create(...) and start() and assert that installQueue.enqueue was
called with the canonicalized slugs (e.g., "ALPHA","BETA") and "managed"; also
apply the same canonicalization rewrite/assertion to the other test block around
lines 438-449 that covers enqueueInstall()/related behavior so the regression is
pinned.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 32c99af0-ffaf-4a1c-97d7-831a140cb201
📒 Files selected for processing (5)
apps/controller/src/routes/skillhub-routes.tsapps/controller/src/services/skillhub-service.tsapps/controller/src/services/skillhub/catalog-manager.tsapps/controller/src/services/skillhub/install-queue.tsapps/controller/tests/skillhub-service.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/controller/src/services/skillhub-service.ts
…ive skills Two fixes from CodeRabbit review: 1. scanDiskSlugs returns null on error instead of []. syncNow bails out entirely, preventing false 'all skills removed' reconciliation. 2. Switch disabled only during local mutation, not during queue activity. Users can toggle OFF to cancel a queued/downloading install.
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
apps/web/src/pages/skills.tsx (1)
267-279:⚠️ Potential issue | 🟡 MinorDeduplicate not-found toasts per attempt, not per slug.
shownErrorSlugsnever clears, so a second failed install of the same slug during the same page session stays silent. Key the set by attempt metadata if the queue exposes it, or remove the slug once it leaves the failed state.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/pages/skills.tsx` around lines 267 - 279, The current dedupe uses shownErrorSlugs (and shownErrorSlugs.current) which never clears, so repeated failed installs for the same slug in one session won't show a new toast; update the logic in the useEffect around data?.queue to deduplicate per install attempt instead of per slug by using an attempt-specific key (e.g., item.attemptId or item.createdAt) when available (use shownErrorAttempts instead of shownErrorSlugs and key by item.attemptId/item.createdAt), or if the queue has no attempt ID then remove the slug from the set when that item leaves the failed state (detect item.status !== "failed" and delete item.slug from shownErrorSlugs.current) before adding on new failures; adjust references to shownErrorSlugs, useEffect, data?.queue, item.status, item.slug, and toast.error accordingly.
🧹 Nitpick comments (1)
apps/web/src/pages/skills.tsx (1)
41-53: Share the queue-status source of truth.These literals are now maintained in multiple places in this file, and they also need to stay aligned with
ACTIVE_QUEUE_STATUSESinapps/web/src/hooks/use-community-catalog.ts:17-27. Extracting a sharedQueueStatus/active-status helper would keep polling and card state from drifting.Also applies to: 63-66, 255-259
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/pages/skills.tsx` around lines 41 - 53, The file repeats the same queue-status string literals (e.g., in the prop type for the Skills card and elsewhere) that must stay aligned with ACTIVE_QUEUE_STATUSES in use-community-catalog.ts; extract a single shared QueueStatus type (string union or enum) and an ACTIVE_QUEUE_STATUSES helper in a common module (or export the existing one from use-community-catalog.ts), replace the inline literals in the Skills component (props: queueStatus) and any other occurrences (e.g., card state checks and polling logic) to import and use the shared QueueStatus type and ACTIVE_QUEUE_STATUSES constant so all places reference the same source of truth.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/controller/src/services/skillhub/skill-dir-watcher.ts`:
- Around line 98-113: The watcher setup using watch(...) with recursive: true
can silently fail on Node.js 20.0–20.12 and the error handler on this.watcher
only logs errors without recovery; update project metadata to require a
compatible Node.js by adding "engines": { "node": ">=20.13" } to
apps/controller/package.json (or add clear documentation about the limitation),
and add watcher error recovery in skill-dir-watcher.ts: when this.watcher emits
"error" call this.watcher.close() (or clear this.watcher), log the failure via
this.log, and schedule a restart of the watcher (e.g., setTimeout to recreate
the watch using the same watch(...) call and reattach the "error" handler) so
scheduleSync and the watch lifecycle are restored after transient failures.
In `@apps/web/src/pages/skills.tsx`:
- Around line 160-175: The new visible strings "Uninstall" and "Installing…" are
not localized; update the JSX in the conditional that uses isInstalled,
isQueueActive, isMutating and handleToggle to call the translation helper (t)
for both labels — e.g. replace the literal "Uninstall" with
t('skills.uninstall') (or the appropriate key used in your app) and replace
"Installing…" with t('skills.installing') — ensure the component imports and
uses the same t function/namespace as other strings in this file so the button
label and fallback span render localized text.
---
Duplicate comments:
In `@apps/web/src/pages/skills.tsx`:
- Around line 267-279: The current dedupe uses shownErrorSlugs (and
shownErrorSlugs.current) which never clears, so repeated failed installs for the
same slug in one session won't show a new toast; update the logic in the
useEffect around data?.queue to deduplicate per install attempt instead of per
slug by using an attempt-specific key (e.g., item.attemptId or item.createdAt)
when available (use shownErrorAttempts instead of shownErrorSlugs and key by
item.attemptId/item.createdAt), or if the queue has no attempt ID then remove
the slug from the set when that item leaves the failed state (detect item.status
!== "failed" and delete item.slug from shownErrorSlugs.current) before adding on
new failures; adjust references to shownErrorSlugs, useEffect, data?.queue,
item.status, item.slug, and toast.error accordingly.
---
Nitpick comments:
In `@apps/web/src/pages/skills.tsx`:
- Around line 41-53: The file repeats the same queue-status string literals
(e.g., in the prop type for the Skills card and elsewhere) that must stay
aligned with ACTIVE_QUEUE_STATUSES in use-community-catalog.ts; extract a single
shared QueueStatus type (string union or enum) and an ACTIVE_QUEUE_STATUSES
helper in a common module (or export the existing one from
use-community-catalog.ts), replace the inline literals in the Skills component
(props: queueStatus) and any other occurrences (e.g., card state checks and
polling logic) to import and use the shared QueueStatus type and
ACTIVE_QUEUE_STATUSES constant so all places reference the same source of truth.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 516553fe-c7e2-4417-be62-19f134f06ff2
📒 Files selected for processing (3)
apps/controller/src/services/skillhub/skill-dir-watcher.tsapps/web/src/components/skills/community-skill-card.tsxapps/web/src/pages/skills.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/src/components/skills/community-skill-card.tsx
… cleanup failure - Add cancelInstall() to SkillhubService that canonicalizes slug before cancelling (consistent with enqueueInstall) - onCancelled throws when uninstallSkill returns ok:false instead of silently ignoring cleanup failure - Route uses cancelInstall() instead of queue.cancel() directly
|
/cr |
|
✅ CR topic created in Feishu topic group Refly CR. |
Summary
fs.watchwith debounce, syncs the ledger on every startup and on file changes.queuearray. Frontend shows queued/downloading state on skill cards with toast notifications.onCancelledcallback removes any files written by the late-finishing executor."curated"source type (all skills are now"managed"or"custom"). Legacy ledgers with"curated"are auto-migrated via Zod transform. Removed unusedopenclawCuratedSkillsDirpath..binsymlink crash (EINVAL onfs.cp). Added catalog blocklist for skills no longer available on ClawHub.Test plan
pnpm typecheckpassespnpm lintpassespnpm testpasses (15 files, 95 tests — 29 new)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Changes
"managed"and"custom"(legacy"curated"removed).