Skip to content

Thin-channel/thick-runner refactor: strangler-fig migration + auth/reliability hardening + MCP interface - #4

Merged
2233admin merged 34 commits into
mainfrom
refactor/thin-channel-thick-runner
Jul 1, 2026
Merged

Thin-channel/thick-runner refactor: strangler-fig migration + auth/reliability hardening + MCP interface#4
2233admin merged 34 commits into
mainfrom
refactor/thin-channel-thick-runner

Conversation

@2233admin

Copy link
Copy Markdown
Owner

Summary

Strangler-fig migration of the channel/runner architecture toward "thin channel, thick runner" (~100 lines per new source), plus everything built on top of it: real per-channel health probes, error-taxonomy-driven retries, CookieCloud auth sync, a Crawl4AI channel (CSS + LLM-fallback extraction), RSS source onboarding, a skill record->distill->CLI workflow, and an MCP interface exposing the system to agents.

29 commits, incremental -- each is independently green. No open PR existed for this branch before (push-trigger allowlist only covers main/develop/codex/**, so CI hadn't run on this work until now).

What's in it

Core refactor

  • api_channel gets a thick-contract fetch(); collect() becomes a thin delegator (kills ~50 lines of duplication), then the same pattern spreads to web_scraper/crawl4ai.
  • Encrypted per-source credential store (AuthManager) with unified bearer/api_key/basic header building; recovers from concurrent-store races on the unique constraint instead of 500ing.
  • Cascade-delete credentials when a source is deleted; CredentialCreate.key_name max_length now matches the DB column.

Reliability

  • Error taxonomy + idempotency hardening for retries; real Celery retry wired for retryable failures.
  • redbeat as the Celery Beat backend, with live schedule sync.
  • Two-tier liveness/readiness health probes per channel (cheap "driver present" check, deeper "target reachable" check only when config is available).
  • Instrumented previously-swallowed exception types; fixed an RSS timeout bug found in the process.

Auth

  • CookieCloud sync (cookie_jar table, domain-keyed, Fernet-encrypted) via PyCookieCloud -- the only place in the codebase that speaks the CookieCloud wire protocol. api_channel/web_scraper_channel gained auth.type: cookie.

Crawl4AI channel

  • JS-rendered pages via Crawl4AI's own managed browser (deliberately outside browser_pool -- its anti-detection browser management is the whole point of using it).
  • CSS-structured extraction (JsonCssExtractionStrategy) is the default, zero AI cost.
  • New in this PR: falls back to LLMExtractionStrategy when selectors isn't configured -- give an instruction describing what to pull instead, optional extraction_schema for schema-mode vs free-form block-mode. Reuses the same ModelProvider row the downstream AI-enrichment step already uses (no new credential UI). This is extraction (deciding what the items are), not enrichment (adding fields to existing records) -- the two pipeline stages don't overlap.

Sources

  • RSS onboarding: discover-feed (parses <link rel=alternate>, falls back to common paths) and import-opml (bulk import, new sources default disabled). Zero new dependencies.

Skills

  • Record -> distill -> replay workflow, opencli-skill CLI entry point, and a dock-chat tool for configuring the AI enrichment provider conversationally (list/update providers via chat tool-calling).
  • Auto-propose redistill after N consecutive failures; dismiss/rollback actions.

Live E2E proof

  • issue07: skill execute-loop verified against real Chrome and real Edge (not headless-only) -- surfaced a real bug (Edge's --remote-debugging-address=127.0.0.1 doesn't bind; needs localhost). Cookie persistence proven end-to-end (disconnect, reconnect, read back).

New in this PR: MCP interface

  • backend/mcp_server.py -- a thin stdio MCP server wrapping the REST API over HTTP. 7 tools covering the highest-frequency actions (list/create/test sources, discover_feed, trigger_task, get_task, list_records) -- not a 1:1 mirror of all ~56 REST endpoints. Runs as its own process, decoupled from the FastAPI/uvicorn lifecycle, so agents (Claude Code or otherwise) can drive collection without going through the UI or raw curl.
  • No new auth layer: the REST API itself has none today (api_key_enabled is read but never enforced by any dependency) -- this server inherits that trust boundary. Only point OPENCLI_ADMIN_API_URL at a host you trust.
  • New mcp optional dependency group + opencli-mcp console script.

Testing

767 passed, 9 skipped, cov ~88%. Everything auth/browser-touching was verified against real infrastructure, not just mocks:

  • Live E2E over real Chrome + real Edge (issue07).
  • CookieCloud sync tested against a real sync round-trip (store -> disconnect -> reconnect -> read back).
  • Crawl4AI channel tested against real crawl4ai + a live backend instance for the new LLM-fallback path (create/list/test source, discover_feed, list_records, get_task error path all exercised over real HTTP before this PR was opened).
  • MCP server's 7 tools driven end-to-end against a running backend instance before committing.

Why no earlier PR

The push-trigger allowlist in .github/workflows/ci.yml only covers main/develop/codex/** branches, and no PR was open, so CI never ran on this branch until now -- not a CI failure, just CI that never fired. This PR is what triggers the first real run.

2233admin added 30 commits July 1, 2026 14:23
…e endpoints

ApiChannel gains a fetch() override (auth resolved from AuthManager's
encrypted store, falling back to legacy env/inline config); collector.py
now routes every channel through run_channel() instead of gating on
capabilities.incremental, with FetchResult/RunResult carrying metadata so
opencli/skill's node_url/awaiting_confirm still reach PipelineResult.
New POST/GET /sources/{id}/credentials + DELETE .../{key_name} endpoints
and AuthManager.list_keys()/.delete() let a source migrate off plaintext
channel_config.auth. Frontend: ChannelConfigForm gets a CredentialField
widget wired to sourceId on the edit form.
…lient

fetch() read config.timeout but only applied it when building its own
one-shot client; in production ctx.http is always the runner's shared
client (fixed 30s), so the per-source timeout was silently dropped.
collect() wraps connection-refused/DNS-failure/etc into a clean
ChannelResult.fail; fetch() only caught TimeoutException/HTTPStatusError,
letting those bypass ChannelFetchError and the runner's retry/backoff
contract.
…elper

AuthManager.resolve_context() and ApiChannel._resolve_auth_headers() each
hand-rolled the same token/key/username+password convention and diverged
on empty credentials: resolve_context() sent a placeholder empty Basic
header, _resolve_auth_headers() fell back to legacy config instead. Both
now delegate to backend.auth.header_builder.build_auth_header(), which
never sends a placeholder credential.
AuthManager.store() did a select-then-insert with no upsert semantics;
two concurrent stores for the same (source_id, key_name) — e.g. a
double-click on the credential-save button — could both miss each
other's row and both attempt an INSERT, and the loser hit an unhandled
IntegrityError (500) instead of updating. Recovers by rolling back and
retrying as an UPDATE.
Pydantic allowed up to 100 chars but SourceCredential.key_name is
String(64); a 65-100 char value passed validation then hit an unhandled
DataError on Postgres (SQLite doesn't enforce VARCHAR length, so this was
invisible in the existing test suite).
source_credentials had no DB-level FK/cascade to data_sources (AuthManager
writes via a separate session, so a real FK would need cross-module
coordination). delete_source() now cleans up matching source_credentials
rows in the same transaction, so encrypted secrets don't outlive the
source that owned them.
…nmigrated channels

collector.py's routing generalization (every channel now goes through
run_channel()) added an unconditional DBCursorStore SELECT and an unused
RateLimitedClient/httpx.AsyncClient/TokenBucket construction on every
collect() call, even for channels whose fetch() is the default
collect()-bridging adapter that never touches cursor state or ctx.http
(opencli/skill/cli/web_scraper). Both are now skipped when the channel
hasn't actually migrated onto the thick contract; RSS's incremental +
rate-limited path is unaffected (its fetch() is a real override).
…plicated lines

collect() had drifted into a second, independently-maintained copy of
fetch()'s request/parse logic with no live production caller left (every
real collection now routes through fetch() via run_channel()) — a future
bugfix to one silently wouldn't reach the other. collect() is now a thin
FetchContext(config, params) wrapper that converts fetch()'s raise-based
ChannelFetchError contract back to its own return-based ChannelResult.fail,
so every existing collect()-level test passes unchanged.
…hinese labels

name was derived via formFieldName(label, 'credential'), which strips
non-ASCII characters — the basic-auth username/password fields' Chinese
labels ('用户名'/'密码') both collapsed to an empty slug, so both
password-type inputs rendered with the identical name attribute
(browser autofill/credential-manager could cross-fill them). Derives
the name from keyName instead, which is already unique and ASCII.
…s unknown'

listSourceCredentials() failures were swallowed silently, leaving the
placeholder indistinguishable from a confirmed-absent credential. A user
could see an 'unconfigured' field for a source that actually had a
working stored credential (the status check merely failed to load), and
overwrite it without realizing one existed.
…ping

collector.py injected plain cursor_pending/cursor_source_id keys into
every channel's metadata dict via {**run_result.metadata, ...}, silently
overwriting any channel-emitted key with the same name (no current
channel collides, but nothing prevented it). Renamed to
__cursor_pending__/__cursor_source_id__ so a channel's own metadata can
never collide with the runner's cursor bookkeeping.
…policy

No current channel is both paginated and metadata-bearing, so there's no
real use case to derive a different merge policy from yet — document the
existing behavior as intentional rather than leaving it looking like an
unreviewed oversight.
run_channel()'s pagination loop had no visibility when chan.fetch() failed
partway through: already-accumulated items from prior pages were silently
discarded even though the cursor may have already advanced past them.
Exception propagation and cursor-commit timing are unchanged (that's a
separate, deliberate design question the team already flagged as needing
its own decision) — this only makes the loss observable via a warning log.
frontend/src/components/ChannelConfigForm.tsx and
frontend/src/pages/SourcesPage.tsx already carried mixed CRLF/LF line
endings before this branch touched them; editing any line made git's
line-based diff show most of the file as changed (git diff -w on this
branch's earlier commits shows the real content delta is small: 154 and
1 lines respectively). Forcing LF stops the same class of noise on future
edits — this does not rewrite the already-pushed history that caused it.
…eout forward

- ChannelResult gains error_type (failing exception class name) so a future
  retryable-vs-permanent error taxonomy has real data to build on, instead of
  re-parsing free-text error strings.
- All 6 channels' collect()/fetch() catch-all except blocks now tag
  error_type; api_channel derives it from ChannelFetchError.__cause__.
- pipeline.py surfaces error_type in both collect-failure and (newly) the
  previously-silent sink-write-failure event, and step2-3 sink exceptions
  now emit a "store" TaskRunEvent (was log-only before).
- rss_channel.py fetch(): ctx.http.get() now forwards the configured
  per-source timeout (was silently using the shared client's hardcoded 30s
  default), matching the GOAL-3 PR1 fix already applied to api_channel.

Non-destructive: no control-flow change, only new observability fields.
616 passed, 7 skipped, zero regressions.
… (GOAL-4 PR-A)

- backend/pipeline/error_taxonomy.py: is_retryable()/is_retryable_http_status()
  classify exception-type/status-code as transient (retry helps) vs
  deterministic (retry reproduces the same failure). Used by pipeline.py in
  PR-B to decide whether to re-raise for celery's retry policy.
- Verified collect->persist idempotency: sequential re-runs of the same batch
  already dedupe correctly via content_hash (existing test coverage).
- Found and closed a real gap: storer.store_records()'s existence-check-then-
  insert is not atomic, so a concurrent writer landing the same content_hash
  between the check and the flush raises IntegrityError and loses the whole
  batch. Now catches it, rolls back, rechecks against the DB, and inserts
  survivors individually so one collision doesn't discard unrelated new
  records. This becomes reachable once PR-B makes celery retries real
  (a retry racing the original attempt), not just theoretical today.

639 passed, 7 skipped, zero regressions.
…(GOAL-4 PR-B)

- pipeline.py: both step1/collect and step2-3/sink except blocks now check
  error_taxonomy.is_retryable() (via effective_error_type(), which unwraps
  ChannelFetchError.__cause__) and re-raise instead of swallowing into a
  failed PipelineResult when the fault is transient. The collect()-only
  ChannelResult.fail(error_type=...) path (no live exception object) raises
  ChannelFetchError so it propagates the same way. Permanent/unclassified
  errors are unchanged: still swallowed, matching every existing test.
- runner.py: run_pipeline()'s call is now wrapped in try/except. A re-raised
  retryable exception still gets recorded as a failed TaskRun/CollectionTask
  (Phase 4's finalize block never runs for a raised exception, so this closes
  what would otherwise be a run stuck at status="running" for the whole
  celery backoff window) before propagating further.
- worker/tasks.py: run_collection gains autoretry_for=(Exception,). Broad on
  purpose and safe here — pipeline.py already filtered to only retryable
  faults before anything reaches this boundary, so this isn't duplicating
  that classification, just consuming its result. max_retries=3/
  default_retry_delay=60 were already declared but dead (nothing ever raised
  through to the task function); this is what actually activates them.

Investigated during PR-A/PR-B: run_scheduled_collection (the cron-fired
sibling task) has no autoretry_for and is out of this PR's declared scope —
its failures now surface as a raised exception instead of a returned error
dict (more consistent with run_collection, arguably better observability)
but still don't retry. Not fixed here; GOAL-4 PR-B only covers run_collection.

645 passed, 7 skipped, zero regressions.
… sync (GOAL-4 PR-C)

Discovered while scoping this PR: celery beat was never actually wired up at
all. worker/beat_schedule.py::build_beat_schedule() existed but had zero
callers, was never assigned to celery_app.conf.beat_schedule, and no beat
process definition referenced it. In task_executor="celery" mode, scheduled
(cron) collection never fired — only manual/webhook dispatch worked. This
isn't a "redbeat swap", there was nothing running to swap.

- Added celery-redbeat dependency; celery_app.py sets
  beat_scheduler="redbeat.RedBeatScheduler" + redbeat_redis_url.
- backend/worker/redbeat_sync.py: sync_entry()/remove_entry() write/delete
  redis-backed redbeat entries directly, so redbeat's per-tick redis read
  reflects a schedule change immediately — no beat restart needed (the
  problem the old dead build_beat_schedule() would have had even if it were
  wired up: celery's static beat_schedule dict is only read at beat startup).
  populate_all() bulk-syncs all enabled schedules once, for drift from
  anything that changed the DB without going through the CRUD endpoints.
- schedule_service.py's create/update/delete_schedule call the sync/remove
  after each write, gated on task_executor == "celery" (mirrors the existing
  browser_pool use_redis gate) so local/dev mode never touches redis.
  Failures are logged and swallowed — the DB write is the source of truth,
  redbeat sync is best-effort, a redis hiccup must not 500 the endpoint.
- main.py calls populate_all() at startup in celery mode (parallel to the
  existing local-scheduler startup branch).
- worker/beat_schedule.py stripped down to just parse_cron_expression (the
  one piece redbeat_sync.py actually reuses); the dead build_beat_schedule()
  and its DB-loader are removed.
- backend/scheduler.py (the local in-process polling loop) is UNCHANGED and
  kept: it's still the only cron mechanism for task_executor=="local", and
  local mode has no reason to require redis. The two are mutually exclusive
  via task_executor, not competing.

Caught and fixed a bug before it shipped: populate_all() first drafted with
the same asyncio.new_event_loop().run_until_complete() pattern
worker/tasks.py uses for celery's sync task threads — wrong here, since its
only caller (main.py's async lifespan) is already inside a running loop and
that pattern raises "Cannot run the event loop while another loop is
running". Made populate_all() async instead.

No real redis in this environment (no fixture, no local server) — new tests
mock the redbeat library boundary, same approach already used elsewhere in
this codebase for other external I/O.

657 passed, 7 skipped, zero regressions.
…li/skill (GOAL-4 PR-D)

- web_scraper_channel.py: collect() is now a thin wrapper delegating to a new
  fetch(), same pattern as GOAL-3 PR8's api_channel migration. Goes through
  ctx.http (the runner's RateLimitedClient) when present, so a scraper
  hitting a site's rate limit now backs off and retries instead of failing
  outright. Headers can't be baked into the shared client's constructor (it's
  reused across sources), so the shared-client path sends them per-request;
  the owns-client path keeps constructor-time headers to match existing
  collect() test mocks exactly. All 16 pre-existing test_collect_* assertions
  pass unchanged (migration DoD); 3 new tests cover the fetch()/ctx.http path.

- opencli: evaluated, not migrated. It's subprocess+browser-pool driven, not
  an HTTP client — fetch() would never read ctx.http, so overriding it would
  just reimplement what the default collect()-bridging adapter already does
  for free, no rate-limit/retry benefit gained. Capabilities also declare no
  auth/cursor/pagination to pick up either.

- cli/skill: evaluated, no standalone retry wrapper added. Realized this
  during evaluation: PR-B's celery-level retry already covers them —
  pipeline.py's is_retryable() check reads ChannelResult.error_type, which
  every channel populates on failure since PR0's instrumentation, not just
  fetch()-migrated ones. A wrapper retrying inside a single collect() call
  would just duplicate what celery's autoretry_for already does by re-calling
  collect() on the next task attempt.

660 passed, 7 skipped, zero regressions.
…readiness (GOAL-4 PR-E)

health_check() widened from () -> bool to (config=None, source_id=None) ->
bool across AbstractChannel and all overrides — needed real per-source data
to probe anything beyond "is a binary on PATH". source_service.py's
test_source_connectivity now passes source.channel_config/source.id through.
Backward compatible: both params default to None, so calling with zero args
(existing tests) is unchanged.

- api_channel: real HEAD (falling back to GET on 404/405) against
  base_url+endpoint, with real auth headers via the existing
  _resolve_auth_headers (encrypted store when source_id migrated, legacy
  inline config otherwise) — not just a bare unauthenticated ping, which
  would report a 401-gated API as unreachable even when it's fine.
- web_scraper: two-tier — the lxml parser ("driver") must be usable at all
  (cheap, no network), then the configured url must actually be reachable
  (HEAD falling back to GET).
- opencli: two-tier — binary-on-PATH liveness (existing check) always runs
  first, then a deep readiness probe hits {cdp_endpoint}/json/version through
  a real browser_pool.acquire()/release() (held only for the probe's
  duration, not kept open). Skipped for agent mode (dispatches to a remote
  node — a different health concern) and bridge mode (no local endpoint to
  hit); pool-not-initialized (e.g. a channel constructed standalone outside
  the app lifecycle) falls back to the binary check.
- cli: signature widened for uniformity only, behaviour unchanged (still
  binary-checked per-collect, as already documented).

Explicitly NOT done, per GOAL-4: wiring health_check into task dispatch as a
skip-if-unhealthy gate. That's a feature (changes what runs), not a fix
(this PR is only about health_check reporting the truth) — out of scope.

675 passed, 7 skipped, zero regressions.
…rollback, last_failing_trace

Self-healing loop for the skill subsystem: skill_channel now checks
terminal_conditions against step text, persists the last failing
journey_trace_v1 for humans to review, and flags (never runs) a
redistill proposal after a configurable fail streak. Dock/API gets
dismiss-correction (reset the streak, false alarm) and rollback
(revert to a prior distilled version) endpoints. auto_confirm can no
longer bypass a matched red line.
…ages

Adds channel_type="skill" so a distilled skill can be scheduled as a
regular data source. backend/skills/record.py drives a live browser
recording session (start/stop) that feeds the existing distill path;
skill_record.py exposes it over the API. backend/cli.py is a thin
httpx client over the same REST API the dock uses (list/show/record/
redistill/dismiss/rollback) so the capability travels outside one
React admin panel. Frontend gets SkillsPage/SkillDetailPage wired
into nav + routes, plus a dismiss-correction action in the agent dock.
Adds list_providers (read) + update_provider (write, proposal/confirm
gated like update_schedule) so the natural-language dock can switch
the default model or toggle a provider on/off, instead of requiring
a separate admin screen. Reuses the existing ModelProvider model and
toolcall parsing helpers unchanged.
No prior test file touched backend/api/v1/chat.py at all — these were
landing untested. Exercises _run_read_tool/_build_proposal directly
(the LLM tool-calling round trip itself has no mocking precedent in
this repo, so out of scope) plus a real POST /chat/confirm through the
ASGI test client for the write-applies-to-DB path.
…ookie persistence proof

issue07's live E2E had never actually been run against a real browser in this
repo (always environment-gated to skip). Ran it for real against both a
launched Chrome and Edge over CDP -- both pass, confirming connect_over_cdp
is genuinely Chromium-family-agnostic, not just Chrome in practice.

Adds test_cookie_persistence_live.py: sets a cookie, detaches
(connect_over_cdp browser.close() does not kill the real browser), reattaches
fresh, and asserts the cookie survived -- the same reuse pattern two separate
task runs hit against the same browser_pool endpoint in production.

TESTING.md: documents the Edge repro steps and a real gotcha found running it
--remote-debugging-address=127.0.0.1 does not stop Edge from binding ::1 only;
Chrome does not have this quirk.
…raper cookie auth

api_channel and web_scraper_channel had no way to reach content behind a
login -- auth_kind only covered none/bearer/api_key/basic. Adds auth.type
"cookie": AuthManager.resolve_cookies(domain) serves a real synced browser
session instead.

backend/auth/cookiecloud_sync.py is the only module that speaks CookieCloud's
own protocol (PyCookieCloud client does the HTTP GET + AES decrypt); it
immediately normalizes into our own Fernet-encrypted cookie_jar table, keyed
by (domain, cookie_name) -- not source_id, since one sync yields a whole
browser's cookie jar across many domains at once, not a single source's
secret. POST /api/v1/cookies/sync triggers a sync manually (v1: no scheduled
re-sync -- a stale cookie just gets re-synced on demand, not worth a celery
job yet).

skill_channel/opencli_channel are untouched: they already reuse a real login
via session_affinity + connect_over_cdp to an already-running browser, so
this only fills the gap for the channels that had nothing.
…i-detection

New channel_type "crawl4ai": CSS-structured extraction only
(JsonCssExtractionStrategy) -- no LLMExtractionStrategy, AI enrichment stays
the pipeline's downstream job like every other channel.

Deliberately does not attach to backend.browser_pool like skill/opencli
(session_affinity + connect_over_cdp to an already-running browser) --
Crawl4AI manages its own browser with enable_stealth/magic mode, which is the
entire reason to reach for it over web_scraper_channel; routing it through an
externally-attached browser would throw that capability away for nothing.

Reuses the CookieCloud plumbing from the previous commit for auth.type
"cookie" (AuthManager.resolve_cookies already returns Playwright-shaped
dicts, which is exactly what BrowserConfig.cookies wants).

Frontend: crawl4ai config form (url/list_selector/selectors/wait_for/cookie
toggle), source type picker entry. Also surfaced the cookie auth option on
ApiConfig and WebScraperConfig, which had the backend support (prior commit)
but no UI to turn it on.
POST /api/v1/sources/discover-feed {url}: parses <link rel="alternate"> feed
tags off the page, falls back to probing common feed paths (/feed, /rss.xml,
...) when a site declares none. Returns every candidate found, never
auto-picks "the main one" -- ambiguous on purpose, a human picks.

POST /api/v1/sources/import-opml: bulk-creates channel_type="rss" sources
from an OPML export. Lands disabled (a human reviews and enables, not an
auto-live firehose from one file), dedups against already-stored feed_urls
and duplicates within the same file.

Zero new dependencies -- feedparser/beautifulsoup4/lxml were already there.
Setup-time convenience only; neither function is ever called from a
scheduled collect().
Thin stdio MCP server (backend/mcp_server.py) wrapping the existing
REST API over HTTP -- 7 tools covering the highest-frequency actions
(list/create/test sources, discover_feed, trigger_task, get_task,
list_records), not a 1:1 mirror of all ~56 endpoints. Runs as its own
process, decoupled from the FastAPI/uvicorn lifecycle.

No new auth: the REST API itself has none today (api_key_enabled is
read but never enforced), so this inherits that trust boundary --
point OPENCLI_ADMIN_API_URL at a trusted host only.

New `mcp` optional dependency group + `opencli-mcp` console script.
Live-verified against a running instance (create/list/test source,
discover_feed, list_records, get_task error path) before committing.
Two extraction paths now: CSS-structured (JsonCssExtractionStrategy)
when 'selectors' is configured -- unchanged, no AI cost -- falling
back to LLMExtractionStrategy when it isn't, for targeted sources
where writing a CSS selector up front isn't practical. Config gains
'instruction' (required to trigger the fallback), optional
'extraction_schema' (schema-mode vs free-form block-mode), and
optional 'provider_id'.

This is extraction (deciding what the items are), not the pipeline's
separate downstream AI enrichment step -- the two don't overlap.
LLM credentials reuse the same ModelProvider row the enrichment step
and AIAgent already use; no provider_id -> first enabled provider,
same autonomous-default convention as pipeline/runner.py.

9 new unit tests (LLM strategy construction, schema vs block mode,
provider-type -> litellm prefix mapping, missing instruction/provider
error paths), reusing the AsyncSessionLocal-patch pattern from
test_manager.py. Full suite: 767 passed.
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@2233admin, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a208cd96-e6a5-42d1-adfe-ea521fce3177

📥 Commits

Reviewing files that changed from the base of the PR and between 563f423 and 75474ee.

📒 Files selected for processing (13)
  • backend/api/v1/skill_record.py
  • backend/auth/cookiecloud_sync.py
  • backend/auth/manager.py
  • backend/channels/base.py
  • backend/channels/crawl4ai_channel.py
  • backend/channels/web_scraper_channel.py
  • backend/mcp_server.py
  • backend/pipeline/error_taxonomy.py
  • backend/pipeline/storer.py
  • backend/skills/record.py
  • backend/worker/redbeat_sync.py
  • tests/skills/test_record_live.py
  • tests/unit/worker/test_redbeat_sync.py
📝 Walkthrough

Walkthrough

This PR adds an end-to-end browser-skill subsystem (record→distill→correct→execute), refactors channels into a thick fetch/collect contract with retryable error classification, introduces CookieCloud sync and encrypted credential/cookie storage, adds RSS feed discovery/OPML import, chat-based AI provider management tools, a Crawl4AI channel, and switches Celery Beat scheduling to RedBeat.

Changes

Skills Subsystem

Layer / File(s) Summary
Skill model & migrations
backend/models/skill.py, backend/migrations/versions/s9n0o1p2q3r4_*.py
Adds last_failing_trace JSON field to Skill.
Recording capture
backend/skills/record.py, tests/skills/test_record*.py
Captures browser DOM interactions into journey_trace_v1 via Playwright bindings.
Record/distill API
backend/api/v1/skill_record.py, backend/api/v1/__init__.py
Adds start/stop/distill endpoints wired into the v1 router.
Correction & rollback
backend/skills/correction.py, tests/skills/test_correction.py
Adds rollback_correction and maybe_propose_correction for consecutive-failure detection.
Risk gating & self-eval
backend/skills/risk.py, backend/skills/trace.py, tests/skills/test_risk.py
Red-line matches now always block, and terminal condition grounding is enforced.
Skill channel wiring
backend/channels/skill_channel.py, tests/skills/test_skill_channel.py
Persists failing traces and proposes corrections during collect().
Skills detail API
backend/api/v1/skills.py, tests/integration/test_skills_api.py
Adds detail, dismiss-correction, rollback endpoints; extends redistill fallback.
CLI & MCP tooling
backend/cli.py, backend/mcp_server.py
Adds a skills CLI client and MCP tool server over the REST API.
Frontend skills UI
frontend/src/pages/Skills*.tsx, frontend/src/App.tsx, frontend/src/components/Layout.tsx, frontend/src/labs/topology/AgentDock.tsx
Adds Skills list/detail pages, recording wizard, and dismiss-correction workflow.

Channels Thick Contract

Layer / File(s) Summary
Contract & taxonomy
backend/channels/base.py, backend/pipeline/error_taxonomy.py
Adds error_type, FetchContext.source_id, FetchResult.metadata, and retryable/permanent classification.
Runner/pipeline/storer
backend/pipeline/{channel_runner,collector,pipeline,runner,storer}.py
Introduces RunResult, reworks cursor metadata keys, re-raises retryable errors, hardens storer against races.
Channel implementations
backend/channels/{api,web_scraper,cli,opencli,rss}_channel.py
Migrates channels to fetch()/ChannelFetchError, adds stored-credential/cookie auth, richer health_check.
Crawl4AI channel
backend/channels/crawl4ai_channel.py, backend/channels/registry.py
New JS-rendering channel with CSS/LLM extraction and provider resolution.
Frontend channel config
frontend/src/components/ChannelConfigForm.tsx, frontend/src/pages/SourcesPage.tsx
Adds Crawl4AI/skill config UI, cookie auth, and credential fields.

Auth, CookieCloud & Onboarding

Layer / File(s) Summary
AuthManager cookies/credentials
backend/auth/{manager,header_builder}.py
Adds concurrency-safe cookie/credential upsert and consolidated header building.
CookieCloud sync
backend/auth/cookiecloud_sync.py, backend/models/cookie_jar.py, backend/api/v1/cookies.py
Adds sync adapter, cookie_jar table, and manual sync endpoint.
Credential store API
backend/api/v1/sources.py, backend/schemas/credential.py, frontend/src/api/endpoints.ts
Adds per-source encrypted credential CRUD endpoints.
Feed discovery & OPML
backend/services/source_service.py
Adds discover_feeds, parse_opml, bulk_import_rss.

Chat Provider Management

Layer / File(s) Summary
Provider tools
backend/api/v1/chat.py
Adds list_providers/update_provider tools and confirm handling.

Celery RedBeat

Layer / File(s) Summary
RedBeat sync
backend/worker/{redbeat_sync,celery_app,beat_schedule,tasks}.py, backend/services/schedule_service.py
Syncs schedule entries with Redis via RedBeat and adds task autoretry.

Estimated code review effort: 5 (Critical) | ~150 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant SkillsPage
  participant RecordAPI as skill_record API
  participant RecordSession
  participant DistillAPI as distill endpoint
  participant DB as Skill table

  User->>SkillsPage: start recording
  SkillsPage->>RecordAPI: POST /skills/record/start
  RecordAPI->>RecordSession: start_recording(cdp_endpoint)
  RecordSession-->>RecordAPI: session_id
  RecordAPI-->>SkillsPage: session_id, cdp_endpoint
  User->>SkillsPage: stop recording
  SkillsPage->>RecordAPI: POST /skills/record/{id}/stop
  RecordAPI->>RecordSession: stop(status)
  RecordSession-->>RecordAPI: journey_trace_v1
  RecordAPI-->>SkillsPage: trace
  SkillsPage->>DistillAPI: POST /skills/distill(trace, domain, capability)
  DistillAPI->>DB: create Skill(version=1, status=draft)
  DB-->>DistillAPI: skill
  DistillAPI-->>SkillsPage: skill id/version
Loading
sequenceDiagram
  participant Channel as Channel.collect()
  participant Runner as run_channel
  participant Fetch as chan.fetch()
  participant Pipeline
  participant ErrorTaxonomy as error_taxonomy

  Pipeline->>Runner: run_channel(chan, ctx)
  Runner->>Fetch: fetch(ctx)
  Fetch-->>Runner: FetchResult(items, metadata) or raises ChannelFetchError
  Runner-->>Pipeline: RunResult(items, metadata)
  Pipeline->>ErrorTaxonomy: effective_error_type(exc)
  ErrorTaxonomy-->>Pipeline: error_type
  Pipeline->>Pipeline: is_retryable(error_type)?
  alt retryable
    Pipeline-->>Pipeline: re-raise for Celery autoretry
  else permanent
    Pipeline-->>Pipeline: return failed PipelineResult
  end
Loading

Poem

A rabbit hops through skills and traces,
Recording clicks in browser places,
CookieCloud jars keep secrets tight,
RedBeat ticks the schedules right,
Channels fetch, and errors retry —
Thump thump, this warren's grown so spry! 🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly reflects the main changes: thick-runner migration, auth/reliability hardening, and the new MCP interface.
Description check ✅ Passed The description is directly aligned with the implemented refactor, auth, reliability, skills, Crawl4AI, RSS, and MCP work.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces several major features, including a human-driven recording leg for browser skill capture, CookieCloud sync for authenticated scraping, a Crawl4AI channel, dynamic Celery schedule syncing via Redbeat, and an MCP server for agent tools. It also enhances error taxonomy and concurrency handling. The code review feedback highlights critical improvements: utilizing nested transactions (begin_nested()) in storer.py to prevent batch insert rollbacks, cleaning up stale recording sessions to avoid browser slot leaks, handling network errors in the MCP server client, resolving cookies/headers during channel health checks, filtering out disabled data sources in the Redbeat startup population, and batching cookie sync database commits to reduce transaction overhead.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread backend/pipeline/storer.py
Comment thread backend/api/v1/skill_record.py
Comment thread backend/mcp_server.py Outdated
Comment thread backend/channels/crawl4ai_channel.py
Comment thread backend/channels/web_scraper_channel.py
Comment thread backend/worker/redbeat_sync.py Outdated
Comment thread backend/auth/cookiecloud_sync.py Outdated
- storer.py: per-record retry now uses session.begin_nested() so a
  failed insert only rolls back its own SAVEPOINT, not every earlier
  survivor's flush in the same recovery loop (session.rollback() was
  undoing all of them, not just the failing one).
- skill_record.py: /record/start clears stale _SESSIONS entries (page
  + pool slot) before acquiring a new one -- a session whose /stop was
  never called would otherwise hold its browser_pool slot forever.
- mcp_server.py: _request wraps the call in try/except httpx.HTTPError
  so a backend that's down or times out returns a structured error
  instead of crashing the MCP process; non-JSON 4xx/5xx bodies no
  longer need raise_for_status() to surface.
- crawl4ai_channel.py / web_scraper_channel.py: health_check now
  resolves cookies (crawl4ai) and cookies+headers (web_scraper) the
  same way fetch() already does, so authenticated sources' health
  checks stop false-failing.
- redbeat_sync.py: populate_all joins DataSource and requires it
  enabled too, so a disabled source's schedule isn't pushed to redis
  at startup.
- auth/manager.py + cookiecloud_sync.py: store_cookie takes an
  optional session param; sync_from_cookiecloud now opens one session
  for the whole batch (can be hundreds of cookies) and commits once,
  instead of a session + commit per cookie.

Full suite: 767 passed, 9 skipped, no regressions.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
backend/api/v1/skills.py (1)

175-187: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Forward the redistill provider override.

body["provider"] is currently ignored, so callers cannot choose the stronger provider that the correction flow expects /redistill to support.

Proposed fix
     body = body or {}
+    provider = body.get("provider")
     traces = body.get("trace") or body.get("traces") or skill.last_failing_trace
     if not traces:
         raise HTTPException(
@@
     try:
-        skill = await correction.re_distill(db, skill, traces)
+        skill = await correction.re_distill(db, skill, traces, provider=provider)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/v1/skills.py` around lines 175 - 187, Forward the provider
override through the redistill flow: in the skills redistill handler,
`body["provider"]` is currently ignored before calling `correction.re_distill`,
so update the request handling to read the provider from `body` and pass it
along to `re_distill` (or into whatever config object it uses). Make sure the
change is applied in the redistill endpoint logic around `skill`, `traces`, and
`correction.re_distill` so callers can select the stronger provider expected by
`/redistill`.
frontend/src/pages/SourcesPage.tsx (1)

652-679: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

sourceTarget() has no crawl4ai branch.

Every other URL-bearing channel (rss, api, web_scraper) has a branch surfacing its target URL; crawl4ai sources (which also store their page under config.url) fall through to the generic source.description || '未配置目标', so node cards/inspectors won't show the actual crawl target.

🩹 Suggested fix
   if (source.channel_type === 'web_scraper') {
     return String(config.url ?? config.start_url ?? config.startUrl ?? CHANNEL_META.web_scraper.hint)
   }
+  if (source.channel_type === 'crawl4ai') {
+    return String(config.url ?? CHANNEL_META.crawl4ai.hint)
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/pages/SourcesPage.tsx` around lines 652 - 679, Add a dedicated
crawl4ai case in sourceTarget() so sources with channel_type equal to crawl4ai
surface their actual target URL from config.url, similar to the
rss/api/web_scraper branches. Use the existing sourceTarget function and
CHANNEL_META fallback pattern so crawl4ai does not fall through to
source.description || '未配置目标' and node cards/inspectors show the real crawl
target.
🟠 Major comments (20)
backend/api/v1/chat.py-123-123 (1)

123-123: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Don’t send provider base_url to the chat model.

list_providers results are fed back into the LLM tool loop, so returning base_url can expose internal model endpoints. The agent only needs id, name, provider_type, default_model, and enabled to propose provider changes.

Proposed fix
-            "description": "列出所有模型提供商 (返回 id / name / provider_type / default_model / base_url / enabled)。AI 富化阶段用哪个模型由 provider 决定。只读, 立即执行。",
+            "description": "列出所有模型提供商 (返回 id / name / provider_type / default_model / enabled)。AI 富化阶段用哪个模型由 provider 决定。只读, 立即执行。",
             {
                 "id": p.id, "name": p.name, "provider_type": p.provider_type,
-                "default_model": p.default_model, "base_url": p.base_url, "enabled": p.enabled,
+                "default_model": p.default_model, "enabled": p.enabled,
             }

Also applies to: 225-231

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/v1/chat.py` at line 123, The list_providers tool schema currently
exposes base_url in the description/returned fields, which should not be sent
back into the chat model. Update the tool definition in chat.py for
list_providers so it only advertises and returns id, name, provider_type,
default_model, and enabled, and remove base_url from any related descriptions or
response shaping used in the tool loop.
backend/api/v1/chat.py-293-300 (1)

293-300: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate update_provider inputs before building or applying the proposal.

bool(args["enabled"]) will treat strings like "false" as True, and /chat/confirm applies proposal.args directly, so invalid default_model values can still be persisted there. Validate enabled as a real boolean and default_model as a non-empty string within the column limit in both paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/v1/chat.py` around lines 293 - 300, Validate the
`update_provider` proposal inputs before populating `out_args` and before
`/chat/confirm` applies `proposal.args` directly. In `update_provider`, treat
`enabled` as a real boolean instead of coercing with `bool(...)`, and reject
invalid `default_model` values unless they are non-empty strings within the
allowed column limit. Apply the same validation in the confirm path that
persists `proposal.args`, using the `update_provider` and `/chat/confirm`
handling to ensure only valid `enabled` and `default_model` values can be saved.
backend/services/schedule_service.py-53-53 (1)

53-53: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Defer RedBeat side effects until the DB commit succeeds.

These calls run after flush() but before the request-level commit, so any later rollback leaves Redis/RedBeat inconsistent with the DB. Move sync/remove to an after-commit hook, outbox job, or router layer after await db.commit().

Also applies to: 65-65, 73-87

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/schedule_service.py` at line 53, RedBeat sync/remove is
happening in schedule_service before the transaction is actually committed,
which can leave Redis inconsistent if the DB later rolls back. Move the side
effects in the schedule_service flow (the _sync_redbeat call and the
corresponding remove path around the related schedule operations) to an
after-commit hook, outbox job, or a caller/router step that runs only after
await db.commit(). Use the existing _sync_redbeat helper and the schedule
create/update/delete methods as the lookup points when refactoring.
backend/worker/redbeat_sync.py-61-88 (1)

61-88: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reconcile disabled schedules during startup populate.

Line 78 only loads enabled rows, so a schedule disabled by migration/direct DB edit can leave its old RedBeat entry firing forever. Load all schedules and let sync_entry() remove disabled entries, or add explicit stale-key pruning.

Suggested minimal fix
-            result = await session.execute(select(CronSchedule).where(CronSchedule.enabled.is_(True)))
+            result = await session.execute(select(CronSchedule))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/worker/redbeat_sync.py` around lines 61 - 88, The startup bulk sync
in populate_all only queries enabled CronSchedule rows, so stale RedBeat entries
for newly-disabled schedules are never cleaned up. Update populate_all to
include all schedules (or add explicit stale-key pruning) and rely on sync_entry
to remove disabled entries; keep the existing load-and-sync flow in
redbeat_sync.populate_all and preserve the per-schedule exception handling.
backend/services/source_service.py-102-145 (1)

102-145: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

SSRF: server-side fetch of an unrestricted, caller-supplied URL.

discover_feeds issues an httpx GET (with redirects) directly against url, then — if no <link> candidates are found — probes six additional fixed paths on the same resolved host. There's no scheme allowlist (http/https only) and no check against private/reserved IP ranges (loopback, link-local incl. cloud metadata 169.254.169.254, RFC1918), so this endpoint can be used to reach internal services. Because both the homepage's declared <link> candidates and the probe results (URL + content-type match) are reflected back in the response, this also functions as a limited internal-network reconnaissance oracle.

follow_redirects=True makes a naive pre-check (validate url before calling) insufficient — the redirect target must be checked too (or redirects disabled and handled manually).

💡 Mitigation approach
+import ipaddress
+import socket
+
+def _is_safe_host(hostname: str) -> bool:
+    try:
+        addrs = socket.getaddrinfo(hostname, None)
+    except socket.gaierror:
+        return False
+    return all(not ipaddress.ip_address(a[4][0]).is_private for a in addrs)
+
 async def discover_feeds(url: str) -> list[dict[str, Any]]:
+    parsed = urlparse(url)
+    if parsed.scheme not in ("http", "https") or not _is_safe_host(parsed.hostname or ""):
+        return []
     ...

Also validate the final URL after each redirect hop (e.g. via an httpx event hook), since follow_redirects=True can otherwise bypass a one-time check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/source_service.py` around lines 102 - 145, The SSRF issue is
in discover_feeds, which fetches a caller-supplied URL and follows redirects
without any destination safety checks. Add strict allowlisting for http/https
and block private/reserved IP ranges for both the initial URL and any redirect
target, or disable follow_redirects and validate each hop before requesting it.
Apply the same validation to the fallback probe_url requests so the feed
discovery and probe logic cannot reach internal or metadata endpoints.
backend/services/source_service.py-148-172 (1)

148-172: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use defusedxml for OPML uploads. import_opml() passes untrusted file content straight into parse_opml(), and xml.etree.ElementTree.fromstring() still leaves entity-expansion DoS exposure here. Add defusedxml and map DefusedXmlException into the existing 400 path, or enforce a hard size/depth limit if you want to avoid the dependency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/source_service.py` around lines 148 - 172, `parse_opml()` is
parsing untrusted OPML with `xml.etree.ElementTree.fromstring`, which leaves
entity-expansion exposure in `import_opml()`. Update `parse_opml` in
`source_service` to use `defusedxml` for XML parsing and catch
`DefusedXmlException` alongside the existing parse error path so malformed or
unsafe uploads still map to the same 400 response. Keep the current
`import_opml()` flow and preserve the `ValueError` behavior for callers.
backend/api/v1/cookies.py-22-28 (1)

22-28: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Gate backend/api/v1/cookies.py behind an admin dependency. backend/api/v1/__init__.py and backend/main.py mount this router without any auth/authz guard, so /api/v1/cookies/sync accepts an arbitrary url and forwards it to PyCookieCloud via sync_from_cookiecloud, leaving an SSRF-capable admin action publicly reachable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/v1/cookies.py` around lines 22 - 28, The /sync endpoint in
cookies.py is currently public and allows arbitrary CookieCloud URLs to be
forwarded through sync_from_cookiecloud, creating an SSRF-capable admin action.
Add the existing admin/authz dependency to the cookies router or directly to
sync_cookies so the route is only accessible to admins, and ensure the router
mounting in api/v1/__init__.py and main.py does not expose it without that
guard.
backend/auth/cookiecloud_sync.py-25-34 (1)

25-34: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the CookieCloud fetch with a timeout
PyCookieCloud doesn’t expose a timeout here, so asyncio.to_thread(_fetch_decrypted, ...) can still block indefinitely if the server hangs. Wrap the call in asyncio.wait_for(...) with a sane deadline, or switch to a client that accepts request timeouts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/auth/cookiecloud_sync.py` around lines 25 - 34, The CookieCloud fetch
in _fetch_decrypted can block indefinitely because it relies on PyCookieCloud
without any timeout. Update the call site that uses
asyncio.to_thread(_fetch_decrypted, ...) to enforce a deadline with
asyncio.wait_for, or replace PyCookieCloud with a timeout-aware client, so hangs
from get_decrypted_data() are bounded. Keep the existing CookieCloudSyncError
path for failed fetch/decrypt and ensure the timeout is handled cleanly in the
sync flow.
frontend/src/pages/SkillsPage.tsx-168-199 (1)

168-199: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cancelling during "recording" leaks the held browser session.

The wizard's Cancel button (line 169) always calls onClose() directly, regardless of step. Per the upstream contract, /skills/record/start Holds the pool's per-endpoint mutex for the session's lifetime (released on /stop), released only via recordStop (line 51-61), which in turn hits /skills/record/{session_id}/stop that Releases the held Chrome back to the pool and closes the CDP connection. If the user opens the wizard, starts recording (sessionId set, step === 'recording'), then clicks 取消 instead of one of the "标记成功/失败并停止" buttons, the wizard just closes — recordStop is never called, and the Chrome endpoint's pool lock is leaked until the backend restarts.

🔧 Proposed fix
+  const handleClose = () => {
+    if (step === 'recording' && sessionId) {
+      recordStop(sessionId, { status: 'failed' }).catch(() => {})
+    }
+    onClose()
+  }
+
   return (
     <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm">
       ...
         <div className="flex justify-end gap-3 border-t border-white/10 p-5">
-          <Button type="button" variant="outline" onClick={onClose}>取消</Button>
+          <Button type="button" variant="outline" onClick={handleClose}>取消</Button>

This mirrors the same abandonment gap in backend/cli.py's cmd_record (Ctrl+C mid-recording) — both client surfaces need to guarantee /stop fires on early exit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/pages/SkillsPage.tsx` around lines 168 - 199, The Cancel button
in the SkillsPage wizard closes the modal without stopping an active recording
session, which can leak the held browser session. Update the Cancel handler in
SkillsPage so it checks the current step/session state and, when step is
'recording' (with a sessionId), it calls the existing recordStop flow before
onClose; keep onClose as the fallback for non-recording steps. Reuse the
existing stop mutation and the recordStop logic already used by the “标记成功/失败并停止”
actions so the session is always released from the backend before the UI closes.
backend/cli.py-110-150 (1)

110-150: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cancelling/interrupting record mid-session leaks the held browser.

Per the upstream contract (backend/api/v1/skill_record.py), /skills/record/start Holds the pool's per-endpoint mutex for the session's lifetime (released on /stop). In cmd_record, if the user hits Ctrl+C (or any exception occurs) while the two input() prompts (lines 122, 125) are blocking, the process exits without ever calling /skills/record/{session_id}/stop. The backend session/mutex is never released, permanently locking that Chrome endpoint until the backend process is restarted.

Wrap the interactive portion in try/finally so an interrupt still stops the session.

🔧 Proposed fix
         session = _unwrap(c.post("/skills/record/start", json=start_body, timeout=60.0))
         session_id = session["session_id"]
         print(f"recording started (session={session_id}, chrome={session['cdp_endpoint']})")
         print("go demo the task in that Chrome window now.")
-        input("press Enter here when done recording... ")
-
-        status = "success"
-        if input("mark as success? [Y/n] ").strip().lower() == "n":
-            status = "failed"
-        stop_result = _unwrap(
-            c.post(f"/skills/record/{session_id}/stop", json={"status": status}, timeout=30.0)
-        )
+        try:
+            input("press Enter here when done recording... ")
+            status = "success"
+            if input("mark as success? [Y/n] ").strip().lower() == "n":
+                status = "failed"
+        except (KeyboardInterrupt, EOFError):
+            status = "failed"
+        stop_result = _unwrap(
+            c.post(f"/skills/record/{session_id}/stop", json={"status": status}, timeout=30.0)
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cli.py` around lines 110 - 150, `cmd_record` leaves an active
recording session open if Ctrl+C or another exception happens during the
interactive `input()` prompts, because `/skills/record/{session_id}/stop` is
never called. Wrap the interactive body in `try/finally` and ensure the `stop`
request is sent from the `finally` block whenever `session_id` has been created,
so the backend mutex held by the session is always released. Use the existing
`cmd_record` flow and `session_id`/`stop_result` handling to locate the cleanup
path.
backend/skills/correction.py-190-195 (1)

190-195: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject rollback when the corrected evidence lacks a snapshot.

Legacy corrected entries won’t have prev_skill_md / prev_elements; the current fallbacks would commit skill_md="" and elements={} instead of failing cleanly.

Proposed guard
     from_version = skill.version
-    skill.skill_md = corrected.get("prev_skill_md") or ""
-    skill.elements = dict(corrected.get("prev_elements") or {})
+    if "prev_skill_md" not in corrected or "prev_elements" not in corrected:
+        raise ValueError(
+            f"skill {skill.id} correction v{to_version} has no rollback snapshot"
+        )
+    prev_elements = corrected["prev_elements"]
+    if not isinstance(prev_elements, dict):
+        raise ValueError(
+            f"skill {skill.id} correction v{to_version} has invalid rollback elements"
+        )
+
+    skill.skill_md = corrected["prev_skill_md"]
+    skill.elements = dict(prev_elements)
     skill.distill_model = corrected.get("prev_distill_model")
     skill.source_trace = corrected.get("prev_source_trace")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/skills/correction.py` around lines 190 - 195, Reject rollback in
correction.py’s rollback path when the corrected evidence has no snapshot data.
In the logic that restores `skill.skill_md` and `skill.elements` from
`corrected`, add a guard in the same branch that handles `from_version`/`prev_*`
so legacy entries without `prev_skill_md` or `prev_elements` do not fall back to
empty values. Update the rollback flow around the `skill.version` reassignment
to fail cleanly instead of committing an empty skill state.
backend/api/v1/__init__.py-9-9 (1)

9-9: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Add auth and URL allowlisting for CookieCloud sync backend/api/v1/__init__.py:31 exposes /api/v1/cookies/sync without any auth guard, and backend/api/v1/cookies.py forwards body.url straight into sync_from_cookiecloud. Gate it behind the intended admin/auth check and validate/allowlist the target URL before calling out.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/v1/__init__.py` at line 9, The CookieCloud sync endpoint is
currently exposed without protection and passes the user-supplied URL through
unchanged. Update the `cookies` route registration in
`backend/api/v1/__init__.py` and the sync handler in `backend/api/v1/cookies.py`
so `/api/v1/cookies/sync` requires the intended admin/auth guard, then validate
`body.url` against an allowlist before calling `sync_from_cookiecloud`. Use the
existing `cookies` module entrypoints and the sync handler’s URL handling logic
as the fix points.
backend/skills/record.py-213-228 (1)

213-228: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Leak the opened page if session.start() fails.

open_skill_page(cdp_endpoint) is awaited before session.start(); if start() raises (e.g. expose_binding failure per the concern above), the already-opened SkillPage (CDP connection + Playwright driver) is never closed, unlike the API layer's own careful "never leak a held Chrome on failure" handling in backend/api/v1/skill_record.py's record_start.

🩹 Proposed fix
     page = await open_skill_page(cdp_endpoint)
     session = RecordSession(
         session_id=uuid.uuid4().hex, domain=domain, capability=capability, page=page,
     )
-    await session.start()
+    try:
+        await session.start()
+    except Exception:
+        await page.aclose()
+        raise
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/skills/record.py` around lines 213 - 228, The start_recording flow
opens a SkillPage before calling RecordSession.start(), so a failure in
session.start() can leak the already-opened page/driver. Update start_recording
to handle exceptions around session.start() and ensure the opened page is always
closed or cleaned up on failure, mirroring the safe failure handling used by
record_start in backend/api/v1/skill_record.py. Use the existing symbols
open_skill_page, RecordSession.start, and logger to locate and adjust the
lifecycle cleanup path.
frontend/src/components/ChannelConfigForm.tsx-589-655 (1)

589-655: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Crawl4AI config form has no way to configure LLM/instruction-based extraction.

The form only exposes CSS selectors (marked required) plus wait_for and cookie auth. The backend channel (backend/channels/crawl4ai_channel.py) explicitly supports an instruction-only path (no selectors) for LLM extraction, and this PR's own commit summary lists "Crawl4AI LLM fallback for selector-free extraction" as a delivered capability — but there's no UI path to configure instruction, extraction_schema, or provider_id. Users can only create CSS-based Crawl4AI sources through this form; the LLM fallback is effectively unreachable from the UI.

♻️ Suggested addition (sketch)
 function Crawl4AIConfig({ config, onChange }: {...}) {
   ...
+  const useLlm = !selectors.length
   return (
     <div className="space-y-3">
       ...
-      <Field label={t('channelConfig.fieldSelectors')} hint={t('channelConfig.fieldSelectorsHint')} required>
+      <Field label={t('channelConfig.fieldSelectors')} hint={t('channelConfig.fieldSelectorsHint')}>
         <KVList pairs={selectors} onChange={updateSelectors} keyPlaceholder="field name" valuePlaceholder="CSS selector" />
       </Field>
+      {selectors.length === 0 && (
+        <Field label="instruction (LLM extraction)" required>
+          <TextInput value={(config.instruction as string) ?? ''} onChange={(v) => update({ instruction: v })} placeholder="extract the article title and author" required />
+        </Field>
+      )}
       ...
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/ChannelConfigForm.tsx` around lines 589 - 655, The
Crawl4AI config UI only exposes CSS selector-based extraction, so the
LLM/instruction-based path is unreachable from the form. Update Crawl4AIConfig
to add fields for instruction, extraction_schema, and provider_id, and wire them
through update/onChange alongside selectors and wait_for. Also make selectors
optional in the UI when instruction-based extraction is used, so the form can
represent the backend’s selector-free path supported by crawl4ai_channel.
backend/channels/crawl4ai_channel.py-61-99 (1)

61-99: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cookie/LLM-provider resolution failures escape the ChannelFetchError taxonomy.

_resolve_cookies (line 73) and _build_llm_strategy_resolve_llm_config (line 91, hitting the DB) run before/outside the try/except ImportError and the later try/except Exception block. If AuthManager().resolve_cookies() or the DB query raises, the exception propagates out of fetch() unclassified — and since collect() (lines 54-58) only catches ChannelFetchError, it escapes collect() entirely, bypassing the retry/error-taxonomy contract this refactor is meant to establish for exactly this kind of external-call hazard.

🛡️ Proposed fix
         cookies: list[dict] = []
         if auth_config.get("type") == "cookie":
-            cookies = await self._resolve_cookies(url)
+            try:
+                cookies = await self._resolve_cookies(url)
+            except Exception as exc:
+                raise ChannelFetchError(f"crawl4ai: cookie resolution failed: {exc}") from exc

Apply the same guard around _resolve_llm_config's DB lookup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/channels/crawl4ai_channel.py` around lines 61 - 99, The external
resolution steps in fetch() are leaking raw exceptions instead of
ChannelFetchError, so wrap the calls to _resolve_cookies and _build_llm_strategy
(including the _resolve_llm_config DB lookup it uses) in the same error-handling
path as the rest of crawl4ai_channel.py. Catch failures from AuthManager/DB
access and re-raise them as ChannelFetchError with context, so collect() can
handle them consistently alongside the existing ImportError and fetch-time
taxonomy.
tests/unit/pipeline/test_pipeline_errors.py-130-132 (1)

130-132: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Point these tests at the current run_pipeline contract.

These calls pass db_session as task_id and task.id as parameters. Also, Line 204 patches backend.pipeline.storer.store_records, but the current pipeline writes through active_sink.write_batch; the retryable sink test won’t exercise the intended path.

🧪 Suggested test shape
+class _FailingSink:
+    async def write_batch(self, ctx, items):
+        raise ConnectionError("pool exhausted")
+
...
-            await run_pipeline(db_session, source, task.id)
+            await run_pipeline(task.id, source)
...
-        result = await run_pipeline(db_session, source, task.id)
+        result = await run_pipeline(task.id, source)
...
-            await run_pipeline(db_session, source, task.id)
+            await run_pipeline(task.id, source)
...
     with (
         patch("backend.pipeline.collector.collect", return_value=channel_result),
-        patch("backend.pipeline.storer.store_records", side_effect=ConnectionError("pool exhausted")),
     ):
         with pytest.raises(ConnectionError, match="pool exhausted"):
-            await run_pipeline(db_session, source, task.id)
+            await run_pipeline(task.id, source, sink=_FailingSink())

Also applies to: 152-153, 178-180, 202-207

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/pipeline/test_pipeline_errors.py` around lines 130 - 132, Update
these pipeline error tests to match the current run_pipeline contract: ensure
the call sites pass task.id as task_id and the parameters object in the correct
position, rather than passing db_session where the task ID belongs. Also change
the retryable sink test to patch the actual write path used by the pipeline,
active_sink.write_batch, instead of backend.pipeline.storer.store_records, so
the intended retry behavior is exercised against the real sink implementation.
backend/channels/base.py-133-134 (1)

133-134: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Preserve ChannelResult.error_type when bridging to ChannelFetchError.

Line 134 drops result.error_type. With collect-only channels now routed through AbstractChannel.fetch(), ChannelResult.fail(..., error_type="TimeoutException") becomes a bare ChannelFetchError, so downstream retry taxonomy can treat a retryable failure as permanent.

🔧 Suggested direction
         result = await self.collect(ctx.config, ctx.params)
         if not result.success:
-            raise ChannelFetchError(result.error or f"{self.channel_type} collect failed")
+            exc = ChannelFetchError(result.error or f"{self.channel_type} collect failed")
+            exc.error_type = result.error_type
+            raise exc

Also ensure effective_error_type() checks getattr(exc, "error_type", None) before falling back to the exception class/cause.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/channels/base.py` around lines 133 - 134, Preserve the error taxonomy
when `AbstractChannel.fetch()` converts a failed `ChannelResult` into
`ChannelFetchError`: in `base.py`, update the failure path in `fetch()` so it
carries `result.error_type` forward instead of only raising with `result.error`
text. Also adjust `effective_error_type()` to prefer `getattr(exc, "error_type",
None)` before falling back to the exception class or cause, so retryable
failures like `TimeoutException` stay classified correctly.
backend/worker/tasks.py-23-34 (1)

23-34: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Blanket autoretry_for=(Exception,) outruns the taxonomy filtering it's justified by.

The decorator comment's invariant ("run_pipeline() only re-raises retryable errors") only holds for the try/except wrapping the Phase 3 run_pipeline() call inside run_collection_pipeline. Phase 1 (create TaskRun), Phase 2 (load source/agent), and Phase 4 (finalize status) have no such filtering — any exception there (DB error, code bug) reaches this Celery boundary unfiltered and is retried the same as a genuinely transient failure.

Concretely: if Phase 4 throws after Phase 3 already succeeded, retrying re-runs the whole pipeline from Phase 1 (creating a new TaskRun row) while the prior attempt's TaskRun — which already completed the actual collection — is never marked failed/completed and is left stuck at "running" forever. That's the exact symptom the Phase-3 bookkeeping was added to prevent, just for a different phase.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/worker/tasks.py` around lines 23 - 34, The `run_collection` Celery
task is retrying every exception, but only the Phase 3 `run_pipeline()` path is
taxonomy-filtered; exceptions from TaskRun creation, source/agent loading, and
final status updates can be non-retryable and should not be blanket retried.
Update the task’s retry handling so only transient/retryable failures are
retried at the Celery boundary, and let unexpected Phase 1/2/4 exceptions fail
normally or be handled explicitly, preserving the existing
`run_collection_pipeline` and `run_collection` behavior without recreating a new
TaskRun on a Phase 4 failure.
backend/channels/opencli_channel.py-477-510 (1)

477-510: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

health_check ignores config/source_id, so it probes an arbitrary pool endpoint instead of this source's bound one.

AbstractChannel.health_check's contract says config/source_id let an override do "a real per-source probe instead of a generic liveness check," and collect() in this same file does resolve a site-specific endpoint (via chrome_endpoint param / agent routing). This override drops straight to pool.acquire() with no endpoint, so with multiple pool members the probe result doesn't necessarily reflect the endpoint this particular source actually uses.

Route to the source's bound endpoint when available
         from backend.browser_pool import get_pool
         try:
             pool = get_pool()
         except RuntimeError:
             return True  # pool not initialized yet (e.g. tested standalone) — binary check stands

+        acquire_endpoint: str | None = None
+        site = (config or {}).get("site")
+        if site:
+            from backend.services import browser_service
+            from backend.database import AsyncSessionLocal
+            async with AsyncSessionLocal() as session:
+                binding = await browser_service.get_binding_by_site(session, site)
+                if binding:
+                    acquire_endpoint = binding.browser_endpoint
+
         try:
-            async with pool.acquire() as cdp_endpoint:
+            async with pool.acquire(endpoint=acquire_endpoint) as cdp_endpoint:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/channels/opencli_channel.py` around lines 477 - 510, The health_check
override in opencli_channel.py is ignoring the passed config/source_id and
probing a random acquired pool endpoint instead of the source’s bound endpoint.
Update health_check to resolve and use the same per-source endpoint selection
path used by collect() and the channel routing logic (for example via the
site-specific chrome_endpoint/agent resolution) before falling back to
pool.acquire(), so the probe reflects the actual source being checked. Keep the
existing binary liveness and agent/bridge-mode short-circuit behavior, but make
the CDP readiness check target the resolved endpoint for that source.
backend/channels/web_scraper_channel.py-101-119 (1)

101-119: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Status-based retry handling is missing for HTTPStatusError

effective_error_type() reduces a wrapped HTTPStatusError to the bare class name, and is_retryable() only checks class-name sets, so every HTTP status falls through as permanent. That means 429/5xx won’t retry here, despite is_retryable_http_status() already encoding the intended split. Wire the status code into retry classification or map HTTPStatusError before the class-name check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/channels/web_scraper_channel.py` around lines 101 - 119, Status-based
retry classification for wrapped HTTPStatusError is missing, so 429/5xx are
treated as permanent instead of retryable. Update the retry path in
`is_retryable()` and/or `effective_error_type()` to preserve or inspect the HTTP
status from `ChannelFetchError` created in `_get()`, rather than relying only on
the exception class name. Use `is_retryable_http_status()` to decide
retryability for HTTP status failures, and ensure `HTTPStatusError` cases map to
retryable vs non-retryable based on the response code before the generic
class-name check.
🟡 Minor comments (7)
TESTING.md-496-498 (1)

496-498: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the verification date.

2026-07-02 is future-dated here, so the claim reads as if the browser checks were verified after this revision. Use the actual run date or drop the date.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@TESTING.md` around lines 496 - 498, The verification note in the Chrome/Edge
CDP guidance uses a future-dated timestamp, so update the date to the actual run
date or remove it entirely. Edit the affected markdown text in TESTING.md where
the browser verification claim appears, keeping the existing `connect_over_cdp`
guidance and the Chrome/Edge reference unchanged.
frontend/src/pages/SkillDetailPage.tsx-102-102 (1)

102-102: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

canRollback stays true even after the correction was already rolled back.

It checks for any corrected event in history rather than whether the most recent correction/rollback event is still corrected. After a rollback, the button remains enabled with nothing left to revert (backend will presumably reject it, surfaced only via an error toast).

🐛 Proposed fix
-  const canRollback = (skill.evidence ?? []).some((ev) => ev.event === 'corrected')
+  const rollbackRelevant = (skill.evidence ?? []).filter(
+    (ev) => ev.event === 'corrected' || ev.event === 'rolled_back',
+  )
+  const canRollback =
+    rollbackRelevant.length > 0 &&
+    rollbackRelevant[rollbackRelevant.length - 1].event === 'corrected'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/pages/SkillDetailPage.tsx` at line 102, The `canRollback` flag
in `SkillDetailPage` is checking the full `skill.evidence` history for any
`corrected` event, so it can stay enabled after a rollback has already consumed
the correction. Update the logic near `canRollback` to inspect only the
current/latest evidence state and determine whether there is an unapplied
correction still available to revert, rather than using `.some(...)` over all
events. Use the `canRollback` computation and the `skill.evidence` event history
shape to locate the change.
backend/api/v1/skills.py-110-110 (1)

110-110: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Address the new Ruff B008 warnings.

The new endpoints use Depends(get_db) in default arguments; Ruff is flagging these lines. Prefer a module-level dependency alias or Annotated pattern so lint stays clean.

Also applies to: 122-122, 140-140, 158-158

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/v1/skills.py` at line 110, Ruff is flagging the direct use of
Depends(get_db) in default parameters on the skill endpoint handlers. Update
get_skill and the other affected endpoint functions to use either a module-level
dependency alias or the Annotated pattern for the db parameter, keeping the
existing get_db dependency behavior while removing the B008 warning. Use the
endpoint function names in this module to locate and apply the same fix
consistently across all affected signatures.

Source: Linters/SAST tools

tests/skills/test_record_live.py-116-118 (1)

116-118: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Page is left open on the success path.

session.stopped is set True inside stop() before it returns, so if not session.stopped: await session.page.aclose() skips cleanup precisely when stop() succeeded — the CDP connection is only closed on the (rarer) error path. This is asymmetric with record_stop in the API, which always closes the page in a finally. Leaving live CDP connections open across repeated live-test runs can accumulate stale Playwright driver processes/websockets.

🧹 Proposed fix
     finally:
-        if not session.stopped:
-            await session.page.aclose()
+        await session.page.aclose()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/skills/test_record_live.py` around lines 116 - 118, The cleanup in the
live test is inverted: `session.stopped` is already set by `session.stop()`, so
the current `finally` block in `test_record_live` only closes `session.page` on
the error path and leaves successful runs open. Update the `finally` cleanup to
always close the page, using the same unconditional pattern as `record_stop`,
and keep the fix anchored around `session.stop()` and `session.page.aclose()`.
backend/api/v1/skill_record.py-147-163 (1)

147-163: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Handle the insert race on /skills/distill.
uq_skill_domain_capability already prevents duplicate rows, but this select-then-insert path can still race and surface as an unhandled IntegrityError at session.commit(). Catch it, rollback, and return 409 instead of a 500.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/v1/skill_record.py` around lines 147 - 163, The
select-then-insert flow in the `/skills/distill` path can still race even though
`uq_skill_domain_capability` exists, so an `IntegrityError` may escape from
`session.commit()` and become a 500. Update the skill creation logic in
`skill_record.py` around the existing `AsyncSessionLocal`, `existing`, and
insert/commit flow to catch `IntegrityError`, roll back the session, and return
an HTTP 409 with the same duplicate-skill message instead of letting the
exception propagate.
frontend/src/pages/SourcesPage.tsx-1072-1075 (1)

1072-1075: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Legacy toggleMut has no error handling, unlike its canvas-page counterpart.

Every other mutation here (createMut, updateMut, deleteMut, and the toggleMut in the newer SourcesPage component at lines 2236-2243) shows an error toast via onError. This one silently no-ops on failure, so a failed enable/pause action gives no user feedback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/pages/SourcesPage.tsx` around lines 1072 - 1075, The legacy
toggleMut mutation in SourcesPage is missing failure feedback, unlike
createMut/updateMut/deleteMut and the newer SourcesPage toggle mutation. Update
this useMutation setup to add an onError handler that surfaces a toast or
equivalent user-visible error message when updateSource(id, { enabled }) fails,
while keeping the existing onSuccess invalidation for ['sources'].
backend/channels/crawl4ai_channel.py-214-241 (1)

214-241: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

health_check doesn't mirror fetch()'s auth/anti-detection setup; source_id is unused.

The liveness probe builds BrowserConfig(headless=True)/CrawlerRunConfig(...) without enable_stealth, magic=True, or resolved cookies — so a source behind cookie auth or anti-bot protection can fail this probe (false "unhealthy") even though the real fetch() (which does set all three) would succeed. source_id is accepted but never read, so there's also no path to look up cookies/config from it when only source_id is passed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/channels/crawl4ai_channel.py` around lines 214 - 241, The
health_check path in crawl4ai_channel.py is using a different browser setup than
fetch(), which can produce false unhealthy results for protected sources. Update
Crawl4AiChannel.health_check to reuse the same auth and anti-detection
configuration as fetch(), including enable_stealth, magic=True, and resolved
cookies, and make sure source_id is actually used to load source-specific config
when config is missing. Keep the probe lightweight, but build the
BrowserConfig/CrawlerRunConfig via the same helper or shared logic used by
fetch() so both paths behave consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 694a9be3-7b14-484d-ad34-7e6b30f4ce0c

📥 Commits

Reviewing files that changed from the base of the PR and between 2178af7 and 563f423.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (90)
  • .gitattributes
  • TESTING.md
  • backend/api/v1/__init__.py
  • backend/api/v1/chat.py
  • backend/api/v1/cookies.py
  • backend/api/v1/skill_record.py
  • backend/api/v1/skills.py
  • backend/api/v1/sources.py
  • backend/auth/cookiecloud_sync.py
  • backend/auth/header_builder.py
  • backend/auth/manager.py
  • backend/channels/api_channel.py
  • backend/channels/base.py
  • backend/channels/cli_channel.py
  • backend/channels/crawl4ai_channel.py
  • backend/channels/opencli_channel.py
  • backend/channels/registry.py
  • backend/channels/rss_channel.py
  • backend/channels/skill_channel.py
  • backend/channels/web_scraper_channel.py
  • backend/cli.py
  • backend/main.py
  • backend/mcp_server.py
  • backend/migrations/versions/s9n0o1p2q3r4_add_skill_last_failing_trace.py
  • backend/migrations/versions/t0o1p2q3r4s5_add_cookie_jar.py
  • backend/models/__init__.py
  • backend/models/cookie_jar.py
  • backend/models/skill.py
  • backend/pipeline/channel_runner.py
  • backend/pipeline/collector.py
  • backend/pipeline/error_taxonomy.py
  • backend/pipeline/pipeline.py
  • backend/pipeline/runner.py
  • backend/pipeline/storer.py
  • backend/schemas/credential.py
  • backend/schemas/source.py
  • backend/services/schedule_service.py
  • backend/services/source_service.py
  • backend/skills/correction.py
  • backend/skills/record.py
  • backend/skills/risk.py
  • backend/skills/trace.py
  • backend/worker/beat_schedule.py
  • backend/worker/celery_app.py
  • backend/worker/redbeat_sync.py
  • backend/worker/tasks.py
  • frontend/src/App.tsx
  • frontend/src/api/endpoints.ts
  • frontend/src/api/types.ts
  • frontend/src/components/ChannelConfigForm.tsx
  • frontend/src/components/Layout.tsx
  • frontend/src/components/StatusBadge.tsx
  • frontend/src/i18n/en.ts
  • frontend/src/i18n/zh.ts
  • frontend/src/labs/topology/AgentDock.tsx
  • frontend/src/pages/SkillDetailPage.tsx
  • frontend/src/pages/SkillsPage.tsx
  • frontend/src/pages/SourcesPage.tsx
  • pyproject.toml
  • tests/integration/test_chat_api.py
  • tests/integration/test_cookies_api.py
  • tests/integration/test_skills_api.py
  • tests/integration/test_sources_api.py
  • tests/skills/test_cookie_persistence_live.py
  • tests/skills/test_correction.py
  • tests/skills/test_record.py
  • tests/skills/test_record_live.py
  • tests/skills/test_risk.py
  • tests/skills/test_skill_channel.py
  • tests/unit/auth/test_cookiecloud_sync.py
  • tests/unit/auth/test_header_builder.py
  • tests/unit/auth/test_manager.py
  • tests/unit/channels/test_api_channel.py
  • tests/unit/channels/test_crawl4ai_channel.py
  • tests/unit/channels/test_opencli_channel.py
  • tests/unit/channels/test_rss_fetch.py
  • tests/unit/channels/test_web_scraper_channel.py
  • tests/unit/pipeline/test_channel_runner.py
  • tests/unit/pipeline/test_collector.py
  • tests/unit/pipeline/test_collector_incremental.py
  • tests/unit/pipeline/test_error_taxonomy.py
  • tests/unit/pipeline/test_pipeline_cursor.py
  • tests/unit/pipeline/test_pipeline_errors.py
  • tests/unit/pipeline/test_storer.py
  • tests/unit/test_runner.py
  • tests/unit/test_schedule_service_redbeat.py
  • tests/unit/test_source_onboarding.py
  • tests/unit/worker/test_beat_schedule.py
  • tests/unit/worker/test_redbeat_sync.py
  • tests/unit/worker/test_tasks.py

Comment thread backend/skills/record.py
2233admin added 2 commits July 2, 2026 04:29
… + health_check parity

- record.py CAPTURE_JS: the change listener now skips capturing
  type=password field values (critical, coderabbit) -- raw passwords
  typed during a recording session were flowing into
  StepRecord.args["text"] and persisted verbatim in journey_trace_v1 /
  Skill.evidence.
- crawl4ai_channel.py fetch(): cookie resolution and LLM-strategy setup
  (_build_llm_strategy, which hits the DB for a provider) are now
  wrapped so a failure raises ChannelFetchError instead of an
  unclassified exception escaping collect()'s catch entirely, bypassing
  the retry/error-taxonomy contract.
- crawl4ai_channel.py health_check(): now sets enable_stealth=True and
  magic=True, same as fetch() -- a probe without them could false-fail
  against a source that's only reachable with anti-detection on.

767 passed, 9 skipped, no regressions.
…beat startup sync; close resource/race gaps

- channels/base.py: ChannelFetchError now carries an optional error_type,
  and fetch()'s bridge from ChannelResult passes result.error_type
  through instead of dropping it -- a channel that already classified
  its own failure (e.g. ChannelResult.fail(..., error_type="TimeoutException"))
  no longer degrades to a bare, unclassified wrapper exception.
- error_taxonomy.py: effective_error_type() prefers the explicit
  error_type when present, before falling back to __cause__/class name.
- web_scraper_channel.py: HTTPStatusError is now classified by status
  code (via is_retryable_http_status) into error_type=Retryable/
  PermanentHTTPStatus instead of falling through as an unclassified,
  always-permanent "HTTPStatusError" -- 429/5xx now actually retry.
- worker/redbeat_sync.py: populate_all reconciles ALL schedules (not
  just enabled ones) against redbeat, calling remove_entry for anything
  effectively disabled (schedule.enabled=False directly in the DB, or
  its DataSource disabled) instead of silently skipping them -- a
  schedule disabled outside the normal CRUD path would otherwise keep
  a stale redbeat entry firing forever, since a startup pass that only
  looked at enabled rows never even saw it.
- skills/record.py: start_recording() closes the already-opened page
  if session.start() raises, instead of leaking the CDP connection.
- api/v1/skill_record.py: /skills/distill's select-then-insert can
  still race a concurrent distill for the same (domain, capability);
  IntegrityError on commit now becomes the same 409 the pre-check
  gives, not an unhandled 500.
- tests/skills/test_record_live.py: fixed an inverted cleanup guard
  (`if not session.stopped: aclose()`) that skipped closing the page
  on exactly the success path, since stop() already sets stopped=True
  before returning.

Not fixed (deferred, reasons noted separately): SSRF hardening for
discover_feeds/OPML fetch, defusedxml for OPML parsing, auth-gating
cookies/sync (the whole REST API has no auth today, not specific to
this route), frontend gaps (Crawl4AI LLM-fields UI, cancel-button
session leak, canRollback staleness), CLI record ctrl+c cleanup,
opencli_channel health_check per-source endpoint routing, and a few
test call-signature mismatches in test_pipeline_errors.py predating
this PR.

767 passed, 9 skipped, no regressions.
@2233admin
2233admin merged commit f731897 into main Jul 1, 2026
4 checks passed
2233admin added a commit that referenced this pull request Jul 1, 2026
health_check() ignored config/source_id and probed an arbitrary pool
member via pool.acquire() with no endpoint. With multiple pool members
the probe result didn't necessarily reflect the endpoint this source's
collect() actually binds to (site-keyed browser binding resolved the
same way pipeline.py does before collect()).

Now resolves config.site -> browser_service.get_binding_by_site and
passes the bound endpoint to pool.acquire(endpoint=...); no binding
falls back to the prior acquire(endpoint=None) behavior.

Flagged by coderabbit on PR #4 (opencli-admin/2233admin), scoped out
at merge time as a follow-up precision fix.
2233admin added a commit that referenced this pull request Jul 2, 2026
…review

Left scoped-out at PR #4 merge time; fixing now:

- SourcesPage.tsx: sourceTarget() had no crawl4ai branch, fell back to
  the generic "未配置目标" hint instead of showing the crawl4ai url.
- ChannelConfigForm.tsx: Crawl4AI form only exposed CSS selectors; no UI
  path for the LLM-fallback extraction (instruction/extraction_schema/
  provider_id) this PR's own backend change shipped. Added a CSS/LLM
  mode toggle with the missing fields.
- SkillDetailPage.tsx: canRollback stayed true after a correction was
  already rolled back (checked for any 'corrected' event instead of the
  most recent correction/rollback event).
- SourcesPage.tsx: legacy toggleMut had no onError toast, unlike every
  other mutation on the page and its newer-component counterpart.
- backend/cli.py cmd_record: Ctrl+C (or EOF) during either interactive
  prompt skipped /stop, leaking the pool's per-endpoint mutex for that
  Chrome session until the backend process restarted. Now caught and
  treated as status=failed so /stop always fires.

Verified live: started backend+frontend, created a real crawl4ai
source through the new LLM-mode UI, confirmed it persisted with
instruction set and rendered its url in the sources list. Added
tests/unit/test_cli.py (cmd_record normal/no/Ctrl+C/EOF paths).
773 passed (was 769), tsc -b + vite build clean.
2233admin added a commit that referenced this pull request Jul 2, 2026
Issue 08 verification found all five PR #4 deferred frontend findings
(SourcesPage crawl4ai target + toggle error handling, SkillsPage recording
cancel leak, ChannelConfigForm LLM extraction fields, SkillDetailPage
canRollback) already fixed by 2f5944b; the only live defect was this stale
test asserting the pre-d157881 source->task:triggers edge for a manual task,
which d157881 intentionally changed to source->task:manual. Frontend suite:
19/19 green.
2233admin added a commit that referenced this pull request Jul 8, 2026
…PR-C)

New channel_type "browser_act" (registered). Generic interpreter of a pack's
channel.manifest.json (no per-pack code): resolves the pack via PackCatalog,
replays manifest.steps against a browser-act session (PR-B), and for each
eval_script step runs the pack's scripts/*.py to emit JS then evals it in the
browser and JSON-parses the DOM result.

- backend/browser_act/scripts.py: run_pack_script() — the second subprocess
  hop, create_subprocess_exec(sys.executable, ...) argv-only (decision #6),
  timeout+kill, ScriptError.
- backend/channels/browser_act_channel.py: collect/validate_config/health_check.
  Login/anti-bot detection (decision #4): a script's {error:true,message}
  matching an auth keyword -> error_type="needs_human", stops, never retries or
  bypasses. Pagination interprets url_page + "result_count<N" stop_when, else
  falls back to max_pages(5) + empty-page stop (limitations documented).
  Bad-manifest KeyError/JSON/schema errors return a ChannelResult, never crash.
  Env secrets never enter error text or metadata.

Registering the channel legitimately expands list_channel_types(), so the
workflow-capabilities exact-set test gains "browser_act" (+1 line).

17 tests (happy/needs_human/generic-error/validate/health/registry/
secret-not-leaked/script-hop injection-safety/template-mismatch).
1467 -> 1484 passed, zero regression (full suite verified).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant