feat(plugins): per-session activation (system-wide or per-number) - #438
Merged
Conversation
Plugins were all-or-nothing: enabled = its hooks fired for every session, and any per-number behavior had to be hand-rolled in the plugin's own config. Make activation a first-class platform concept. A plugin declares `sessionScoped` in its manifest (default true). A session-scoped plugin carries an `activeSessions` set — ['*'] = all numbers (the default on enable), an explicit list, or [] for none — settable via PUT /plugins/:id/sessions (ADMIN) and persisted on the registry so it survives a restart. Hook delivery is gated by it: a session-scoped plugin only sees events whose sessionId is in its active set (both the in-process registerHook wrapper and the sandboxed worker shim). A global plugin (sessionScoped:false) ignores this and always runs. Per-number *settings* (different config per session) are deferred to the config-UI work; this PR is activation (on/off per number) only. isPluginActiveForSession is a pure, unit-tested gate; end-to-end tests drive the real HookManager to prove delivery is scoped, plus setPluginSessions persists + rejects activating a global plugin.
…on-activation # Conflicts: # CHANGELOG.md # src/core/plugins/plugin.interfaces.ts
rmyndharis
added a commit
that referenced
this pull request
Jun 23, 2026
* feat(plugins): per-session config overrides
A session-scoped plugin can now carry per-session config overrides on top of its
base ('*') config. ctx.config is the resolved slice for the firing session:
resolvePluginConfig(base, sessionConfig, sessionId, sessionScoped) shallow-merges
the override over the base (global plugins / non-session events get the base).
- ctx.config is now a getter. In-process: an AsyncLocalStorage carries the firing
sessionId across the hook handler so the getter resolves the right slice, even
when events for different sessions interleave (no shared-state race). Sandboxed:
the host ships the resolved slice on each hook dispatch (new optional protocol
field) and the worker scopes it via its own AsyncLocalStorage during the call;
ctx.config falls back to the base outside a hook (lifecycle/onConfigChange).
- Storage: PluginInstance/registry gain sessionConfig (keyed by sessionId),
persisted and reloaded across restart.
- API: PUT /plugins/:id/config/:sessionId (ADMIN) sets/clears a session override;
the DTO exposes sessionConfig with secrets redacted per slice. Secret restore
applies per slice (a sentinel keeps the stored per-session secret).
Tests: resolvePluginConfig unit; worker integration (slice delivery + interleave
race-safety); service (override store, per-slice redact/restore, clear). Full
gate green (tsc + lint + 1257 jest).
* fix(plugins): persist activeSessions + sessionConfig across reload; 400 for global per-session config
Review of the per-session config backend found two issues:
- HIGH: ensureRegistryEntry rebuilt the registry entry on every load preserving
only config + installedAt, so it wiped activeSessions AND sessionConfig from
disk each boot — an operator's per-session activation/config was silently lost
after the second restart (the first still had the pre-wipe in-memory copy). Now
carries both over. This also fixes the same pre-existing data loss for #438's
per-session activation. Regression test exercises two reload cycles.
- LOW: per-session config on a global (sessionScoped:false) plugin now returns
400 (mirrors PUT /:id/sessions) instead of 200 {success:false}.
Full gate green (tsc + lint + 1259 jest).
rmyndharis
added a commit
that referenced
this pull request
Jun 23, 2026
* docs(spec): per-session plugin dashboard UI design (v0.7) * feat(plugins): per-session activation + config UI in the dashboard Surfaces the two per-session backends that shipped without UI (#438 activation, #441 per-session config) in a tabbed plugin config modal. - API client: Plugin type gains sessionScoped/activeSessions/sessionConfig; setSessions (PUT /plugins/:id/sessions) + updateSessionConfig (PUT /plugins/:id/config/:sessionId). - Config modal: session-scoped non-engine plugins get [Configuration] [Sessions] tabs. Configuration is unchanged. Sessions has: - Activation: All sessions vs a chosen set -> setSessions. - Per-session config override (when the plugin has a configSchema/configUi): a session picker -> the B-lite form (seeded from the resolved slice) or the sandboxed iframe (sessionId passed to the bridge). Save writes only non-secret keys that differ from base + all secret keys (sparse override: untouched keys inherit Global; the backend restores untouched secrets), so editing Global still propagates to a session's unchanged fields. Clear override resets to Global. - i18n: 13 new keys across all 9 locales (parity gate passes). CSS for tabs + Sessions sections. Dashboard-only; all backend APIs pre-existed. Build (tsc) + lint + i18n parity green. * fix(plugins): harden per-session config (deep-merge, sparse override, live modal state) Adversarial review of the per-session dashboard UI found 7 issues; fixes: - HIGH: resolvePluginConfig now DEEP-merges the override over the base (was shallow). A sparse override (e.g. an object whose nested secret the operator didn't touch) no longer drops the base's untouched nested keys at runtime; arrays/scalars still replace. (+unit tests for nested merge + array replace.) - The per-session override save is centralized in sparseSessionOverride(): only non-secret keys that differ from base, plus all secret keys; an untouched optional field with no Global value no longer creates a spurious empty override. Used by BOTH the B-lite form and the iframe per-session save (the iframe path was writing every resolved key as an override). - The config modal derives the open plugin from the LIVE plugins query instead of an open-time snapshot, so the Sessions tab reflects fresh activeSessions/sessionConfig after a save + invalidate. - The per-session form renders the localized schema (labels/descriptions), matching the Configuration tab. Backend tsc + 1231 jest; dashboard build + lint + i18n parity green. * fix(plugins): don't wipe in-progress per-session edits on a background refetch configPlugin is derived from the live query, so the plugin prop gets a new reference on every refetch (refetchOnWindowFocus). Key the override-seed effect on [selSession, plugin.id] instead of the plugin object so it reseeds only when the selected session/plugin changes — a window-focus refetch mid-edit no longer clobbers the operator's unsaved override. * docs(plugins): scope sparseSessionOverride inherit guarantee (array-of-rows secret caveat) Re-review noted the docstring overstated the 'untouched secret inherits' promise: it holds for top-level + object-nested secrets, but NOT a secret column inside an array-of-rows (arrays replace wholesale on resolve; the redacted dashboard can't resend untouched rows' real secrets). Documented the limitation; no bundled plugin ships that shape. Comment-only.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The headline of the v0.7 plugin contract: a plugin can now be activated system-wide (all numbers) or for specific numbers — first-class, instead of every plugin hand-rolling it (which is exactly what made PR #435 reach for a hack).
What
sessionScoped?: boolean(defaulttrue). Afalseplugin is global — always runs, no per-number notion (e.g. a metrics logger).activeSessions—['*']= all numbers (the default on enable), an explicit list, or[]for none. Set viaPUT /plugins/:id/sessions(ADMIN), surfaced on the plugin DTO, and persisted on the registry so it survives a restart.sessionIdis in its active set. Enforced in both registration paths (the in-processregisterHookwrapper and the sandboxed worker shim), so the plugin physically never receives an event for a number it isn't active for. (Per the design decision: gate hook delivery; capability-level gating not needed since message-driven plugins act only from their hooks.)Per-number settings (different config per session) are intentionally deferred to the config-UI PRs — editing them is the config UI. This PR is activation (on/off per number) only.
Tests
isPluginActiveForSession: pure gate — global always-on,*, explicit list, empty = none, non-session events not gated.HookManager: delivery is scoped to active sessions,['*']/unset = all, global = always.setPluginSessions: persists the new set; rejects activating a global plugin.Full backend suite green (1218), build + lint clean. Additive; existing plugins default to
sessionScoped:true+['*']= today's "runs everywhere" behavior, so no behavior change on upgrade.