Files
LangBot/plugins/misc/DailyLimitPlugin/SHARED_RUNTIME.md
T

6.2 KiB

Shared runtime placement (shared-runtime-v1 + stateless-v1)

This source declares execution.sharedRuntime: shared-runtime-v1 and execution.componentModel: stateless-v1 in manifest.yaml. It is a source declaration only: it is not certified, has no signed archive, and has not been accepted against a live multi-Workspace deployment. The exact archive still needs independent review and acceptance runs.

Under shared placement one DailyLimitPlugin object and one of every declared component serve every installation of the artifact digest. Installation attachments are lightweight contexts; they do not create another object graph.

Per invocation

  • self.get_config() is read on every invocation, and no config value, credential or installation identity is stored on the shared object. The invocation config is only a seed: it is read when an installation first loads its own row, and that installation's stored settings win from then on.
  • get_installation_binding() is read per invocation and never retained beyond the call. It is the only authority for which installation's data is served.
  • The EventListener, Page and Tool paths all resolve the invoking installation's state on entry, so a request can never be answered from another installation's cached data.
  • The management page renders session ids and labels through DOM nodes, element properties and listener closures (textContent, value, addEventListener); no session field is interpolated into markup, so a stored id cannot add attributes or event handlers to the page.

Per binding, released on revocation

  • _states is keyed by the installation scope <instance_uuid>:<workspace_uuid>:<installation_uuid> and holds that installation's settings, sessions and its async lock. runtime_revision is deliberately excluded from the token so a worker upgrade does not orphan a tenant's data.
  • BasePlugin.on_installation_revoked(binding) pops exactly that key. It touches process-local state only, because no Host API is available during revocation.
  • Durable state is written under daily_limit_state:<instance>:<workspace>:<installation>. The Host row key ([instance_uuid, workspace_uuid, owner_type, owner, key]) has no installation dimension, so the installation identity has to live in the plugin's own key; without it, two installations in one Workspace would share a row.
  • Dedicated (binding-less) workers keep the legacy daily_limit_state key and a single in-process state, i.e. their existing behaviour.

Concurrency and durability

  • Every change of an installation's state runs inside that installation's async lock and consists of read, modify, serialize and await of the Host write: the EventListener counting path and the management page's update_settings, set_session_limit, reset_session, reset_all and delete_session all go through _transaction. The stored row is overwritten whole, so an unlocked writer could commit a snapshot that a concurrent writer had already moved past and roll the newer state back when the two Host writes complete out of call order. snapshot() reads under the same lock.
  • If a mutation or its write fails, or the call is cancelled, the in-memory state is restored to the last snapshot that actually landed, so memory and the durable row do not diverge. The counting path opts out of that rollback on purpose: a message that was already answered keeps consuming quota, and the next successful write persists the whole snapshot again.
  • update_settings validates a copy of the patched settings before swapping it in, so a rejected value cannot leave a partially updated settings dict behind.

Known limits

  • initialize() is process-scoped and does nothing: it runs once per worker with an empty config and, under shared placement, before any installation is known.
  • Settings are seeded from the invocation config when an installation first loads and are then owned by that installation's stored row (unchanged pre-existing semantics). Editing the plugin config of an installation whose row already exists does not override the stored values.
  • A shared worker that gets an invocation without a trusted installation binding raises instead of falling back to a binding-less key, so such a request is refused rather than served with shared state. A route that does not carry the installation binding therefore cannot work under shared placement.
  • Legacy data migration is not performed: on shared placement a pre-existing shared daily_limit_state row is no longer read (each installation starts from the config seed). Dedicated workers keep reading the legacy key unchanged.
  • The per-installation cache is unbounded in size until revocation; the runtime revokes on uninstall, disable, Workspace removal and upgrade.
  • Revocation is serialized per installation but may overlap with sibling installations' invocations; per-installation state is locked, cross-installation state is not shared.

Tests

tests/test_installation_scoped_daily_limit.py drives two distinct InstallationBindings (same instance and Workspace, different installation uuids) through one plugin object with the installed SDK's bind_invocation, over a synthetic Host KV whose row key mirrors LangBot's. It asserts that a write from one installation never appears in the other's snapshot, that a fresh object ("restart of the loader") reloads each installation's own row, that revocation drops only the revoked binding, and that dedicated binding-less workers keep the legacy key.

tests/test_admin_write_serialization.py uses a synthetic Host that keeps SET_PLUGIN_STORAGE pending and commits newest-first. It overlaps an admin write with a runtime write and two admin writes of one installation, and asserts that the durable row still holds both effects (pre-fix, the older snapshot was committed last and rolled the newer state back).

tests/test_page_session_id_injection.py renders components/pages/manage/index.html with tests/page_dom_probe.js (Node) against a recording DOM, with a session id of x');alert(1);//. It asserts that no markup string carries the id, that parsing every markup string yields no event-handler attribute, and that the row's Reset/Remove/limit controls still call the Page API with that exact id.