Skip to content

feat(plugins): per-session activation (system-wide or per-number) - #438

Merged
rmyndharis merged 2 commits into
mainfrom
feat/plugin-per-session-activation
Jun 23, 2026
Merged

feat(plugins): per-session activation (system-wide or per-number)#438
rmyndharis merged 2 commits into
mainfrom
feat/plugin-per-session-activation

Conversation

@rmyndharis

Copy link
Copy Markdown
Owner

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

  • Manifest: sessionScoped?: boolean (default true). A false plugin is global — always runs, no per-number notion (e.g. a metrics logger).
  • Activation state: a session-scoped plugin carries activeSessions['*'] = all numbers (the default on enable), an explicit list, or [] for none. Set via PUT /plugins/:id/sessions (ADMIN), surfaced on the plugin DTO, and persisted on the registry so it survives a restart.
  • Enforcement: hook delivery is gated — a session-scoped plugin only sees events whose sessionId is in its active set. Enforced in both registration paths (the in-process registerHook wrapper 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.
  • End-to-end through the real 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.

Part of the v0.7 plugin-contract series. The vendored OpenWA-plugins/types/openwa.d.ts + PLUGIN-STANDARD.md are updated once the contract pieces land.

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
rmyndharis merged commit 9a8be80 into main Jun 23, 2026
@rmyndharis
rmyndharis deleted the feat/plugin-per-session-activation branch June 23, 2026 02:05
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.
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