Skip to content

refactor(channel,settings): connection entity + preference scopes (07-16 §5.2/§5.3) - #698

Draft
kaizhou-lab wants to merge 187 commits into
mainfrom
feat/channel-settings-refactor
Draft

refactor(channel,settings): connection entity + preference scopes (07-16 §5.2/§5.3)#698
kaizhou-lab wants to merge 187 commits into
mainfrom
feat/channel-settings-refactor

Conversation

@kaizhou-lab

Copy link
Copy Markdown
Contributor

Implements the deferred structural work from the 2026-07-16 multi-account database design (§5.2 channel tables, §5.3 settings dedup), scoped by the 2026-07-27 gap analysis and split plan. Based on feat/multi-account-user-scope; ships as one migration file (030_channel_settings_refactor.sql, four segments).

Channel: connection entity (A1–A4)

  • A1 assistant_pluginschannel_connections: generated conn_* ids, platform moves to plugin_key, phase-1 one-instance-per-(owner, platform) unique index. Runtime map, incoming messages, and repository address channels by (owner_user_id, connection_id); PluginStatusResponse gains connection_id (UI addressing unchanged). Also fixes a latent no-op in the disable route for persisted extension channels.
  • A2 assistant_userschannel_users (composite FK into connections, revocation is a soft delete with audit retention, external_user_id rename) and assistant_pairing_codeschannel_pairing_requests (surrogate id, HMAC-SHA256 code_hash — plaintext codes never persisted, pending partial uniques, approved_channel_user_id). conversations gains UNIQUE(user_id, id) as the §5.4 composite-FK foundation.
  • A3 assistant_sessionschannel_conversation_bindings: owner/connection derived from the active channel user (forged input ignored; revoked users cannot open bindings), agent_type/workspace dropped (owned by channel settings + conversation snapshot), cross-account conversation bindings made unrepresentable via triggers.
  • A4 preference keys assistant.{platform}.*assistant.{connection_id}.* with legacy read fallback.

Settings: dedup groundwork (B1–B2)

  • B1 client_preferences['language'] becomes the single language truth; /api/settings proxies reads/writes (column kept as convergent fallback).
  • B2 client_preferences gains scope (device|account): machine-level keys (system.closeToTray, keepAwake, autoPreviewOfficeFiles, pet.*) promoted to single device rows; the four system_settings switches materialize as normalized account keys and /api/settings serves all five fields preference-first — leaving B3 (frontend switch + table drop) mechanical.

Frontend-facing API notes

  • Pairing pending listings expose id instead of code; approve/reject accept {id} or {code}; the channel.pairing-requested event carries both.
  • ChannelSessionResponse.agent_type/workspace are deprecated optional fields (omitted from JSON).

Verification

Full workspace test suite, clippy -D warnings, and fmt pass on the merged branch; migration segments carry row-conservation and cross-account checks that abort the migration on mismatch, plus dedicated migration tests (backfill inheritance, hashed-code storage, device-key promotion, trigger rejections).

Out of scope per plan: multi-instance productization, channel_chats, code_encrypted redisplay, B3.

🤖 Generated with Claude Code

zk added 30 commits July 22, 2026 14:12
zk added 27 commits July 24, 2026 16:44
…for AionPro adoption

adopt_system_default_data discovered ownership tables only by a user_id
column, silently skipping tables whose owner column is owner_user_id
(project_explorer, channel assistant_users/pairing_codes/plugins) — on an
AionUi->AionPro upgrade those rows stayed on system_default_user while their
paired roots moved.

- Adoption now sweeps both ownership column names.
- New adoption-coverage gate (aionui-db tests): every table in the live
  schema must be classified as ownership root / declared parent-scoped
  (parent column verified) / declared machine-global (with rationale) /
  infrastructure. An unclassified new table — including ones merged from
  main — fails the suite until a conscious adoption decision is made.
- Upgrade e2e: first external user adopts the projects/explorer pair
  together; a second external account closes the window in either order.
…r delta table

The mixed-scope agent_metadata catalog let one agent_id have a global template
row plus per-user copy-on-write copies. Machine-level startup probes (unified in
main #678) took the no-acting-user write path, which minted system_default_user
copies on every AionPro boot — the catalog's single source of truth forked, and
identity fields could drift out of sync across copies.

- agent_metadata is now UNIQUE(agent_id): exactly one row per agent (builtin =
  user_id NULL; custom = creator's id as an ownership attribute). No copies.
- New agent_user_state(user_id, agent_id) holds every per-user delta: enabled
  toggle, command/env overrides, session-scoped availability, handshake caches.
  Reads LEFT JOIN and overlay user-first; the availability snapshot is taken
  whole from the winning side so its fields never mix.
- Startup probes UPDATE the catalog row in place (never insert); session
  outcomes / manual checks upsert the user's delta row.
- AionPro startup no longer runs the default-user generated-assistant reconcile
  or ingests the legacy shared skill directory (unattributable files); each real
  user materializes generated assistants lazily. Channel default-assistant
  resolution triggers that lazy materialization via a composition-layer port so
  the bare fallback still resolves.
- Gate e2e: after a full AionPro boot, no ownership column outside users holds a
  system_default_user row. Handshake/binding tests updated to the single-row model.
Replace the SHA256-hash .users/{hash} directory scheme (and the flat
root special-case for the default user) with a type-first layout:
skills/users/{user_dir}/ for every user, including the default user.
user_dir is derived via aionui_common::user_dir_name, keeping the
scheme consistent across domains.

is_user_scoped_skill_storage_path now matches the "users" path
component instead of ".users".
…/{dir}

Thread user_id through the avatar read/write/display path so avatar files
land in and are read from assistant-avatars/users/{user_dir}/. Generated
assistants share bare assistant_ids across users, so a flat avatars dir
collided; per-user dirs fix that. Reconcile and cleanup stay default-user
scoped; response builders pass the acting user.
New auto-provisioned conversation/team workspaces now land under
conversations/users/{user_dir}/{Y}/{M}/{D}/{leaf}. is_auto_workspace and the
delete/prune path accept BOTH the new per-user layout and the legacy userless
one, so pre-existing conversations keep working and are not misclassified as
custom (no historical migration needed — workspace paths are keyed by unique
conversation/team id). Threads user_id through the ConversationPort
create_team_temp_workspace trait.
…Pro adoption

When DB adoption re-owns the local default user's rows to the first external
(AionPro) user, physically move the corresponding per-user files
(skills/assistant-rules/skills/avatars) from users/system_default_user/ to
users/{adopter_dir}/ and rewrite skills.path, so the upgraded account sees its
files. Implemented as aionui_extension::fs_adopt (same-volume rename, EXDEV
copy fallback, idempotent) behind a SystemDefaultFilesystemAdopter port wired
in the composition layer. Best-effort; never fails provisioning.
…ackend

Boots real AppServices in AionPro mode and asserts against the production
filesystem: two users importing the same-named skill get distinct on-disk
roots (skills/users/{dir}) and materialization never crosses users; and
provisioning the first AionPro user physically moves system_default_user's
files to the adopter's root and rewrites skills.path.
…ser layout

The HTTP-driven e2e tests asserted the old flat/encode paths (assistant-rules/{id},
assistant-avatars/{file}, skills path .users/{hash}). Point them at the new
type-first users/{dir} layout; meaning preserved (still assert distinct per-user
on-disk storage and correct file contents).
…tests

Left dangling after the single-row refactor removed the custom_params test
helper; tripped -W unused-imports / CI.
…t state table

An agent is a machine-level resource: one CLI binary + login per device,
shared by every Core User. Its enable/disable state gates whether the
registry starts the agent (machine-level singleton reads it from the
catalog), so it belongs on the single agent_metadata catalog row, not a
per-user overlay. The frontend has no agent-enable entry point; per-user
enable/disable happens one layer up on assistants (assistant_overrides).

- 029 migration: remove the agent_overrides (per-user delta) table; the
  catalog row holds ALL agent state (capabilities, availability probe,
  command/env overrides, enabled).
- sqlite_agent_metadata: drop the per-user LEFT JOIN from the read
  projection (AGENT_METADATA_FROM); enabled comes straight from the
  catalog. set_enabled_for_user now UPDATEs the catalog after an
  owner-visibility check; delete no longer cleans a per-user table.
- registry: handshake writes always target the catalog (no
  system_default_user special-case).
- custom service: reload_one after enable/delete/upsert is now
  unconditional (machine-level cache refresh), fixing stale machine cache
  when a non-default user toggled a builtin agent.

Full workspace: 7682 passed.
Review doc: docs/superpowers/2026-07-27-multi-account-user-scope-review.md

Must-fix 1 — adoption file-move can now re-run after a partial failure:
- IUserRepository::is_sole_external_user exposes the adoption-window
  predicate (sole external user == the caller).
- AuthProvisionService gates fs_adopter on the window predicate instead of
  "rows adopted this call", so leftover files under
  users/system_default_user/ are picked up on a later login. The window
  closes with a second external user, so files can never leak.

Must-fix 2 — is_auto_workspace matches by path structure:
- session_context::is_auto_workspace now wildcards the users/{dir} segment
  and the {Y}/{M}/{D} date (leaf still carries the globally-unique
  conversation id), mirroring the delete-side matcher. Fixes two
  misclassifications: workspaces created on an earlier day, and adopted
  workspaces still under users/system_default_user/.

Channel sessions (review §3.1):
- get_or_create_session INSERT now requires a bound conversation to belong
  to the same Core owner (NULL stays allowed); new cross-account test.
- adoption_coverage gate grows a NON_CORE_USER_ID_TABLES category so
  assistant_sessions.user_id (a channel-user FK, not a Core owner) is a
  conscious classification instead of a false positive.

Dead-code removal (review §4.1, verified no callers incl. AionUi/aionpro):
- Drop ICronRepository::{update_system, delete_system,
  delete_by_conversation_system}; tests moved to the _for_user paths.
  Conversation-delete cascade is handled by the DB FK, not the removed
  method.

Fallback observability (review §4.3-4.5, kept per decision):
- The no-repo skill discovery fallback and the six no-dispatcher
  assistant-rule/skill route fallbacks now log a warn when taken; they are
  test/degraded-mode paths that production wiring never hits.
Review §6 (docs/superpowers/2026-07-27-multi-account-user-scope-review.md):

- Real cleanup-hook e2e (new session_revoke_cleanup_e2e.rs): boots the
  production router (real session_revoked_hook, not an injected double),
  revokes an AionPro session over HTTP, and asserts observable cleanup —
  the revoked token stops working and the user's channel sessions are
  cleared from the DB by the hook's async task (polled). Legs that would
  need live runtimes/websockets to observe are listed as not-covered in
  the test header.
- CROSS_ACCOUNT_REFERENCE over HTTP (cron_e2e): user B creates a cron job
  bound to user A's conversation and gets a real 409 with the exact error
  code; no job row leaks to either user.
- Two-user filesystem isolation: avatars (assistants_e2e) land under each
  owner's assistant-avatars/users/{dir}/ with their own bytes and per-user
  serving, and cross-user assistant-id reuse is rejected; auto-provisioned
  conversation workspaces (conversation_e2e) live under distinct
  conversations/users/{dir}/ roots on disk.

Also updates gitignored design docs under docs/superpowers/ (not committed):
matrix rows 9/22 + §6.3 in the 2026-07-17 root-scope design (agent is
machine-level incl. enabled; cron_jobs is an ownership root), the
2026-07-24 fs-isolation design (delete keeps files; adoption file-move
reinstated with window-gated re-run; invalid-id rejection dropped), and a
disposition table appended to the review doc itself.
…ge truth

Settings-dedup B1 (plans/2026-07-27-channel-settings-refactor-split-plan.md):

- SettingsService now proxies language through client_preferences:
  reads prefer the 'language' preference (JSON or raw string tolerated,
  non-string ignored with a warn) and fall back to the legacy
  system_settings.language column; PATCH writes the preference as the
  stored truth and keeps the column convergent for pre-B1 readers.
- /api/settings and /api/settings/client now agree on one stored value;
  covered by a cross-route integration test plus service-level tests for
  pref-wins, column fallback, raw-string tolerance, and non-string reject.
- Add consumer inventory + column disposition doc for B2
  (docs/superpowers/2026-07-27-settings-dedup-b1-inventory.md).
…to channel_connections

Channel refactor A1 (plans/2026-07-27-channel-settings-refactor-split-plan.md,
carrying 07-16 §5.2 forward):

- Migration 030 rebuilds assistant_plugins as channel_connections: the id
  becomes a generated meaning-free connection id, the platform type moves
  to plugin_key, PRIMARY KEY (owner_user_id, id) stays the composite
  identity for later composite FKs, and a unique index keeps the phase-1
  one-instance-per-(owner, plugin_key) rule. Backfill preserves the legacy
  identity as plugin_key with migration-fatal row-conservation checks.
- Repository speaks connections: get/upsert/delete/update_status by
  connection id plus get_connection_by_plugin_key as the bridge for
  callers that still address channels by platform.
- ChannelManager keys its runtime map by (owner_user_id, connection_id),
  resolves or generates the connection on enable, and stamps
  owner_user_id + connection_id onto every incoming message; public
  send/edit/is-running APIs keep platform addressing until sessions carry
  connection ids (A2/A3).
- UnifiedIncomingMessage and PluginStatusResponse expose connection_id
  (plugin_id stays the platform key so the UI is unchanged).
- Fix disable route guard that looked up extension connections by
  plugin_key through the id lookup, which would have turned disable into
  a silent no-op for persisted extension channels.
- Tests: 030 migration backfill test, phase-1 unique-index and plugin-key
  lookup coverage, manager/e2e assertions updated to the generated-id
  model.
…soft revoke and hashed codes

Channel refactor A2 (plans/2026-07-27-channel-settings-refactor-split-plan.md):

- Migration 030 segment 2:
  * assistant_users -> channel_users: rows attach to their connection via
    composite FK (owner_user_id, connection_id) -> channel_connections,
    platform_user_id becomes external_user_id (platform now derived from
    the connection), revocation becomes a soft delete (status
    active|revoked + revoked_at) so authorization history survives for
    audit, and the semantics-free session_id column is dropped. Orphan
    platforms get a synthesized disabled connection so no authorization
    silently disappears; row-conservation checks abort the migration on
    mismatch.
  * assistant_pairing_codes -> channel_pairing_requests: surrogate id,
    connection linkage, and code_hash (server-side HMAC-SHA256) — the
    plaintext code never touches the database. Legacy 10-minute codes are
    dropped by design. Partial unique indexes: one pending request per
    (owner, connection, external user) and per (owner, code hash).
  * assistant_sessions rebuilt so its user FK follows channel_users.
  * conversations gains UNIQUE(user_id, id) — the shared foundation for
    composite cross-account FKs (07-16 §5.4), consumed by segment 3.
- Repository: create_user reactivates revoked identities (active
  duplicate stays a conflict), revoke_user soft-deletes and removes the
  user's sessions explicitly, user reads join the connection for the
  derived platform and filter active rows; pairing CRUD keyed by
  surrogate id + pending-by-code-hash lookup.
- PairingService hashes codes with a key shared with channel credential
  encryption; approve/reject accept a PairingSelector (stored id from the
  cold-loaded list, or the transient plaintext code from the WS event);
  approvals record approved_channel_user_id.
- API: pending pairing listings expose id instead of the (no longer
  recoverable) code; approve/reject accept {id} or {code}; the
  channel.pairing-requested event carries both the id and the transient
  code. Frontends approving from a cold-loaded list must switch to id.
- Tests adapted to the new model at equal or stricter strength: plaintext
  absence, soft-revoke audit rows, reactivation on re-pairing, FK-backed
  connection seeding, migration backfill coverage.
…indings

Channel refactor A3 (plans/2026-07-27-channel-settings-refactor-split-plan.md):

- Migration 030 segment 3: assistant_sessions -> channel_conversation_bindings
  (owner_user_id + connection_id + channel_user_id derived from the channel
  user, chat_id -> external_chat_id, last_activity -> last_active_at) with a
  composite FK into channel_users (ON DELETE CASCADE) and one binding per
  (owner, connection, channel user, external chat). agent_type/workspace are
  dropped: agent configuration is owned by channel settings + the
  conversation snapshot, and workspace had no reader.
- Cross-account conversation bindings are made unrepresentable by INSERT and
  UPDATE triggers raising CROSS_ACCOUNT_REFERENCE. Triggers instead of a
  composite FK to conversations(user_id, id): a composite ON DELETE SET NULL
  would null owner_user_id together with conversation_id, and NO ACTION would
  block conversation deletion; the single-column FK keeps ON DELETE SET NULL.
  The service-layer EXISTS guards remain as defense in depth.
- Repository INSERT derives owner/connection from the ACTIVE channel user
  row, so forged owner/connection values in the input row are ignored and a
  revoked user cannot open new bindings (both covered by tests).
- SessionManager loses the agent_type/workspace parameters;
  update_agent_type is removed (no production caller). Action responses now
  display the currently configured agent from channel settings instead of a
  value frozen at session creation.
- ChannelSessionResponse keeps agent_type/workspace as deprecated optional
  fields (omitted from JSON) for API compatibility.
- adoption_coverage: the assistant_sessions non-core-user_id exception is
  cleared — the binding table's channel_user_id no longer masquerades as an
  ownership column and owner_user_id classifies it as a normal owned root.
- revoke_user's session cleanup gains an owner predicate (defense in depth).
- Migration test covers backfill inheritance, column renames, dropped
  columns, and both cross-account triggers.
Settings-dedup B2 (plans/2026-07-27-channel-settings-refactor-split-plan.md):

- Migration 031 rebuilds client_preferences with scope (device|account):
  device rows are machine-level (user_id NULL, one value per key via a
  partial unique index), account rows stay per-user. Keys confirmed
  device-level by the B1 inventory (system.closeToTray, keepAwake,
  autoPreviewOfficeFiles, pet.*) are promoted latest-write-wins and their
  per-user copies dropped. The four system_settings switches are
  materialized as normalized account keys (system.notificationEnabled,
  cron.notificationEnabled, system.commandQueueEnabled,
  system.saveUploadToWorkspace) with a migration-fatal read-back check;
  existing preference rows are never clobbered.
- Repository gains device-scope operations; the existing per-user methods
  are account-scoped.
- ClientPrefService routes keys by scope: device keys are readable and
  writable machine-wide (written by one user, visible to all), account
  keys stay isolated. keepAwake is device state now, so the startup
  restore no longer needs a user and runs in both identity modes.
- SettingsService extends the B1 language pattern to all four switches:
  preferences are the read truth (column fallback), writes land in
  preferences with the legacy row kept convergent — /api/settings and
  /api/settings/client agree on one stored value for every field, making
  the B3 proxy/drop step mechanical.
- adoption coverage: device rows (user_id NULL) are machine-global and
  excluded from adoption, account rows adopt normally — pinned by test.
Channel refactor A4 (plans/2026-07-27-channel-settings-refactor-split-plan.md):

- ChannelSettingsService resolves preference keys through the platform's
  connection: writes land on assistant.{connection_id}.* whenever the
  platform has a connection (falling back to the legacy platform key for
  platforms configured before their plugin is first enabled), and reads
  prefer the connection key with a legacy platform-key fallback so
  pre-refactor settings keep working. Phase 1 keeps one connection per
  (owner, platform), so old and new keys map one-to-one.
- Composition injects the channel repository into the settings service;
  without it the service keeps the legacy platform-only addressing
  (existing unit tests pin that behavior unchanged).
- Tests: writes land on the connection key and not the platform key;
  reads prefer the connection key over a stale platform value; legacy
  platform values still resolve; a connection-less platform stays on the
  platform key.
Ship the combined channel/settings refactor as one migration file:
031_client_preference_scope.sql becomes segment 4 of the renamed
030_channel_settings_refactor.sql (order preserved — the settings segment
never touched the channel tables). Migration tests updated to run the
combined version; the immutability gate passes (030 is not on main).
@kaizhou-lab
kaizhou-lab marked this pull request as draft July 28, 2026 03:58
Base automatically changed from feat/multi-account-user-scope to main July 29, 2026 09:43
…-refactor

# Conflicts:
#	crates/aionui-ai-agent/src/factory/acp.rs
#	crates/aionui-ai-agent/src/registry.rs
#	crates/aionui-api-types/src/channel.rs
#	crates/aionui-app/src/router/state.rs
#	crates/aionui-app/tests/channel_e2e.rs
#	crates/aionui-app/tests/extension_e2e.rs
#	crates/aionui-app/tests/session_revoke_cleanup_e2e.rs
#	crates/aionui-channel/src/action.rs
#	crates/aionui-channel/src/channel_settings.rs
#	crates/aionui-channel/src/manager.rs
#	crates/aionui-channel/src/message_service.rs
#	crates/aionui-channel/src/pairing.rs
#	crates/aionui-channel/src/plugins/dingtalk/plugin.rs
#	crates/aionui-channel/src/plugins/lark/plugin.rs
#	crates/aionui-channel/src/plugins/telegram/plugin.rs
#	crates/aionui-channel/src/plugins/weixin/plugin.rs
#	crates/aionui-channel/src/routes.rs
#	crates/aionui-channel/src/session.rs
#	crates/aionui-channel/src/types.rs
#	crates/aionui-channel/tests/manager_integration.rs
#	crates/aionui-channel/tests/orchestrator_test.rs
#	crates/aionui-channel/tests/pairing_integration.rs
#	crates/aionui-channel/tests/session_action_integration.rs
#	crates/aionui-channel/tests/telegram_integration.rs
#	crates/aionui-conversation/src/service.rs
#	crates/aionui-db/src/models/channel.rs
#	crates/aionui-db/src/models/client_preference.rs
#	crates/aionui-db/src/repository/channel.rs
#	crates/aionui-db/src/repository/client_preference.rs
#	crates/aionui-db/src/repository/sqlite_channel.rs
#	crates/aionui-db/src/repository/sqlite_client_preference.rs
#	crates/aionui-db/src/repository/sqlite_user.rs
#	crates/aionui-db/tests/adoption_coverage.rs
#	crates/aionui-db/tests/channel_repository.rs
#	crates/aionui-db/tests/feedback_diagnostics_repository.rs
#	crates/aionui-db/tests/user_scope_migration.rs
#	crates/aionui-realtime/src/handler.rs
#	crates/aionui-system/src/client_pref.rs
#	crates/aionui-system/src/settings.rs
#	crates/aionui-system/tests/settings_routes.rs
#	crates/aionui-team/src/service.rs
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