197 lines
11 KiB
Markdown
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).
|