Skip to content

feat(channels): configure sidecar adapters (telegram/ntfy) from dashboard#5252

Merged
houko merged 29 commits into
mainfrom
feat/sidecar-channel-configure
May 19, 2026
Merged

feat(channels): configure sidecar adapters (telegram/ntfy) from dashboard#5252
houko merged 29 commits into
mainfrom
feat/sidecar-channel-configure

Conversation

@houko

@houko houko commented May 19, 2026

Copy link
Copy Markdown
Contributor

Summary

After #5249 / #5250 the dashboard could discover telegram and ntfy sidecar adapters but had no save button — operators still had to hand-edit ~/.librefang/config.toml. This PR closes that gap end-to-end:

  1. SDK self-description. Sidecar adapters now expose python -m <module> --describe returning a JSON schema (name, display_name, description, fields[]). A new run_stdio_main(adapter_class) entry point describes the class without instantiating it, so adapters whose __init__ requires env vars (telegram → TELEGRAM_BOT_TOKEN, ntfy → NTFY_TOPIC) still describe correctly when daemon spawns them with empty env.
  2. Daemon schema cache. At boot the daemon runs --describe for every entry in SIDECAR_CATALOG, caches the result with a 5s per-adapter timeout, and surfaces fields[] on /api/channels discovery rows. SDK-absent dev environments fall back to empty fields[] (logged at WARN) — dashboard renders the card without form fields rather than failing boot.
  3. Save endpoint. POST /api/channels/sidecar/{name}/configure accepts { values: {key: value} }. It validates required fields against the cached schema, then splits the payload: every type=secret field goes to ~/.librefang/secrets.env (mode 0600, atomic write+rename); every other field plus the [[sidecar_channels]] boilerplate (name / channel_type / command / args / env) goes to ~/.librefang/config.toml via toml_edit (preserves comments + sibling blocks). Response never echoes the request payload.
  4. Hot-reload. config_reload.rs now diffs sidecar_channels and reuses the existing HotAction::ReloadChannels path. The save handler explicitly follows up kernel.reload_config() with channel_bridge::reload_channels_from_disk(&state) — without that follow-up the kernel clears mesh.channel_adapters but the bridge cycle never re-runs, so the response claims ReloadChannels applied while no sidecar is actually spawned.
  5. Dashboard. Picker click on a category === "sidecar" row now opens a real SidecarForm (schema-driven inputs, password type for secrets, Show Advanced toggle) instead of the read-only details modal. Save → toast → channelKeys.all invalidated → configured row appears.

Reused infrastructure (kept this PR small)

  • crates/librefang-extensions/src/dotenv.rs already loads ~/.librefang/secrets.env into std::env at daemon startup; sidecar children inherit it through normal env inheritance. No new env-passing code needed.
  • HotAction::ReloadChannels already calls mesh.channel_adapters.clear() in config_reload_ops.rs:246-256; we only had to add the diff trigger and the handler-side reload_channels_from_disk follow-up.
  • toml_edit = "0.25" already in crates/librefang-api/Cargo.toml.

Tests

Layer File Tests
SDK Python tests/test_describe_schema.py 4 (Field/Schema shape)
SDK Python tests/test_describe_main.py 3 (describe_main + run_stdio_main class-not-instance dispatch)
SDK Python tests/test_first_party_describe.py 2 (telegram/ntfy SCHEMA contracts via subprocess)
Rust API tests/sidecar_describe_test.rs 2 (schema-fetch helper, error path)
Rust API tests/secrets_env_test.rs 4 (upsert atomicity, 0600 perms, append/replace, newline rejection)
Rust API tests/sidecar_toml_test.rs 3 (block upsert idempotency, sibling preservation)
Rust API tests/channels_routes_test.rs 35 — adds 3 new tests for configure_sidecar_channel (success splits values correctly, missing-required → 400, unknown name → 404). The success test asserts channel_adapters_ref().contains_key("telegram") post-save to catch any future regression of the broken bridge-re-init chain that the audit caught here; also asserts the response never echoes the secret value or includes a values field (Plan Risk #3 regression).
Rust kernel config_reload.rs 1 new unit test (sidecar_channels diff triggers ReloadChannels)
Dashboard mutations/channels.test.tsx 1 new (mutation invokes API + invalidates channelKeys.all)

Verification

All gates green on HEAD:

  • cargo check --workspace --lib --tests — clean (1m57s)
  • cargo clippy --workspace --all-targets -- -D warnings — clean (1m49s)
  • cargo test -p librefang-api --test channels_routes_test — 35/35
  • cargo test -p librefang-api --test sidecar_describe_test — 2/2
  • cargo test -p librefang-api --test sidecar_toml_test — 3/3
  • cargo test -p librefang-api --test secrets_env_test — 4/4
  • cargo test -p librefang-kernel --lib config_reload — 35/35
  • pytest sdk/python/tests/ — 75/75
  • pnpm typecheck — clean
  • pnpm test --run — 651/651 (68 files)
  • pnpm build — clean

Out-of-scope follow-ups (intentionally deferred)

  • Parallel describe_sidecar at boot — currently serial, 5s × 2 = 10s worst case. Plan-Risk Bump docker/build-push-action from 6 to 7 #1: trip-wire at SIDECAR_CATALOG.len() > 5.
  • secrets.env duplicate-key scrub — upsert_secret first-match-wins preserves any second occurrence of the same key. Loaders pick deterministically so behaviour is sound; cleanup is cosmetic.
  • __test_seed_sidecar_schema_cache is #[doc(hidden)] pub rather than #[cfg(any(test, feature = "test-utils"))]. Idiomatic Rust convention; tighten later if needed.

Plan

Detailed implementation plan committed in docs/plans/2026-05-19-sidecar-channel-configure.md (1881 lines). 15 commits across 6 phases; final code-review pass surfaced two flake-risk + leak-regression issues that were fixed in the last commit before this PR.

Test plan

  • Fresh config.toml with no [[sidecar_channels]]: dashboard Add picker shows Telegram + ntfy with form fields driven by SDK --describe
  • Click Telegram → form shows TELEGRAM_BOT_TOKEN (password input, required), ALLOWED_USERS + TELEGRAM_CLEAR_DONE_REACTION under Show Advanced
  • Submit with token → toast "Saved", drawer closes
  • ~/.librefang/secrets.env contains TELEGRAM_BOT_TOKEN=... with mode 600; config.toml [[sidecar_channels]] exists with name = "telegram", NO token in the toml file
  • Dashboard refreshes; Telegram now appears as configured (online)
  • Telegram sidecar comes up automatically via hot-reload — no daemon restart needed
  • Save again with new token → secrets.env replaces in place, no duplicate lines
  • ntfy save flow works the same way

@github-actions github-actions Bot added size/XL 1000+ lines changed area/docs Documentation and guides area/kernel Core kernel (scheduling, RBAC, workflows) area/sdk JavaScript and Python SDKs labels May 19, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

@github-actions github-actions Bot added the ready-for-review PR is ready for maintainer review label May 19, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d2207f6f6e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1298 to +1299
let home = librefang_home();
let secrets_path = home.join("secrets.env");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the running kernel home for sidecar saves

When the daemon is running with a configured KernelConfig.home_dir that differs from LIBREFANG_HOME/~/.librefang, this endpoint writes secrets.env and config.toml under the recomputed default home, while reload_config() and reload_channels_from_disk() read from state.kernel.home_dir(). The request can return a save/reload error or leave the actual sidecar config unchanged even though the dashboard submitted valid values; use the kernel's home directory from AppState for these paths.

Useful? React with 👍 / 👎.

…n, tighter secret validation

- configure_sidecar_channel now acquires state.config_write_lock for
  the secrets.env + config.toml write phase (issue #3183 parity with
  legacy configure_channel). Two concurrent
  POST /api/channels/sidecar/{a,b}/configure calls can no longer
  lost-update on config.toml or secrets.env. Guard is dropped before
  reload_config().await so hot-reload does not gate other handlers.
- Register routes::configure_sidecar_channel and the three schema
  types (ConfigureSidecarBody, SidecarSchema, SidecarSchemaField) in
  openapi.rs; openapi.json regenerated.
- upsert_secret rejects CR, NUL, leading/trailing whitespace, and
  values starting with a quote — all shapes the dotenv reader would
  silently corrupt on round-trip. Test renamed to
  upsert_rejects_invalid_value_chars with 4 sub-assertions; legacy
  'newline' substring check preserved.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 14a0daf19f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1323 to +1324
if f.field_type == "secret" {
super::secrets_env::upsert_secret(&secrets_path, &f.key, trimmed).map_err(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Load newly saved sidecar secrets before restarting sidecars

When a sidecar schema marks a field as secret (for example TELEGRAM_BOT_TOKEN or NTFY_TOKEN), this path writes it only to secrets.env and omits it from the [[sidecar_channels]].env table, then immediately hot-reloads the bridge. The sidecar subprocess only receives ctx.env plus the daemon's already-loaded process environment (crates/librefang-channels/src/sidecar.rs:407-409), while load_dotenv() is a boot-time Once loader (crates/librefang-extensions/src/dotenv.rs:38-42), so a freshly saved token is not visible to the child until a daemon restart or manual env export. In the dashboard save flow this makes first-party Telegram fail to start even though the endpoint reports status: saved; either update the live env used for this child or include the saved secret in the spawned sidecar environment before the reload.

Useful? React with 👍 / 👎.

github-actions Bot and others added 2 commits May 19, 2026 03:53
The dotenv loader's priority is system env > vault > .env > secrets.env,
so if the operator already exported (e.g.) TELEGRAM_BOT_TOKEN in the
shell that launched the daemon, the saved secrets.env value never
reaches the sidecar child. The save still mechanically succeeds — the
operator only finds out by debugging why the new token isn't applied.

configure_sidecar_channel now compares each non-empty secret field's
key against std::env::var (subtracting keys already present in the
on-disk secrets.env, which would otherwise count as our own previous
write). Matches are returned under `shadowed_secrets` in the 200
response. SidecarForm renders an additional warning toast (error
variant + 'Warning:' prefix, since the toast store has no warning
variant) when the array is non-empty.

Regression covered by the new channels_routes_test
configure_sidecar_warns_on_shell_env_shadow; the existing happy-path
test now also asserts shadowed_secrets is empty when no shadow is set.
OpenAPI regenerated (response description already enumerated the new
field).

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 347cf764a3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +52 to +55
let out = tokio::time::timeout(Duration::from_secs(5), fut)
.await
.map_err(|_| format!("`{command} ...--describe` timed out after 5s"))?
.map_err(|e| format!("spawn failed: {e}"))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Kill timed-out describe subprocesses

When an adapter hangs before producing --describe output, this timeout returns after 5 seconds but the spawned process was not configured with kill_on_drop(true) or explicitly killed, so cancelling the Command::output() future can leave the Python sidecar running after daemon boot continues. This affects the exact hang case described in the comment above and can accumulate orphaned adapter processes across restarts; set kill-on-drop or manage the child handle so timeout cleanup terminates it.

Useful? React with 👍 / 👎.

Add zh/en translations for keys introduced by feat/sidecar-channel-configure
(PR #5252). Without these the zh locale fell back to the English defaultValue,
so zh users saw English where they should have seen Chinese.

New keys (both locales):
- channels.saved
- channels.saved_restart_required
- channels.shadowed_secrets_warning  (refactored to use {{keys}} interpolation
  so the whole user-visible string lives in the translation file, including
  the "Warning:" prefix — addressed the "leave-it-as-English-prefix" question
  in the brief by translating the prefix too: zh = "警告:…")
- common.show_advanced / common.hide_advanced

Existing keys reused (no new entry, defaultValue retained as a build-time
sanity fallback to match the surrounding ChannelsPage convention):
common.close, common.cancel, common.save, common.saving, common.error.

config.saved_restart was NOT reused — it lives in a different namespace
(config.*), and the channels save toast intentionally owns its own
channels.saved_restart_required string.

Widened the t-prop signature on SidecarForm to also accept `keys?: string`
so the i18next interpolation typechecks.

Verified:
- pnpm typecheck (clean)
- pnpm test --run (68 files, 651 tests pass)
- pnpm build (clean)

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5d9202eadd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1395 to +1396
super::sidecar_toml::upsert_sidecar_block(
&config_path,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve included sidecar entries on save

When a deployment keeps [[sidecar_channels]] in a file referenced by include = [...], this call writes a new root-level sidecar_channels array into config.toml. The config include resolver deep-merges tables but replaces arrays (deep_merge_toml in crates/librefang-kernel/src/config.rs), so on the subsequent reload the newly written root array shadows the included array and any pre-existing sidecars from the include disappear. Preserve the effective sidecar list or update the file that owns the existing array before hot-reloading.

Useful? React with 👍 / 👎.

.and_then(|i| i.as_str())
.unwrap_or("");
if existing_name == name {
*aot.get_mut(i).expect("indexed") = block.clone();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve existing sidecar supervisor settings

When this helper updates an already-declared sidecar with the same name, it replaces the entire table with a minimal block containing only name, channel_type, command, args, and env. That drops valid SidecarChannelConfig tuning such as restart = false, retry/backoff limits, ready_timeout_secs, message_buffer, or overflow; the immediate hot-reload then restarts the adapter with defaults instead of the operator's existing settings. Carry forward non-form fields while replacing only the schema-managed env values.

Useful? React with 👍 / 👎.

@github-actions github-actions Bot added the area/channels Messaging channel adapters label May 19, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4eafd78d11

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1509 to +1513
if plan
.hot_actions
.contains(&librefang_kernel::config_reload::HotAction::ReloadChannels)
{
if let Err(e) = crate::channel_bridge::reload_channels_from_disk(&state).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reload sidecars after secret-only saves

When an existing sidecar entry is already present and the request only rotates a secret field, upsert_sidecar_block rewrites the same sidecar_channels config, so reload_config() can return no ReloadChannels action. Because the bridge reload is gated on that action here, the newly written secrets.env value is not loaded/restarted into the child process even though the endpoint returns status: saved; trigger a channel reload whenever a secret was written, or include secret-file changes in the reload decision.

Useful? React with 👍 / 👎.

github-actions Bot and others added 4 commits May 19, 2026 05:09
Move the `secrets.env` membership read and the `shadowed_secrets`
computation inside the `config_write_lock` block in
`configure_sidecar_channel`. Previously the read happened before the
lock was acquired, so two concurrent saves on different keys could
each see the pre-write file state and falsely report a shadow on a
key the other handler was about to write — cosmetic-only since the
writes are still serialised by the lock, but trivially closed by
reading under the same lock that gates the writes.

No behaviour change on the single-writer path.
Operators sometimes hand-edit `[[sidecar_channels]]` to point `command`
at a venv-pinned interpreter (`/opt/venv/bin/python`) or add extra
`args` (`--debug`). Saving from the dashboard ships the static
SIDECAR_CATALOG defaults (`python3` + module-load args); the previous
schema-managed write reverted those edits on every save.

Split the upsert helper into:

  - `write_command_and_args_defaults` — catalog defaults, written on
    **insert** and as a backfill when the existing block has neither
    `command` nor `args` (hand-written stub case).
  - `write_form_managed` — the keys the dashboard form actually owns
    (`name`, `channel_type`, `env`), always written. `env` is still
    replaced wholesale (form is source of truth at save time).

`restart`, `restart_*`, `ready_timeout_secs`, `shutdown_grace_secs`,
`message_buffer`, `overflow` (the codex review preserve set) keep
their existing semantics — untouched.

Tests:

  - `preserves_operator_custom_command_and_args_on_replace` — venv
    command + `--debug` arg survive a save with catalog defaults.
  - `backfills_command_and_args_when_existing_block_is_a_stub` — a
    block hand-written without `command`/`args` still gets the
    catalog defaults so the kernel can spawn it.

Existing tests (`replaces_existing_block_with_same_name`,
`preserves_operator_tuned_fields_on_replace`,
`does_not_touch_other_sidecar_blocks`) cover the cases where the
existing `command`/`args` already match the catalog defaults — they
keep passing because the preserve path is a no-op there.
`SidecarSchemaField.options` is rendered into the sidecar discovery
row's `fields[]` payload but the SidecarForm only knew about `secret`
and free-text inputs — a sidecar that declares
`{type: "select", options: [...]}` would render as a plain text
input, defeating the discrete-choice intent.

Switch to the existing `ui/Select` component (already used by
`SchemaForm` for the same `string + options` pattern) when
`f.type === "select"` and `f.options` is non-empty.

No first-party adapter currently emits `select`, so this is
forward-compatible plumbing rather than a user-visible regression
fix.
@houko
houko merged commit 3d1e837 into main May 19, 2026
27 of 31 checks passed
@houko
houko deleted the feat/sidecar-channel-configure branch May 19, 2026 05:27

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 15a8f84b9e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +442 to +443
if std::env::var(&k).is_err() {
merged.insert(k, v);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not let boot-loaded secrets mask rotations

When a secret key was already present in secrets.env at daemon start, load_dotenv() has copied that old value into the process environment. After the dashboard writes a new token and a channel reload respawns the sidecar, this check skips the updated file entry because std::env::var(&k) is still set, so the child inherits the stale process value instead of the just-saved secret. This affects normal token rotation for existing Telegram/ntfy sidecars; distinguish true shell/vault shadows from values originally loaded from secrets.env, or otherwise ensure the updated file value is applied to the child.

Useful? React with 👍 / 👎.

Comment on lines +1260 to +1263
let include_arr = match doc.get("include").and_then(|i| i.as_array()) {
Some(a) => a,
None => return Vec::new(),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recurse through included configs before saving sidecars

Fresh evidence: this helper only inspects the root include array, but the kernel config loader resolves includes recursively. If config.toml includes base.toml and base.toml includes sidecars.toml with [[sidecar_channels]], this check returns empty and the endpoint writes a root-level sidecar array; on reload, the root array overrides the nested included array, so the existing sidecars disappear. Walk the include graph the same way as the loader before deciding it is safe to write to the root file.

Useful? React with 👍 / 👎.

houko pushed a commit that referenced this pull request May 19, 2026
The Quality CI job on PR #5253 failed at `cargo xtask fmt` against 8
files under `crates/librefang-api/src/routes/`,
`crates/librefang-api/tests/`, and `crates/librefang-channels/src/`,
all merged in from the sidecar work on main (#5249/#5250/#5252/#5263).
None of these files are touched by this PR's logic — the drift sits
on main and any later dashboard-only PR rebased onto current main
would hit the same gate.

Mechanical rustfmt application:

- `routes/channels.rs`: alphabetize `use super::sidecar_describe`
  before `use super::skills`; collapse the `secrets_env_keys` and
  `upsert_secret(...).map_err(...)` chains to rustfmt's preferred
  break style.
- `routes/secrets_env.rs`: collapse the `parent.join(format!(...))`
  to a single line that fits in 100 cols.
- `routes/sidecar_describe.rs` & `channels/src/sidecar.rs`: collapse
  two-arg fn signatures (`describe_sidecar`, `build_spawn_env`,
  `SidecarAdapter::new`) onto one line.
- `tests/channels_routes_test.rs`: wrap the inline `vec![X { ... }]`
  literal across multiple lines so each field reads at indentation.
- `tests/sidecar_describe_test.rs`: collapse the `describe_sidecar(...)`
  call to one line.
- `channels/src/sidecar.rs`: split the long
  `got.get(...).map(|s| s.as_str())` chain across two lines per
  rustfmt's chain_width heuristic.

Verification:

- Each diff applied byte-for-byte from the CI log
  `gh api repos/.../actions/jobs/76681052747/logs`. cargo not
  available locally; relying on CI to confirm.
- No behavioural change.

Refs #5199, #5253
houko pushed a commit that referenced this pull request May 19, 2026
The Quality CI job (cargo xtask fmt -> cargo fmt --all --check) was
red on this PR because the sidecar files merged in from main via
#5252 (3d1e837) carried fmt drift — wrapped multi-line function
signatures rustfmt wants flattened to a single line, and a use-import
ordering tweak in channels.rs. The drift came in with the merge, not
with our scheduler changes, but CI runs against the branch tip so the
red shows up against our PR.

Fix by running `cargo fmt -p librefang-channels -p librefang-api`.
Pure mechanical rewrap — no behaviour change.
houko added a commit that referenced this pull request May 20, 2026
… to live connection (#5199) (#5253)

* fix(dashboard/chat): smooth auto-pin of agentId-only sessions; explicit "Unpinned" label (#5199)

PR #5211 added URL auto-pinning after the first message of an unpinned
`?agentId=` chat, but reused `handleBackendNewSession` which is wired
for `/new` semantics — it bumps `sessionVersion` to force a full reload.
The cascade was visible:
  data.session_id arrives → handleBackendNewSession
   → navigate(?sessionId=...) → useChatMessages.sessionId changes
   → load effect re-runs with sessionVersion bumped
   → deleteCachedChatMessages + setMessages([])
   → fetchQuery(agentQueries.session) refetch + flicker.

B fix: split the callback. New `handleAutoPinSession`:
  - navigates with `replace: true` (no Back-button trail through bare
    `?agentId=` URLs)
  - does NOT bump sessionVersion (the messages we just rendered belong
    to this session; bumping would wipe them)
  - invalidates only the per-agent sessions list so a freshly-created
    session surfaces in the dropdown

Before navigating, the WS response handler seeds the chat-message cache
under the new `(agentId, sessionId)` key from the just-applied response
patch. The post-navigation load effect then takes a cache hit on
sessionVersion=0 and renders instantly — no flicker, no refetch.
The response patch is reified as a pure mapper so the cache snapshot
matches what `setMessages` enqueues (messagesRef hasn't picked up the
update yet at handler time).

Auto-pin is also gated on `sendAgentId === currentAgentRef.current` so a
late WS response for an agent the user has already navigated away from
doesn't rewrite the URL off the agent they're now viewing.

C fix: replace the generic "Session" placeholder in the dropdown label
with an explicit "Unpinned" hint (new i18n keys en/zh) when
`activeSessionId` is undefined. PR #5211 already prevents the misleading
highlight from appearing on a row, but the label was ambiguous —
"Unpinned" surfaces the ambiguity directly so users know messages are
riding the canonical pointer rather than a chosen session.

Backend issue #5198 (canonical pointer context loss) is intentionally
out of scope here.

Closes #5199

* fix(dashboard/chat): guard auto-pin against user mid-flight session swap; test dropdown label contract (#5199)

Review follow-ups to PR #5253:

- Tighten the WS-response auto-pin gate to also require
  `currentSessionRef.current === null`. The existing gate only checked
  `sendAgentId === currentAgentRef.current`, so if the user manually
  pinned a session by clicking another row in the dropdown between
  send and response, an already-queued WS frame on the about-to-detach
  listener could overwrite the user's deliberate pin. `ws.close()` is
  async — a frame queued in the browser can still reach the listener
  before close takes effect — so the WS swap-out alone is not enough
  to drop in-flight responses. Skipping the auto-pin in that case
  preserves the worse-case behaviour from before #5253 (URL stays
  unpinned for that one turn) instead of regressing into "URL silently
  rewrote off the session I just selected".

- Extract the dropdown label-picking logic into a pure
  `pickSessionDropdownLabel` helper alongside
  `deriveDropdownActiveSessionId`. The label branch was inline JSX in
  a 2.5k-line file; with the helper extracted the contract (active
  row label > short id prefix > null) is unit-testable and a future
  refactor that drops the short-id fallback or the unpinned guard
  will be caught by the tests rather than landing silently.

- 6 new vitest cases for `pickSessionDropdownLabel` covering the
  unpinned case (returns null so the caller renders the localized
  "Unpinned" hint), label hit, short-id fallback when the session
  hasn't surfaced in the list yet, defensive empty-id handling, and
  non-truncation of human labels.

Verification:
- pnpm typecheck: clean
- pnpm test --run src/lib/sessionSelector.test.ts: 15/15 pass
- pnpm test --run: 649/662 pass; the 13 failures are pre-existing in
  `ModelsPage.test.tsx` on main and untouched by this change (same
  baseline noted in PR #5253's body).
- pnpm build: clean (ChatPage chunk 83.40 kB / 22.31 kB gz, unchanged
  from prior commit).
- node scripts/i18n-parity.mjs: clean.

Refs #5199

* style(channels): apply cargo fmt to sidecar + channel routes

The Quality CI job on PR #5253 failed at `cargo xtask fmt` against 8
files under `crates/librefang-api/src/routes/`,
`crates/librefang-api/tests/`, and `crates/librefang-channels/src/`,
all merged in from the sidecar work on main (#5249/#5250/#5252/#5263).
None of these files are touched by this PR's logic — the drift sits
on main and any later dashboard-only PR rebased onto current main
would hit the same gate.

Mechanical rustfmt application:

- `routes/channels.rs`: alphabetize `use super::sidecar_describe`
  before `use super::skills`; collapse the `secrets_env_keys` and
  `upsert_secret(...).map_err(...)` chains to rustfmt's preferred
  break style.
- `routes/secrets_env.rs`: collapse the `parent.join(format!(...))`
  to a single line that fits in 100 cols.
- `routes/sidecar_describe.rs` & `channels/src/sidecar.rs`: collapse
  two-arg fn signatures (`describe_sidecar`, `build_spawn_env`,
  `SidecarAdapter::new`) onto one line.
- `tests/channels_routes_test.rs`: wrap the inline `vec![X { ... }]`
  literal across multiple lines so each field reads at indentation.
- `tests/sidecar_describe_test.rs`: collapse the `describe_sidecar(...)`
  call to one line.
- `channels/src/sidecar.rs`: split the long
  `got.get(...).map(|s| s.as_str())` chain across two lines per
  rustfmt's chain_width heuristic.

Verification:

- Each diff applied byte-for-byte from the CI log
  `gh api repos/.../actions/jobs/76681052747/logs`. cargo not
  available locally; relying on CI to confirm.
- No behavioural change.

Refs #5199, #5253

* fix(api/chat): pin HTTP fallback turns too — close Codex P2 on #5253

PR #5253 added URL auto-pinning for unpinned `?agentId=` chats, but
only on the WS `response` event path. When `wsConnected` is false on
first send, or the WS drops and `sendViaHttp` handles the turn, the
HTTP fallback updates the assistant message but never calls
`onAutoPinSession` — and the wire shape had no `session_id` body
field anyway. So a bare `?agentId=` chat that took the HTTP path
stayed unpinned even though the kernel persisted to a concrete
session, leaving the user bookmarkable into a different canonical
session after a daemon restart. That is the exact regression #5253
was meant to close, just for the second transport.

Backend (preferred over a follow-up GET because the WS path already
includes the field — adding it here is the natural symmetry, not a
new surface):

- `MessageResponse` gains `pub session_id: Option<String>` with
  `#[serde(default, skip_serializing_if = "Option::is_none")]`. Field
  is omitted entirely on the wire when None — matches the WS handler
  which only emits `session_id` when `explicit_session.is_none()`.
- `routes/agents.rs::send_message` populates `session_id` from the
  post-turn registry entry only when `session_id_override.is_none()`
  — exact mirror of the WS branch in `ws.rs:1363`. When the caller
  explicitly pinned a session, the field is omitted so we never imply
  a server-side auto-resolution that did not happen.
- `routes/skills.rs::hand_send_message` passes `session_id: None`
  (hands do not surface an auto-pinnable session id via this body;
  #5199 is dashboard-chat-only).
- Two new serde-level tests in `types.rs::tests` lock the wire
  contract: `session_id` is omitted (not `null`) when None, and
  serializes as a bare string when present. A regression that flips
  the field to `null` would silently break the client's `typeof ===
  "string"` check.

Frontend:

- New shared gate `shouldAutoPinResolvedSession` in
  `src/lib/sessionSelector.ts` — pure type-predicate function that
  both transports call. Five guards: same agent, no manual pin in
  flight, URL was unpinned, `resolvedSessionId` is a non-empty
  string. Extracted so the WS and HTTP paths cannot drift apart in a
  future refactor.
- `ChatPage.tsx::sendViaHttp` now reifies its response-patch mapper
  (same pattern as the WS handler) and calls
  `shouldAutoPinResolvedSession` + `onAutoPinSession` exactly like
  the WS branch. Same cache-seed-before-navigate trick to avoid the
  post-nav flicker described in #5199-B.
- WS handler refactored to call the same gate — round-1 had this as
  an inline 4-guard block; folding both paths through the helper
  preserves the round-1 invariant verbatim (verified against the
  invariants partner listed: deep-link, second-message-on-unpinned,
  back-button, reconnect — see the helper's docstring).
- `AgentMessageResponse` TS type gains optional `session_id?: string`.
- 8 new vitest cases for `shouldAutoPinResolvedSession` covering:
  happy path, agent-swap mid-flight (Codex P1), manual pin mid-flight
  (round-1), already-pinned request, omitted/`null`/empty
  `session_id`, non-string `session_id` (defensive), and the type
  predicate narrowing contract.

Generated artifacts:

- `openapi.json` updated by hand to add the `session_id` property in
  alphabetical position (utoipa's stable ordering). The OpenAPI Drift
  CI job will overwrite from the struct via `cargo xtask codegen
  --openapi`; if my edit differs from the regen the auto-commit
  branch handles it transparently for internal PRs.
- `xtask/baselines/openapi.sha256` updated to match the hand-edited
  spec (`shasum -a 256 openapi.json`).
- SDKs not regenerated locally — rustfmt unavailable, and the JS/Go/
  Python generators don't model the response shape (no diff). CI's
  `python3 scripts/codegen-sdks.py` step regenerates and auto-commits
  if drift remains.

Verification:

- `pnpm typecheck`: clean.
- `pnpm test --run src/lib/sessionSelector.test.ts`: 24/24 pass
  (was 15/15 after round-1, +9 for the new gate).
- `pnpm test --run`: 658/671 pass; the 13 failures are the
  pre-existing `ModelsPage.test.tsx` baseline noted in #5253's body
  and unrelated to this change.
- `pnpm build`: clean. ChatPage chunk 83.65 kB / 22.36 kB gz (was
  83.40 kB / 22.31 kB — +0.25 kB for the new gate + shared HTTP
  fallback code path).
- cargo not available locally; CI is the gate for
  `cargo check --workspace --lib`, `cargo clippy --workspace
  --all-targets -- -D warnings`, and the two new types.rs serde
  tests.

Refs #5199, #5253

---------

Co-authored-by: vip <[email protected]>
houko added a commit that referenced this pull request May 20, 2026
…) (#5254)

* fix(dashboard): cron picker click no longer closes schedule form (#5247)

ScheduleModal previously used DrawerPanel, which pushes its body into a
single global push-slot consumed by PushDrawer. When the picker was
opened from inside another DrawerPanel (SchedulerPage create-schedule
form, HandsPage hand-detail panel), both DrawerPanels competed for the
slot — but as soon as the inner DrawerPanel claimed it, PushDrawer
stopped rendering the outer body, which unmounted the inner DrawerPanel
and tore the slot back down. Visible symptom: the user clicked the cron
chip, the create-schedule form vanished, no picker appeared.

Switch ScheduleModal to a Modal (fixed overlay, panel-right variant)
with the same size as before (3xl ≈ 768px, matched the previous
DrawerPanel xl=720px). The picker now renders independently of the
push-slot and can safely appear over any DrawerPanel without
collateral-closing it.

Also tighten the CanvasPage Schedule button's disabled tooltip to
explain why it doesn't open while the workflow is unsaved — addresses
the third symptom in the issue ("Workflow button doesn't open the
window at all"): the button was correctly disabled but the tooltip
just said "Scheduler" so the disabled state looked like a broken
button.

- crates/librefang-api/dashboard/src/components/ui/ScheduleModal.tsx
- crates/librefang-api/dashboard/src/pages/CanvasPage.tsx
- crates/librefang-api/dashboard/src/pages/SchedulerPage.test.tsx
  (regression test: opens cron picker, asserts the create-form body
  stays mounted; fails on baseline, passes with the fix)

Verification:
- pnpm typecheck: green
- pnpm build: green
- pnpm test --run src/components/ui/DrawerPanel.test.tsx
    src/components/ui/PushDrawer.test.tsx
    src/pages/SchedulerPage.test.tsx
    src/pages/HandsPage.test.tsx
    src/pages/WorkflowsPage.test.tsx — 42/42 pass
- Pre-existing test infra issue in ModelsPage.test.tsx (Zustand persist
  storage.setItem in jsdom) is unrelated — fails on baseline too.

Closes #5247

* fix(dashboard): keep cron picker above PushDrawer mobile overlay + i18n key (#5247 follow-ups)

Review follow-ups for #5254:

- Modal default zIndex is 50; PushDrawer mobile overlay is z-[55]. After
  switching ScheduleModal from DrawerPanel to Modal in #5254, opening
  the cron picker from inside a drawer on <lg viewports rendered the
  picker BEHIND the drawer overlay, invisible to phone users. Pass
  zIndex={70} on the panel-right Modal — above the mobile drawer (55)
  and OfflineBanner (60), still below notification dropdowns (90/100)
  and ConfirmDialog (150). On lg+ the host drawer is a flex <aside>,
  so any z >= 50 works; no desktop change.

- Add the new "canvas.save_before_schedule" key to en.json and zh.json
  per project convention. The PR was relying on the defaultValue
  fallback, which left the disabled-Schedule-button tooltip
  un-localized for Chinese users.

- Extend the #5247 regression test to also assert the picker's outer
  z-index is >= 56 so the mobile rendering bug above can't silently
  come back. Verified the assertion fails when zIndex is omitted.

* fix(dashboard): keep parent drawer open when Esc fires inside nested picker (#5247 Codex P2)

PushDrawer's window-level Escape handler was treating the drawer-root
as the *outermost* role=dialog ancestor and ignoring the fact that
nested dialogs (the cron picker's Modal, ConfirmDialog, etc.) might
sit between the focus target and the drawer-root. On <lg viewports the
drawer-root *is* a role=dialog, so the previous check —

    target.closest("[role='dialog']")
      && !target.closest("[role='dialog'][data-drawer-root]")

— matched the drawer-root for the second clause whenever the picker
was open inside the drawer, the guard returned false, and the handler
called triggerClose(). Esc from inside the cron picker tore the
surrounding create-schedule drawer down before the Modal could
dismiss itself.

Narrow the check to the *nearest* dialog ancestor: only close the
drawer when target's nearest role=dialog is the drawer-root itself.
Nested dialogs get first crack at Esc, as they should.

Regression test in PushDrawer.test.tsx forces mobile via matchMedia
spy, embeds a role=dialog inside the drawer body to simulate a
nested Modal, scopes the picker-input lookup to the drawer-root
subtree (PushDrawer renders content.body twice — once in the
desktop aside, once in the mobile overlay — and the bug only
exercises in the mobile copy), dispatches Esc at the nested input,
and asserts the drawer store is still open. A positive control
asserts plain-body Esc still dismisses the drawer.

Verified the test fails on the old condition and passes on the new
one (TDD discipline).

* style: apply cargo fmt rescue for sidecar adapter files merged from main

The Quality CI job (cargo xtask fmt -> cargo fmt --all --check) was
red on this PR because the sidecar files merged in from main via
#5252 (3d1e837) carried fmt drift — wrapped multi-line function
signatures rustfmt wants flattened to a single line, and a use-import
ordering tweak in channels.rs. The drift came in with the merge, not
with our scheduler changes, but CI runs against the branch tip so the
red shows up against our PR.

Fix by running `cargo fmt -p librefang-channels -p librefang-api`.
Pure mechanical rewrap — no behaviour change.

* fix(deps): clear brace-expansion 5.0.5 audit advisory (GHSA-jxxr-4gwj-5jf2)

The Security CI job (cargo xtask deps --audit --web) was failing on
the dashboard pnpm-audit step: openapi-typescript >
@redocly/openapi-core > minimatch resolved [email protected],
which is in the GHSA-jxxr-4gwj-5jf2 vulnerable range
(>= 5.0.0 < 5.0.6 — "Large numeric range defeats documented `max`
DoS protection", moderate).

Add a pnpm override for the v5 range, matching the pattern of the
existing v2 override added in #4227 (`brace-expansion@>=2.0.0 <2.0.3:
'>=2.0.3'`). [email protected] is a patch bump and ships in the
same 5.x line as the resolved version, so no semver-major surface
change.

Lockfile delta is intentionally minimal — `pnpm update brace-expansion
--recursive --lockfile-only --depth 999` only touches the
brace-expansion resolution and the one `[email protected]` consumer that
depends on it, leaving the rest of the lockfile untouched. Audit clean
after the change (`pnpm audit` returns "No known vulnerabilities
found").

---------

Co-authored-by: vip <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/channels Messaging channel adapters area/docs Documentation and guides area/kernel Core kernel (scheduling, RBAC, workflows) area/sdk JavaScript and Python SDKs ready-for-review PR is ready for maintainer review size/XL 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant