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 hivegrid.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/*
hivegrid-cli plugins list --tenantId X [--enabled true|false]
hivegrid-cli plugins enable --tenantId X --pluginId com.acme.slack
hivegrid-cli plugins disable --tenantId X --pluginId com.acme.slack
hivegrid-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 remaining hook points (
onWebhookOutbound,onEmailSend,afterAuthLogin, billing, integration-connected, api-key-created, chatbot-message, export-run, etc.). - Synchronous output hooks (
emitHookSyncwith a 500ms wall-clock budget fortransform/cancelcapabilities). - 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.entrymetadata is routed through the server-side bundle loader, and non-secret user config persists throughPluginUserConfig.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) andPLUGIN_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 tooutbox.plugin-hook; that consumer fans out tohivegrid.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 productionworker-pluginshould remain idle/gated unlessPLUGIN_RUNTIME_PRODUCTION_ENABLED=trueis intentionally enabled. - Audit events use action codes
plugin.installed,plugin.enabled,plugin.disabledandplugin.crash.auto_disabled(the last fired by the crash-tracker notifier).