Skip to content

thin-channel-thick-runner: strangler-fig collection refactor + AuthManager + per-domain limits - #3

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

thin-channel-thick-runner: strangler-fig collection refactor + AuthManager + per-domain limits#3
2233admin merged 41 commits into
mainfrom
refactor/thin-channel-thick-runner

Conversation

@2233admin

Copy link
Copy Markdown
Owner

thin-channel-thick-runner → main

Lands the long-running refactor/thin-channel-thick-runner branch. Headline work is a strangler-fig refactor of the collection pipeline ("thin channel + thick runner": adding a data source ≈ 100 lines) plus Phase 2/3 hardening. The branch also carries earlier work already developed on it (skills dock, node-kit, topology canvas).

Strangler-fig channel refactor (PR1–PR5b)

  • PR1 LegacyDbSink write seam — pipeline writes through an ItemSink, behavior unchanged.
  • PR2 Lock the ODP forward contract — typed RecordEvent/OdpIngestResponse mirroring the Rust odp-contracts; triple_to_event routes through one mapper.
  • PR3 Move the ODP forward into OdpSink; DualSink writes legacy + ODP exactly once (no double-send) via a forward_to_odp gate.
  • PR4 data_sources.write_strategy state machine selects the sink (legacy / odp_shadow / odp_dual_required / odp_primary / odp_only).
  • PR5a source_cursors table + DBCursorStore; RSS on the thick contract (fetch() etag/304 conditional GET, identity() = entry id).
  • PR5b Incremental channels collect via run_channel; the cursor is committed only after the write sink durably accepts the batch.

Phase 2/3

  • PR-A Browser-binding gated by capabilities.session_affinity instead of a hardcoded channel list.
  • PR-C AuthManager + Fernet-encrypted source_credentials — secrets leave channel_config plaintext; channels get a decrypted AuthContext. Inline-plaintext auth is deprecated with a warning.
  • PR-B Per-domain concurrency cap at the task layer (in-process; PER_DOMAIN_CONCURRENCY, default 3).

Notes

  • Tests: 404 passing (tests/unit).
  • Migrations: 3 new (o5j6k7l8m9n0 write_strategy, p6k7l8m9n0o1 source_cursors, q7l8m9n0o1p2 source_credentials) — run alembic upgrade head.
  • Ops: set CREDENTIAL_ENCRYPTION_KEY (Fernet key) in prod for the credential store.
  • Follow-ups: migrate api_channel onto fetch() + AuthManager; swap the per-domain limiter to Redis for a multi-worker fleet.

2233admin and others added 30 commits June 21, 2026 18:38
…llection network

Node-kit (frontend/src/node-kit): spec/define/registry contract, generic xyflow
KitNode renderer, L3 atom components, 19 real atomic nodes (sources/processors/
pipeline/primitives), agent JSON-schema bridge. ComfyUI-style NodeWorkbench
(left palette + dnd-kit drag, Tab/double-click search, collision animation) at
/labs/node-kit.

ELK auto-layout: render/elkLayout.ts (elkjs layered, direction RIGHT) + ambient
elk.d.ts (elkjs ships no types); "auto-layout" toolbar button snaps a scattered
graph into a clean dataflow then fitView.

Integration: NetworkPage atomMode toggle maps a collection source into an atom
seed graph (L1 project -> L2 stage -> L3 atoms). Self-built P0 runtime
(runtime/engine.ts: Kahn topo-sort, pure-node run, backend hook).

Also: agent dock (backend/api/v1/chat.py + frontend AgentDock), nav/i18n/Layout
wiring, backend pipeline runner tweaks.
Step 1 of unifying 采集网络 and the node-kit workbench into one engine, two
views. The topology canvas stage cards now render through the generic <KitNode>
instead of a hand-rolled TopologyNodeView — both surfaces share one node
language (same registry, same specs).

- node-kit/nodes/collection.tsx (new): 8 collection.* specs (source/schedule/
  task/agent/record/notification/edge-node/worker). Specs with actions carry a
  shared StageBody render (status/gap facts + badge chips, matching the old
  card) and ops calling the same endpoints StageOperations uses.
- ReactFlowTopologyCanvas: dropped TopologyNodeView + static nodeTypes + dead
  health-class helpers; registers ALL_NODES, memoizes nodeTypesForXyflow(), and
  the sync effect re-types nodes to collection.<kind> with a data superset
  ({...data, config, facts}) so the right-drawer inspector keeps working.
- Guard unknown kinds (hasNode fallback) so a new backend kind can't silently
  vanish; badges coerced with String() instead of an unsound cast.

NetworkPage atomMode + StageOperations untouched. tsc green; verified in
browser: 采集网络 renders the source stage via collection.source, node-kit
palette now lists all 8 collection nodes (unified language). Review false-
positive noted: stageConfig enabled derives from health, which topologyModel
builds from enabled (health==='disabled' iff !enabled) — toggle is correct.
…node

Step 2 of unifying 采集网络 (macro/zoom-out view) and the workbench (atom/zoom-in
view). A macro = a saved atom subgraph that collapses to one node and expands
back. Iron rule honored: NO second executor — runGraph stays the only engine; a
macro is flattened to its atoms before a run.

NEW node-kit/macros/:
- macro.ts: MacroDef/MacroPort, deriveBoundaryPorts (a handle is a boundary iff
  it has no internal edge, keyed like the engine: targetHandle??'in' /
  sourceHandle??'out'), buildMacroDef, makeMacroSpec (synthetic NodeSpec per
  macro via defineNode, ports id = innerNodeId:innerHandle), inlineMacro (pure
  namespace-by-instance-id + offset + reconnect), flattenForRun (loop-inline,
  guard 50). In-memory MACRO_DEFS map + getMacroDef for the hot paths.
- store.ts: localStorage 'node-kit:macros' (list/save/get/delete) + isMacroDef
  guard + try/catch; strips transient node fields before persist.
- MacroNode.tsx: collapsed body (child-type chips + 双击展开 hint).
- index.ts: barrel + registerSavedMacros() (boot-time re-register).

NodeWorkbench: 组成宏 button (>=2 selected) captures selection -> derives ports
-> saves/registers -> replaces selection with one macro node, rewiring crossing
edges onto synthetic ports; double-click a macro expands it back; runNow
flattens first; nodeTypes/palette memos keyed on a registryVersion so an
in-session macro renders + lists immediately. Nested macros refused (MVP bound).
NodeKitPage/NetworkPage/ReactFlowTopologyCanvas register saved macros at boot.

Review fixes applied: single atomic inlineMacro on expand (was a torn two-call
update); createElement(MacroBody) instead of a plain call (hooks-safe);
getMacroDef in-memory lookup replaces localStorage JSON.parse on the run/expand
hot path; saveMacro logs on quota failure.

tsc green; /labs/node-kit loads clean (组成宏 button present, no console errors).
Node config was display-only — "里面东西不能改". Make the spec-driven node body
editable in place, ComfyUI-style.

- atoms: NodeFieldEdit renders one FieldDef as the right control by type —
  text / number / select / json(textarea) / boolean(NodeToggle). Carries
  `nodrag nopan` + a pointerdown guard so editing never drags the node or pans
  the canvas; half-typed JSON is kept as raw text rather than lost.
- KitNode: AutoBody now renders config.fields as editable NodeFieldEdit controls
  (facts stay read-only) and writes changes back via useReactFlow().updateNodeData
  onto this node's data.config, so edits flow into runGraph.

Only affects nodes WITHOUT a custom spec.render — collection.* (StageBody) and
macro nodes keep their own bodies, so 采集网络 stays backend-authoritative.

tsc green; verified in browser: web_scraper/processor nodes render text/number/
select/toggle inputs, typing into a field persists through re-render (controlled
value bound to data.config — proves write-back), zero console errors.
Two asks: node config editing should be more convenient (humans), and atomic
nodes more convenient for AI development.

1. Side property panel (render/NodeInspector.tsx): selecting ONE node shows a
   roomy right-side form of its full config (same NodeFieldEdit controls as the
   inline body, bigger + always visible). Writes go through updateNodeData, so
   panel and inline edits stay in sync. Works for any node with config.fields
   incl. collection.* whose compact body is StageBody.

2. AI graph authoring API (agent/graph.ts): instantiateGraph({nodes,edges}) —
   the inverse of agent/toSchema. Validates an agent-emitted graph against the
   registry (unknown types, config errors, dangling edges, bad port refs all
   collected, never thrown), returns canvas-ready xyflow nodes+edges + a list of
   problems so the agent can fix-and-retry. A fuchsia "AI 产图" toolbar button
   loads pasted graph JSON onto the canvas with ELK fit + error toast.

tsc green; verified in browser: AI 产图 with a 4-node/3-edge blob (one unknown
type, one dangling edge) loads exactly the 3 valid nodes + 2 valid edges,
rejects the rest; clicking a node opens the property panel with its editable
config; zero console errors.
Closed-loop browser-skill subsystem (ADR-0003): a `skill` channel reads a
distilled SKILL.md and a cheap text model drives a real Chrome page over CDP
through a perceive -> gate -> act loop, staying inside the existing
task/run/pipeline/events/record spine.

- backend/skills/: distill kernel, Playwright connect_over_cdp page wrapper,
  injected-JS perception snapshot, ref-addressed action executor, step loop with
  9-element prompt + tool-calling harness, risk-tiered confirm gate,
  journey_trace_v1 emission + self-eval + re-distill correction
- models/Skill with (domain, capability) unique; awaiting_confirm run status
- skill_channel wired into the pipeline (run_id via parameters, per-step events,
  extract -> records); AbstractChannel.collect signature unchanged
- migrations m3h4i5j6k7l8 (skills), n4i5j6k7l8m9 (awaiting_confirm); /skills API
- tests/skills (browser-free unit) + live e2e behind a `live` marker
- fix 2 pre-existing async-mock failures in tests/unit/test_runner.py
- docs: ADR-0003, GLOSSARY, PRD, per-issue specs

Built via grilled design (/grill-with-docs) + multi-agent implementation workflow.
Two normalized triples can share a content_hash (e.g. two CLI sub-commands that
normalize to identical content). Both passed the existing-hashes check and were
added, failing the whole batch on the UNIQUE(source_id, content_hash) constraint
at flush. Track hashes seen in this batch too.
Wire the dock correct leg (ADR-0003 D7/D8): when the context node is a
failing skill, surface a 重蒸技能 button that opens an amber confirm card
and, on confirm, POSTs the failing journey_trace_v1 to
/skills/{id}/redistill. Reuses the existing proposal->confirm contract and
never auto-fires (D8: re-distill is human-triggered only). Pairs with the
backend endpoint (api/v1/skills.py) + correction.re_distill from 3bb827a.

- RedistillTarget state + failingTrace prop (falls back to a minimal
  context-only trace so the flow is exercisable without a run)
- propose/confirm/cancel handlers with loading guards + toast/append feedback
Close the execute-from-store seam (ADR-0003). The skill channel could only run
an inline config['skill_md']; a skill_id / (domain, capability) was rejected
with "resolution not wired yet", so a distilled skill in the DB could not be
executed (blocking end-to-end QA). _resolve_skill now loads the persisted Skill
via a short-lived AsyncSessionLocal — the same pattern as the self-eval evidence
write, since collect() holds no injected session.

- _load_skill_fields: read-only load by skill_id then the unique (domain,
  capability); reads columns inside the session and returns a plain dict, so the
  caller never touches a detached ORM instance
- _resolve_skill: inline skill_md still wins (fast path); else resolve from DB,
  guarding disabled / empty-body / not-found with clean ChannelResult.fail
- resolved identity (skill_id/domain/capability/version) flows into the
  journey_trace + self-eval write-back
- tests: resolve by skill_id and by (domain, capability), disabled refusal,
  unknown-skill clean failure (whole skills suite: 96 passed)
Make backend/skills/ a self-contained, reusable package — importing the execute
loop no longer drags in the FastAPI dock or the pipeline/DB spine.

- new backend/skills/toolcall.py: the pure tool-call parse helpers
  (_is_xml_tool_model / _parse_tool_use / _safe_json + their constants), owned by
  the skills package instead of api.v1.chat. Breaks the skills.loop ->
  api.v1.chat import cycle (the lazy-import workarounds in skill_channel existed
  only because of it).
- loop.py imports those helpers from skills.toolcall and takes an injected `emit`
  sink (default None / no-op) instead of importing backend.pipeline.events,
  removing the loop's last spine dependency. skill_channel passes
  emit=events.emit, so run-event behaviour is unchanged.
- proof: `import backend.skills.loop` pulls in neither backend.api.v1.chat nor
  backend.pipeline.events; tests/skills green (96 passed, 2 live deselected).

Residual (host-side, does not block reuse): api.v1.chat still defines its own
copy of the three helpers — collapse to `from backend.skills.toolcall import ...`
when that (currently WIP) file is next committed. correction.py / distill.py
still touch ORM models at the adapter edge.
… entry into the skill execute domain

backend/api/v1/skill_bridge.py: a thin, domain-neutral mapper around SkillChannel.collect
for the universal-studio kernel's PythonBridge transport. Honors the cross-language wire
envelope { capability, params, inputs } -> { ok, outputs:{records,trace,self_eval}, events,
error? }; outputs are typed (records DataRef<Record>, trace DataRef<JourneyTrace>, self_eval
Value<SelfEval>) mapped from ChannelResult items+metadata; events project the journey trace
steps (post-hoc node.progress). Own router (NOT the agent dock chat.py), registered in
api/v1/__init__.py. Test via existing browser/model patch fixtures: tests/skills 97 passed.

Verified live end-to-end: real qwen3:4b (Ollama) drove real Chrome (CDP :9222) through the
real SkillChannel, returning extracted records + journey trace back through the kernel's
PythonBridge with node.progress events.

Pairs with universal-studio commit ed38c7e (PythonBridge TS half).
…FetchResult/Capabilities)

North star: adding a real data source should be ~100 lines of source-specific
"send one request, parse the response into items". Every cross-cutting concern —
token refresh, pagination, rate limiting, cursor persistence — belongs to the
runner, not the channel. Today they are inlined per-channel (opencli's collect()
is ~469 lines), so each source either reimplements them or can't do them. This
work flips it: thin channels that declare capabilities + source logic, a thick
runner that owns the cross-cutting concerns once.

Phase 0 is purely additive and non-breaking: it introduces the thick contract and
lets the existing channels inherit it via a default adapter. No runtime path
changes, no behaviour change.

- backend/channels/base.py: new Capabilities (frozen — incremental / paginated /
  auth_kind / session_affinity / default_rate), AuthContext (Phase 2 placeholder),
  FetchContext (context in), FetchResult (items + next_cursor + has_more), and
  ChannelFetchError. AbstractChannel gains a default `capabilities`, a default
  `fetch(ctx)` that bridges to the legacy collect() (one-shot, no cursor), and a
  default `identity(item) -> str | None` (None → the normalizer keeps its content
  hash, so dedup is unchanged this phase).
- collect() stays the abstract method, so the six existing channels are unchanged
  and inherit the contract for free. The adapter lives once in the base class
  (locality), not as six wrappers.
- pipeline / normalizer untouched — the new hooks are ignored by the runtime path
  this phase. identity() wiring, the runner three-piece (cursor store + retry/
  backoff client + token bucket), and RSS etag land in Phase 1.

tests/unit/channels/test_contract.py proves the seam: a collect-only channel (the
shape of all six) gets fetch()/identity()/capabilities for free, a failed collect
surfaces as ChannelFetchError, and RSSChannel inherits the contract unchanged. All
channel unit tests green (117 passed).
2233admin added 10 commits July 1, 2026 01:26
…te-limited retrying client + pagination)

The cross-cutting concerns a channel should never reimplement, built once for the
runner to own. Additive and not yet wired into the live collect stage
(collector.py still calls channel.collect); the DB-backed cursor store + migration,
the RSS etag override, and the pipeline switch are the next slice.

- backend/pipeline/http_client.py: TokenBucket (async, burst-aware) + parse_rate
  ("60/min" -> tokens/s) + RateLimitedClient wrapping httpx.AsyncClient — every
  request waits on the bucket then retries 429/5xx with exponential backoff +
  jitter, honoring a numeric Retry-After. A channel does `await ctx.http.get()` and
  gets all of it for free.
- backend/pipeline/cursor_store.py: CursorStore Protocol + InMemoryCursorStore. The
  runner depends on the Protocol (accept dependencies, don't create them); the
  DB-backed adapter swaps in behind it with no change above.
- backend/pipeline/channel_runner.py: run_channel(source, params) — loads the
  cursor (when the channel is incremental), builds the rate-limited client from the
  channel's declared rate, drives fetch() through pagination via
  has_more/next_cursor, persists the cursor after each page (crash mid-pagination
  resumes, not restarts), and guards with MAX_PAGES. channel/http/cursor_store are
  injectable for tests.

tests/unit/pipeline: run_channel drives pagination + saves a cursor per page +
resumes from a stored cursor + runs a collect-only channel once + honors MAX_PAGES;
the client retries 429->200, honors Retry-After, and gives up after max_retries.
All unit tests green (168 passed across pipeline + channels).
…PR1)

Insert an ItemSink seam between collection and the write destination, so a
source's data can later flow to the ODP hot path (OdpSink) or both at once
(DualSink) by selecting a sink — never by rewriting the pipeline. First cut of
the strangler-fig migration: make the boundary replaceable without changing
what crosses it.

This PR is behavior-preserving. LegacyDbSink wraps the existing
normalizer + storer path, INCLUDING the storer-level ODP forward that already
fires when ODP_INGEST_URL is configured. Extracting that forward into OdpSink
(and gating LegacyDbSink so DualSink cannot double-send) is intentionally
deferred to PR3 — LegacyDbSink is therefore not yet a pure legacy-DB sink.

- backend/pipeline/sinks/base.py: ItemSink Protocol + RunContext + SinkResult.
  SinkResult.records carries the persisted ORM rows so the downstream AI/notify
  steps keep working unchanged; its count semantics (accepted/duplicates/
  rejected) are pinned per-sink relative to each sink's own durable boundary.
- backend/pipeline/sinks/legacy_db_sink.py: the original normalize+store path,
  moved behind the seam with no behavior change. Carries a PR3 TODO at the
  storer call for the forward_to_odp gate.
- backend/pipeline/pipeline.py: steps 2+3 now delegate to active_sink.write_batch;
  run_pipeline gains an injectable `sink=` (defaults to LegacyDbSink).

Tests: LegacyDbSink normalizes then stores and maps the result; run_pipeline
delegates through an injected sink end-to-end. Existing test_pipeline /
test_storer / test_normalizer stay green = the behavior-unchanged proof.
All tests/unit green.
…rangler-fig PR2)

Pin the wire shape opencli-admin forwards to the Rust ingest service so a later
step can move the forward out of storer into an OdpSink behind a characterization
test proving equivalence. Forward is NOT moved yet — that is PR3.

- backend/odp/schemas.py: RecordEvent / OdpIngestResponse / IngestReject, a typed
  mirror of odp-rs/crates/odp-contracts (SCHEMA_VERSION=1). to_wire() reproduces
  the legacy forwarder bytes exactly (explicit nulls, stringified ids); now also
  parses the response `errors` array the old code dropped.
- backend/odp/mapper.py: RecordEventMapper — normalized record -> RecordEvent,
  single source of truth for the ODP payload shape (input is the normalized
  record, not the raw collector item).
- backend/pipeline/odp_client.py: triple_to_event delegates to the mapper;
  post_batch parses OdpIngestResponse. Public signatures unchanged; storer untouched.
- tests: pin the literal wire dict, mapper field mapping, and the storer forward
  gate (url set/unset, fail-open default, ODP_INGEST_REQUIRED fail-closed).

347 passed (was 325).
…(strangler-fig PR3)

The double-send trap: storer.store_records forwards to ODP on its own when
ODP_INGEST_URL is set. A naive DualSink(LegacyDbSink + OdpSink) would then send
each batch to ODP twice, polluting the shadow comparison. This slice resolves it
without breaking the legacy path.

- storer.store_records: add forward_to_odp gate (default True = behavior
  unchanged); the env-driven forward only fires when the flag is on.
- LegacyDbSink(forward_to_odp=True): threads the gate to storer.
- OdpSink: forward-only sink — normalizes, posts via the PR2 mapper/client,
  SinkResult.records=[] so AI/notify no-op on the ODP leg. Propagates failures
  (odp_primary/odp_only need to see them).
- DualSink: legacy write (authoritative, forward_to_odp=False) + OdpSink shadow
  forward exactly once; ODP failure is logged + recorded in SinkResult.errors and
  never blocks the legacy write.

write_strategy selection of these sinks is PR4 — default pipeline still uses
LegacyDbSink(), behavior unchanged. 355 passed (was 347).
…angler-fig PR4)

Pipeline write destination is now chosen per-source by a declared strategy
instead of an implicit env-var side effect. An injected sink still wins (tests,
callers); otherwise select_sink(source.write_strategy) decides.

- data_sources.write_strategy column (default 'legacy') + alembic migration
  o5j6k7l8m9n0 (server_default='legacy' so existing rows keep current behavior).
- backend/pipeline/sinks/strategy.py select_sink:
    legacy            -> LegacyDbSink()            (DB + original env-gated shadow)
    odp_shadow        -> DualSink(require_odp=False)(DB authoritative, ODP best-effort once)
    odp_dual_required -> DualSink(require_odp=True) (ODP failure surfaced)
    odp_primary       -> DualSink(require_odp=True) (write path == dual_required;
                                                     read-routing out of scope)
    odp_only          -> OdpSink()                 (no DB row)
    unknown/None      -> legacy (warns)
- DualSink gains require_odp: re-raise on ODP failure instead of swallow.
- pipeline.py wires select_sink at the write seam.

Default 'legacy' = behavior unchanged. 365 passed (was 355).
…r-fig PR5a)

The additive building blocks for the RSS vertical slice — all behind the
existing thick-channel seam, so the live pipeline (still on collect()) is
untouched. The collect-stage cutover to run_channel() is PR5b.

- source_cursors table (model + alembic p6k7l8m9n0o1, one row per source).
- DBCursorStore: CursorStore Protocol backed by source_cursors, upsert on save,
  own short-lived session (mirrors the sinks).
- RSSChannel migrated onto the thick contract:
    * capabilities = incremental (resumes from a persisted cursor)
    * fetch(): conditional GET via the cursor's etag/last_modified — 304 keeps the
      cursor and returns no items; 200 reparses and advances the cursor to the
      response ETag/Last-Modified. Uses ctx.http (rate-limited) when present.
    * identity() = entry id — a stable dedup key (edited title != new item).
- test_contract: RSS is now a migrated channel, so the "inherits defaults"
  witness moves to CLIChannel (still collect-only).

374 passed (was 365).
…committed post-write (strangler-fig PR5b)

The live cutover, opt-in by capability: only channels declaring
capabilities.incremental (RSS today) route through the thick runner; every other
channel keeps the unchanged one-shot collect() path.

- collector.collect: incremental channels go through run_channel with an
  in-memory staging cursor seeded from the DB cursor. The advanced cursor rides
  back in metadata['cursor_pending'] instead of being persisted during fetch.
- pipeline: after the write sink accepts the batch (a failed sink returned
  earlier), commit the staged cursor to DBCursorStore. The cursor never advances
  past data that did not durably land; committing during fetch would skip
  unwritten items. Deeper ODP durability (a queued 202 that never persists) is an
  ODP-side guarantee, tracked separately.
- test_collector: the dispatch mocks now declare Capabilities() (non-incremental)
  so they exercise the legacy path explicitly.

Non-incremental behavior unchanged. 379 passed (was 374).
…nity (Phase 3 PR-A)

Generalize the chrome-binding pre-step: instead of a hardcoded
`channel_type in ("opencli", "skill")`, the pipeline now gates on the channel's
declared `capabilities.session_affinity`. A new session-bound channel needs no
edit to the pipeline.

- OpenCLIChannel / SkillChannel declare Capabilities(session_affinity=True).
- pipeline.py resolves the channel via the registry and checks the capability
  (unknown channel_type still surfaces in the collect step, unchanged).

Behavior-preserving: the same two channels are gated as before. 382 passed (was 379).
Secrets stop living as inline plaintext in channel_config: store them encrypted
and resolve them at runtime into the runner's AuthContext, so channels never
touch raw values.

- backend/auth/crypto.py: Fernet wrapper, master key from env
  CREDENTIAL_ENCRYPTION_KEY (read lazily; encrypt/decrypt raise a clear
  CredentialCryptoError on missing/invalid key or corrupt token).
- source_credentials table (model + alembic q7l8m9n0o1p2): ciphertext only, one
  row per (source_id, key_name).
- backend/auth/manager.py AuthManager: store() encrypts+upserts; resolve()
  decrypts to {key_name: value}; resolve_context(source_id, auth_kind) shapes
  bearer/api_key/basic into AuthContext (auth_kind="none" short-circuits, no DB).
- channel_runner: fills AuthContext via AuthManager.resolve_context (replaces the
  Phase-0 placeholder); RSS (auth_kind=none) path unchanged, no DB hit.
- api_channel: logs a deprecation warning when an inline plaintext token/key/
  password is used; resolved header is byte-identical. Env indirection
  (token_env / {{secret:ENV}}) stays quiet.
- cryptography promoted to a direct dependency.

Wiring depth stops at AuthManager (api_channel is not forced onto fetch() — that
is a follow-up). 397 passed (was 382).
…R-B)

Bound how many collection runs touch the same host at once, so the fleet stays
polite to a site even when many sources target it. Enforced around the pipeline
run in run_collection_pipeline, so it covers every channel type — including the
browser-driven opencli/skill channels that never go through run_channel.

- backend/pipeline/domain_limiter.py: domain_of(source) derives the host from
  the channel_config (feed_url/base_url/url/site/endpoint); domain_slot() is an
  async per-domain semaphore (limit from PER_DOMAIN_CONCURRENCY, default 3).
  No-op when no domain can be derived (e.g. cli). The registry is keyed by
  (loop, domain) so a semaphore is never reused across event loops.
- runner.py wraps Phase 3 in `async with domain_slot(source)`.

In-process cap (one worker); strict cross-worker limiting would swap a Redis
limiter behind the same call site. 404 passed (was 397).
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 238 files, which is 88 over the limit of 150.

To get a review, narrow the scope:
• coderabbit review --type committed # exclude uncommitted changes
• coderabbit review --dir # limit to a subdirectory
• coderabbit review --base # compare against a closer base

Upgrade to a paid plan to raise the limit.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 15bda172-5607-4781-a2cf-a9fc2022e8c8

📥 Commits

Reviewing files that changed from the base of the PR and between 250422f and 5eab908.

⛔ Files ignored due to path filters (3)
  • frontend/package-lock.json is excluded by !**/package-lock.json
  • package-lock.json is excluded by !**/package-lock.json
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (238)
  • .github/workflows/ci.yml
  • .gitignore
  • .gitleaks.toml
  • .nvmrc
  • CONTEXT.md
  • DESIGN.md
  • README.md
  • README_HANDOVER.md
  • TESTING.md
  • backend/api/v1/__init__.py
  • backend/api/v1/chat.py
  • backend/api/v1/skill_bridge.py
  • backend/api/v1/skills.py
  • backend/api/v1/tasks.py
  • backend/auth/__init__.py
  • backend/auth/crypto.py
  • backend/auth/manager.py
  • backend/channels/api_channel.py
  • backend/channels/base.py
  • backend/channels/opencli_channel.py
  • backend/channels/registry.py
  • backend/channels/rss_channel.py
  • backend/channels/skill_channel.py
  • backend/migrations/versions/m3h4i5j6k7l8_add_skills.py
  • backend/migrations/versions/n4i5j6k7l8m9_add_awaiting_confirm_run_status.py
  • backend/migrations/versions/o5j6k7l8m9n0_add_write_strategy_to_data_sources.py
  • backend/migrations/versions/p6k7l8m9n0o1_add_source_cursors.py
  • backend/migrations/versions/q7l8m9n0o1p2_add_source_credentials.py
  • backend/models/__init__.py
  • backend/models/skill.py
  • backend/models/source.py
  • backend/models/source_credential.py
  • backend/models/source_cursor.py
  • backend/odp/__init__.py
  • backend/odp/mapper.py
  • backend/odp/schemas.py
  • backend/pipeline/channel_runner.py
  • backend/pipeline/collector.py
  • backend/pipeline/cursor_store.py
  • backend/pipeline/domain_limiter.py
  • backend/pipeline/http_client.py
  • backend/pipeline/odp_client.py
  • backend/pipeline/pipeline.py
  • backend/pipeline/runner.py
  • backend/pipeline/sinks/__init__.py
  • backend/pipeline/sinks/base.py
  • backend/pipeline/sinks/dual_sink.py
  • backend/pipeline/sinks/legacy_db_sink.py
  • backend/pipeline/sinks/odp_sink.py
  • backend/pipeline/sinks/strategy.py
  • backend/pipeline/storer.py
  • backend/skills/__init__.py
  • backend/skills/actions.py
  • backend/skills/correction.py
  • backend/skills/distill.py
  • backend/skills/loop.py
  • backend/skills/page.py
  • backend/skills/perception.py
  • backend/skills/prompt.py
  • backend/skills/risk.py
  • backend/skills/toolcall.py
  • backend/skills/trace.py
  • chrome/extension-src/project.json
  • docker-compose.build.yml
  • docker-compose.yml
  • docs/ARCHITECTURE.md
  • docs/COLLECTION_OPERATIONS_CONSOLE.md
  • docs/GLOSSARY.md
  • docs/PROJECT_MANAGEMENT.md
  • docs/adr/0001-use-flowgram-for-canvas-infrastructure.md
  • docs/adr/0002-use-accessible-operator-ui-foundations.md
  • docs/adr/0003-skill-execute-loop-architecture.md
  • docs/skills-execute-loop-PRD.md
  • docs/skills-issues/01-playwright-dependency-cdp-page-wrapper-injected-js.md
  • docs/skills-issues/02-action-executor-fixed-verb-set-to-playwright-ops-w.md
  • docs/skills-issues/03-cheap-model-step-loop-with-9-element-prompt-and-to.md
  • docs/skills-issues/04-risk-tiered-confirm-gate-auto-confirm-awaiting-con.md
  • docs/skills-issues/05-run-integration-wire-skillchannel-collect-into-the.md
  • docs/skills-issues/06-journey-trace-v1-emission-re-distill-correction-pa.md
  • docs/skills-issues/07-end-to-end-test-against-a-real-local-chrome-live-m.md
  • experiments/next-web/Dockerfile
  • experiments/next-web/README.md
  • experiments/next-web/next.config.ts
  • frontend/.dockerignore
  • frontend/Dockerfile
  • frontend/components.json
  • frontend/i18n-localization-audit.md
  • frontend/index.html
  • frontend/nginx.conf
  • frontend/package.json
  • frontend/postcss.config.js
  • frontend/project.json
  • frontend/src/App.tsx
  • frontend/src/api/client.ts
  • frontend/src/api/endpoints.ts
  • frontend/src/api/types.ts
  • frontend/src/components/AgentFlightBoard.tsx
  • frontend/src/components/Card.tsx
  • frontend/src/components/ChannelConfigForm.tsx
  • frontend/src/components/CommandPalette.tsx
  • frontend/src/components/ConfirmDialog.tsx
  • frontend/src/components/DataTable.tsx
  • frontend/src/components/EmptyState.tsx
  • frontend/src/components/ErrorAlert.tsx
  • frontend/src/components/ErrorBoundary.tsx
  • frontend/src/components/Layout.tsx
  • frontend/src/components/LoadingSpinner.tsx
  • frontend/src/components/NotifierConfigForm.tsx
  • frontend/src/components/PageHeader.tsx
  • frontend/src/components/Pagination.tsx
  • frontend/src/components/SkeletonLoader.tsx
  • frontend/src/components/StatusBadge.tsx
  • frontend/src/components/TruncatedText.tsx
  • frontend/src/components/opencli/MetricTile.tsx
  • frontend/src/components/opencli/OperatorCard.tsx
  • frontend/src/components/opencli/PanelHeader.tsx
  • frontend/src/components/opencli/PlaybackControls.tsx
  • frontend/src/components/opencli/WorkbenchPanel.tsx
  • frontend/src/components/opencli/index.ts
  • frontend/src/components/ui/alert-dialog.tsx
  • frontend/src/components/ui/badge.tsx
  • frontend/src/components/ui/button.tsx
  • frontend/src/components/ui/dialog.tsx
  • frontend/src/components/ui/input.tsx
  • frontend/src/components/ui/select.tsx
  • frontend/src/components/ui/separator.tsx
  • frontend/src/components/ui/skeleton.tsx
  • frontend/src/components/ui/tooltip.tsx
  • frontend/src/i18n/en.ts
  • frontend/src/i18n/index.ts
  • frontend/src/i18n/locales.ts
  • frontend/src/i18n/zh.ts
  • frontend/src/index.css
  • frontend/src/labs/topology/AGENT_DOCK_DESIGN.md
  • frontend/src/labs/topology/AgentDock.tsx
  • frontend/src/labs/topology/FlowGramTopologyCanvas.tsx
  • frontend/src/labs/topology/NetworkPage.tsx
  • frontend/src/labs/topology/NodeKitPage.tsx
  • frontend/src/labs/topology/ReactFlowTopologyCanvas.tsx
  • frontend/src/labs/topology/TopologyPage.tsx
  • frontend/src/labs/topology/flags.ts
  • frontend/src/labs/topology/nodes/StageOperations.tsx
  • frontend/src/labs/topology/topologyModel.test.ts
  • frontend/src/labs/topology/topologyModel.ts
  • frontend/src/labs/topology/workflow/WorkflowCanvas.tsx
  • frontend/src/labs/topology/workflow/WorkflowEditor.tsx
  • frontend/src/labs/topology/workflow/WorkflowNodes.tsx
  • frontend/src/labs/topology/workflow/WorkflowPage.tsx
  • frontend/src/lib/collectionWorkflowModel.test.ts
  • frontend/src/lib/collectionWorkflowModel.ts
  • frontend/src/lib/nodeActions.ts
  • frontend/src/lib/nodeRunService.test.ts
  • frontend/src/lib/nodeRunService.ts
  • frontend/src/lib/notificationDisplay.test.ts
  • frontend/src/lib/notificationDisplay.ts
  • frontend/src/lib/preferences.ts
  • frontend/src/lib/runInbox.test.ts
  • frontend/src/lib/runInbox.ts
  • frontend/src/lib/utils.ts
  • frontend/src/main.tsx
  • frontend/src/node-kit/README.md
  • frontend/src/node-kit/agent/graph.ts
  • frontend/src/node-kit/agent/toSchema.ts
  • frontend/src/node-kit/define.ts
  • frontend/src/node-kit/index.ts
  • frontend/src/node-kit/macros/MacroNode.tsx
  • frontend/src/node-kit/macros/index.ts
  • frontend/src/node-kit/macros/macro.ts
  • frontend/src/node-kit/macros/store.ts
  • frontend/src/node-kit/nodes/collection.tsx
  • frontend/src/node-kit/nodes/index.ts
  • frontend/src/node-kit/nodes/pipeline.ts
  • frontend/src/node-kit/nodes/primitives.ts
  • frontend/src/node-kit/nodes/processors.ts
  • frontend/src/node-kit/nodes/sources.ts
  • frontend/src/node-kit/registry.ts
  • frontend/src/node-kit/render/KitNode.tsx
  • frontend/src/node-kit/render/NodeInspector.tsx
  • frontend/src/node-kit/render/NodeSearchMenu.tsx
  • frontend/src/node-kit/render/NodeWorkbench.tsx
  • frontend/src/node-kit/render/atoms.tsx
  • frontend/src/node-kit/render/elk.d.ts
  • frontend/src/node-kit/render/elkLayout.ts
  • frontend/src/node-kit/render/nodeTypes.tsx
  • frontend/src/node-kit/runtime/engine.ts
  • frontend/src/node-kit/spec.ts
  • frontend/src/pages/AgentsPage.tsx
  • frontend/src/pages/BrowsersPage.tsx
  • frontend/src/pages/DashboardPage.tsx
  • frontend/src/pages/NodesPage.tsx
  • frontend/src/pages/NotificationsPage.tsx
  • frontend/src/pages/ProvidersPage.tsx
  • frontend/src/pages/RecordsPage.tsx
  • frontend/src/pages/SchedulesPage.tsx
  • frontend/src/pages/SettingsPage.tsx
  • frontend/src/pages/SourcesPage.tsx
  • frontend/src/pages/TasksPage.tsx
  • frontend/src/pages/WorkersPage.tsx
  • frontend/src/vite-env.d.ts
  • frontend/tailwind.config.js
  • frontend/tsconfig.json
  • frontend/vite.config.ts
  • iii/README.md
  • nx.json
  • package.json
  • pyproject.toml
  • tests/skills/__init__.py
  • tests/skills/test_actions.py
  • tests/skills/test_correction.py
  • tests/skills/test_execute_loop_live.py
  • tests/skills/test_loop.py
  • tests/skills/test_perception.py
  • tests/skills/test_risk.py
  • tests/skills/test_skill_channel.py
  • tests/unit/auth/__init__.py
  • tests/unit/auth/test_crypto.py
  • tests/unit/auth/test_manager.py
  • tests/unit/channels/test_api_auth_deprecation.py
  • tests/unit/channels/test_contract.py
  • tests/unit/channels/test_rss_fetch.py
  • tests/unit/odp/__init__.py
  • tests/unit/odp/test_mapper.py
  • tests/unit/odp/test_schemas.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_db_cursor_store.py
  • tests/unit/pipeline/test_domain_limiter.py
  • tests/unit/pipeline/test_dual_sink.py
  • tests/unit/pipeline/test_http_client.py
  • tests/unit/pipeline/test_legacy_db_sink.py
  • tests/unit/pipeline/test_odp_client.py
  • tests/unit/pipeline/test_odp_sink.py
  • tests/unit/pipeline/test_pipeline_affinity.py
  • tests/unit/pipeline/test_pipeline_cursor.py
  • tests/unit/pipeline/test_sink_strategy.py
  • tests/unit/pipeline/test_storer_odp_forward.py
  • tests/unit/test_runner.py

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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 the 'Skill' execute loop subsystem, enabling browser automation over CDP using Playwright, along with encrypted credential storage, incremental cursors, and a decoupled write sink architecture supporting both legacy database writes and ODP hot-path forwarding. On the frontend, Vite is established as the sole production mainline. The review feedback identifies several key issues: a regex bug in parsing nested JSON payloads within tool calls, database query inefficiencies in event polling and skill counting, tight coupling in the dual-write sink on ODP failures, potential flakiness on dynamic SPAs due to virtual DOM re-renders, a lack of double-quote escaping in prompt generation, and missing type validation on traces during re-distillation.

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/api/v1/chat.py
Comment on lines +367 to +369
_TOOL_USE_RE = re.compile(
r'<tool_use\s+name="([^"]+)"[^>]*?(?:/\s*>|>\s*(\{.*?\}|)\s*</tool_use>)', re.DOTALL
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The non-greedy brace matching \{.*?\} in the _TOOL_USE_RE regular expression will stop at the first closing brace }. If the JSON payload contains nested objects (e.g., {"options": {"cron": "..."}}), this regex will truncate the JSON string, causing json.loads to fail with a JSONDecodeError (which then returns an empty dict {} via _safe_json).\n\nSince the JSON is enclosed by the </tool_use> tag, we can safely match any characters up to the closing tag using (.*?) instead.

_TOOL_USE_RE = re.compile(\n    r'<tool_use\\s+name="([^"]+)"[^>]*?(?:/\\s*>|>\\s*(.*?)\\s*</tool_use>)', re.DOTALL\n)

Comment on lines +28 to +30
_TOOL_USE_RE = re.compile(
r'<tool_use\s+name="([^"]+)"[^>]*?(?:/\s*>|>\s*(\{.*?\}|)\s*</tool_use>)', re.DOTALL
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The non-greedy brace matching \{.*?\} in the _TOOL_USE_RE regular expression will stop at the first closing brace }. If the JSON payload contains nested objects, this regex will truncate the JSON string, causing json.loads to fail with a JSONDecodeError.\n\nSince the JSON is enclosed by the </tool_use> tag, we can safely match any characters up to the closing tag using (.*?) instead.

_TOOL_USE_RE = re.compile(\n    r'<tool_use\\s+name="([^"]+)"[^>]*?(?:/\\s*>|>\\s*(.*?)\\s*</tool_use>)', re.DOTALL\n)

Comment thread backend/api/v1/tasks.py
Comment on lines +155 to +160
result = await db.execute(
select(TaskRunEvent)
.where(TaskRunEvent.run_id == run_id)
.order_by(TaskRunEvent.created_at, TaskRunEvent.id)
)
events = result.scalars().all()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Polling the entire history of TaskRunEvent records for the active run every second is highly inefficient and does not scale. As the run progresses and accumulates events, the query will fetch and serialize an increasing number of rows on every single poll, only to discard the already-seen ones in Python.\n\nTo optimize this, you should track the last seen event's timestamp or ID and only query for new events (e.g., where(TaskRunEvent.run_id == run_id, TaskRunEvent.created_at > last_seen_time)).

Comment on lines +54 to +57
if self.require_odp:
# Dual-write required: surface the failure even though legacy wrote.
logger.error("odp forward failed under require_odp: %s", exc)
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

When require_odp is enabled, raising an exception here on ODP failure will cause the entire pipeline run to be marked as failed (success=False in run_pipeline), which completely skips downstream AI processing and notification steps.\n\nHowever, the legacy database write (self.legacy.write_batch) has already succeeded and committed the data. This creates a severe coupling where a transient ODP failure blocks all legacy downstream processing for successfully persisted records, and retrying the task might lead to duplicate processing or unique constraint violations. Consider decoupling the ODP write failure from the legacy pipeline completion, or handling this state transition more gracefully.

Comment thread backend/api/v1/skills.py
Comment on lines +58 to +66
count_stmt = select(Skill)
if domain is not None:
stmt = stmt.where(Skill.domain == domain)
count_stmt = count_stmt.where(Skill.domain == domain)
if enabled is not None:
stmt = stmt.where(Skill.enabled.is_(enabled))
count_stmt = count_stmt.where(Skill.enabled.is_(enabled))

total = len((await db.execute(count_stmt)).scalars().all())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using len(scalars().all()) to get the total count of skills is highly inefficient because it loads all matching Skill records from the database into memory. If the database grows large, this will cause significant memory overhead and latency.\n\nInstead, use SQLAlchemy's func.count() to perform the count query directly on the database side.

    from sqlalchemy import func\n    count_stmt = select(func.count()).select_from(Skill)\n    if domain is not None:\n        stmt = stmt.where(Skill.domain == domain)\n        count_stmt = count_stmt.where(Skill.domain == domain)\n    if enabled is not None:\n        stmt = stmt.where(Skill.enabled.is_(enabled))\n        count_stmt = count_stmt.where(Skill.enabled.is_(enabled))\n\n    total = (await db.execute(count_stmt)).scalar() or 0

const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;

el.setAttribute('data-skill-ref', String(ref));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Injecting custom data-skill-ref attributes directly into the live DOM is simple and clean, but on highly dynamic Single Page Applications (SPAs) built with React, Vue, or Svelte, virtual DOM re-renders can easily strip these custom attributes between the perception snapshot and the action execution.\n\nIf the attributes are stripped, subsequent selectors like [data-skill-ref="N"] will fail to find the elements. Consider adding a fallback mechanism or warning the operator about potential flakiness on highly dynamic SPA pages.

Comment thread backend/skills/prompt.py
role = str(el.get("role", "") or "")
name = str(el.get("name", "") or "")
value = str(el.get("value", "") or "")
line = f'#{ref} {role} "{name}"'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If the accessible name of an element contains double quotes (e.g., Click "Submit" button), this line will produce unescaped nested double quotes in the prompt (e.g., #3 button "Click "Submit" button"). This can confuse the LLM when parsing the element list.\n\nConsider escaping or stripping double quotes from the name before rendering.

        escaped_name = name.replace('"', '\\"')\n        line = f'#{ref} {role} "{escaped_name}"'

Comment on lines +101 to +106
if isinstance(traces, dict):
trace = traces
else:
if not traces:
raise ValueError("re_distill requires at least one trace")
trace = traces[-1] # v1: distill the most recent failing trace

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

There is no type validation on traces when it is not a dictionary. If traces is passed as a string or a list of strings/integers instead of dictionaries, trace = traces[-1] will resolve to a string/scalar, and the subsequent call to distill_trace(trace, provider) will raise an AttributeError when trying to call .get() on it.\n\nEnsure that trace is validated to be a dictionary before proceeding.

    if isinstance(traces, dict):\n        trace = traces\n    else:\n        if not traces:\n            raise ValueError("re_distill requires at least one trace")\n        trace = traces[-1]\n        if not isinstance(trace, dict):\n            raise ValueError("trace must be a dictionary")

…ggers edge label

node --test on .ts files needs Node's built-in type-stripping (default-on
since 23.6), unavailable on the pinned Node 20 runner -> ERR_UNKNOWN_FILE_EXTENSION
for every frontend test file.

topologyModel.ts labeled the manual-trigger source->task edge as "manual"
instead of "triggers", so buildTopologyGraph never emitted the edge the
test (and downstream consumers) expect.
@2233admin
2233admin merged commit 2178af7 into main Jul 1, 2026
4 checks passed
@2233admin
2233admin deleted the refactor/thin-channel-thick-runner branch July 1, 2026 03:55
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