Task Breakdown: Dynamic Plugin Distribution (dual-mode)
Ordered, granular tasks derived from
plan.md. Each task is small enough to land in a single PR and ships with tests per Constitution Principle VI.
Feature ID: dynamic-plugin-distribution
Plan: ./plan.md
Status: In progress — Phase 7: T27 is complete (bundled plugins hydrate; a plugin the worker image does not carry is installed into the worker's own store and registered, in dynamic mode). T26's first long-running caller ships behind a switch that is off by default; its FR-15 facade path (a second switch, off by default) takes effect since T27's runtime-installed half. See the Phase 7 status note.
Last updated: 2026-09-26
How to use
- Tasks are sequential by default.
(parallel)tasks can run alongside their predecessor. - Each task has explicit file paths so an implementer can pick it up cold.
- Jira mapping: the parent Epic groups these into child Tasks named
[EW-<epic> Tn-Tm] …(see the Epic for the live keys).
Phase 1 — SDK & manifest (T1–T4)
- T1. Add
distribution?: 'core' | 'registry'andexecutionProfile?: 'sync' | 'long-running'toPluginManifestatpackages/plugin/src/contracts/plugin-manifest.types.ts.- Document default derivation:
systemPlugin === true ⇒ 'core', else'registry'. - Test: manifest type + default-derivation unit test in
packages/plugin.
- Document default derivation:
- T2. Update the manifest JSON-schema validator
(
packages/agent/src/plugins/services/plugin-manifest-validator.service.ts) to accept and validate the new fields; keep old manifests valid (forward-compat).- Test: validator spec covering present/absent/invalid values.
- T3 (parallel with T2). Add install/catalog DTOs in
packages/contracts/src/api/plugins/(PluginInstallStateDto, catalog entry, allowlist DTOs); export from the package index. - T4. Bump
@ever-works/pluginminor; note additive change in its changelog. Confirmpnpm build:plugins+ type-check green.
Phase 2 — Data model & migrations (T5–T8b)
- T5. Add columns to
PluginEntityatpackages/agent/src/plugins/entities/plugin.entity.ts:source,registrySpec,installedVersion,integrity,installState.- Test: entity spec.
- T6. Add
PluginAllowlistEntityatpackages/agent/src/plugins/entities/plugin-allowlist.entity.ts; register inPLUGIN_ENTITIESand the agent entities index. - T7. Add
PluginAllowlistRepository+PluginRepositorymethods for install-state transitions atpackages/agent/src/plugins/repositories/.- Test: repository specs.
- T8. Generate migrations from
apps/api/:AddPluginDistributionColumns+CreatePluginAllowlist(pnpm typeorm migration:generate -d typeorm.config.ts src/migrations/<Name>). Read the SQL by hand — additive, forward-only, noDROP. (NN #16) - T8b. Decouple the API from storage plugins: remove
@ever-works/{aws-s3,minio,github-storage}-pluginfromapps/api/package.json; resolve storage via the capability facade/registry instead of static imports. Set those three plugins' manifestdistribution: 'registry'. Keeplocal-fs(systemPlugin) bundled as the core default so the API boots with working storage and no distributable plugin is boot-critical (FR-4).- Test: API boots with only
local-fs; s3/minio/github-storage resolve via facade when enabled; e2e for default-storage path.
- Test: API boots with only
Phase 3 — Publish pipeline (T9–T13)
- T9. Add Changesets (
.changeset/config.json) configured for independent versioning ofpackages/plugins/*+packages/plugin. - T10. Flip
private: true → falseand addpublishConfig(access + registry) to each distributable pluginpackage.json. Leave core plugins as-is. Verify the core/distributable split against T1's rule. - T11. Add a dual-publish GitHub Actions workflow at
.github/workflows/publish-plugins.ymlmirroring the auth pattern in.github/workflows/publish-cli.yml: build changed plugins, publish to public npm and GitHub Packages (@ever-worksscope), gated on release/changeset. - T12. Add a
release/publishscript per distributable plugin (or a root orchestration script) and wire into the workflow; include a--dry-run. - T13. CI dry-run publish on a PR; confirm both registries resolve the
package and that
npm view/ GitHub Packages show the version.
Phase 4 — Config & feature flag (T14–T15)
- T14. Add config to
apps/api/src/config/constants.ts(lazy-fn pattern):PLUGIN_DISTRIBUTION_MODE(bundled|dynamic, defaultbundled),PLUGIN_REGISTRY_URL,PLUGIN_REGISTRY_GITHUB_URL,PLUGIN_REGISTRY_TOKEN(secret),PLUGIN_INSTALL_DIR(default/app/plugins),FEATURE_DYNAMIC_PLUGINS. Fail-fast validation when dynamic + no registry.- Test: config validation spec.
- T15. Thread the mode/paths into
PluginsModule.forRootAsyncoptions (packages/agent/src/plugins/plugins.module.ts,apps/api/src/api.module.ts) sopluginPaths/install dir derive from config.
Phase 5 — Installer & boot reconcile (T16–T20)
- T16. Implement
PluginInstallerServiceatpackages/agent/src/plugins/services/plugin-installer.service.ts: resolvepkg@versionfrom the registry, allowlist-check first, download, verify integrity, place intoPLUGIN_INSTALL_DIR. Per-id concurrency guard; idempotent (skip if present + integrity matches).- Test: installer spec with mocked registry + allowlist + integrity paths.
- T17. Allowlist enforcement: first-party
@ever-works/*implicitly allowed; everything else must match an enabledplugin_allowlistrow; refuse before download (FR-11). Test: allow/deny matrix. - T18. Wire installer into the enable flow in
packages/agent/src/plugins/services/plugin-operations.service.ts(enablePluginForUser): in dynamic mode, if not installed → install →PluginLoaderService.load(path)→ register → then enable. Bundled mode unchanged. UpdateinstallStatetransitions + failure reason.- Test: enable-installs-then-enables; failure leaves no partial registration (FR-14).
- T19. Lazy install-on-use
ensurePluginAvailable(pluginId)on the installer/loader path: before any node invokes a distributable plugin, install-if-missing (pinned version+integrity, per-id lock) then load+register. This is the correctness guarantee (FR-13) — a plugin enabled on replica A is usable on replica B and in the worker with no restart/shared volume. Also add boot warmup inplugin-bootstrap.service.tsthat pre-installs the DB-recorded set (FR-13a, optimisation only). Idempotent.- Test: enable on one registry instance → second instance with empty store lazily installs on first use; warmup pre-installs; both safe to re-run.
- T20. Uninstall path (
DELETE /plugins/:id/installservice method); refuse for core/systemPlugin; default retention = keep files, mark not-installed. Test: core refusal + non-core uninstall.
Phase 6 — API surface (T21–T24)
- T21. Add controller methods in
apps/api/src/plugins/plugins.controller.ts:GET /plugins/catalog,POST /plugins/:id/install,DELETE /plugins/:id/install,GET /plugins/:id/install-status. Swagger decorators + error mapping (409/424/502/422). - T22. Catalog service: list distributable plugins (manifest summaries)
from the registry/catalog source, merged with local install state.
- Test: catalog merge + registry-down degradation.
- T23. Admin allowlist endpoints
GET/POST/DELETE /admin/plugins/allowlist(admin-gated) in a newapps/api/src/plugins/allowlist.controller.ts. - T24. e2e:
apps/api/test/plugins-dynamic.e2e-spec.ts— install → enable → use, non-allowlisted refusal, integrity mismatch, registry-down.
Phase 7 — Execution router (T25–T28)
Status (2026-09-24). The router existed but could never run a long-running call: it was not exported and had no caller; it lazy-imported
@trigger.dev/sdk(which does not resolve frompackages/agent) and waited onwait.forRunToComplete(absent in SDK 4.5.11); and the worker task booted a context with no plugin registry. Now:
- the router routes an explicit per-call profile, or the manifest's
executionProfile, in bundled AND dynamic mode (an unmarked call stays in-process in bundled mode, FR-22), is exported fromPluginsModule, and dispatches through the active job runtime —dispatchers.dispatchPluginOperation, then the contract's new optionalgetRunResult(deadline, backoff,AbortSignal), withstartLongRunning/pollLongRunningfor callers that must not block;run-plugin-operationbootsTriggerRunPluginOperationModule(registry + remote cache), hydrates the plugins bundled into the worker image, and resolves the operation on the materialised plugin — an end-to-end spec runs a fixture plugin through the REAL module.Review follow-ups:
- Only operations a plugin DECLARES in
everworks.plugin.operationscan be called by name, on both paths. Before this, TS-privatehelpers, inheritedBasePluginhelpers and function-valued class fields were all reachable. Each declaration may carry its ownexecutionProfile(FR-17), which the router ranks between an explicit call profile and the manifest-level one.- A lazily loaded plugin whose
onLoadfails is refused: the task answersWORKER_PLUGIN_LOAD_FAILEDand the routerPLUGIN_LOAD_FAILED. Before, the failure was recorded on the registry entry but the operation ran anyway.- Wait hardening:
- every read has a time limit (30 s, or the time left) and ends on an abort;
pollLongRunningreads for at most 20 s;sleepno longer leaves listeners on the caller's signal;timeoutMs/pollIntervalMsare validated, and the interval never drops below 250 ms;- a run that stays unreadable answers
JOB_RUNTIME_RUN_UNREADABLE("not cancelled"), no longerJOB_RUNTIME_FAILED;- the last sleep is cut to the time left, and one final read is made at the deadline, so a run that finished inside the budget is answered.
- A completed run whose offloaded output failed to download is
completed+outputUnavailable(a new optionalJobRunResultfield). It is read again, and if it stays that way the answer isJOB_RUNTIME_OUTPUT_UNREADABLE: completed, do not re-dispatch. It is never reported as failed or as "may still be running".- Second review:
- a caller that arrives while another caller's first materialisation is still in
onLoadnow waits for it (__materialize({ waitForLoad: true })) before the state check;operationsandexecutionProfileare read from the static manifest only, never fromgetManifest(), so cold and warm replicas route alike;- an invalid plugin manifest is logged, not dropped silently.
- The task id, the payload type, the queue TTL and
maxDurationare shared constants in@ever-works/agent/tasks. The router's default wait (80 min: TTL +maxDuration+ boot) is derived from them, and an unmocked spec pins the wiring.T26 (owner decision 2026-09-25: all three, each behind configuration, nothing removed):
- The first long-running caller.
claude-managed-agentdeclaresrunSandboxSessionineverworks.plugin.operationswithexecutionProfile: 'long-running'.ManagedAgentSandboxRunnerService(packages/agent/src/plugins/services/managed-agent-sandbox-runner.service.ts, provided and exported byPluginsModule) runs one sandbox session through the router: it starts, polls, waits for and cancels the session, with the Work's tenant. The switch isPLUGIN_SANDBOX_SESSIONS_VIA_JOB_RUNTIME(PluginsModuleOptions.sandboxSessionsViaJobRuntime). It is off by default, and then the session runs in the API process through the plugin and stops on the caller'sAbortSignal.
- The runner names no plugin id (Constitution Principle II). Its caller passes the plugin id on every
run/start. APW-04 T48's session runner selects that plugin byenforcesRuntimeNetworkingplusrunSandboxSession(the first enabled such pipeline with resolvable settings); only specs nameclaude-managed-agent.- On the job-runtime path, a signal that is already aborted starts nothing, on
runandstartalike. Onstart, a signal aborted while the run is being started cancels that run.- The router gained
cancelLongRunning(runId, { tenantId }). It callsprovider.cancelthrough the tenant's view, answers within 20 s and never throws.- It also gained
dispatchSync(…, { signal }), which hands the signal to the operation as its second argument.- The APW-04 App Provisioner does not exist yet. Its session runner (T48) is meant to select the pipeline and then open the session through this runner.
- FR-15 facade install-on-use.
BaseFacadeService.resolvePluginasksFacadePluginAvailabilityServicefor a plugin this process has not registered. Two lookups ask: an explicit provider override and the Work's active plugin. The service callsinstaller.ensureLocalInstall(pluginId)(the pinned version and integrity, allowlist first, into THIS replica's store, never writing the shared row), thenloader.registerFromPath(installPath, { expectedId }), loads the plugin and reads the registry again.
- It is active only in dynamic mode, and only with
PLUGIN_FACADE_INSTALL_ON_USE(PluginsModuleOptions.facadeInstallOnUse, off by default) turned on.- Only a plugin the platform already installed and pinned qualifies: a
registry-sourced row in stateinstalledwith itsregistrySpecandinstalledVersion. A miss is not retried for 60 s, and at most 1,000 misses are remembered (oldest forgotten first).- In effect since T27's runtime-installed half. A row with no integrity pin is refused before any fetch and answered as absent.
- T27's acceptance proof stays the real-module fixture test
packages/tasks/src/trigger/worker/modules/__tests__/trigger-run-plugin-operation.module.spec.ts.T27 runtime-installed half (2026-09-25,
05e4b0236):
run-plugin-operationhydrates first. Then, in dynamic mode and for a plugin the worker image does not carry, it callsPluginInstallerService.ensureLocalInstall: the version the API pinned (exact version + integrity), allowlist first (FR-11), into the worker's own store (PLUGIN_INSTALL_DIR, default<cwd>/.plugin-store). It never writes the shared install row: the worker only readsPluginRepository.findByPluginId.PluginLoaderService.registerFromPaththen registers the extracted directory.- New codes:
WORKER_INSTALL_REFUSED(refused before any download) andWORKER_INSTALL_FAILED(the fetch or the registration failed).- The worker reads
PLUGIN_DISTRIBUTION_MODE,PLUGIN_REGISTRY_*andPLUGIN_INSTALL_DIRexactly as the API does.- Allowlisted third-party packages may run in the worker, through the read-only
PluginAllowlistReaderremote target (owner decision).- With
PLUGIN_DISTRIBUTION_MODE=dynamicset forpnpm deploy:trigger,prepare-plugins.jscopies core plugins only (mirrors T29). Bundled stays the default.pacoteis external and installed viaadditionalPackages.- A worker run in dynamic mode on an image built without
PLUGIN_DISTRIBUTION_MODE=dynamicruns the image's copy of a distributable plugin, and logs a warning once per plugin version per process.- The store marks a complete copy with
.ew-install.json(name, version, and the integrity it was verified against), written before the extract is renamed into place. A tree without it (for example an interrupted pre-T27 in-place extract) or marked for another integrity (a re-published version) is fetched again, once. This includes an explicit re-install.- A pin that is not a plain npm package name and an exact semver version is refused before anything on disk is touched: 409 on the API,
WORKER_INSTALL_REFUSEDin the worker. The store path and thenode_moduleslink are built from row data.- API side:
ensurePluginAvailableno longer takes the shared row as proof of local files. It answers from this replica's store, or fetches the pinned version there without writing the row. The enable path andPOST /plugins/:id/installregister what was installed (PluginOperationsService.registerInstalledPlugin).- The boot warmup gives each plugin at most
PLUGIN_WARMUP_TIMEOUT_MS(default 60000;0= no bound). A slower plugin keeps fetching in the background.- Acceptance: the dynamic-mode describe in
trigger-run-plugin-operation.module.spec.ts(real module, stub registry, stub API).Tenant-aware routing (2026-09-25,
05e4b0236; bounds 2026-09-26,68ffb9a97).dispatch,dispatchLongRunning,startLongRunning(pluginId, op, args, { tenantId }),pollLongRunning(runId, { tenantId })andcancelLongRunningtake the Work's tenant. With one, the run goes throughTenantAwareRuntimeResolver.resolve(tenantId), looked up throughModuleRef(getOptionalProvider) because the resolver lives in the non-globalTenantJobRuntimeModule. A BYO tenant's run starts and is read in the tenant's own Trigger.dev project, and the wait reads through the same view it dispatched through; the caller keeps the tenant next to the run id for polling. With no resolver, or a resolver that throws, the call uses the platform runtime; a resolver answeringnullgivesJOB_RUNTIME_UNAVAILABLE. WithouttenantIdnothing is looked up and the payload is unchanged.
- The lookup is time-limited in
startLongRunning,pollLongRunningandcancelLongRunning(the HTTP callers' methods), not indispatchLongRunning. InstartLongRunningthe lookup and the FR-5 stamp share a budget of about 20 s (the stamp always gets at least 1 s): a lookup that does not answer in time isJOB_RUNTIME_DISPATCH_FAILEDwith nothing dispatched (deliberately not the platform provider), and a stamp that does not answer in time dispatches unstamped. IndispatchLongRunning, an abort before or during the lookup dispatches nothing (JOB_RUNTIME_WAIT_ABORTED, norunId); an abort during or after the dispatch keeps therunId, andManagedAgentSandboxRunnerService.run()cancels by it.- FR-5: a tenant call's
run-plugin-operationpayload carriestenantIdand, whenRuntimeBindingStamperServiceis bound, itsproviderId/credentialVersion; the worker ignores them. The BYO dispatcher map (dispatchersFromTenantClient) gaineddispatchPluginOperation, so a BYO tenant's Trigger.dev project must deployrun-plugin-operation.- Two latent defects were fixed along the way.
TenantJobRuntimeModulebound its own, never-registeredJOB_RUNTIME_PROVIDER_REGISTRY, so the resolver answerednullfor every tenant. And a BYO view's stamping Proxy used the frozen BYO dispatcher map as its target, so every BYO dispatch threw a Proxy-invariantTypeError.- Known limits are recorded with the router consumer in
tenant-job-runtime-overlay/tasks.md(Phase 3).Still open:
PluginExecutionRouterService.dispatchSyncand the boot warmup place a runtime-installed plugin on the replica but do not register it (the warmup is bounded byPLUGIN_WARMUP_TIMEOUT_MS).- Worker tasks other than
run-plugin-operationdo not install at runtime, so do not build a core-only worker image while they need a distributable plugin.onLoaddoes not run for a runtime-registered plugin whenPLUGIN_LAZY_LOAD=false.
- T25. Implement
PluginExecutionRouterServiceatpackages/agent/src/plugins/services/plugin-execution-router.service.ts: decide in-process vs job-runtime per capability/operation fromexecutionProfile+ operation classification (FR-17).- Test: routing matrix.
- T26. In-process path: facades (
packages/agent/src/facades/*) call the dynamically-loaded plugin directly forsyncoperations (FR-15). No change for bundled/core. Test: short call stays in-process (no job dispatch).- Status (2026-09-25): the long-running caller is done
(
ManagedAgentSandboxRunnerService, behindPLUGIN_SANDBOX_SESSIONS_VIA_JOB_RUNTIME, off by default). The FR-15 facade path (FacadePluginAvailabilityService, behindPLUGIN_FACADE_INSTALL_ON_USE, off by default) is wired, and takes effect since T27's runtime-installed half (ensureLocalInstall+registerFromPath; see the Phase 7 note).
- Status (2026-09-25): the long-running caller is done
(
- T27. Long-running path: route
long-runningplugin calls through the job runtime (Trigger.dev task inpackages/tasks/src/tasks/trigger/). The task MUST callensurePluginAvailable(T19) first — the worker is a separate runtime with its own store, so a runtime-installed plugin is absent there until installed — then import the plugin and return via the existing result channel (FR-16). Coordinate with [EW-683] for provider abstraction.- Test: long call for a runtime-installed plugin succeeds in the worker (worker installs into its own store first), not just the API.
- Acceptance proof: the real-module fixture test
packages/tasks/src/trigger/worker/modules/__tests__/trigger-run-plugin-operation.module.spec.ts. - Status (2026-09-25): done. The task installs via
ensureLocalInstall(notensurePluginAvailable, which can write the shared row); acceptance proof as above.
- T28. Result/error propagation + timeout/retry parity between paths.
(Both paths answer one
PluginExecutionResult; the job-runtime path adds the run id and namedJOB_RUNTIME_*codes; the worker task runs one attempt.)
Phase 8 — Deployment (T29–T32)
- T29. Dynamic-mode image: build a core-only plugin set into the image
(build arg / variant) at
.deploy/docker/api/Dockerfile; keep the current all-bundled image as thebundleddefault. Confirm only core plugins land in/app/plugins. - T30. Writable runtime store: ensure
PLUGIN_INSTALL_DIRis writable in k8s (.deploy/k8s/k8s-manifest.prod.yaml) — emptyDir (per-replica) or optional PVC; readiness gate until boot reconcile completes. - T31. Entrypoint/boot wiring (
.deploy/docker/api/entrypoint.sh) so reconcile runs before serving; document ordering vs migrations. - T32. Document that read-only-FS serverless targets (Vercel) support
bundledonly; dynamic mode requires a writable store.
Phase 9 — Web / CLI (T33–T36)
- T33. Extend
apps/web/src/lib/api/plugins.tswith catalog, install, install-status, uninstall calls + types. - T34. Plugins settings page
(
apps/web/src/app/[locale]/(dashboard)/settings/plugins/[category]/page.tsx): install-state chips, Install action, progress, error surfacing; Enable triggers install-then-enable in dynamic mode (FR-18/19/20). - T35. Admin allowlist management page + components.
- T36 (optional). CLI
ever-works plugins install|uninstall|listunderapps/cli/src/commands/.
Phase 10 — Observability, docs & rollout (T37–T40)
- T37. Activity-log events + metrics for install/upgrade/uninstall/reconcile
(
plugin.install.*), Sentry tags (plugin_source,distribution_mode). - T38. Update canonical
docs/plugin-system/built-in-plugins.mdwith the core vs distributable split (Principle VIII); updatedocs/specs/architecture/runtime-plugins.mdcross-links anddocs/specs/README.mdindex. - T39. Operator runbook: enabling dynamic mode, registry config, store
volume, troubleshooting failed installs (under
docs/+ Workspace KB). - T40. Flip spec
Status: Implemented, mark plan/tasksDone; runpnpm format && pnpm lint && pnpm type-check && pnpm test && pnpm buildgreen.
Definition of Done
- All checkboxes ticked; all new code tested and green in CI.
pnpm format:checkandpnpm lintgreen.pnpm --filter ever-works-docs buildproduces no broken-link warnings.- Bundled-mode behaviour is byte-for-byte unchanged for a no-config deployment.
- Both execution paths (in-process + job-runtime) covered by integration tests.
- Constitution gates in
spec.md§9 all confirmed satisfied.