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

197 lines
11 KiB
Markdown

# 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:
```ts
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).