Files
HiveGrid-Wireframes/hivegrid-cloud/docs/architecture/plugin-hooks.md
T

11 KiB

Plugin Hooks & Lifecycle

Phase 06 Task #22 foundation. Builds on the WASM sandbox runtime from #21.

What ships

This task delivers the foundation for plugin host integration. Marketplace browsing, hot-reload, per-instruction fuel, the full 15-20 hook-point catalogue and broader marketplace workflows are deferred to follow-up work (#23 audit-docs + backlog/marketplace). Tenant plugin audit events are now exposed through apps/web/app/routes/app.$tenant/settings/plugins.$id.audit.tsx.

1. Hook registry — packages/plugin-runtime/src/hooks/registry.ts

A central, immutable map of hook-name → zod payload schema. Foundation hooks are pre-registered:

Hook name Outbox event type When it fires
tenant.created plugin.hook.tenant.created createTenant row insert succeeded
user.created plugin.hook.user.created register user row insert succeeded
entity.created plugin.hook.entity.created RowsApi.create after row + log write
entity.updated plugin.hook.entity.updated RowsApi.update after row + log write
entity.deleted plugin.hook.entity.deleted RowsApi.del after hardDelete
workflow.created plugin.hook.workflow.created Tenant-scoped workflow definition is created through reviewed service/import paths
workflow.updated plugin.hook.workflow.updated Tenant-scoped workflow definition is updated through reviewed service paths
workflow.deleted plugin.hook.workflow.deleted Tenant-scoped workflow definition is deleted through reviewed service paths
workflow.executed plugin.hook.workflow.executed Tenant-scoped workflow execution reaches terminal status
workflow.failed plugin.hook.workflow.failed Tenant-scoped workflow execution reaches terminal error status

Adding a new hook is a single registerHook({ name, outboxEventType, payloadSchema }) call at module load time. The registry validates that the event type matches plugin.hook.<entity>.<verb> so the outbox dispatcher's existing routing rules cover it without changes.

2. Hook emission — packages/plugin-runtime/src/hooks/emit.ts

emitPluginHook(tx, def, payload, publish) validates the payload and forwards to the outbox publisher. The host-side wrapper apps/web/app/modules/plugins/services/.server/pluginHookPublisher.ts owns a one-statement Prisma transaction so callers stay a single line:

await emitPluginHookFireAndForget(PLUGIN_HOOK_TENANT_CREATED, {...}, {
  tenantId,
  dispatchKey: `tenant:${id}:created`,
});

The wrapper is intentionally non-fatal to the surrounding business write: hook delivery failures are logged but never propagate into tenant signup, row CRUD, or workflow service calls. It still returns a promise, and reviewed workflow definition producers await it so the one-shot outbox transaction is attempted before the service returns. A malformed payload triggers PluginHookValidationError, which is also swallowed and logged.

3. Plugin lifecycle service — apps/web/app/modules/plugins/services/.server/PluginLifecycleService.ts

installPlugin, enablePlugin, disablePlugin, listPlugins. Each mutation writes a hash-chained audit-event row through the existing createAuditEvent helper. The service is permission-guarded at the route layer: read-only list/settings views require app.plugins.use; capability-use audit trails require app.settings.auditTrails.view; install, capability grant, enable, and disable require app.plugins.manage.

4. Tenant admin UI — apps/web/app/routes/app.$tenant/settings/plugins.tsx

A minimal settings page that lists installed plugins with their capabilities, manifest preview, and an enable/disable toggle. CSRF-validated form submissions go through the lifecycle service.

5. Plugin migration applier — packages/plugin-runtime/src/migrations.ts

applyPluginMigration({ pluginId, sql }, deps) parses the SQL, enforces plugin_<sanitized-pluginId>_ table prefix, requires a tenantId column on every CREATE TABLE, and rejects anything outside the CREATE TABLE / CREATE INDEX allowlist. Forbidden tokens (GRANT, DROP DATABASE, COPY, pg_*, etc.) are caught at validation time so the host never runs them.

6. Crash tracker — packages/plugin-runtime/src/crash-tracker.ts

SharedStore-backed sliding window per (pluginId, tenantId). Failures increment the counter (TTL = configurable window, default 10 minutes). Successes decrement (floored at 0). Once the threshold (default 10) is crossed the tracker calls the injected disablePlugin seam, calls the optional notify seam, and resets the counter so a re-enable starts fresh. Wired into apps/worker/src/jobs/plugin-invoke.ts via an optional recordOutcomeForCrashTracking seam; plugin_not_found / plugin_disabled outcomes are deliberately skipped so configuration glitches don't trigger auto-disable.

7. Workflow definition hooks

apps/web/app/modules/workflowEngine/services/WorkflowsService.ts emits metadata-only workflow.created, workflow.updated, and workflow.deleted hooks for tenant-scoped service-path definition writes and reviewed template imports. The reviewed create/update/delete/import paths await the publisher attempt after the workflow write succeeds; publisher failures are recorded by the hook publisher logger and remain non-fatal to the workflow mutation. Platform-scoped definitions and writes that bypass those reviewed paths remain outside current producer coverage.

8. Workflow execution hook — workflow.executed

apps/web/app/modules/workflowEngine/services/WorkflowsExecutionsService.ts emits workflow.executed after updateWorkflowExecution returns a terminal tenant-scoped success or error status. The payload intentionally carries only execution metadata (tenantId, workflowId, workflowExecutionId, type, status, durationMs, errorCode, appliesToAllTenants, occurredAt). errorCode is null for success and required for error. Raw error strings, workflow input, and workflow output bodies are not included because they can contain provider responses, URLs, or secret-like values.

Failed executions also emit workflow.failed with the same metadata-only shape but a fixed status: "error" and required errorCode. The separate failure hook lets plugins subscribe only to failure automation while existing workflow.executed subscribers continue to receive the full terminal-status stream.

Current producer coverage for workflow lifecycle hooks:

Lifecycle event Hook(s) Producer coverage Payload boundary
Tenant-scoped definition create workflow.created WorkflowsService.create and reviewed tenant template imports Metadata only; no blocks, credentials, or raw input
Tenant-scoped definition update workflow.updated WorkflowsService.update Metadata plus changed field names only
Tenant-scoped definition delete workflow.deleted WorkflowsService.del Metadata only
Tenant-scoped web/API/manual execution success workflow.executed WorkflowsExecutionsService Terminal metadata, errorCode: null
Tenant-scoped web/API/manual execution error workflow.executed, workflow.failed WorkflowsExecutionsService Terminal metadata, stable errorCode; no raw error
Worker-native trigger execution workflow.executed, workflow.failed apps/worker/src/jobs/workflow-trigger-native-executor.ts Terminal metadata only; failures use the stable workflow_execution_failed code

9. Worker-side hook fanout — apps/worker/src/jobs/plugin-hook-fanout.ts

The outbox dispatcher routes plugin.hook.* events to outbox.plugin-hook on the general worker group. The fanout consumer loads enabled plugins for the tenant, filters manifests that declare the hook, and enqueues one idempotent plugin.invoke job per subscriber on the isolated toolctrl.plugin-invoke queue. Plugin execution itself stays on WORKER_QUEUE_GROUP=plugin.

10. Per-user enablement — prisma/schema/plugins.prisma

Adds PluginUserConfig { id, pluginId, userId, enabled, configJson } with unique (pluginId, userId) and FK cascade on Plugin delete. User-scoped hook fanout loads PluginUserConfig.enabled when the validated hook payload includes userId; enabled === false skips that user's plugin invocation while tenant-default subscribers remain eligible.

11. Operator CLI — packages/cli/src/commands/plugins/*

toolctrl-cli plugins list [--tenantId X] [--enabled true|false]
toolctrl-cli plugins enable --tenantId X --pluginId com.acme.slack
toolctrl-cli plugins disable --tenantId X --pluginId com.acme.slack
toolctrl-cli plugins uninstall --tenantId X --pluginId com.acme.slack

Remaining Slices

Current status and operator-facing caveats live in docs/features/plugins.md and docs/operations/plugin-lifecycle.md. The historical implementation plan in docs/improvement-plan/06-plugin-system/hooks-lifecycle.md remains useful context, but it is not the current source of truth.

  • The remaining hook points (onWebhookOutbound, onEmailSend, afterAuthLogin, billing, integration-connected, api-key-created, chatbot-message, export-run, etc.).
  • Synchronous output hooks (emitHookSync with a 500ms wall-clock budget for transform / cancel capabilities).
  • Dedicated marketplace/install dialog UX. The operator-side install path runs through manifest verification, rejects unknown hook names, and tenant settings already approve missing manifest-declared grants via checkbox plus a server-verified digest.
  • Plugin secret storage and encrypted host bindings remain pending. The reusable settings iframe shell already enforces opaque-origin sandboxing, CSP, and a versioned postMessage protocol; manifest ui.settings.entry metadata is routed through the server-side bundle loader, and non-secret user config persists through PluginUserConfig.configJson.
  • Marketplace browsing + plugin signing-key rotation UI.
  • Hot-reload + per-instruction fuel limit.
  • Global (cross-tenant) crash counter for operator-level auto-disable.

Operational notes

  • PLUGIN_AUTO_DISABLE_THRESHOLD (default 10) and PLUGIN_AUTO_DISABLE_WINDOW_MS (default 600_000) are read by the worker bootstrap when constructing the crash tracker.
  • The outbox dispatcher routes plugin.hook.* events to outbox.plugin-hook; that consumer fans out to toolctrl.plugin-invoke. Keep the general worker and the separate plugin worker running in production-like topology so health checks, queue wiring, and isolation are exercised. Before external runtime signoff, the production worker-plugin should remain idle/gated unless PLUGIN_RUNTIME_PRODUCTION_ENABLED=true is intentionally enabled.
  • Audit events use action codes plugin.installed, plugin.enabled, plugin.disabled and plugin.crash.auto_disabled (the last fired by the crash-tracker notifier).