From 60a9cb3260174610010eed41a5a9592ca385dc59 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:09:26 +0000 Subject: [PATCH 01/45] feat: add production plugin runtime Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- packages/plugin-runtime/README.md | 7 + packages/plugin-runtime/package.json | 26 + packages/plugin-runtime/src/contract.ts | 50 + packages/plugin-runtime/src/index.ts | 10 + packages/plugin-runtime/src/manifest.ts | 46 + packages/plugin-runtime/src/planner.ts | 236 +++++ packages/plugin-runtime/src/runtime.ts | 494 ++++++++++ packages/plugin-runtime/test/manifest.test.ts | 79 ++ packages/plugin-runtime/test/runtime.test.ts | 45 + .../plugin-runtime/test/runtimeContract.ts | 868 ++++++++++++++++++ packages/plugin-runtime/tsconfig.json | 7 + pnpm-lock.yaml | 13 + 12 files changed, 1881 insertions(+) create mode 100644 packages/plugin-runtime/README.md create mode 100644 packages/plugin-runtime/package.json create mode 100644 packages/plugin-runtime/src/contract.ts create mode 100644 packages/plugin-runtime/src/index.ts create mode 100644 packages/plugin-runtime/src/manifest.ts create mode 100644 packages/plugin-runtime/src/planner.ts create mode 100644 packages/plugin-runtime/src/runtime.ts create mode 100644 packages/plugin-runtime/test/manifest.test.ts create mode 100644 packages/plugin-runtime/test/runtime.test.ts create mode 100644 packages/plugin-runtime/test/runtimeContract.ts create mode 100644 packages/plugin-runtime/tsconfig.json diff --git a/packages/plugin-runtime/README.md b/packages/plugin-runtime/README.md new file mode 100644 index 000000000000..1918b791cf16 --- /dev/null +++ b/packages/plugin-runtime/README.md @@ -0,0 +1,7 @@ +# plugin runtime + +internal runtime for t3 product plugins. + +it uses a deterministic, stack-safe reconciliation planner and one effect child scope per active plugin. updates stage changed plugins and their dependents, publish contributions atomically, and roll back without replacing the live composition when activation fails. + +cordis is not a dependency. a pure-only executor was rejected because plugin lifetimes and async cleanup should be owned by effect scopes. diff --git a/packages/plugin-runtime/package.json b/packages/plugin-runtime/package.json new file mode 100644 index 000000000000..8a984c8ec76b --- /dev/null +++ b/packages/plugin-runtime/package.json @@ -0,0 +1,26 @@ +{ + "name": "@t3tools/plugin-runtime", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./manifest": { + "types": "./src/manifest.ts", + "import": "./src/manifest.ts" + } + }, + "scripts": { + "test": "vp test run", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "effect": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/plugin-runtime/src/contract.ts b/packages/plugin-runtime/src/contract.ts new file mode 100644 index 000000000000..b9c2e796cf04 --- /dev/null +++ b/packages/plugin-runtime/src/contract.ts @@ -0,0 +1,50 @@ +export interface Contribution { + readonly id: string; + readonly label: string; +} + +export interface PluginDefinition { + readonly id: string; + readonly version: string; + readonly requires?: ReadonlyArray; + readonly provides?: Readonly>; + readonly activate: (context: PluginActivationContext) => void | Promise; +} + +export interface PluginActivationContext { + readonly resolve: (capability: string) => Service; + readonly register: (slot: string, contribution: Contribution) => void; + readonly onDispose: (finalizer: () => void | Promise) => void; +} + +export interface PluginRuntimeSnapshot { + readonly active: ReadonlyArray; + readonly blocked: Readonly>>; + readonly contributions: Readonly>>>; +} + +export interface PluginRuntime { + readonly reconcile: ( + definitions: ReadonlyArray, + ) => Promise; + readonly snapshot: () => PluginRuntimeSnapshot; + readonly dispose: () => Promise; +} + +export interface PluginRuntimeOptions { + readonly onLifecycle?: (event: { + readonly phase: "activate" | "deactivate"; + readonly pluginId: string; + }) => void; + readonly onLifecycleError?: (event: { + readonly phase: "activate" | "deactivate"; + readonly pluginId: string; + readonly error: unknown; + }) => void; + readonly onCleanupError?: (event: { + readonly phase: "retire" | "rollback"; + readonly error: unknown; + }) => void; +} + +export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime; diff --git a/packages/plugin-runtime/src/index.ts b/packages/plugin-runtime/src/index.ts new file mode 100644 index 000000000000..8c27e0d81b47 --- /dev/null +++ b/packages/plugin-runtime/src/index.ts @@ -0,0 +1,10 @@ +export type { + Contribution, + PluginActivationContext, + PluginDefinition, + PluginRuntime, + PluginRuntimeFactory, + PluginRuntimeOptions, + PluginRuntimeSnapshot, +} from "./contract.ts"; +export { createPluginRuntime } from "./runtime.ts"; diff --git a/packages/plugin-runtime/src/manifest.ts b/packages/plugin-runtime/src/manifest.ts new file mode 100644 index 000000000000..47421d2e3c46 --- /dev/null +++ b/packages/plugin-runtime/src/manifest.ts @@ -0,0 +1,46 @@ +import * as Schema from "effect/Schema"; + +const NamespacedId = Schema.String.check( + Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/), +); + +const SemanticVersion = Schema.String.check( + Schema.isPattern( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, + ), +); + +const CapabilityId = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@[1-9]\d*$/)); + +const RelativeEntrypoint = Schema.String.check( + Schema.isPattern(/^\.\/(?!(?:\.\.(?:\/|$)|.*\/\.\.(?:\/|$)))[A-Za-z0-9_./-]+$/), +); +const Permission = Schema.String.check(Schema.isPattern(/^[a-z][a-z-]*:.+$/)); + +const ContributionCatalog = Schema.Struct({ + commands: Schema.optional(Schema.Array(NamespacedId)), + settings: Schema.optional(Schema.Array(NamespacedId)), + views: Schema.optional(Schema.Array(NamespacedId)), + mobileCards: Schema.optional(Schema.Array(NamespacedId)), +}); + +export const PluginManifest = Schema.Struct({ + id: NamespacedId, + version: SemanticVersion, + engines: Schema.Struct({ + t3: Schema.String.check(Schema.isNonEmpty()), + }), + surfaces: Schema.optional(Schema.Array(Schema.Literals(["web", "desktop", "mobile"]))), + entrypoints: Schema.Struct({ + server: Schema.optional(RelativeEntrypoint), + web: Schema.optional(RelativeEntrypoint), + desktop: Schema.optional(RelativeEntrypoint), + }).annotate({ parseOptions: { onExcessProperty: "error" } }), + requires: Schema.optional(Schema.Array(CapabilityId)), + optional: Schema.optional(Schema.Array(CapabilityId)), + provides: Schema.optional(Schema.Array(CapabilityId)), + permissions: Schema.optional(Schema.Array(Permission)), + contributes: ContributionCatalog, +}); + +export type PluginManifest = typeof PluginManifest.Type; diff --git a/packages/plugin-runtime/src/planner.ts b/packages/plugin-runtime/src/planner.ts new file mode 100644 index 000000000000..94f5c7a7d080 --- /dev/null +++ b/packages/plugin-runtime/src/planner.ts @@ -0,0 +1,236 @@ +import * as Schema from "effect/Schema"; + +import type { PluginDefinition } from "./contract.ts"; + +export interface PlannedComposition { + readonly blocked: Readonly>; + readonly definitions: ReadonlyArray; +} + +export class DuplicatePluginIdError extends Schema.TaggedErrorClass()( + "DuplicatePluginIdError", + { pluginId: Schema.String }, +) { + override get message(): string { + return `Duplicate plugin id: ${this.pluginId}`; + } +} + +export class DuplicateCapabilityError extends Schema.TaggedErrorClass()( + "DuplicateCapabilityError", + { + capability: Schema.String, + pluginId: Schema.String, + previousPluginId: Schema.String, + }, +) { + override get message(): string { + return `Duplicate capability ${this.capability} provided by ${this.previousPluginId} and ${this.pluginId}`; + } +} + +export class DependencyCycleError extends Schema.TaggedErrorClass()( + "DependencyCycleError", + { cycle: Schema.Array(Schema.String) }, +) { + override get message(): string { + return `Dependency cycle: ${this.cycle.join(" -> ")}`; + } +} + +export const PluginPlanningError = Schema.Union([ + DuplicatePluginIdError, + DuplicateCapabilityError, + DependencyCycleError, +]); +export type PluginPlanningError = typeof PluginPlanningError.Type; + +export const isPluginPlanningError = Schema.is(PluginPlanningError); + +const createNullPrototypeRecord = (): Record => + Object.create(null) as Record; + +export const planComposition = ( + definitions: ReadonlyArray, +): PlannedComposition => { + const definitionsById = new Map(); + const providersByCapability = new Map(); + + for (const definition of definitions) { + if (definitionsById.has(definition.id)) { + throw new DuplicatePluginIdError({ pluginId: definition.id }); + } + definitionsById.set(definition.id, definition); + + for (const capability of Object.keys(definition.provides ?? {})) { + const previous = providersByCapability.get(capability); + if (previous !== undefined) { + throw new DuplicateCapabilityError({ + capability, + pluginId: definition.id, + previousPluginId: previous.id, + }); + } + providersByCapability.set(capability, definition); + } + } + + interface VisitFrame { + readonly definition: PluginDefinition; + requirementIndex: number; + reason: string | undefined; + } + + const states = new Map(); + const path: Array = []; + const pathIndexes = new Map(); + const ordered: Array = []; + const blocked = createNullPrototypeRecord(); + + for (const root of definitions) { + if (states.has(root.id)) continue; + + const stack: Array = [{ definition: root, requirementIndex: 0, reason: undefined }]; + states.set(root.id, "visiting"); + pathIndexes.set(root.id, path.length); + path.push(root.id); + + while (stack.length > 0) { + const frame = stack.at(-1); + if (frame === undefined) break; + const requirements = frame.definition.requires ?? []; + const capability = requirements[frame.requirementIndex]; + + if (capability !== undefined) { + const provider = providersByCapability.get(capability); + if (provider === undefined) { + frame.reason ??= `Missing dependency: ${capability}`; + frame.requirementIndex += 1; + continue; + } + + const state = states.get(provider.id); + if (state === "visiting") { + const cycleStart = pathIndexes.get(provider.id); + if (cycleStart === undefined) { + throw new DependencyCycleError({ cycle: [provider.id, provider.id] }); + } + throw new DependencyCycleError({ cycle: [...path.slice(cycleStart), provider.id] }); + } + if (state === undefined) { + stack.push({ definition: provider, requirementIndex: 0, reason: undefined }); + states.set(provider.id, "visiting"); + pathIndexes.set(provider.id, path.length); + path.push(provider.id); + continue; + } + + if (Object.hasOwn(blocked, provider.id)) { + frame.reason ??= `Dependency ${capability} is blocked: ${blocked[provider.id]}`; + } + frame.requirementIndex += 1; + continue; + } + + stack.pop(); + path.pop(); + pathIndexes.delete(frame.definition.id); + states.set(frame.definition.id, "visited"); + if (frame.reason === undefined) { + ordered.push(frame.definition); + } else { + blocked[frame.definition.id] = frame.reason; + } + } + } + + return { blocked, definitions: ordered }; +}; + +const sameStrings = (left: ReadonlyArray, right: ReadonlyArray): boolean => { + if (left.length !== right.length) return false; + const sortedLeft = [...left].sort(); + const sortedRight = [...right].sort(); + return sortedLeft.every((value, index) => value === sortedRight[index]); +}; + +const sameDefinition = (left: PluginDefinition, right: PluginDefinition): boolean => { + if ( + left.id !== right.id || + left.version !== right.version || + !Object.is(left.activate, right.activate) + ) { + return false; + } + if (!sameStrings(left.requires ?? [], right.requires ?? [])) return false; + + const leftProvides = left.provides ?? {}; + const rightProvides = right.provides ?? {}; + const leftCapabilities = Object.keys(leftProvides); + const rightCapabilities = Object.keys(rightProvides); + return ( + leftCapabilities.length === rightCapabilities.length && + leftCapabilities.every( + (capability) => + Object.hasOwn(rightProvides, capability) && + Object.is(leftProvides[capability], rightProvides[capability]), + ) + ); +}; + +const dependentsByPlugin = (definitions: ReadonlyArray) => { + const providers = new Map(); + const dependents = new Map>(); + for (const definition of definitions) { + for (const capability of Object.keys(definition.provides ?? {})) { + providers.set(capability, definition.id); + } + } + for (const definition of definitions) { + for (const capability of definition.requires ?? []) { + const providerId = providers.get(capability); + if (providerId === undefined) continue; + const values = dependents.get(providerId) ?? new Set(); + values.add(definition.id); + dependents.set(providerId, values); + } + } + return dependents; +}; + +export const affectedPluginIds = ( + current: ReadonlyArray, + desired: ReadonlyArray, +): ReadonlySet => { + const currentById = new Map(current.map((definition) => [definition.id, definition])); + const desiredById = new Map(desired.map((definition) => [definition.id, definition])); + const affected = new Set(); + + for (const definition of current) { + const next = desiredById.get(definition.id); + if (next === undefined || !sameDefinition(definition, next)) affected.add(definition.id); + } + for (const definition of desired) { + const previous = currentById.get(definition.id); + if (previous === undefined || !sameDefinition(previous, definition)) + affected.add(definition.id); + } + + const currentDependents = dependentsByPlugin(current); + const desiredDependents = dependentsByPlugin(desired); + const queue = [...affected]; + for (let index = 0; index < queue.length; index += 1) { + const pluginId = queue[index]; + if (pluginId === undefined) continue; + for (const dependent of [ + ...(currentDependents.get(pluginId) ?? []), + ...(desiredDependents.get(pluginId) ?? []), + ]) { + if (affected.has(dependent)) continue; + affected.add(dependent); + queue.push(dependent); + } + } + + return affected; +}; diff --git a/packages/plugin-runtime/src/runtime.ts b/packages/plugin-runtime/src/runtime.ts new file mode 100644 index 000000000000..5de6e64ff793 --- /dev/null +++ b/packages/plugin-runtime/src/runtime.ts @@ -0,0 +1,494 @@ +import * as NodeAsyncHooks from "node:async_hooks"; + +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import type { + Contribution, + PluginActivationContext, + PluginDefinition, + PluginRuntime, + PluginRuntimeFactory, + PluginRuntimeSnapshot, +} from "./contract.ts"; +import { + affectedPluginIds, + isPluginPlanningError, + planComposition, + type PluginPlanningError, +} from "./planner.ts"; + +interface LivePlugin { + readonly definition: PluginDefinition; + readonly scope: Scope.Closeable; + readonly contributions: ReadonlyMap>; + readonly cleanupErrors: Array; +} + +interface LiveComposition { + readonly plugins: ReadonlyArray; + readonly snapshot: PluginRuntimeSnapshot; +} + +type RuntimeOperation = "reconcile" | "dispose"; +type PluginCallback = "activate" | "finalizer"; + +interface PluginCallbackContext { + active: boolean; + readonly callback: PluginCallback; + readonly pluginId: string; +} + +interface CleanupFailure { + readonly error: unknown; + readonly pluginId: string; +} + +class PluginResolutionError extends Schema.TaggedErrorClass()( + "PluginResolutionError", + { capability: Schema.String, pluginId: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} cannot resolve inactive capability: ${this.capability}`; + } +} + +class PluginUndeclaredCapabilityError extends Schema.TaggedErrorClass()( + "PluginUndeclaredCapabilityError", + { capability: Schema.String, pluginId: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} did not declare capability: ${this.capability}`; + } +} + +class PluginActivationContextExpiredError extends Schema.TaggedErrorClass()( + "PluginActivationContextExpiredError", + { + method: Schema.Literals(["resolve", "register", "onDispose"]), + pluginId: Schema.String, + }, +) { + override get message(): string { + return `activation context for ${this.pluginId} is no longer active (${this.method})`; + } +} + +class PluginCallbackError extends Schema.TaggedErrorClass()( + "PluginCallbackError", + { + callback: Schema.Literals(["activate", "finalizer"]), + cause: Schema.Defect(), + pluginId: Schema.String, + }, +) { + override get message(): string { + return `Plugin ${this.pluginId} ${this.callback} callback failed`; + } +} + +class PluginStagingError extends Schema.TaggedErrorClass()( + "PluginStagingError", + { pluginId: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} was not staged`; + } +} + +class PluginRuntimeDisposedError extends Schema.TaggedErrorClass()( + "PluginRuntimeDisposedError", + { operation: Schema.Literals(["reconcile", "dispose"]) }, +) { + override get message(): string { + return `Plugin runtime is disposed; cannot ${this.operation}`; + } +} + +class PluginRuntimeReentrancyError extends Schema.TaggedErrorClass()( + "PluginRuntimeReentrancyError", + { + callback: Schema.Literals(["activate", "finalizer"]), + operation: Schema.Literals(["reconcile", "dispose"]), + pluginId: Schema.String, + }, +) { + override get message(): string { + return `Plugin runtime ${this.operation} is reentrant from ${this.callback} callback for ${this.pluginId}`; + } +} + +class PluginRuntimeCleanupError extends Schema.TaggedErrorClass()( + "PluginRuntimeCleanupError", + { + failures: Schema.Array( + Schema.Struct({ + cause: Schema.Defect(), + pluginId: Schema.String, + }), + ), + }, +) { + override get message(): string { + return `Failed to close plugin scopes (${this.failures.length} cleanup error${this.failures.length === 1 ? "" : "s"})`; + } +} + +type PluginReconcileError = + | PluginPlanningError + | PluginCallbackError + | PluginRuntimeDisposedError + | PluginStagingError; + +const createNullPrototypeRecord = (): Record => + Object.create(null) as Record; + +const snapshotDefinitions = ( + definitions: ReadonlyArray, +): ReadonlyArray => + definitions.map((definition) => { + const requires = + definition.requires === undefined ? undefined : Object.freeze([...definition.requires]); + const provides = + definition.provides === undefined + ? undefined + : Object.freeze(Object.assign(createNullPrototypeRecord(), definition.provides)); + return Object.freeze({ + id: definition.id, + version: definition.version, + activate: definition.activate, + ...(requires === undefined ? {} : { requires }), + ...(provides === undefined ? {} : { provides }), + }); + }); + +const emptySnapshot = (): PluginRuntimeSnapshot => + Object.freeze({ + active: Object.freeze([]), + blocked: Object.freeze(createNullPrototypeRecord()), + contributions: Object.freeze(createNullPrototypeRecord>()), + }); + +const snapshotOf = ( + plugins: ReadonlyArray, + blocked: Readonly>, +): PluginRuntimeSnapshot => { + const contributions = createNullPrototypeRecord>(); + for (const plugin of plugins) { + for (const [slot, registrations] of plugin.contributions) { + const values = contributions[slot] ?? []; + contributions[slot] = Object.freeze([ + ...values, + ...registrations.map((registration) => + Object.freeze({ id: registration.id, label: registration.label }), + ), + ]); + } + } + return Object.freeze({ + active: Object.freeze(plugins.map((plugin) => plugin.definition.id)), + blocked: Object.freeze(Object.assign(createNullPrototypeRecord(), blocked)), + contributions: Object.freeze(contributions), + }); +}; + +export const createPluginRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => { + let current: LiveComposition = { plugins: [], snapshot: emptySnapshot() }; + let disposed = false; + let transition: Promise = Promise.resolve(); + let runtimeScope: Scope.Closeable | undefined; + const callbackContext = new NodeAsyncHooks.AsyncLocalStorage(); + + const getRuntimeScope = (): Effect.Effect => + Effect.gen(function* () { + if (runtimeScope === undefined) runtimeScope = yield* Scope.make("sequential"); + return runtimeScope; + }); + + const reportLifecycle = ( + phase: "activate" | "deactivate", + pluginId: string, + ): Effect.Effect => + Effect.sync(() => { + try { + options.onLifecycle?.({ phase, pluginId }); + } catch (error) { + try { + options.onLifecycleError?.({ phase, pluginId, error }); + } catch { + // Observer error reporting must never interrupt a commit or cleanup. + } + } + }); + + const reportCleanupErrors = ( + phase: "retire" | "rollback", + failures: ReadonlyArray, + ): Effect.Effect => + Effect.sync(() => { + for (const { error } of failures) { + try { + options.onCleanupError?.({ phase, error }); + } catch { + // Cleanup reporting must not replace activation errors or undo a committed snapshot. + } + } + }); + + const closePlugins = ( + plugins: ReadonlyArray, + notifyDeactivation: boolean, + ): Effect.Effect> => + Effect.gen(function* () { + const failures: Array = []; + for (const plugin of plugins.toReversed()) { + const closeExit = yield* Effect.exit(Scope.close(plugin.scope, Exit.void)); + if (Exit.isFailure(closeExit)) { + failures.push({ + error: Cause.squash(closeExit.cause), + pluginId: plugin.definition.id, + }); + } + for (const error of plugin.cleanupErrors.splice(0)) { + failures.push({ error, pluginId: plugin.definition.id }); + } + if (notifyDeactivation) { + yield* reportLifecycle("deactivate", plugin.definition.id); + } + } + return failures; + }); + + const invokePluginCallback = ( + callback: PluginCallback, + pluginId: string, + invoke: () => Result | PromiseLike, + onSettled?: () => void, + ): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const callbackState: PluginCallbackContext = { active: true, callback, pluginId }; + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + callbackState.active = false; + onSettled?.(); + }; + try { + const result = callbackContext.run(callbackState, invoke); + if (typeof result === "object" && result !== null && "then" in result) { + return await Promise.resolve(result).finally(settle); + } + settle(); + return result; + } catch (error) { + settle(); + throw error; + } + }, + catch: (cause) => new PluginCallbackError({ callback, cause, pluginId }), + }); + + const activatePlugin = ( + definition: PluginDefinition, + capabilities: ReadonlyMap, + ): Effect.Effect => + Effect.gen(function* () { + const parentScope = yield* getRuntimeScope(); + const scope = yield* Scope.fork(parentScope, "sequential"); + const contributions = new Map>(); + const cleanupErrors: Array = []; + const finalizers: Array<() => void | Promise> = []; + const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors }; + let activating = true; + const assertActivating = (method: "resolve" | "register" | "onDispose") => { + if (!activating) { + throw new PluginActivationContextExpiredError({ method, pluginId: definition.id }); + } + }; + + const context: PluginActivationContext = { + resolve: (capability: string): Service => { + assertActivating("resolve"); + if (!(definition.requires ?? []).includes(capability)) { + throw new PluginUndeclaredCapabilityError({ capability, pluginId: definition.id }); + } + if (!capabilities.has(capability)) { + throw new PluginResolutionError({ capability, pluginId: definition.id }); + } + return capabilities.get(capability) as Service; + }, + register: (slot, contribution) => { + assertActivating("register"); + const values = contributions.get(slot) ?? []; + values.push(Object.freeze({ id: contribution.id, label: contribution.label })); + contributions.set(slot, values); + }, + onDispose: (finalizer) => { + assertActivating("onDispose"); + finalizers.push(finalizer); + }, + }; + + const activationExit = yield* Effect.exit( + invokePluginCallback( + "activate", + definition.id, + () => definition.activate(context), + () => { + activating = false; + }, + ), + ); + for (const finalizer of finalizers) { + const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe( + Effect.catch((error) => + Effect.sync(() => { + cleanupErrors.push(error); + }), + ), + ); + yield* Scope.addFinalizer(scope, finalizerEffect); + } + + if (Exit.isFailure(activationExit)) { + const failures = yield* closePlugins([plugin], false); + yield* reportCleanupErrors("rollback", failures); + return yield* Effect.failCause(activationExit.cause); + } + return plugin; + }); + + const reconcileEffect = ( + definitions: ReadonlyArray, + ): Effect.Effect => + Effect.gen(function* () { + if (disposed) { + return yield* new PluginRuntimeDisposedError({ operation: "reconcile" }); + } + + const plan = yield* Effect.try({ + try: () => planComposition(definitions), + catch: (error) => { + if (!isPluginPlanningError(error)) throw error; + return error; + }, + }); + const affected = affectedPluginIds( + current.plugins.map((plugin) => plugin.definition), + plan.definitions, + ); + const currentById = new Map(current.plugins.map((plugin) => [plugin.definition.id, plugin])); + const capabilities = new Map(); + for (const plugin of current.plugins) { + if (affected.has(plugin.definition.id)) continue; + for (const [capability, service] of Object.entries(plugin.definition.provides ?? {})) { + capabilities.set(capability, service); + } + } + + const staged = new Map(); + const candidateExit = yield* Effect.exit( + Effect.gen(function* () { + for (const definition of plan.definitions) { + if (!affected.has(definition.id)) continue; + const plugin = yield* activatePlugin(definition, capabilities); + staged.set(definition.id, plugin); + for (const [capability, service] of Object.entries(definition.provides ?? {})) { + capabilities.set(capability, service); + } + } + + const nextPlugins: Array = []; + for (const definition of plan.definitions) { + const plugin = staged.get(definition.id) ?? currentById.get(definition.id); + if (plugin === undefined) { + return yield* new PluginStagingError({ pluginId: definition.id }); + } + nextPlugins.push(plugin); + } + return { + plugins: nextPlugins, + snapshot: snapshotOf(nextPlugins, plan.blocked), + }; + }), + ); + if (Exit.isFailure(candidateExit)) { + const failures = yield* closePlugins([...staged.values()], false); + yield* reportCleanupErrors("rollback", failures); + return yield* Effect.failCause(candidateExit.cause); + } + + const previous = current.plugins.filter((plugin) => affected.has(plugin.definition.id)); + current = candidateExit.value; + for (const plugin of staged.values()) { + yield* reportLifecycle("activate", plugin.definition.id); + } + const failures = yield* closePlugins(previous, true); + yield* reportCleanupErrors("retire", failures); + return current.snapshot; + }); + + const disposeEffect = (): Effect.Effect => + Effect.gen(function* () { + if (disposed) return; + disposed = true; + const previous = current.plugins; + current = { plugins: [], snapshot: emptySnapshot() }; + const failures = [...(yield* closePlugins(previous, true))]; + const parentScope = runtimeScope; + runtimeScope = undefined; + if (parentScope !== undefined) { + const closeExit = yield* Effect.exit(Scope.close(parentScope, Exit.void)); + if (Exit.isFailure(closeExit)) { + failures.push({ error: Cause.squash(closeExit.cause), pluginId: "plugin-runtime" }); + } + } + if (failures.length > 0) { + return yield* new PluginRuntimeCleanupError({ + failures: failures.map(({ error, pluginId }) => ({ cause: error, pluginId })), + }); + } + }); + + const runPromiseAdapter = ( + operation: RuntimeOperation, + effect: () => Effect.Effect, + ): Promise => { + const callback = callbackContext.getStore(); + if (callback?.active === true) { + return Promise.reject( + new PluginRuntimeReentrancyError({ + callback: callback.callback, + operation, + pluginId: callback.pluginId, + }), + ); + } + + const result = transition.then(() => Effect.runPromise(effect())); + transition = result.then( + () => undefined, + () => undefined, + ); + return result; + }; + + return { + reconcile: (definitions) => { + let desired: ReadonlyArray; + try { + desired = snapshotDefinitions(definitions); + } catch (error) { + return Promise.reject(error); + } + return runPromiseAdapter("reconcile", () => reconcileEffect(desired)); + }, + snapshot: () => current.snapshot, + dispose: () => runPromiseAdapter("dispose", disposeEffect), + }; +}; diff --git a/packages/plugin-runtime/test/manifest.test.ts b/packages/plugin-runtime/test/manifest.test.ts new file mode 100644 index 000000000000..98380a5b7732 --- /dev/null +++ b/packages/plugin-runtime/test/manifest.test.ts @@ -0,0 +1,79 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { PluginManifest } from "../src/manifest.ts"; + +const decodeManifest = Schema.decodeUnknownSync(PluginManifest); + +const validManifest = { + id: "com.acme.linear", + version: "1.2.0", + engines: { t3: "^0.1.0" }, + entrypoints: { + server: "./dist/server.js", + web: "./dist/web.js", + }, + requires: ["t3.commands@1", "t3.secrets@1"], + provides: ["com.acme.linear@1"], + permissions: ["network:https://api.linear.app", "secrets:linear-token"], + contributes: { + commands: ["linear.create-issue"], + settings: ["linear.settings"], + views: ["thread.right-panel"], + }, +}; + +describe("PluginManifest", () => { + it("decodes a namespaced multi-surface plugin manifest", () => { + expect(decodeManifest(validManifest)).toEqual(validManifest); + }); + + it("rejects unnamespaced plugin and contribution ids", () => { + expect(() => decodeManifest({ ...validManifest, id: "linear" })).toThrow(); + expect(() => + decodeManifest({ + ...validManifest, + contributes: { ...validManifest.contributes, commands: ["create-issue"] }, + }), + ).toThrow(); + }); + + it("rejects malformed versions and capability ids", () => { + expect(() => decodeManifest({ ...validManifest, version: "next" })).toThrow(); + expect(() => decodeManifest({ ...validManifest, version: "01.2.3" })).toThrow(); + expect(() => decodeManifest({ ...validManifest, version: "1.2.3-.." })).toThrow(); + expect(decodeManifest({ ...validManifest, version: "1.2.3+build.7" }).version).toBe( + "1.2.3+build.7", + ); + expect(() => decodeManifest({ ...validManifest, requires: ["t3.commands"] })).toThrow(); + }); + + it("rejects entrypoints that escape the plugin directory", () => { + for (const server of ["./../outside.js", "./dist/../../outside.js"]) { + expect(() => + decodeManifest({ + ...validManifest, + entrypoints: { ...validManifest.entrypoints, server }, + }), + ).toThrow(); + } + }); + + it("keeps mobile declarative by excluding a mobile executable entrypoint", () => { + const decoded = decodeManifest({ + ...validManifest, + surfaces: ["web", "desktop", "mobile"], + contributes: { ...validManifest.contributes, mobileCards: ["linear.summary"] }, + }); + + expect(decoded.surfaces).toEqual(["web", "desktop", "mobile"]); + expect(decoded.contributes.mobileCards).toEqual(["linear.summary"]); + expect(decoded.entrypoints).not.toHaveProperty("mobile"); + expect(() => + decodeManifest({ + ...validManifest, + entrypoints: { ...validManifest.entrypoints, mobile: "./dist/mobile.js" }, + }), + ).toThrow(); + }); +}); diff --git a/packages/plugin-runtime/test/runtime.test.ts b/packages/plugin-runtime/test/runtime.test.ts new file mode 100644 index 000000000000..7f907b4571f7 --- /dev/null +++ b/packages/plugin-runtime/test/runtime.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { PluginDefinition } from "../src/contract.ts"; +import { createPluginRuntime } from "../src/runtime.ts"; +import { defineRuntimeContract } from "./runtimeContract.ts"; + +defineRuntimeContract("plugin runtime", createPluginRuntime); + +describe("plugin runtime errors", () => { + it("returns schema-tagged planning errors", async () => { + const runtime = createPluginRuntime(); + const duplicate = { + id: "acme.duplicate", + version: "1.0.0", + activate() {}, + }; + + await expect(runtime.reconcile([duplicate, duplicate])).rejects.toMatchObject({ + _tag: "DuplicatePluginIdError", + pluginId: "acme.duplicate", + }); + }); +}); + +describe("plugin runtime planner", () => { + it("plans a deep acyclic dependency chain without using the call stack", async () => { + const runtime = createPluginRuntime(); + const pluginCount = 20_000; + const definitions: Array = Array.from( + { length: pluginCount }, + (_, index) => ({ + id: `plugin-${index}`, + version: "1.0.0", + ...(index === 0 ? {} : { requires: [`capability-${index - 1}`] }), + provides: { [`capability-${index}`]: index }, + activate() {}, + }), + ); + + const snapshot = await runtime.reconcile(definitions.toReversed()); + + expect(snapshot.active).toHaveLength(pluginCount); + await runtime.dispose(); + }); +}); diff --git a/packages/plugin-runtime/test/runtimeContract.ts b/packages/plugin-runtime/test/runtimeContract.ts new file mode 100644 index 000000000000..6fe0739be09f --- /dev/null +++ b/packages/plugin-runtime/test/runtimeContract.ts @@ -0,0 +1,868 @@ +import * as NodeTimersPromises from "node:timers/promises"; + +import { describe, expect, it } from "vite-plus/test"; + +import type { + PluginDefinition, + PluginRuntimeFactory, + PluginRuntimeSnapshot, +} from "../src/contract.ts"; + +const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) => + snapshot.contributions[slot]?.map((item) => item.label) ?? []; + +const withOperationTimeout = async (operation: Promise): Promise => { + const controller = new AbortController(); + const timeout = NodeTimersPromises.setTimeout(100, undefined, { + ref: false, + signal: controller.signal, + }).then(() => { + throw new Error("operation timed out"); + }); + try { + return await Promise.race([operation, timeout]); + } finally { + controller.abort(); + } +}; + +const failureCause = (error: unknown): unknown => + typeof error === "object" && error !== null && "cause" in error + ? (error as { readonly cause: unknown }).cause + : error; + +const failureMessage = (error: unknown): string => { + const cause = failureCause(error); + return cause instanceof Error ? cause.message : String(cause); +}; + +const provider = (version = "1.0.0"): PluginDefinition => ({ + id: "acme.database", + version, + provides: { "acme.database@1": { name: `database-${version}` } }, + activate(context) { + context.register("status", { id: "database", label: `database ${version}` }); + }, +}); + +const consumer = (): PluginDefinition => ({ + id: "acme.issues", + version: "1.0.0", + requires: ["acme.database@1"], + activate(context) { + const database = context.resolve<{ readonly name: string }>("acme.database@1"); + context.register("commands", { id: "create-issue", label: database.name }); + }, +}); + +export function defineRuntimeContract(name: string, createRuntime: PluginRuntimeFactory) { + describe(name, () => { + it("activates providers before consumers regardless of manifest order", async () => { + const runtime = createRuntime(); + + const snapshot = await runtime.reconcile([consumer(), provider()]); + + expect(snapshot.active).toEqual(["acme.database", "acme.issues"]); + expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]); + await runtime.dispose(); + }); + + it("blocks missing dependencies without blocking independent plugins", async () => { + const runtime = createRuntime(); + const independent: PluginDefinition = { + id: "acme.clock", + version: "1.0.0", + activate(context) { + context.register("status", { id: "clock", label: "clock" }); + }, + }; + + const snapshot = await runtime.reconcile([consumer(), independent]); + + expect(snapshot.active).toEqual(["acme.clock"]); + expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1"); + expect(contributionLabels(snapshot, "status")).toEqual(["clock"]); + await runtime.dispose(); + }); + + it("deactivates dependents before providers", async () => { + const lifecycle: Array = []; + const runtime = createRuntime({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), + }); + await runtime.reconcile([provider(), consumer()]); + lifecycle.length = 0; + + await runtime.reconcile([]); + + expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]); + await runtime.dispose(); + }); + + it("runs plugin finalizers in reverse registration order", async () => { + const disposed: Array = []; + const runtime = createRuntime(); + await runtime.reconcile([ + { + id: "acme.finalizers", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + disposed.push("first"); + }); + context.onDispose(() => { + disposed.push("second"); + }); + }, + }, + ]); + + await runtime.reconcile([]); + + expect(disposed).toEqual(["second", "first"]); + await runtime.dispose(); + }); + + it("keeps unchanged plugin scopes alive across reconciliation", async () => { + let activations = 0; + let disposals = 0; + const stable: PluginDefinition = { + id: "acme.stable", + version: "1.0.0", + activate(context) { + activations += 1; + context.onDispose(() => { + disposals += 1; + }); + }, + }; + const runtime = createRuntime(); + + await runtime.reconcile([stable]); + await runtime.reconcile([stable]); + + expect(activations).toBe(1); + expect(disposals).toBe(0); + await runtime.dispose(); + expect(disposals).toBe(1); + }); + + it("treats reordered requirements and providers as the same definition", async () => { + let activations = 0; + const firstService = {}; + const secondService = {}; + const activate: PluginDefinition["activate"] = () => { + activations += 1; + }; + const dependencies: ReadonlyArray = [ + { + id: "acme.first-dependency", + version: "1.0.0", + provides: { "acme.first@1": true }, + activate() {}, + }, + { + id: "acme.second-dependency", + version: "1.0.0", + provides: { "acme.second@1": true }, + activate() {}, + }, + ]; + const runtime = createRuntime(); + await runtime.reconcile([ + ...dependencies, + { + id: "acme.stable-order", + version: "1.0.0", + requires: ["acme.first@1", "acme.second@1"], + provides: { "acme.output-one@1": firstService, "acme.output-two@1": secondService }, + activate, + }, + ]); + + await runtime.reconcile([ + ...dependencies, + { + id: "acme.stable-order", + version: "1.0.0", + requires: ["acme.second@1", "acme.first@1"], + provides: { "acme.output-two@1": secondService, "acme.output-one@1": firstService }, + activate, + }, + ]); + + expect(activations).toBe(1); + await runtime.dispose(); + }); + + it("restarts a plugin when its activation implementation changes", async () => { + const runtime = createRuntime(); + const first: PluginDefinition = { + id: "acme.implementation", + version: "1.0.0", + activate(context) { + context.register("commands", { id: "implementation", label: "first" }); + }, + }; + const second: PluginDefinition = { + id: "acme.implementation", + version: "1.0.0", + activate(context) { + context.register("commands", { id: "implementation", label: "second" }); + }, + }; + + await runtime.reconcile([first]); + const snapshot = await runtime.reconcile([second]); + + expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); + await runtime.dispose(); + }); + + it("restarts a changed provider and its dependents without touching independent plugins", async () => { + let independentActivations = 0; + const lifecycle: Array = []; + const independent: PluginDefinition = { + id: "acme.independent", + version: "1.0.0", + activate() { + independentActivations += 1; + }, + }; + const runtime = createRuntime({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), + }); + await runtime.reconcile([consumer(), independent, provider()]); + lifecycle.length = 0; + + const snapshot = await runtime.reconcile([consumer(), independent, provider("2.0.0")]); + + expect(independentActivations).toBe(1); + expect(lifecycle).not.toContain("activate:acme.independent"); + expect(lifecycle).not.toContain("deactivate:acme.independent"); + expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([ + "activate:acme.database", + "activate:acme.issues", + ]); + expect(lifecycle.filter((event) => event.startsWith("deactivate:"))).toEqual([ + "deactivate:acme.issues", + "deactivate:acme.database", + ]); + expect(contributionLabels(snapshot, "commands")).toEqual(["database-2.0.0"]); + await runtime.dispose(); + }); + + it("returns deeply frozen snapshots", async () => { + const runtime = createRuntime(); + + const snapshot = await runtime.reconcile([provider()]); + const status = snapshot.contributions.status; + + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.active)).toBe(true); + expect(Object.isFrozen(snapshot.blocked)).toBe(true); + expect(Object.isFrozen(snapshot.contributions)).toBe(true); + expect(Object.isFrozen(status)).toBe(true); + expect(Object.isFrozen(status?.[0])).toBe(true); + await runtime.dispose(); + }); + + it("detaches registered contributions from later plugin mutation", async () => { + const contribution = { id: "mutable", label: "original" }; + const plugin: PluginDefinition = { + id: "acme.mutable-contribution", + version: "1.0.0", + activate(context) { + context.register("commands", contribution); + }, + }; + const unrelated: PluginDefinition = { + id: "acme.unrelated-contribution", + version: "1.0.0", + activate() {}, + }; + const runtime = createRuntime(); + await runtime.reconcile([plugin]); + + contribution.label = "changed"; + const snapshot = await runtime.reconcile([plugin, unrelated]); + + expect(contributionLabels(snapshot, "commands")).toEqual(["original"]); + await runtime.dispose(); + }); + + it("treats every plugin id and contribution slot as data", async () => { + const runtime = createRuntime(); + const unusualProvider: PluginDefinition = { + id: "toString", + version: "1.0.0", + provides: { "acme.unusual@1": "available" }, + activate(context) { + context.register("__proto__", { id: "provider", label: "provider" }); + }, + }; + const unusualConsumer: PluginDefinition = { + id: "constructor", + version: "1.0.0", + requires: ["acme.unusual@1"], + activate(context) { + context.resolve("acme.unusual@1"); + context.register("toString", { id: "consumer", label: "consumer" }); + }, + }; + const unusualBlocked: PluginDefinition = { + id: "__proto__", + version: "1.0.0", + requires: ["acme.missing@1"], + activate() { + throw new Error("blocked plugin activated"); + }, + }; + + const snapshot = await runtime.reconcile([unusualConsumer, unusualProvider, unusualBlocked]); + + expect(snapshot.active).toEqual(["toString", "constructor"]); + expect(Object.hasOwn(snapshot.blocked, "__proto__")).toBe(true); + expect(snapshot.blocked.__proto__).toContain("acme.missing@1"); + expect(Object.hasOwn(snapshot.contributions, "__proto__")).toBe(true); + expect(Object.hasOwn(snapshot.contributions, "toString")).toBe(true); + expect(contributionLabels(snapshot, "__proto__")).toEqual(["provider"]); + expect(contributionLabels(snapshot, "toString")).toEqual(["consumer"]); + await runtime.dispose(); + }); + + it("restarts dependents when a provided value changes", async () => { + const lifecycle: Array = []; + const runtime = createRuntime({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), + }); + const serviceProvider = (name: string): PluginDefinition => ({ + id: "acme.service", + version: "1.0.0", + provides: { "acme.service@1": { name } }, + activate() {}, + }); + const serviceConsumer: PluginDefinition = { + id: "acme.service-consumer", + version: "1.0.0", + requires: ["acme.service@1"], + activate(context) { + const service = context.resolve<{ readonly name: string }>("acme.service@1"); + context.register("commands", { id: "service", label: service.name }); + }, + }; + await runtime.reconcile([serviceProvider("first"), serviceConsumer]); + lifecycle.length = 0; + + const snapshot = await runtime.reconcile([serviceProvider("second"), serviceConsumer]); + + expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); + expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([ + "activate:acme.service", + "activate:acme.service-consumer", + ]); + await runtime.dispose(); + }); + + it("does not report a rolled-back candidate as activated", async () => { + const lifecycle: Array = []; + const runtime = createRuntime({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), + }); + const staged: PluginDefinition = { + id: "acme.staged", + version: "1.0.0", + activate() {}, + }; + const broken: PluginDefinition = { + id: "acme.broken", + version: "1.0.0", + activate() { + throw new Error("activation failed"); + }, + }; + + let stagingFailure: unknown; + try { + await runtime.reconcile([staged, broken]); + } catch (error) { + stagingFailure = error; + } + expect(failureMessage(stagingFailure)).toContain("activation failed"); + + expect(lifecycle).toEqual([]); + await runtime.dispose(); + }); + + it("preserves activation failures when rollback cleanup also fails", async () => { + const activationError = new Error("activation failed"); + const cleanupError = new Error("rollback cleanup failed"); + const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; + const runtime = createRuntime({ + onCleanupError: (event) => { + cleanupEvents.push(event); + throw new Error("cleanup observer failed"); + }, + }); + const broken: PluginDefinition = { + id: "acme.rollback-error", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + throw cleanupError; + }); + throw activationError; + }, + }; + + let activationFailure: unknown; + try { + await runtime.reconcile([broken]); + } catch (error) { + activationFailure = error; + } + expect(failureCause(activationFailure)).toBe(activationError); + expect(cleanupEvents).toHaveLength(1); + expect(cleanupEvents[0]?.phase).toBe("rollback"); + expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError); + await runtime.dispose(); + }); + + it("returns the committed snapshot when retiring an old plugin reports cleanup errors", async () => { + const cleanupError = new Error("retirement cleanup failed"); + const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; + const runtime = createRuntime({ + onCleanupError: (event) => { + cleanupEvents.push(event); + throw new Error("cleanup observer failed"); + }, + }); + await runtime.reconcile([ + { + id: "acme.retirement-error", + version: "1.0.0", + activate(context) { + context.register("commands", { id: "retirement", label: "old" }); + context.onDispose(() => { + throw cleanupError; + }); + }, + }, + ]); + + const snapshot = await runtime.reconcile([ + { + id: "acme.retirement-error", + version: "2.0.0", + activate(context) { + context.register("commands", { id: "retirement", label: "new" }); + }, + }, + ]); + + expect(contributionLabels(snapshot, "commands")).toEqual(["new"]); + expect(runtime.snapshot()).toBe(snapshot); + expect(cleanupEvents).toHaveLength(1); + expect(cleanupEvents[0]?.phase).toBe("retire"); + expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError); + await runtime.dispose(); + }); + + it("does not let lifecycle observers interrupt a committed transition", async () => { + let oldDisposed = false; + const observerErrors: Array = []; + const runtime = createRuntime({ + onLifecycle: ({ phase, pluginId }) => { + throw new Error(`${phase}:${pluginId}`); + }, + onLifecycleError: ({ phase, pluginId }) => { + observerErrors.push(`${phase}:${pluginId}`); + }, + }); + await runtime.reconcile([ + { + id: "acme.lifecycle-observer", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + oldDisposed = true; + }); + }, + }, + ]); + + const snapshot = await runtime.reconcile([ + { + id: "acme.lifecycle-observer", + version: "2.0.0", + activate(context) { + context.register("commands", { id: "observer", label: "committed" }); + }, + }, + ]); + + expect(oldDisposed).toBe(true); + expect(contributionLabels(snapshot, "commands")).toEqual(["committed"]); + expect(observerErrors).toEqual([ + "activate:acme.lifecycle-observer", + "activate:acme.lifecycle-observer", + "deactivate:acme.lifecycle-observer", + ]); + await runtime.dispose(); + }); + + it("rejects runtime operations reentered from plugin activation", async () => { + const runtime = createRuntime(); + const reentrant: PluginDefinition = { + id: "acme.reentrant", + version: "1.0.0", + async activate() { + await runtime.reconcile([]); + }, + }; + await expect(withOperationTimeout(runtime.reconcile([reentrant]))).rejects.toThrow( + "reentrant", + ); + }); + + it("rejects runtime operations reentered from plugin finalizers", async () => { + let nestedFailure: unknown; + const runtime = createRuntime(); + await runtime.reconcile([ + { + id: "acme.reentrant-finalizer", + version: "1.0.0", + activate(context) { + context.onDispose(async () => { + try { + await runtime.dispose(); + } catch (error) { + nestedFailure = error; + } + }); + }, + }, + ]); + await expect(withOperationTimeout(runtime.reconcile([]))).resolves.toBeDefined(); + expect(nestedFailure).toBeInstanceOf(Error); + expect((nestedFailure as Error).message).toContain("reentrant"); + await runtime.dispose(); + }); + + it("allows descendant tasks to use the runtime after their plugin callback settles", async () => { + let releaseBackground!: () => void; + const gate = new Promise((resolve) => { + releaseBackground = resolve; + }); + let backgroundResult: Promise | undefined; + const runtime = createRuntime(); + + await runtime.reconcile([ + { + id: "acme.background-task", + version: "1.0.0", + activate() { + backgroundResult = gate.then(() => runtime.reconcile([])); + }, + }, + ]); + + releaseBackground(); + await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined(); + await runtime.dispose(); + }); + + it("allows microtasks spawned by synchronous plugin callbacks to use the runtime", async () => { + let backgroundResult: Promise | undefined; + const runtime = createRuntime(); + + await runtime.reconcile([ + { + id: "acme.microtask", + version: "1.0.0", + activate() { + queueMicrotask(() => { + backgroundResult = runtime.reconcile([]); + }); + }, + }, + ]); + + expect(backgroundResult).toBeDefined(); + await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined(); + await runtime.dispose(); + }); + + it("allows microtasks spawned by synchronous callbacks that return plain functions", async () => { + let backgroundResult: Promise | undefined; + const runtime = createRuntime(); + const activate = (() => { + queueMicrotask(() => { + backgroundResult = runtime.reconcile([]); + }); + return () => undefined; + }) as unknown as PluginDefinition["activate"]; + + await runtime.reconcile([ + { + id: "acme.function-return", + version: "1.0.0", + activate, + }, + ]); + + expect(backgroundResult).toBeDefined(); + await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined(); + await runtime.dispose(); + }); + + it("rejects activation-context calls from microtasks queued by synchronous callbacks", async () => { + let lateFailure: unknown; + let disposed = false; + const runtime = createRuntime(); + + await runtime.reconcile([ + { + id: "acme.microtask-context", + version: "1.0.0", + activate(context) { + queueMicrotask(() => { + try { + context.onDispose(() => { + disposed = true; + }); + } catch (error) { + lateFailure = error; + } + }); + }, + }, + ]); + + expect(lateFailure).toBeInstanceOf(Error); + expect((lateFailure as Error).message).toContain("no longer active"); + await runtime.dispose(); + expect(disposed).toBe(false); + }); + + it("rejects finalizers registered after activation settles", async () => { + let registerLateFinalizer!: () => void; + let disposed = false; + const runtime = createRuntime(); + await runtime.reconcile([ + { + id: "acme.late-finalizer", + version: "1.0.0", + activate(context) { + registerLateFinalizer = () => + context.onDispose(() => { + disposed = true; + }); + }, + }, + ]); + + expect(registerLateFinalizer).toThrow("no longer active"); + await runtime.dispose(); + expect(disposed).toBe(false); + }); + + it("snapshots the requested definitions before waiting for an earlier transition", async () => { + let releaseActivation!: () => void; + const activationGate = new Promise((resolve) => { + releaseActivation = resolve; + }); + const runtime = createRuntime(); + const first = runtime.reconcile([ + { + id: "acme.blocking", + version: "1.0.0", + async activate() { + await activationGate; + }, + }, + ]); + const requested: Array = [provider()]; + const second = runtime.reconcile(requested); + requested.splice(0); + + releaseActivation(); + await first; + const snapshot = await second; + + expect(snapshot.active).toEqual(["acme.database"]); + await runtime.dispose(); + }); + + it("snapshots each requested definition and its declarations before queueing", async () => { + let releaseActivation!: () => void; + const activationGate = new Promise((resolve) => { + releaseActivation = resolve; + }); + const runtime = createRuntime(); + const first = runtime.reconcile([ + { + id: "acme.blocking", + version: "1.0.0", + async activate() { + await activationGate; + }, + }, + ]); + const requires = ["acme.database@1"]; + const provides: Record = { "acme.consumer@1": true }; + const requestedDefinition: PluginDefinition = { + id: "acme.consumer", + version: "1.0.0", + requires, + provides, + activate() {}, + }; + const second = runtime.reconcile([provider(), requestedDefinition]); + + (requestedDefinition as { id: string }).id = "acme.changed"; + requires[0] = "acme.missing@1"; + delete provides["acme.consumer@1"]; + + releaseActivation(); + await first; + const snapshot = await second; + + expect(snapshot.active).toEqual(["acme.database", "acme.consumer"]); + await runtime.dispose(); + }); + + it("rejects capabilities that the plugin did not declare", async () => { + const runtime = createRuntime(); + const undeclaredConsumer: PluginDefinition = { + id: "acme.undeclared-consumer", + version: "1.0.0", + activate(context) { + context.resolve("acme.database@1"); + }, + }; + + let undeclaredFailure: unknown; + try { + await runtime.reconcile([provider(), undeclaredConsumer]); + } catch (error) { + undeclaredFailure = error; + } + expect(failureMessage(undeclaredFailure)).toContain("did not declare"); + expect(runtime.snapshot().active).toEqual([]); + await runtime.dispose(); + }); + + it("rolls back a plugin scope when contribution snapshotting throws", async () => { + let disposed = false; + const runtime = createRuntime(); + const badContribution = { + get id(): string { + throw new Error("bad contribution getter"); + }, + label: "bad", + }; + + let snapshotFailure: unknown; + try { + await runtime.reconcile([ + { + id: "acme.snapshot-defect", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + disposed = true; + }); + context.register("commands", badContribution); + }, + }, + ]); + } catch (error) { + snapshotFailure = error; + } + + expect(failureMessage(snapshotFailure)).toContain("bad contribution getter"); + expect(disposed).toBe(true); + expect(runtime.snapshot().active).toEqual([]); + await runtime.dispose(); + }); + + it("attempts every plugin cleanup when one finalizer fails", async () => { + const disposed: Array = []; + const runtime = createRuntime(); + await runtime.reconcile([ + { + id: "acme.first-cleanup", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + disposed.push("first"); + }); + }, + }, + { + id: "acme.second-cleanup", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + disposed.push("second"); + throw new Error("cleanup failed"); + }); + }, + }, + ]); + + await expect(runtime.dispose()).rejects.toThrow(); + + expect(disposed).toEqual(["second", "first"]); + }); + + it("keeps the old plugin active when a replacement fails", async () => { + const runtime = createRuntime(); + await runtime.reconcile([provider()]); + const broken: PluginDefinition = { + ...provider("2.0.0"), + async activate(context) { + context.register("status", { id: "database", label: "database 2.0.0" }); + throw new Error("candidate failed"); + }, + }; + + let candidateFailure: unknown; + try { + await runtime.reconcile([broken]); + } catch (error) { + candidateFailure = error; + } + expect(failureMessage(candidateFailure)).toContain("candidate failed"); + + expect(runtime.snapshot().active).toEqual(["acme.database"]); + expect(contributionLabels(runtime.snapshot(), "status")).toEqual(["database 1.0.0"]); + await runtime.dispose(); + }); + + it("rejects dependency cycles without disturbing the current composition", async () => { + const runtime = createRuntime(); + await runtime.reconcile([provider()]); + const alpha: PluginDefinition = { + id: "acme.alpha", + version: "1.0.0", + requires: ["acme.beta@1"], + provides: { "acme.alpha@1": true }, + activate() {}, + }; + const beta: PluginDefinition = { + id: "acme.beta", + version: "1.0.0", + requires: ["acme.alpha@1"], + provides: { "acme.beta@1": true }, + activate() {}, + }; + + await expect(runtime.reconcile([alpha, beta])).rejects.toThrow("cycle"); + + expect(runtime.snapshot().active).toEqual(["acme.database"]); + await runtime.dispose(); + }); + }); +} diff --git a/packages/plugin-runtime/tsconfig.json b/packages/plugin-runtime/tsconfig.json new file mode 100644 index 000000000000..9241f01b3037 --- /dev/null +++ b/packages/plugin-runtime/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c79aea36a0e..528a6f9856d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -830,6 +830,19 @@ importers: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/plugin-runtime: + dependencies: + effect: + specifier: 4.0.0-beta.103 + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + devDependencies: + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 'catalog:' + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/shared: dependencies: '@noble/curves': From 14cd84cdddadf75bbd119e4dde684198cbfcff96 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:36:00 +0000 Subject: [PATCH 02/45] refactor: expose plugin runtime as effect service Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- packages/plugin-runtime/README.md | 4 +- packages/plugin-runtime/package.json | 1 + packages/plugin-runtime/src/contract.ts | 10 - packages/plugin-runtime/src/index.ts | 10 +- packages/plugin-runtime/src/runtime.ts | 565 ++--- packages/plugin-runtime/test/runtime.test.ts | 113 +- .../plugin-runtime/test/runtimeContract.ts | 1817 +++++++++-------- pnpm-lock.yaml | 3 + 8 files changed, 1385 insertions(+), 1138 deletions(-) diff --git a/packages/plugin-runtime/README.md b/packages/plugin-runtime/README.md index 1918b791cf16..d377361a28ff 100644 --- a/packages/plugin-runtime/README.md +++ b/packages/plugin-runtime/README.md @@ -1,7 +1,7 @@ # plugin runtime -internal runtime for t3 product plugins. +internal effect service for t3 product plugins. -it uses a deterministic, stack-safe reconciliation planner and one effect child scope per active plugin. updates stage changed plugins and their dependents, publish contributions atomically, and roll back without replacing the live composition when activation fails. +it uses a deterministic, stack-safe reconciliation planner and one effect child scope per active plugin. `PluginRuntime` is provided through `layer()`, so the composing effect scope owns shutdown. updates stage changed plugins and their dependents, publish contributions atomically, and roll back without replacing the live composition when activation fails. cordis is not a dependency. a pure-only executor was rejected because plugin lifetimes and async cleanup should be owned by effect scopes. diff --git a/packages/plugin-runtime/package.json b/packages/plugin-runtime/package.json index 8a984c8ec76b..07316e7e33dd 100644 --- a/packages/plugin-runtime/package.json +++ b/packages/plugin-runtime/package.json @@ -20,6 +20,7 @@ "effect": "catalog:" }, "devDependencies": { + "@effect/vitest": "catalog:", "@types/node": "catalog:", "vite-plus": "catalog:" } diff --git a/packages/plugin-runtime/src/contract.ts b/packages/plugin-runtime/src/contract.ts index b9c2e796cf04..248bdba167a8 100644 --- a/packages/plugin-runtime/src/contract.ts +++ b/packages/plugin-runtime/src/contract.ts @@ -23,14 +23,6 @@ export interface PluginRuntimeSnapshot { readonly contributions: Readonly>>>; } -export interface PluginRuntime { - readonly reconcile: ( - definitions: ReadonlyArray, - ) => Promise; - readonly snapshot: () => PluginRuntimeSnapshot; - readonly dispose: () => Promise; -} - export interface PluginRuntimeOptions { readonly onLifecycle?: (event: { readonly phase: "activate" | "deactivate"; @@ -46,5 +38,3 @@ export interface PluginRuntimeOptions { readonly error: unknown; }) => void; } - -export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime; diff --git a/packages/plugin-runtime/src/index.ts b/packages/plugin-runtime/src/index.ts index 8c27e0d81b47..332e5ba55d8a 100644 --- a/packages/plugin-runtime/src/index.ts +++ b/packages/plugin-runtime/src/index.ts @@ -2,9 +2,13 @@ export type { Contribution, PluginActivationContext, PluginDefinition, - PluginRuntime, - PluginRuntimeFactory, PluginRuntimeOptions, PluginRuntimeSnapshot, } from "./contract.ts"; -export { createPluginRuntime } from "./runtime.ts"; +export { + layer, + make, + PluginRuntime, + type PluginRuntimeDisposeError, + type PluginRuntimeReconcileError, +} from "./runtime.ts"; diff --git a/packages/plugin-runtime/src/runtime.ts b/packages/plugin-runtime/src/runtime.ts index 5de6e64ff793..53291998ac8f 100644 --- a/packages/plugin-runtime/src/runtime.ts +++ b/packages/plugin-runtime/src/runtime.ts @@ -1,17 +1,19 @@ import * as NodeAsyncHooks from "node:async_hooks"; import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import type { Contribution, PluginActivationContext, PluginDefinition, - PluginRuntime, - PluginRuntimeFactory, + PluginRuntimeOptions, PluginRuntimeSnapshot, } from "./contract.ts"; import { @@ -137,12 +139,26 @@ class PluginRuntimeCleanupError extends Schema.TaggedErrorClass, + ) => Effect.Effect; + readonly snapshot: Effect.Effect; + readonly dispose: Effect.Effect; + } +>()("@t3tools/plugin-runtime/runtime/PluginRuntime") {} + const createNullPrototypeRecord = (): Record => Object.create(null) as Record; @@ -195,300 +211,297 @@ const snapshotOf = ( }); }; -export const createPluginRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => { - let current: LiveComposition = { plugins: [], snapshot: emptySnapshot() }; - let disposed = false; - let transition: Promise = Promise.resolve(); - let runtimeScope: Scope.Closeable | undefined; - const callbackContext = new NodeAsyncHooks.AsyncLocalStorage(); - - const getRuntimeScope = (): Effect.Effect => - Effect.gen(function* () { - if (runtimeScope === undefined) runtimeScope = yield* Scope.make("sequential"); - return runtimeScope; - }); - - const reportLifecycle = ( - phase: "activate" | "deactivate", - pluginId: string, - ): Effect.Effect => - Effect.sync(() => { - try { - options.onLifecycle?.({ phase, pluginId }); - } catch (error) { - try { - options.onLifecycleError?.({ phase, pluginId, error }); - } catch { - // Observer error reporting must never interrupt a commit or cleanup. - } - } - }); - - const reportCleanupErrors = ( - phase: "retire" | "rollback", - failures: ReadonlyArray, - ): Effect.Effect => - Effect.sync(() => { - for (const { error } of failures) { +export const make = (options: PluginRuntimeOptions = {}) => + Effect.gen(function* () { + const parentScope = yield* Effect.scope; + const transitionSemaphore = yield* Semaphore.make(1); + let current: LiveComposition = { plugins: [], snapshot: emptySnapshot() }; + let disposed = false; + const callbackContext = new NodeAsyncHooks.AsyncLocalStorage(); + + const reportLifecycle = ( + phase: "activate" | "deactivate", + pluginId: string, + ): Effect.Effect => + Effect.sync(() => { try { - options.onCleanupError?.({ phase, error }); - } catch { - // Cleanup reporting must not replace activation errors or undo a committed snapshot. - } - } - }); - - const closePlugins = ( - plugins: ReadonlyArray, - notifyDeactivation: boolean, - ): Effect.Effect> => - Effect.gen(function* () { - const failures: Array = []; - for (const plugin of plugins.toReversed()) { - const closeExit = yield* Effect.exit(Scope.close(plugin.scope, Exit.void)); - if (Exit.isFailure(closeExit)) { - failures.push({ - error: Cause.squash(closeExit.cause), - pluginId: plugin.definition.id, - }); - } - for (const error of plugin.cleanupErrors.splice(0)) { - failures.push({ error, pluginId: plugin.definition.id }); - } - if (notifyDeactivation) { - yield* reportLifecycle("deactivate", plugin.definition.id); + options.onLifecycle?.({ phase, pluginId }); + } catch (error) { + try { + options.onLifecycleError?.({ phase, pluginId, error }); + } catch { + // Observer error reporting must never interrupt a commit or cleanup. + } } - } - return failures; - }); + }); - const invokePluginCallback = ( - callback: PluginCallback, - pluginId: string, - invoke: () => Result | PromiseLike, - onSettled?: () => void, - ): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const callbackState: PluginCallbackContext = { active: true, callback, pluginId }; - let settled = false; - const settle = () => { - if (settled) return; - settled = true; - callbackState.active = false; - onSettled?.(); - }; - try { - const result = callbackContext.run(callbackState, invoke); - if (typeof result === "object" && result !== null && "then" in result) { - return await Promise.resolve(result).finally(settle); + const reportCleanupErrors = ( + phase: "retire" | "rollback", + failures: ReadonlyArray, + ): Effect.Effect => + Effect.sync(() => { + for (const { error } of failures) { + try { + options.onCleanupError?.({ phase, error }); + } catch { + // Cleanup reporting must not replace activation errors or undo a committed snapshot. } - settle(); - return result; - } catch (error) { - settle(); - throw error; } - }, - catch: (cause) => new PluginCallbackError({ callback, cause, pluginId }), - }); + }); - const activatePlugin = ( - definition: PluginDefinition, - capabilities: ReadonlyMap, - ): Effect.Effect => - Effect.gen(function* () { - const parentScope = yield* getRuntimeScope(); - const scope = yield* Scope.fork(parentScope, "sequential"); - const contributions = new Map>(); - const cleanupErrors: Array = []; - const finalizers: Array<() => void | Promise> = []; - const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors }; - let activating = true; - const assertActivating = (method: "resolve" | "register" | "onDispose") => { - if (!activating) { - throw new PluginActivationContextExpiredError({ method, pluginId: definition.id }); + const closePlugins = ( + plugins: ReadonlyArray, + notifyDeactivation: boolean, + ): Effect.Effect> => + Effect.gen(function* () { + const failures: Array = []; + for (const plugin of plugins.toReversed()) { + const closeExit = yield* Effect.exit(Scope.close(plugin.scope, Exit.void)); + if (Exit.isFailure(closeExit)) { + failures.push({ + error: Cause.squash(closeExit.cause), + pluginId: plugin.definition.id, + }); + } + for (const error of plugin.cleanupErrors.splice(0)) { + failures.push({ error, pluginId: plugin.definition.id }); + } + if (notifyDeactivation) { + yield* reportLifecycle("deactivate", plugin.definition.id); + } } - }; + return failures; + }); - const context: PluginActivationContext = { - resolve: (capability: string): Service => { - assertActivating("resolve"); - if (!(definition.requires ?? []).includes(capability)) { - throw new PluginUndeclaredCapabilityError({ capability, pluginId: definition.id }); - } - if (!capabilities.has(capability)) { - throw new PluginResolutionError({ capability, pluginId: definition.id }); + const invokePluginCallback = ( + callback: PluginCallback, + pluginId: string, + invoke: () => Result | PromiseLike, + onSettled?: () => void, + ): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const callbackState: PluginCallbackContext = { active: true, callback, pluginId }; + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + callbackState.active = false; + onSettled?.(); + }; + try { + const result = callbackContext.run(callbackState, invoke); + if (typeof result === "object" && result !== null && "then" in result) { + return await Promise.resolve(result).finally(settle); + } + settle(); + return result; + } catch (error) { + settle(); + throw error; } - return capabilities.get(capability) as Service; - }, - register: (slot, contribution) => { - assertActivating("register"); - const values = contributions.get(slot) ?? []; - values.push(Object.freeze({ id: contribution.id, label: contribution.label })); - contributions.set(slot, values); - }, - onDispose: (finalizer) => { - assertActivating("onDispose"); - finalizers.push(finalizer); }, - }; - - const activationExit = yield* Effect.exit( - invokePluginCallback( - "activate", - definition.id, - () => definition.activate(context), - () => { - activating = false; + catch: (cause) => new PluginCallbackError({ callback, cause, pluginId }), + }); + + const activatePlugin = ( + definition: PluginDefinition, + capabilities: ReadonlyMap, + ): Effect.Effect => + Effect.gen(function* () { + const scope = yield* Scope.fork(parentScope, "sequential"); + const contributions = new Map>(); + const cleanupErrors: Array = []; + const finalizers: Array<() => void | Promise> = []; + const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors }; + let activating = true; + const assertActivating = (method: "resolve" | "register" | "onDispose") => { + if (!activating) { + throw new PluginActivationContextExpiredError({ method, pluginId: definition.id }); + } + }; + + const context: PluginActivationContext = { + resolve: (capability: string): Service => { + assertActivating("resolve"); + if (!(definition.requires ?? []).includes(capability)) { + throw new PluginUndeclaredCapabilityError({ capability, pluginId: definition.id }); + } + if (!capabilities.has(capability)) { + throw new PluginResolutionError({ capability, pluginId: definition.id }); + } + return capabilities.get(capability) as Service; }, - ), - ); - for (const finalizer of finalizers) { - const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe( - Effect.catch((error) => - Effect.sync(() => { - cleanupErrors.push(error); - }), + register: (slot, contribution) => { + assertActivating("register"); + const values = contributions.get(slot) ?? []; + values.push(Object.freeze({ id: contribution.id, label: contribution.label })); + contributions.set(slot, values); + }, + onDispose: (finalizer) => { + assertActivating("onDispose"); + finalizers.push(finalizer); + }, + }; + + const activationExit = yield* Effect.exit( + invokePluginCallback( + "activate", + definition.id, + () => definition.activate(context), + () => { + activating = false; + }, ), ); - yield* Scope.addFinalizer(scope, finalizerEffect); - } - - if (Exit.isFailure(activationExit)) { - const failures = yield* closePlugins([plugin], false); - yield* reportCleanupErrors("rollback", failures); - return yield* Effect.failCause(activationExit.cause); - } - return plugin; - }); + for (const finalizer of finalizers) { + const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe( + Effect.catch((error) => + Effect.sync(() => { + cleanupErrors.push(error); + }), + ), + ); + yield* Scope.addFinalizer(scope, finalizerEffect); + } - const reconcileEffect = ( - definitions: ReadonlyArray, - ): Effect.Effect => - Effect.gen(function* () { - if (disposed) { - return yield* new PluginRuntimeDisposedError({ operation: "reconcile" }); - } - - const plan = yield* Effect.try({ - try: () => planComposition(definitions), - catch: (error) => { - if (!isPluginPlanningError(error)) throw error; - return error; - }, + if (Exit.isFailure(activationExit)) { + const failures = yield* closePlugins([plugin], false); + yield* reportCleanupErrors("rollback", failures); + return yield* Effect.failCause(activationExit.cause); + } + return plugin; }); - const affected = affectedPluginIds( - current.plugins.map((plugin) => plugin.definition), - plan.definitions, - ); - const currentById = new Map(current.plugins.map((plugin) => [plugin.definition.id, plugin])); - const capabilities = new Map(); - for (const plugin of current.plugins) { - if (affected.has(plugin.definition.id)) continue; - for (const [capability, service] of Object.entries(plugin.definition.provides ?? {})) { - capabilities.set(capability, service); + + const reconcileEffect = ( + definitions: ReadonlyArray, + ): Effect.Effect => + Effect.gen(function* () { + if (disposed) { + return yield* new PluginRuntimeDisposedError({ operation: "reconcile" }); } - } - - const staged = new Map(); - const candidateExit = yield* Effect.exit( - Effect.gen(function* () { - for (const definition of plan.definitions) { - if (!affected.has(definition.id)) continue; - const plugin = yield* activatePlugin(definition, capabilities); - staged.set(definition.id, plugin); - for (const [capability, service] of Object.entries(definition.provides ?? {})) { - capabilities.set(capability, service); - } + + const plan = yield* Effect.try({ + try: () => planComposition(definitions), + catch: (error) => { + if (!isPluginPlanningError(error)) throw error; + return error; + }, + }); + const affected = affectedPluginIds( + current.plugins.map((plugin) => plugin.definition), + plan.definitions, + ); + const currentById = new Map( + current.plugins.map((plugin) => [plugin.definition.id, plugin]), + ); + const capabilities = new Map(); + for (const plugin of current.plugins) { + if (affected.has(plugin.definition.id)) continue; + for (const [capability, service] of Object.entries(plugin.definition.provides ?? {})) { + capabilities.set(capability, service); } + } - const nextPlugins: Array = []; - for (const definition of plan.definitions) { - const plugin = staged.get(definition.id) ?? currentById.get(definition.id); - if (plugin === undefined) { - return yield* new PluginStagingError({ pluginId: definition.id }); + const staged = new Map(); + const candidateExit = yield* Effect.exit( + Effect.gen(function* () { + for (const definition of plan.definitions) { + if (!affected.has(definition.id)) continue; + const plugin = yield* activatePlugin(definition, capabilities); + staged.set(definition.id, plugin); + for (const [capability, service] of Object.entries(definition.provides ?? {})) { + capabilities.set(capability, service); + } } - nextPlugins.push(plugin); - } - return { - plugins: nextPlugins, - snapshot: snapshotOf(nextPlugins, plan.blocked), - }; - }), - ); - if (Exit.isFailure(candidateExit)) { - const failures = yield* closePlugins([...staged.values()], false); - yield* reportCleanupErrors("rollback", failures); - return yield* Effect.failCause(candidateExit.cause); - } - - const previous = current.plugins.filter((plugin) => affected.has(plugin.definition.id)); - current = candidateExit.value; - for (const plugin of staged.values()) { - yield* reportLifecycle("activate", plugin.definition.id); - } - const failures = yield* closePlugins(previous, true); - yield* reportCleanupErrors("retire", failures); - return current.snapshot; - }); - const disposeEffect = (): Effect.Effect => - Effect.gen(function* () { - if (disposed) return; - disposed = true; - const previous = current.plugins; - current = { plugins: [], snapshot: emptySnapshot() }; - const failures = [...(yield* closePlugins(previous, true))]; - const parentScope = runtimeScope; - runtimeScope = undefined; - if (parentScope !== undefined) { - const closeExit = yield* Effect.exit(Scope.close(parentScope, Exit.void)); - if (Exit.isFailure(closeExit)) { - failures.push({ error: Cause.squash(closeExit.cause), pluginId: "plugin-runtime" }); + const nextPlugins: Array = []; + for (const definition of plan.definitions) { + const plugin = staged.get(definition.id) ?? currentById.get(definition.id); + if (plugin === undefined) { + return yield* new PluginStagingError({ pluginId: definition.id }); + } + nextPlugins.push(plugin); + } + return { + plugins: nextPlugins, + snapshot: snapshotOf(nextPlugins, plan.blocked), + }; + }), + ); + if (Exit.isFailure(candidateExit)) { + const failures = yield* closePlugins([...staged.values()], false); + yield* reportCleanupErrors("rollback", failures); + return yield* Effect.failCause(candidateExit.cause); } - } - if (failures.length > 0) { - return yield* new PluginRuntimeCleanupError({ - failures: failures.map(({ error, pluginId }) => ({ cause: error, pluginId })), - }); - } - }); - const runPromiseAdapter = ( - operation: RuntimeOperation, - effect: () => Effect.Effect, - ): Promise => { - const callback = callbackContext.getStore(); - if (callback?.active === true) { - return Promise.reject( - new PluginRuntimeReentrancyError({ - callback: callback.callback, - operation, - pluginId: callback.pluginId, - }), - ); - } + const previous = current.plugins.filter((plugin) => affected.has(plugin.definition.id)); + current = candidateExit.value; + for (const plugin of staged.values()) { + yield* reportLifecycle("activate", plugin.definition.id); + } + const failures = yield* closePlugins(previous, true); + yield* reportCleanupErrors("retire", failures); + return current.snapshot; + }); + + const disposeEffect = (): Effect.Effect => + Effect.gen(function* () { + if (disposed) return; + disposed = true; + const previous = current.plugins; + current = { plugins: [], snapshot: emptySnapshot() }; + const failures = [...(yield* closePlugins(previous, true))]; + if (failures.length > 0) { + return yield* new PluginRuntimeCleanupError({ + failures: failures.map(({ error, pluginId }) => ({ cause: error, pluginId })), + }); + } + }); + + const runTransition = ( + operation: RuntimeOperation, + effect: () => Effect.Effect, + ): Effect.Effect => + Effect.suspend(() => { + const callback = callbackContext.getStore(); + if (callback?.active === true) { + return Effect.fail( + new PluginRuntimeReentrancyError({ + callback: callback.callback, + operation, + pluginId: callback.pluginId, + }), + ); + } + return transitionSemaphore.withPermits(1)(effect()); + }); - const result = transition.then(() => Effect.runPromise(effect())); - transition = result.then( - () => undefined, - () => undefined, + yield* Effect.addFinalizer(() => + disposeEffect().pipe( + Effect.catch((error) => + reportCleanupErrors( + "retire", + error.failures.map(({ cause, pluginId }) => ({ error: cause, pluginId })), + ), + ), + ), ); - return result; - }; - - return { - reconcile: (definitions) => { - let desired: ReadonlyArray; - try { - desired = snapshotDefinitions(definitions); - } catch (error) { - return Promise.reject(error); - } - return runPromiseAdapter("reconcile", () => reconcileEffect(desired)); - }, - snapshot: () => current.snapshot, - dispose: () => runPromiseAdapter("dispose", disposeEffect), - }; -}; + + return { + reconcile: (definitions) => { + let desired: ReadonlyArray; + try { + desired = snapshotDefinitions(definitions); + } catch (error) { + return Effect.die(error); + } + return runTransition("reconcile", () => reconcileEffect(desired)); + }, + snapshot: Effect.sync(() => current.snapshot), + dispose: runTransition("dispose", disposeEffect), + } satisfies PluginRuntime["Service"]; + }); + +export const layer = (options: PluginRuntimeOptions = {}) => + Layer.effect(PluginRuntime, make(options)); diff --git a/packages/plugin-runtime/test/runtime.test.ts b/packages/plugin-runtime/test/runtime.test.ts index 7f907b4571f7..5ac36e82e4c4 100644 --- a/packages/plugin-runtime/test/runtime.test.ts +++ b/packages/plugin-runtime/test/runtime.test.ts @@ -1,45 +1,92 @@ -import { describe, expect, it } from "vite-plus/test"; +import { it } from "@effect/vitest"; +import { describe, expect } from "vite-plus/test"; +import * as Effect from "effect/Effect"; -import type { PluginDefinition } from "../src/contract.ts"; -import { createPluginRuntime } from "../src/runtime.ts"; +import type { PluginDefinition, PluginRuntimeOptions } from "../src/contract.ts"; +import { layer, make, PluginRuntime } from "../src/runtime.ts"; import { defineRuntimeContract } from "./runtimeContract.ts"; -defineRuntimeContract("plugin runtime", createPluginRuntime); +const makeTestRuntime = (options: PluginRuntimeOptions = {}) => make(options); + +defineRuntimeContract("plugin runtime", makeTestRuntime); describe("plugin runtime errors", () => { - it("returns schema-tagged planning errors", async () => { - const runtime = createPluginRuntime(); - const duplicate = { - id: "acme.duplicate", - version: "1.0.0", - activate() {}, - }; - - await expect(runtime.reconcile([duplicate, duplicate])).rejects.toMatchObject({ - _tag: "DuplicatePluginIdError", - pluginId: "acme.duplicate", - }); - }); + it.effect("returns schema-tagged planning errors", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeTestRuntime(); + const duplicate = { + id: "acme.duplicate", + version: "1.0.0", + activate() {}, + }; + + const error = yield* Effect.flip(runtime.reconcile([duplicate, duplicate])); + + expect(error).toMatchObject({ + _tag: "DuplicatePluginIdError", + pluginId: "acme.duplicate", + }); + yield* runtime.dispose; + }), + ), + ); }); describe("plugin runtime planner", () => { - it("plans a deep acyclic dependency chain without using the call stack", async () => { - const runtime = createPluginRuntime(); - const pluginCount = 20_000; - const definitions: Array = Array.from( - { length: pluginCount }, - (_, index) => ({ - id: `plugin-${index}`, - version: "1.0.0", - ...(index === 0 ? {} : { requires: [`capability-${index - 1}`] }), - provides: { [`capability-${index}`]: index }, - activate() {}, + it.effect("plans a deep acyclic dependency chain without using the call stack", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeTestRuntime(); + const pluginCount = 20_000; + const definitions: Array = Array.from( + { length: pluginCount }, + (_, index) => ({ + id: `plugin-${index}`, + version: "1.0.0", + ...(index === 0 ? {} : { requires: [`capability-${index - 1}`] }), + provides: { [`capability-${index}`]: index }, + activate() {}, + }), + ); + + const snapshot = yield* runtime.reconcile(definitions.toReversed()); + + expect(snapshot.active).toHaveLength(pluginCount); + yield* runtime.dispose; }), - ); + ), + ); +}); + +describe("plugin runtime layer", () => { + it.effect("closes active plugin scopes when the layer is released", () => + Effect.gen(function* () { + let disposed = false; + const lifecycle: Array = []; - const snapshot = await runtime.reconcile(definitions.toReversed()); + yield* PluginRuntime.use((runtime) => + runtime.reconcile([ + { + id: "acme.layer-owned", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + disposed = true; + }); + }, + }, + ]), + ).pipe( + Effect.provide( + layer({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), + }), + ), + ); - expect(snapshot.active).toHaveLength(pluginCount); - await runtime.dispose(); - }); + expect(disposed).toBe(true); + expect(lifecycle).toEqual(["activate:acme.layer-owned", "deactivate:acme.layer-owned"]); + }), + ); }); diff --git a/packages/plugin-runtime/test/runtimeContract.ts b/packages/plugin-runtime/test/runtimeContract.ts index 6fe0739be09f..bdab09596d83 100644 --- a/packages/plugin-runtime/test/runtimeContract.ts +++ b/packages/plugin-runtime/test/runtimeContract.ts @@ -1,31 +1,52 @@ -import * as NodeTimersPromises from "node:timers/promises"; +import { it } from "@effect/vitest"; +import { describe, expect } from "vite-plus/test"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Scope from "effect/Scope"; -import { describe, expect, it } from "vite-plus/test"; +import type { PluginRuntime, PluginRuntimeReconcileError } from "../src/runtime.ts"; import type { PluginDefinition, - PluginRuntimeFactory, + PluginRuntimeOptions, PluginRuntimeSnapshot, } from "../src/contract.ts"; -const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) => - snapshot.contributions[slot]?.map((item) => item.label) ?? []; +type TestPluginRuntimeFactory = ( + options?: PluginRuntimeOptions, +) => Effect.Effect; + +const makeEffectCallback = () => { + let unsafeResume: (effect: Effect.Effect) => void = () => { + throw new Error("effect callback has not started"); + }; + return { + await: Effect.callback((resume) => { + unsafeResume = resume; + }), + resume: (effect: Effect.Effect) => unsafeResume(effect), + } as const; +}; -const withOperationTimeout = async (operation: Promise): Promise => { - const controller = new AbortController(); - const timeout = NodeTimersPromises.setTimeout(100, undefined, { - ref: false, - signal: controller.signal, - }).then(() => { - throw new Error("operation timed out"); - }); - try { - return await Promise.race([operation, timeout]); - } finally { - controller.abort(); - } +const failureOf = (effect: Effect.Effect) => + Effect.exit(effect).pipe( + Effect.map((exit) => (Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined)), + ); + +const settlePromise = ( + exit: Exit.Exit, + resolve: () => void, + reject: (error: unknown) => void, +) => { + if (Exit.isFailure(exit)) reject(Cause.squash(exit.cause)); + else resolve(); }; +const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) => + snapshot.contributions[slot]?.map((item) => item.label) ?? []; + const failureCause = (error: unknown): unknown => typeof error === "object" && error !== null && "cause" in error ? (error as { readonly cause: unknown }).cause @@ -55,814 +76,982 @@ const consumer = (): PluginDefinition => ({ }, }); -export function defineRuntimeContract(name: string, createRuntime: PluginRuntimeFactory) { +export function defineRuntimeContract(name: string, createRuntime: TestPluginRuntimeFactory) { describe(name, () => { - it("activates providers before consumers regardless of manifest order", async () => { - const runtime = createRuntime(); - - const snapshot = await runtime.reconcile([consumer(), provider()]); - - expect(snapshot.active).toEqual(["acme.database", "acme.issues"]); - expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]); - await runtime.dispose(); - }); - - it("blocks missing dependencies without blocking independent plugins", async () => { - const runtime = createRuntime(); - const independent: PluginDefinition = { - id: "acme.clock", - version: "1.0.0", - activate(context) { - context.register("status", { id: "clock", label: "clock" }); - }, - }; - - const snapshot = await runtime.reconcile([consumer(), independent]); - - expect(snapshot.active).toEqual(["acme.clock"]); - expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1"); - expect(contributionLabels(snapshot, "status")).toEqual(["clock"]); - await runtime.dispose(); - }); - - it("deactivates dependents before providers", async () => { - const lifecycle: Array = []; - const runtime = createRuntime({ - onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), - }); - await runtime.reconcile([provider(), consumer()]); - lifecycle.length = 0; - - await runtime.reconcile([]); - - expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]); - await runtime.dispose(); - }); - - it("runs plugin finalizers in reverse registration order", async () => { - const disposed: Array = []; - const runtime = createRuntime(); - await runtime.reconcile([ - { - id: "acme.finalizers", - version: "1.0.0", - activate(context) { - context.onDispose(() => { - disposed.push("first"); - }); - context.onDispose(() => { - disposed.push("second"); - }); - }, - }, - ]); - - await runtime.reconcile([]); - - expect(disposed).toEqual(["second", "first"]); - await runtime.dispose(); - }); - - it("keeps unchanged plugin scopes alive across reconciliation", async () => { - let activations = 0; - let disposals = 0; - const stable: PluginDefinition = { - id: "acme.stable", - version: "1.0.0", - activate(context) { - activations += 1; - context.onDispose(() => { - disposals += 1; - }); - }, - }; - const runtime = createRuntime(); - - await runtime.reconcile([stable]); - await runtime.reconcile([stable]); - - expect(activations).toBe(1); - expect(disposals).toBe(0); - await runtime.dispose(); - expect(disposals).toBe(1); - }); - - it("treats reordered requirements and providers as the same definition", async () => { - let activations = 0; - const firstService = {}; - const secondService = {}; - const activate: PluginDefinition["activate"] = () => { - activations += 1; - }; - const dependencies: ReadonlyArray = [ - { - id: "acme.first-dependency", - version: "1.0.0", - provides: { "acme.first@1": true }, - activate() {}, - }, - { - id: "acme.second-dependency", - version: "1.0.0", - provides: { "acme.second@1": true }, - activate() {}, - }, - ]; - const runtime = createRuntime(); - await runtime.reconcile([ - ...dependencies, - { - id: "acme.stable-order", - version: "1.0.0", - requires: ["acme.first@1", "acme.second@1"], - provides: { "acme.output-one@1": firstService, "acme.output-two@1": secondService }, - activate, - }, - ]); - - await runtime.reconcile([ - ...dependencies, - { - id: "acme.stable-order", - version: "1.0.0", - requires: ["acme.second@1", "acme.first@1"], - provides: { "acme.output-two@1": secondService, "acme.output-one@1": firstService }, - activate, - }, - ]); - - expect(activations).toBe(1); - await runtime.dispose(); - }); - - it("restarts a plugin when its activation implementation changes", async () => { - const runtime = createRuntime(); - const first: PluginDefinition = { - id: "acme.implementation", - version: "1.0.0", - activate(context) { - context.register("commands", { id: "implementation", label: "first" }); - }, - }; - const second: PluginDefinition = { - id: "acme.implementation", - version: "1.0.0", - activate(context) { - context.register("commands", { id: "implementation", label: "second" }); - }, - }; - - await runtime.reconcile([first]); - const snapshot = await runtime.reconcile([second]); - - expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); - await runtime.dispose(); - }); - - it("restarts a changed provider and its dependents without touching independent plugins", async () => { - let independentActivations = 0; - const lifecycle: Array = []; - const independent: PluginDefinition = { - id: "acme.independent", - version: "1.0.0", - activate() { - independentActivations += 1; - }, - }; - const runtime = createRuntime({ - onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), - }); - await runtime.reconcile([consumer(), independent, provider()]); - lifecycle.length = 0; - - const snapshot = await runtime.reconcile([consumer(), independent, provider("2.0.0")]); - - expect(independentActivations).toBe(1); - expect(lifecycle).not.toContain("activate:acme.independent"); - expect(lifecycle).not.toContain("deactivate:acme.independent"); - expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([ - "activate:acme.database", - "activate:acme.issues", - ]); - expect(lifecycle.filter((event) => event.startsWith("deactivate:"))).toEqual([ - "deactivate:acme.issues", - "deactivate:acme.database", - ]); - expect(contributionLabels(snapshot, "commands")).toEqual(["database-2.0.0"]); - await runtime.dispose(); - }); - - it("returns deeply frozen snapshots", async () => { - const runtime = createRuntime(); - - const snapshot = await runtime.reconcile([provider()]); - const status = snapshot.contributions.status; - - expect(Object.isFrozen(snapshot)).toBe(true); - expect(Object.isFrozen(snapshot.active)).toBe(true); - expect(Object.isFrozen(snapshot.blocked)).toBe(true); - expect(Object.isFrozen(snapshot.contributions)).toBe(true); - expect(Object.isFrozen(status)).toBe(true); - expect(Object.isFrozen(status?.[0])).toBe(true); - await runtime.dispose(); - }); - - it("detaches registered contributions from later plugin mutation", async () => { - const contribution = { id: "mutable", label: "original" }; - const plugin: PluginDefinition = { - id: "acme.mutable-contribution", - version: "1.0.0", - activate(context) { - context.register("commands", contribution); - }, - }; - const unrelated: PluginDefinition = { - id: "acme.unrelated-contribution", - version: "1.0.0", - activate() {}, - }; - const runtime = createRuntime(); - await runtime.reconcile([plugin]); - - contribution.label = "changed"; - const snapshot = await runtime.reconcile([plugin, unrelated]); - - expect(contributionLabels(snapshot, "commands")).toEqual(["original"]); - await runtime.dispose(); - }); - - it("treats every plugin id and contribution slot as data", async () => { - const runtime = createRuntime(); - const unusualProvider: PluginDefinition = { - id: "toString", - version: "1.0.0", - provides: { "acme.unusual@1": "available" }, - activate(context) { - context.register("__proto__", { id: "provider", label: "provider" }); - }, - }; - const unusualConsumer: PluginDefinition = { - id: "constructor", - version: "1.0.0", - requires: ["acme.unusual@1"], - activate(context) { - context.resolve("acme.unusual@1"); - context.register("toString", { id: "consumer", label: "consumer" }); - }, - }; - const unusualBlocked: PluginDefinition = { - id: "__proto__", - version: "1.0.0", - requires: ["acme.missing@1"], - activate() { - throw new Error("blocked plugin activated"); - }, - }; - - const snapshot = await runtime.reconcile([unusualConsumer, unusualProvider, unusualBlocked]); - - expect(snapshot.active).toEqual(["toString", "constructor"]); - expect(Object.hasOwn(snapshot.blocked, "__proto__")).toBe(true); - expect(snapshot.blocked.__proto__).toContain("acme.missing@1"); - expect(Object.hasOwn(snapshot.contributions, "__proto__")).toBe(true); - expect(Object.hasOwn(snapshot.contributions, "toString")).toBe(true); - expect(contributionLabels(snapshot, "__proto__")).toEqual(["provider"]); - expect(contributionLabels(snapshot, "toString")).toEqual(["consumer"]); - await runtime.dispose(); - }); - - it("restarts dependents when a provided value changes", async () => { - const lifecycle: Array = []; - const runtime = createRuntime({ - onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), - }); - const serviceProvider = (name: string): PluginDefinition => ({ - id: "acme.service", - version: "1.0.0", - provides: { "acme.service@1": { name } }, - activate() {}, - }); - const serviceConsumer: PluginDefinition = { - id: "acme.service-consumer", - version: "1.0.0", - requires: ["acme.service@1"], - activate(context) { - const service = context.resolve<{ readonly name: string }>("acme.service@1"); - context.register("commands", { id: "service", label: service.name }); - }, - }; - await runtime.reconcile([serviceProvider("first"), serviceConsumer]); - lifecycle.length = 0; - - const snapshot = await runtime.reconcile([serviceProvider("second"), serviceConsumer]); - - expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); - expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([ - "activate:acme.service", - "activate:acme.service-consumer", - ]); - await runtime.dispose(); - }); - - it("does not report a rolled-back candidate as activated", async () => { - const lifecycle: Array = []; - const runtime = createRuntime({ - onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), - }); - const staged: PluginDefinition = { - id: "acme.staged", - version: "1.0.0", - activate() {}, - }; - const broken: PluginDefinition = { - id: "acme.broken", - version: "1.0.0", - activate() { - throw new Error("activation failed"); - }, - }; - - let stagingFailure: unknown; - try { - await runtime.reconcile([staged, broken]); - } catch (error) { - stagingFailure = error; - } - expect(failureMessage(stagingFailure)).toContain("activation failed"); - - expect(lifecycle).toEqual([]); - await runtime.dispose(); - }); - - it("preserves activation failures when rollback cleanup also fails", async () => { - const activationError = new Error("activation failed"); - const cleanupError = new Error("rollback cleanup failed"); - const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; - const runtime = createRuntime({ - onCleanupError: (event) => { - cleanupEvents.push(event); - throw new Error("cleanup observer failed"); - }, - }); - const broken: PluginDefinition = { - id: "acme.rollback-error", - version: "1.0.0", - activate(context) { - context.onDispose(() => { - throw cleanupError; + it.effect("activates providers before consumers regardless of manifest order", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + + const snapshot = yield* runtime.reconcile([consumer(), provider()]); + + expect(snapshot.active).toEqual(["acme.database", "acme.issues"]); + expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("blocks missing dependencies without blocking independent plugins", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + const independent: PluginDefinition = { + id: "acme.clock", + version: "1.0.0", + activate(context) { + context.register("status", { id: "clock", label: "clock" }); + }, + }; + + const snapshot = yield* runtime.reconcile([consumer(), independent]); + + expect(snapshot.active).toEqual(["acme.clock"]); + expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1"); + expect(contributionLabels(snapshot, "status")).toEqual(["clock"]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("deactivates dependents before providers", () => + Effect.scoped( + Effect.gen(function* () { + const lifecycle: Array = []; + const runtime = yield* createRuntime({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), }); - throw activationError; - }, - }; - - let activationFailure: unknown; - try { - await runtime.reconcile([broken]); - } catch (error) { - activationFailure = error; - } - expect(failureCause(activationFailure)).toBe(activationError); - expect(cleanupEvents).toHaveLength(1); - expect(cleanupEvents[0]?.phase).toBe("rollback"); - expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError); - await runtime.dispose(); - }); - - it("returns the committed snapshot when retiring an old plugin reports cleanup errors", async () => { - const cleanupError = new Error("retirement cleanup failed"); - const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; - const runtime = createRuntime({ - onCleanupError: (event) => { - cleanupEvents.push(event); - throw new Error("cleanup observer failed"); - }, - }); - await runtime.reconcile([ - { - id: "acme.retirement-error", - version: "1.0.0", - activate(context) { - context.register("commands", { id: "retirement", label: "old" }); - context.onDispose(() => { - throw cleanupError; - }); - }, - }, - ]); - - const snapshot = await runtime.reconcile([ - { - id: "acme.retirement-error", - version: "2.0.0", - activate(context) { - context.register("commands", { id: "retirement", label: "new" }); - }, - }, - ]); - - expect(contributionLabels(snapshot, "commands")).toEqual(["new"]); - expect(runtime.snapshot()).toBe(snapshot); - expect(cleanupEvents).toHaveLength(1); - expect(cleanupEvents[0]?.phase).toBe("retire"); - expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError); - await runtime.dispose(); - }); - - it("does not let lifecycle observers interrupt a committed transition", async () => { - let oldDisposed = false; - const observerErrors: Array = []; - const runtime = createRuntime({ - onLifecycle: ({ phase, pluginId }) => { - throw new Error(`${phase}:${pluginId}`); - }, - onLifecycleError: ({ phase, pluginId }) => { - observerErrors.push(`${phase}:${pluginId}`); - }, - }); - await runtime.reconcile([ - { - id: "acme.lifecycle-observer", - version: "1.0.0", - activate(context) { - context.onDispose(() => { - oldDisposed = true; - }); - }, - }, - ]); - - const snapshot = await runtime.reconcile([ - { - id: "acme.lifecycle-observer", - version: "2.0.0", - activate(context) { - context.register("commands", { id: "observer", label: "committed" }); - }, - }, - ]); - - expect(oldDisposed).toBe(true); - expect(contributionLabels(snapshot, "commands")).toEqual(["committed"]); - expect(observerErrors).toEqual([ - "activate:acme.lifecycle-observer", - "activate:acme.lifecycle-observer", - "deactivate:acme.lifecycle-observer", - ]); - await runtime.dispose(); - }); - - it("rejects runtime operations reentered from plugin activation", async () => { - const runtime = createRuntime(); - const reentrant: PluginDefinition = { - id: "acme.reentrant", - version: "1.0.0", - async activate() { - await runtime.reconcile([]); - }, - }; - await expect(withOperationTimeout(runtime.reconcile([reentrant]))).rejects.toThrow( - "reentrant", - ); - }); - - it("rejects runtime operations reentered from plugin finalizers", async () => { - let nestedFailure: unknown; - const runtime = createRuntime(); - await runtime.reconcile([ - { - id: "acme.reentrant-finalizer", - version: "1.0.0", - activate(context) { - context.onDispose(async () => { - try { - await runtime.dispose(); - } catch (error) { - nestedFailure = error; - } - }); - }, - }, - ]); - await expect(withOperationTimeout(runtime.reconcile([]))).resolves.toBeDefined(); - expect(nestedFailure).toBeInstanceOf(Error); - expect((nestedFailure as Error).message).toContain("reentrant"); - await runtime.dispose(); - }); - - it("allows descendant tasks to use the runtime after their plugin callback settles", async () => { - let releaseBackground!: () => void; - const gate = new Promise((resolve) => { - releaseBackground = resolve; - }); - let backgroundResult: Promise | undefined; - const runtime = createRuntime(); - - await runtime.reconcile([ - { - id: "acme.background-task", - version: "1.0.0", - activate() { - backgroundResult = gate.then(() => runtime.reconcile([])); - }, - }, - ]); - - releaseBackground(); - await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined(); - await runtime.dispose(); - }); - - it("allows microtasks spawned by synchronous plugin callbacks to use the runtime", async () => { - let backgroundResult: Promise | undefined; - const runtime = createRuntime(); - - await runtime.reconcile([ - { - id: "acme.microtask", - version: "1.0.0", - activate() { - queueMicrotask(() => { - backgroundResult = runtime.reconcile([]); - }); - }, - }, - ]); - - expect(backgroundResult).toBeDefined(); - await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined(); - await runtime.dispose(); - }); - - it("allows microtasks spawned by synchronous callbacks that return plain functions", async () => { - let backgroundResult: Promise | undefined; - const runtime = createRuntime(); - const activate = (() => { - queueMicrotask(() => { - backgroundResult = runtime.reconcile([]); - }); - return () => undefined; - }) as unknown as PluginDefinition["activate"]; - - await runtime.reconcile([ - { - id: "acme.function-return", - version: "1.0.0", - activate, - }, - ]); - - expect(backgroundResult).toBeDefined(); - await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined(); - await runtime.dispose(); - }); - - it("rejects activation-context calls from microtasks queued by synchronous callbacks", async () => { - let lateFailure: unknown; - let disposed = false; - const runtime = createRuntime(); - - await runtime.reconcile([ - { - id: "acme.microtask-context", - version: "1.0.0", - activate(context) { - queueMicrotask(() => { - try { + yield* runtime.reconcile([provider(), consumer()]); + lifecycle.length = 0; + + yield* runtime.reconcile([]); + + expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("runs plugin finalizers in reverse registration order", () => + Effect.scoped( + Effect.gen(function* () { + const disposed: Array = []; + const runtime = yield* createRuntime(); + yield* runtime.reconcile([ + { + id: "acme.finalizers", + version: "1.0.0", + activate(context) { context.onDispose(() => { - disposed = true; + disposed.push("first"); }); - } catch (error) { - lateFailure = error; - } - }); - }, - }, - ]); - - expect(lateFailure).toBeInstanceOf(Error); - expect((lateFailure as Error).message).toContain("no longer active"); - await runtime.dispose(); - expect(disposed).toBe(false); - }); - - it("rejects finalizers registered after activation settles", async () => { - let registerLateFinalizer!: () => void; - let disposed = false; - const runtime = createRuntime(); - await runtime.reconcile([ - { - id: "acme.late-finalizer", - version: "1.0.0", - activate(context) { - registerLateFinalizer = () => + context.onDispose(() => { + disposed.push("second"); + }); + }, + }, + ]); + + yield* runtime.reconcile([]); + + expect(disposed).toEqual(["second", "first"]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("keeps unchanged plugin scopes alive across reconciliation", () => + Effect.scoped( + Effect.gen(function* () { + let activations = 0; + let disposals = 0; + const stable: PluginDefinition = { + id: "acme.stable", + version: "1.0.0", + activate(context) { + activations += 1; context.onDispose(() => { - disposed = true; + disposals += 1; }); - }, - }, - ]); - - expect(registerLateFinalizer).toThrow("no longer active"); - await runtime.dispose(); - expect(disposed).toBe(false); - }); - - it("snapshots the requested definitions before waiting for an earlier transition", async () => { - let releaseActivation!: () => void; - const activationGate = new Promise((resolve) => { - releaseActivation = resolve; - }); - const runtime = createRuntime(); - const first = runtime.reconcile([ - { - id: "acme.blocking", - version: "1.0.0", - async activate() { - await activationGate; - }, - }, - ]); - const requested: Array = [provider()]; - const second = runtime.reconcile(requested); - requested.splice(0); - - releaseActivation(); - await first; - const snapshot = await second; - - expect(snapshot.active).toEqual(["acme.database"]); - await runtime.dispose(); - }); - - it("snapshots each requested definition and its declarations before queueing", async () => { - let releaseActivation!: () => void; - const activationGate = new Promise((resolve) => { - releaseActivation = resolve; - }); - const runtime = createRuntime(); - const first = runtime.reconcile([ - { - id: "acme.blocking", - version: "1.0.0", - async activate() { - await activationGate; - }, - }, - ]); - const requires = ["acme.database@1"]; - const provides: Record = { "acme.consumer@1": true }; - const requestedDefinition: PluginDefinition = { - id: "acme.consumer", - version: "1.0.0", - requires, - provides, - activate() {}, - }; - const second = runtime.reconcile([provider(), requestedDefinition]); - - (requestedDefinition as { id: string }).id = "acme.changed"; - requires[0] = "acme.missing@1"; - delete provides["acme.consumer@1"]; - - releaseActivation(); - await first; - const snapshot = await second; - - expect(snapshot.active).toEqual(["acme.database", "acme.consumer"]); - await runtime.dispose(); - }); - - it("rejects capabilities that the plugin did not declare", async () => { - const runtime = createRuntime(); - const undeclaredConsumer: PluginDefinition = { - id: "acme.undeclared-consumer", - version: "1.0.0", - activate(context) { - context.resolve("acme.database@1"); - }, - }; - - let undeclaredFailure: unknown; - try { - await runtime.reconcile([provider(), undeclaredConsumer]); - } catch (error) { - undeclaredFailure = error; - } - expect(failureMessage(undeclaredFailure)).toContain("did not declare"); - expect(runtime.snapshot().active).toEqual([]); - await runtime.dispose(); - }); - - it("rolls back a plugin scope when contribution snapshotting throws", async () => { - let disposed = false; - const runtime = createRuntime(); - const badContribution = { - get id(): string { - throw new Error("bad contribution getter"); - }, - label: "bad", - }; - - let snapshotFailure: unknown; - try { - await runtime.reconcile([ - { - id: "acme.snapshot-defect", + }, + }; + const runtime = yield* createRuntime(); + + yield* runtime.reconcile([stable]); + yield* runtime.reconcile([stable]); + + expect(activations).toBe(1); + expect(disposals).toBe(0); + yield* runtime.dispose; + expect(disposals).toBe(1); + }), + ), + ); + + it.effect("treats reordered requirements and providers as the same definition", () => + Effect.scoped( + Effect.gen(function* () { + let activations = 0; + const firstService = {}; + const secondService = {}; + const activate: PluginDefinition["activate"] = () => { + activations += 1; + }; + const dependencies: ReadonlyArray = [ + { + id: "acme.first-dependency", + version: "1.0.0", + provides: { "acme.first@1": true }, + activate() {}, + }, + { + id: "acme.second-dependency", + version: "1.0.0", + provides: { "acme.second@1": true }, + activate() {}, + }, + ]; + const runtime = yield* createRuntime(); + yield* runtime.reconcile([ + ...dependencies, + { + id: "acme.stable-order", + version: "1.0.0", + requires: ["acme.first@1", "acme.second@1"], + provides: { "acme.output-one@1": firstService, "acme.output-two@1": secondService }, + activate, + }, + ]); + + yield* runtime.reconcile([ + ...dependencies, + { + id: "acme.stable-order", + version: "1.0.0", + requires: ["acme.second@1", "acme.first@1"], + provides: { "acme.output-two@1": secondService, "acme.output-one@1": firstService }, + activate, + }, + ]); + + expect(activations).toBe(1); + yield* runtime.dispose; + }), + ), + ); + + it.effect("restarts a plugin when its activation implementation changes", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + const first: PluginDefinition = { + id: "acme.implementation", + version: "1.0.0", + activate(context) { + context.register("commands", { id: "implementation", label: "first" }); + }, + }; + const second: PluginDefinition = { + id: "acme.implementation", + version: "1.0.0", + activate(context) { + context.register("commands", { id: "implementation", label: "second" }); + }, + }; + + yield* runtime.reconcile([first]); + const snapshot = yield* runtime.reconcile([second]); + + expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); + yield* runtime.dispose; + }), + ), + ); + + it.effect( + "restarts a changed provider and its dependents without touching independent plugins", + () => + Effect.scoped( + Effect.gen(function* () { + let independentActivations = 0; + const lifecycle: Array = []; + const independent: PluginDefinition = { + id: "acme.independent", + version: "1.0.0", + activate() { + independentActivations += 1; + }, + }; + const runtime = yield* createRuntime({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), + }); + yield* runtime.reconcile([consumer(), independent, provider()]); + lifecycle.length = 0; + + const snapshot = yield* runtime.reconcile([consumer(), independent, provider("2.0.0")]); + + expect(independentActivations).toBe(1); + expect(lifecycle).not.toContain("activate:acme.independent"); + expect(lifecycle).not.toContain("deactivate:acme.independent"); + expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([ + "activate:acme.database", + "activate:acme.issues", + ]); + expect(lifecycle.filter((event) => event.startsWith("deactivate:"))).toEqual([ + "deactivate:acme.issues", + "deactivate:acme.database", + ]); + expect(contributionLabels(snapshot, "commands")).toEqual(["database-2.0.0"]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("returns deeply frozen snapshots", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + + const snapshot = yield* runtime.reconcile([provider()]); + const status = snapshot.contributions.status; + + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.active)).toBe(true); + expect(Object.isFrozen(snapshot.blocked)).toBe(true); + expect(Object.isFrozen(snapshot.contributions)).toBe(true); + expect(Object.isFrozen(status)).toBe(true); + expect(Object.isFrozen(status?.[0])).toBe(true); + yield* runtime.dispose; + }), + ), + ); + + it.effect("detaches registered contributions from later plugin mutation", () => + Effect.scoped( + Effect.gen(function* () { + const contribution = { id: "mutable", label: "original" }; + const plugin: PluginDefinition = { + id: "acme.mutable-contribution", + version: "1.0.0", + activate(context) { + context.register("commands", contribution); + }, + }; + const unrelated: PluginDefinition = { + id: "acme.unrelated-contribution", + version: "1.0.0", + activate() {}, + }; + const runtime = yield* createRuntime(); + yield* runtime.reconcile([plugin]); + + contribution.label = "changed"; + const snapshot = yield* runtime.reconcile([plugin, unrelated]); + + expect(contributionLabels(snapshot, "commands")).toEqual(["original"]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("treats every plugin id and contribution slot as data", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + const unusualProvider: PluginDefinition = { + id: "toString", + version: "1.0.0", + provides: { "acme.unusual@1": "available" }, + activate(context) { + context.register("__proto__", { id: "provider", label: "provider" }); + }, + }; + const unusualConsumer: PluginDefinition = { + id: "constructor", + version: "1.0.0", + requires: ["acme.unusual@1"], + activate(context) { + context.resolve("acme.unusual@1"); + context.register("toString", { id: "consumer", label: "consumer" }); + }, + }; + const unusualBlocked: PluginDefinition = { + id: "__proto__", + version: "1.0.0", + requires: ["acme.missing@1"], + activate() { + throw new Error("blocked plugin activated"); + }, + }; + + const snapshot = yield* runtime.reconcile([ + unusualConsumer, + unusualProvider, + unusualBlocked, + ]); + + expect(snapshot.active).toEqual(["toString", "constructor"]); + expect(Object.hasOwn(snapshot.blocked, "__proto__")).toBe(true); + expect(snapshot.blocked.__proto__).toContain("acme.missing@1"); + expect(Object.hasOwn(snapshot.contributions, "__proto__")).toBe(true); + expect(Object.hasOwn(snapshot.contributions, "toString")).toBe(true); + expect(contributionLabels(snapshot, "__proto__")).toEqual(["provider"]); + expect(contributionLabels(snapshot, "toString")).toEqual(["consumer"]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("restarts dependents when a provided value changes", () => + Effect.scoped( + Effect.gen(function* () { + const lifecycle: Array = []; + const runtime = yield* createRuntime({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), + }); + const serviceProvider = (name: string): PluginDefinition => ({ + id: "acme.service", + version: "1.0.0", + provides: { "acme.service@1": { name } }, + activate() {}, + }); + const serviceConsumer: PluginDefinition = { + id: "acme.service-consumer", + version: "1.0.0", + requires: ["acme.service@1"], + activate(context) { + const service = context.resolve<{ readonly name: string }>("acme.service@1"); + context.register("commands", { id: "service", label: service.name }); + }, + }; + yield* runtime.reconcile([serviceProvider("first"), serviceConsumer]); + lifecycle.length = 0; + + const snapshot = yield* runtime.reconcile([serviceProvider("second"), serviceConsumer]); + + expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); + expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([ + "activate:acme.service", + "activate:acme.service-consumer", + ]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("does not report a rolled-back candidate as activated", () => + Effect.scoped( + Effect.gen(function* () { + const lifecycle: Array = []; + const runtime = yield* createRuntime({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), + }); + const staged: PluginDefinition = { + id: "acme.staged", + version: "1.0.0", + activate() {}, + }; + const broken: PluginDefinition = { + id: "acme.broken", + version: "1.0.0", + activate() { + throw new Error("activation failed"); + }, + }; + + const stagingFailure = yield* failureOf(runtime.reconcile([staged, broken])); + expect(failureMessage(stagingFailure)).toContain("activation failed"); + + expect(lifecycle).toEqual([]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("preserves activation failures when rollback cleanup also fails", () => + Effect.scoped( + Effect.gen(function* () { + const activationError = new Error("activation failed"); + const cleanupError = new Error("rollback cleanup failed"); + const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; + const runtime = yield* createRuntime({ + onCleanupError: (event) => { + cleanupEvents.push(event); + throw new Error("cleanup observer failed"); + }, + }); + const broken: PluginDefinition = { + id: "acme.rollback-error", version: "1.0.0", activate(context) { context.onDispose(() => { - disposed = true; + throw cleanupError; }); - context.register("commands", badContribution); + throw activationError; }, - }, - ]); - } catch (error) { - snapshotFailure = error; - } - - expect(failureMessage(snapshotFailure)).toContain("bad contribution getter"); - expect(disposed).toBe(true); - expect(runtime.snapshot().active).toEqual([]); - await runtime.dispose(); - }); - - it("attempts every plugin cleanup when one finalizer fails", async () => { - const disposed: Array = []; - const runtime = createRuntime(); - await runtime.reconcile([ - { - id: "acme.first-cleanup", - version: "1.0.0", - activate(context) { - context.onDispose(() => { - disposed.push("first"); + }; + + const activationFailure = yield* failureOf(runtime.reconcile([broken])); + expect(failureCause(activationFailure)).toBe(activationError); + expect(cleanupEvents).toHaveLength(1); + expect(cleanupEvents[0]?.phase).toBe("rollback"); + expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError); + yield* runtime.dispose; + }), + ), + ); + + it.effect( + "returns the committed snapshot when retiring an old plugin reports cleanup errors", + () => + Effect.scoped( + Effect.gen(function* () { + const cleanupError = new Error("retirement cleanup failed"); + const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; + const runtime = yield* createRuntime({ + onCleanupError: (event) => { + cleanupEvents.push(event); + throw new Error("cleanup observer failed"); + }, }); - }, - }, - { - id: "acme.second-cleanup", - version: "1.0.0", - activate(context) { - context.onDispose(() => { - disposed.push("second"); - throw new Error("cleanup failed"); + yield* runtime.reconcile([ + { + id: "acme.retirement-error", + version: "1.0.0", + activate(context) { + context.register("commands", { id: "retirement", label: "old" }); + context.onDispose(() => { + throw cleanupError; + }); + }, + }, + ]); + + const snapshot = yield* runtime.reconcile([ + { + id: "acme.retirement-error", + version: "2.0.0", + activate(context) { + context.register("commands", { id: "retirement", label: "new" }); + }, + }, + ]); + + expect(contributionLabels(snapshot, "commands")).toEqual(["new"]); + expect(yield* runtime.snapshot).toBe(snapshot); + expect(cleanupEvents).toHaveLength(1); + expect(cleanupEvents[0]?.phase).toBe("retire"); + expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError); + yield* runtime.dispose; + }), + ), + ); + + it.effect("does not let lifecycle observers interrupt a committed transition", () => + Effect.scoped( + Effect.gen(function* () { + let oldDisposed = false; + const observerErrors: Array = []; + const runtime = yield* createRuntime({ + onLifecycle: ({ phase, pluginId }) => { + throw new Error(`${phase}:${pluginId}`); + }, + onLifecycleError: ({ phase, pluginId }) => { + observerErrors.push(`${phase}:${pluginId}`); + }, + }); + yield* runtime.reconcile([ + { + id: "acme.lifecycle-observer", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + oldDisposed = true; + }); + }, + }, + ]); + + const snapshot = yield* runtime.reconcile([ + { + id: "acme.lifecycle-observer", + version: "2.0.0", + activate(context) { + context.register("commands", { id: "observer", label: "committed" }); + }, + }, + ]); + + expect(oldDisposed).toBe(true); + expect(contributionLabels(snapshot, "commands")).toEqual(["committed"]); + expect(observerErrors).toEqual([ + "activate:acme.lifecycle-observer", + "activate:acme.lifecycle-observer", + "deactivate:acme.lifecycle-observer", + ]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("rejects runtime operations reentered from plugin activation", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + const nested = makeEffectCallback, never>(); + const nestedFiber = yield* Effect.forkChild(nested.await); + yield* Effect.yieldNow; + let settleActivation!: (exit: Exit.Exit) => void; + const activationGate = new Promise((resolve, reject) => { + settleActivation = (exit) => settlePromise(exit, resolve, reject); + }); + const reentrant: PluginDefinition = { + id: "acme.reentrant", + version: "1.0.0", + activate() { + nested.resume( + Effect.exit(runtime.reconcile([])).pipe( + Effect.tap((exit) => Effect.sync(() => settleActivation(exit))), + ), + ); + return activationGate; + }, + }; + const activationFailure = yield* failureOf(runtime.reconcile([reentrant])); + yield* Fiber.join(nestedFiber); + + expect(failureMessage(activationFailure)).toContain("reentrant"); + }), + ), + ); + + it.effect("rejects runtime operations reentered from plugin finalizers", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + const nested = makeEffectCallback, never>(); + const nestedFiber = yield* Effect.forkChild(nested.await); + yield* Effect.yieldNow; + let finishFinalizer!: (exit: Exit.Exit) => void; + const finalizerGate = new Promise((resolve) => { + finishFinalizer = () => resolve(); + }); + yield* runtime.reconcile([ + { + id: "acme.reentrant-finalizer", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + nested.resume( + Effect.exit(runtime.dispose).pipe( + Effect.tap((exit) => Effect.sync(() => finishFinalizer(exit))), + ), + ); + return finalizerGate; + }); + }, + }, + ]); + const snapshot = yield* runtime.reconcile([]); + const nestedExit = yield* Fiber.join(nestedFiber); + const nestedFailure = Exit.isFailure(nestedExit) + ? Cause.squash(nestedExit.cause) + : undefined; + + expect(snapshot).toBeDefined(); + expect(nestedFailure).toBeInstanceOf(Error); + expect((nestedFailure as Error).message).toContain("reentrant"); + yield* runtime.dispose; + }), + ), + ); + + it.effect( + "allows descendant tasks to use the runtime after their plugin callback settles", + () => + Effect.scoped( + Effect.gen(function* () { + let releaseBackground!: () => void; + const gate = new Promise((resolve) => { + releaseBackground = resolve; }); - }, - }, - ]); - - await expect(runtime.dispose()).rejects.toThrow(); - - expect(disposed).toEqual(["second", "first"]); - }); - - it("keeps the old plugin active when a replacement fails", async () => { - const runtime = createRuntime(); - await runtime.reconcile([provider()]); - const broken: PluginDefinition = { - ...provider("2.0.0"), - async activate(context) { - context.register("status", { id: "database", label: "database 2.0.0" }); - throw new Error("candidate failed"); - }, - }; - - let candidateFailure: unknown; - try { - await runtime.reconcile([broken]); - } catch (error) { - candidateFailure = error; - } - expect(failureMessage(candidateFailure)).toContain("candidate failed"); - - expect(runtime.snapshot().active).toEqual(["acme.database"]); - expect(contributionLabels(runtime.snapshot(), "status")).toEqual(["database 1.0.0"]); - await runtime.dispose(); - }); - - it("rejects dependency cycles without disturbing the current composition", async () => { - const runtime = createRuntime(); - await runtime.reconcile([provider()]); - const alpha: PluginDefinition = { - id: "acme.alpha", - version: "1.0.0", - requires: ["acme.beta@1"], - provides: { "acme.alpha@1": true }, - activate() {}, - }; - const beta: PluginDefinition = { - id: "acme.beta", - version: "1.0.0", - requires: ["acme.alpha@1"], - provides: { "acme.beta@1": true }, - activate() {}, - }; - - await expect(runtime.reconcile([alpha, beta])).rejects.toThrow("cycle"); - - expect(runtime.snapshot().active).toEqual(["acme.database"]); - await runtime.dispose(); - }); + const runtime = yield* createRuntime(); + const background = makeEffectCallback< + PluginRuntimeSnapshot, + PluginRuntimeReconcileError + >(); + const backgroundFiber = yield* Effect.forkChild(background.await); + yield* Effect.yieldNow; + + yield* runtime.reconcile([ + { + id: "acme.background-task", + version: "1.0.0", + activate() { + void gate.then(() => background.resume(runtime.reconcile([]))); + }, + }, + ]); + + releaseBackground(); + expect(yield* Fiber.join(backgroundFiber)).toBeDefined(); + yield* runtime.dispose; + }), + ), + ); + + it.effect("allows microtasks spawned by synchronous plugin callbacks to use the runtime", () => + Effect.scoped( + Effect.gen(function* () { + let backgroundScheduled = false; + const runtime = yield* createRuntime(); + const background = makeEffectCallback< + PluginRuntimeSnapshot, + PluginRuntimeReconcileError + >(); + const backgroundFiber = yield* Effect.forkChild(background.await); + yield* Effect.yieldNow; + + yield* runtime.reconcile([ + { + id: "acme.microtask", + version: "1.0.0", + activate() { + queueMicrotask(() => { + backgroundScheduled = true; + background.resume(runtime.reconcile([])); + }); + }, + }, + ]); + + expect(backgroundScheduled).toBe(true); + expect(yield* Fiber.join(backgroundFiber)).toBeDefined(); + yield* runtime.dispose; + }), + ), + ); + + it.effect( + "allows microtasks spawned by synchronous callbacks that return plain functions", + () => + Effect.scoped( + Effect.gen(function* () { + let backgroundScheduled = false; + const runtime = yield* createRuntime(); + const background = makeEffectCallback< + PluginRuntimeSnapshot, + PluginRuntimeReconcileError + >(); + const backgroundFiber = yield* Effect.forkChild(background.await); + yield* Effect.yieldNow; + const activate = (() => { + queueMicrotask(() => { + backgroundScheduled = true; + background.resume(runtime.reconcile([])); + }); + return () => undefined; + }) as unknown as PluginDefinition["activate"]; + + yield* runtime.reconcile([ + { + id: "acme.function-return", + version: "1.0.0", + activate, + }, + ]); + + expect(backgroundScheduled).toBe(true); + expect(yield* Fiber.join(backgroundFiber)).toBeDefined(); + yield* runtime.dispose; + }), + ), + ); + + it.effect( + "rejects activation-context calls from microtasks queued by synchronous callbacks", + () => + Effect.scoped( + Effect.gen(function* () { + let lateFailure: unknown; + let disposed = false; + const runtime = yield* createRuntime(); + + yield* runtime.reconcile([ + { + id: "acme.microtask-context", + version: "1.0.0", + activate(context) { + queueMicrotask(() => { + try { + context.onDispose(() => { + disposed = true; + }); + } catch (error) { + lateFailure = error; + } + }); + }, + }, + ]); + + expect(lateFailure).toBeInstanceOf(Error); + expect((lateFailure as Error).message).toContain("no longer active"); + yield* runtime.dispose; + expect(disposed).toBe(false); + }), + ), + ); + + it.effect("rejects finalizers registered after activation settles", () => + Effect.scoped( + Effect.gen(function* () { + let registerLateFinalizer!: () => void; + let disposed = false; + const runtime = yield* createRuntime(); + yield* runtime.reconcile([ + { + id: "acme.late-finalizer", + version: "1.0.0", + activate(context) { + registerLateFinalizer = () => + context.onDispose(() => { + disposed = true; + }); + }, + }, + ]); + + expect(registerLateFinalizer).toThrow("no longer active"); + yield* runtime.dispose; + expect(disposed).toBe(false); + }), + ), + ); + + it.effect("snapshots the requested definitions before waiting for an earlier transition", () => + Effect.scoped( + Effect.gen(function* () { + let releaseActivation!: () => void; + const activationGate = new Promise((resolve) => { + releaseActivation = resolve; + }); + const runtime = yield* createRuntime(); + const first = yield* Effect.forkChild( + runtime.reconcile([ + { + id: "acme.blocking", + version: "1.0.0", + async activate() { + await activationGate; + }, + }, + ]), + ); + yield* Effect.yieldNow; + const requested: Array = [provider()]; + const second = yield* Effect.forkChild(runtime.reconcile(requested)); + requested.splice(0); + + releaseActivation(); + yield* Fiber.join(first); + const snapshot = yield* Fiber.join(second); + + expect(snapshot.active).toEqual(["acme.database"]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("snapshots each requested definition and its declarations before queueing", () => + Effect.scoped( + Effect.gen(function* () { + let releaseActivation!: () => void; + const activationGate = new Promise((resolve) => { + releaseActivation = resolve; + }); + const runtime = yield* createRuntime(); + const first = yield* Effect.forkChild( + runtime.reconcile([ + { + id: "acme.blocking", + version: "1.0.0", + async activate() { + await activationGate; + }, + }, + ]), + ); + yield* Effect.yieldNow; + const requires = ["acme.database@1"]; + const provides: Record = { "acme.consumer@1": true }; + const requestedDefinition: PluginDefinition = { + id: "acme.consumer", + version: "1.0.0", + requires, + provides, + activate() {}, + }; + const second = yield* Effect.forkChild( + runtime.reconcile([provider(), requestedDefinition]), + ); + + (requestedDefinition as { id: string }).id = "acme.changed"; + requires[0] = "acme.missing@1"; + delete provides["acme.consumer@1"]; + + releaseActivation(); + yield* Fiber.join(first); + const snapshot = yield* Fiber.join(second); + + expect(snapshot.active).toEqual(["acme.database", "acme.consumer"]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("rejects capabilities that the plugin did not declare", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + const undeclaredConsumer: PluginDefinition = { + id: "acme.undeclared-consumer", + version: "1.0.0", + activate(context) { + context.resolve("acme.database@1"); + }, + }; + + const undeclaredFailure = yield* failureOf( + runtime.reconcile([provider(), undeclaredConsumer]), + ); + expect(failureMessage(undeclaredFailure)).toContain("did not declare"); + expect((yield* runtime.snapshot).active).toEqual([]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("rolls back a plugin scope when contribution snapshotting throws", () => + Effect.scoped( + Effect.gen(function* () { + let disposed = false; + const runtime = yield* createRuntime(); + const badContribution = { + get id(): string { + throw new Error("bad contribution getter"); + }, + label: "bad", + }; + + const snapshotFailure = yield* failureOf( + runtime.reconcile([ + { + id: "acme.snapshot-defect", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + disposed = true; + }); + context.register("commands", badContribution); + }, + }, + ]), + ); + + expect(failureMessage(snapshotFailure)).toContain("bad contribution getter"); + expect(disposed).toBe(true); + expect((yield* runtime.snapshot).active).toEqual([]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("attempts every plugin cleanup when one finalizer fails", () => + Effect.scoped( + Effect.gen(function* () { + const disposed: Array = []; + const runtime = yield* createRuntime(); + yield* runtime.reconcile([ + { + id: "acme.first-cleanup", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + disposed.push("first"); + }); + }, + }, + { + id: "acme.second-cleanup", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + disposed.push("second"); + throw new Error("cleanup failed"); + }); + }, + }, + ]); + + expect(yield* failureOf(runtime.dispose)).toBeInstanceOf(Error); + + expect(disposed).toEqual(["second", "first"]); + }), + ), + ); + + it.effect("keeps the old plugin active when a replacement fails", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + yield* runtime.reconcile([provider()]); + const broken: PluginDefinition = { + ...provider("2.0.0"), + async activate(context) { + context.register("status", { id: "database", label: "database 2.0.0" }); + throw new Error("candidate failed"); + }, + }; + + const candidateFailure = yield* failureOf(runtime.reconcile([broken])); + expect(failureMessage(candidateFailure)).toContain("candidate failed"); + + expect((yield* runtime.snapshot).active).toEqual(["acme.database"]); + expect(contributionLabels(yield* runtime.snapshot, "status")).toEqual(["database 1.0.0"]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("rejects dependency cycles without disturbing the current composition", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + yield* runtime.reconcile([provider()]); + const alpha: PluginDefinition = { + id: "acme.alpha", + version: "1.0.0", + requires: ["acme.beta@1"], + provides: { "acme.alpha@1": true }, + activate() {}, + }; + const beta: PluginDefinition = { + id: "acme.beta", + version: "1.0.0", + requires: ["acme.alpha@1"], + provides: { "acme.beta@1": true }, + activate() {}, + }; + + expect(failureMessage(yield* failureOf(runtime.reconcile([alpha, beta])))).toContain( + "cycle", + ); + + expect((yield* runtime.snapshot).active).toEqual(["acme.database"]); + yield* runtime.dispose; + }), + ), + ); }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 528a6f9856d1..d5a93e957a53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -836,6 +836,9 @@ importers: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: + '@effect/vitest': + specifier: 4.0.0-beta.103 + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 From 431c7c7014aa4700cf7bf7f2494e2c2230c49547 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:57:30 +0000 Subject: [PATCH 03/45] fix: make plugin shutdown interruption safe Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- packages/plugin-runtime/src/runtime.ts | 66 ++++---- packages/plugin-runtime/test/runtime.test.ts | 169 +++++++++++++++++++ 2 files changed, 206 insertions(+), 29 deletions(-) diff --git a/packages/plugin-runtime/src/runtime.ts b/packages/plugin-runtime/src/runtime.ts index 53291998ac8f..5a978f4194be 100644 --- a/packages/plugin-runtime/src/runtime.ts +++ b/packages/plugin-runtime/src/runtime.ts @@ -216,6 +216,7 @@ export const make = (options: PluginRuntimeOptions = {}) => const parentScope = yield* Effect.scope; const transitionSemaphore = yield* Semaphore.make(1); let current: LiveComposition = { plugins: [], snapshot: emptySnapshot() }; + let disposalStarted = false; let disposed = false; const callbackContext = new NodeAsyncHooks.AsyncLocalStorage(); @@ -278,17 +279,18 @@ export const make = (options: PluginRuntimeOptions = {}) => pluginId: string, invoke: () => Result | PromiseLike, onSettled?: () => void, - ): Effect.Effect => - Effect.tryPromise({ + ): Effect.Effect => { + const callbackState: PluginCallbackContext = { active: true, callback, pluginId }; + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + callbackState.active = false; + onSettled?.(); + }; + + return Effect.tryPromise({ try: async () => { - const callbackState: PluginCallbackContext = { active: true, callback, pluginId }; - let settled = false; - const settle = () => { - if (settled) return; - settled = true; - callbackState.active = false; - onSettled?.(); - }; try { const result = callbackContext.run(callbackState, invoke); if (typeof result === "object" && result !== null && "then" in result) { @@ -302,7 +304,8 @@ export const make = (options: PluginRuntimeOptions = {}) => } }, catch: (cause) => new PluginCallbackError({ callback, cause, pluginId }), - }); + }).pipe(Effect.ensuring(Effect.sync(settle))); + }; const activatePlugin = ( definition: PluginDefinition, @@ -377,7 +380,7 @@ export const make = (options: PluginRuntimeOptions = {}) => definitions: ReadonlyArray, ): Effect.Effect => Effect.gen(function* () { - if (disposed) { + if (disposalStarted) { return yield* new PluginRuntimeDisposedError({ operation: "reconcile" }); } @@ -446,18 +449,21 @@ export const make = (options: PluginRuntimeOptions = {}) => }); const disposeEffect = (): Effect.Effect => - Effect.gen(function* () { - if (disposed) return; - disposed = true; - const previous = current.plugins; - current = { plugins: [], snapshot: emptySnapshot() }; - const failures = [...(yield* closePlugins(previous, true))]; - if (failures.length > 0) { - return yield* new PluginRuntimeCleanupError({ - failures: failures.map(({ error, pluginId }) => ({ cause: error, pluginId })), - }); - } - }); + Effect.uninterruptible( + Effect.gen(function* () { + if (disposed) return; + disposalStarted = true; + const previous = current.plugins; + const failures = [...(yield* closePlugins(previous, true))]; + current = { plugins: [], snapshot: emptySnapshot() }; + disposed = true; + if (failures.length > 0) { + return yield* new PluginRuntimeCleanupError({ + failures: failures.map(({ error, pluginId }) => ({ cause: error, pluginId })), + }); + } + }), + ); const runTransition = ( operation: RuntimeOperation, @@ -478,12 +484,14 @@ export const make = (options: PluginRuntimeOptions = {}) => }); yield* Effect.addFinalizer(() => - disposeEffect().pipe( + runTransition("dispose", disposeEffect).pipe( Effect.catch((error) => - reportCleanupErrors( - "retire", - error.failures.map(({ cause, pluginId }) => ({ error: cause, pluginId })), - ), + "failures" in error + ? reportCleanupErrors( + "retire", + error.failures.map(({ cause, pluginId }) => ({ error: cause, pluginId })), + ) + : Effect.void, ), ), ); diff --git a/packages/plugin-runtime/test/runtime.test.ts b/packages/plugin-runtime/test/runtime.test.ts index 5ac36e82e4c4..1a7ed0d6f87f 100644 --- a/packages/plugin-runtime/test/runtime.test.ts +++ b/packages/plugin-runtime/test/runtime.test.ts @@ -1,6 +1,11 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vite-plus/test"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; import type { PluginDefinition, PluginRuntimeOptions } from "../src/contract.ts"; import { layer, make, PluginRuntime } from "../src/runtime.ts"; @@ -89,4 +94,168 @@ describe("plugin runtime layer", () => { expect(lifecycle).toEqual(["activate:acme.layer-owned", "deactivate:acme.layer-owned"]); }), ); + + it.effect("serializes layer release with an in-flight reconcile", () => + Effect.gen(function* () { + let markActivationStarted!: () => void; + const activationStarted = new Promise((resolve) => { + markActivationStarted = resolve; + }); + let releaseActivation!: () => void; + const activationGate = new Promise((resolve) => { + releaseActivation = resolve; + }); + const lifecycle: Array = []; + const runtimeScope = yield* Scope.make("sequential"); + const services = yield* Layer.buildWithScope( + layer({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), + }), + runtimeScope, + ); + const runtime = Context.get(services, PluginRuntime); + const reconcileFiber = yield* Effect.forkChild( + runtime.reconcile([ + { + id: "acme.layer-release-race", + version: "1.0.0", + async activate() { + markActivationStarted(); + await activationGate; + }, + }, + ]), + ); + yield* Effect.callback((resume) => { + void activationStarted.then(() => resume(Effect.void)); + }); + + const layerRelease = yield* Effect.forkChild(Scope.close(runtimeScope, Exit.void)); + yield* Effect.yieldNow; + expect(layerRelease.pollUnsafe()).toBeUndefined(); + + releaseActivation(); + yield* Fiber.join(reconcileFiber); + yield* Fiber.join(layerRelease); + expect(lifecycle).toEqual([ + "activate:acme.layer-release-race", + "deactivate:acme.layer-release-race", + ]); + }), + ); +}); + +describe("plugin runtime disposal", () => { + it.effect("retries scope cleanup after an interrupted dispose", () => + Effect.scoped( + Effect.gen(function* () { + let markStarted!: () => void; + let releaseCleanup!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const cleanupGate = new Promise((resolve) => { + releaseCleanup = resolve; + }); + const runtime = yield* makeTestRuntime(); + + yield* runtime.reconcile([ + { + id: "acme.interrupted-dispose", + version: "1.0.0", + activate(context) { + context.onDispose(async () => { + markStarted(); + await cleanupGate; + }); + }, + }, + ]); + + const firstDispose = yield* Effect.forkChild(runtime.dispose); + yield* Effect.callback((resume) => { + void started.then(() => resume(Effect.void)); + }); + const interruption = yield* Effect.forkChild(Fiber.interrupt(firstDispose)); + yield* Effect.yieldNow; + + const retry = yield* Effect.forkChild(runtime.dispose); + yield* Effect.yieldNow; + expect(retry.pollUnsafe()).toBeUndefined(); + + releaseCleanup(); + yield* Fiber.join(interruption); + yield* Fiber.join(retry); + expect(yield* runtime.snapshot).toEqual({ + active: [], + blocked: {}, + contributions: {}, + }); + }), + ), + ); +}); + +describe("plugin runtime interruption", () => { + it.effect("expires activation context when reconcile is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + let markActivationStarted!: () => void; + const activationStarted = new Promise((resolve) => { + markActivationStarted = resolve; + }); + let interruptCompleted = false; + let lateRegistrationSucceeded = false; + let lateFailure: unknown; + let finalizerRan = false; + let releaseActivation!: () => void; + const activationGate = new Promise((resolve) => { + releaseActivation = resolve; + }); + const runtime = yield* makeTestRuntime(); + const reconcileFiber = yield* Effect.forkChild( + runtime.reconcile([ + { + id: "acme.interrupted-activation", + version: "1.0.0", + async activate(context) { + markActivationStarted(); + await activationGate; + try { + context.onDispose(() => { + finalizerRan = true; + }); + lateRegistrationSucceeded = true; + } catch (error) { + lateFailure = error; + } + }, + }, + ]), + ); + yield* Effect.callback((resume) => { + void activationStarted.then(() => resume(Effect.void)); + }); + + const interruption = yield* Effect.forkChild( + Fiber.interrupt(reconcileFiber).pipe( + Effect.ensuring(Effect.sync(() => (interruptCompleted = true))), + ), + ); + yield* Effect.yieldNow; + expect(interruptCompleted).toBe(true); + + releaseActivation(); + yield* Fiber.join(interruption); + yield* Effect.callback((resume) => { + queueMicrotask(() => resume(Effect.void)); + }); + yield* runtime.dispose; + + expect(lateRegistrationSucceeded).toBe(false); + expect(lateFailure).toBeInstanceOf(Error); + expect(finalizerRan).toBe(false); + }), + ), + ); }); From 0f78d2b9eb754a8c9e72a23ac8152a90f82493bf Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:06:29 +0000 Subject: [PATCH 04/45] fix: roll back interrupted plugin reconciliation Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- packages/plugin-runtime/src/index.ts | 8 +- packages/plugin-runtime/src/runtime.ts | 216 ++++++++++--------- packages/plugin-runtime/test/runtime.test.ts | 27 ++- 3 files changed, 133 insertions(+), 118 deletions(-) diff --git a/packages/plugin-runtime/src/index.ts b/packages/plugin-runtime/src/index.ts index 332e5ba55d8a..133b22ad3811 100644 --- a/packages/plugin-runtime/src/index.ts +++ b/packages/plugin-runtime/src/index.ts @@ -5,10 +5,4 @@ export type { PluginRuntimeOptions, PluginRuntimeSnapshot, } from "./contract.ts"; -export { - layer, - make, - PluginRuntime, - type PluginRuntimeDisposeError, - type PluginRuntimeReconcileError, -} from "./runtime.ts"; +export * as PluginRuntime from "./runtime.ts"; diff --git a/packages/plugin-runtime/src/runtime.ts b/packages/plugin-runtime/src/runtime.ts index 5a978f4194be..370711453bf2 100644 --- a/packages/plugin-runtime/src/runtime.ts +++ b/packages/plugin-runtime/src/runtime.ts @@ -311,70 +311,78 @@ export const make = (options: PluginRuntimeOptions = {}) => definition: PluginDefinition, capabilities: ReadonlyMap, ): Effect.Effect => - Effect.gen(function* () { - const scope = yield* Scope.fork(parentScope, "sequential"); - const contributions = new Map>(); - const cleanupErrors: Array = []; - const finalizers: Array<() => void | Promise> = []; - const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors }; - let activating = true; - const assertActivating = (method: "resolve" | "register" | "onDispose") => { - if (!activating) { - throw new PluginActivationContextExpiredError({ method, pluginId: definition.id }); - } - }; - - const context: PluginActivationContext = { - resolve: (capability: string): Service => { - assertActivating("resolve"); - if (!(definition.requires ?? []).includes(capability)) { - throw new PluginUndeclaredCapabilityError({ capability, pluginId: definition.id }); - } - if (!capabilities.has(capability)) { - throw new PluginResolutionError({ capability, pluginId: definition.id }); + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const scope = yield* Scope.fork(parentScope, "sequential"); + const contributions = new Map>(); + const cleanupErrors: Array = []; + const finalizers: Array<() => void | Promise> = []; + const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors }; + let activating = true; + const assertActivating = (method: "resolve" | "register" | "onDispose") => { + if (!activating) { + throw new PluginActivationContextExpiredError({ method, pluginId: definition.id }); } - return capabilities.get(capability) as Service; - }, - register: (slot, contribution) => { - assertActivating("register"); - const values = contributions.get(slot) ?? []; - values.push(Object.freeze({ id: contribution.id, label: contribution.label })); - contributions.set(slot, values); - }, - onDispose: (finalizer) => { - assertActivating("onDispose"); - finalizers.push(finalizer); - }, - }; - - const activationExit = yield* Effect.exit( - invokePluginCallback( - "activate", - definition.id, - () => definition.activate(context), - () => { - activating = false; + }; + + const context: PluginActivationContext = { + resolve: (capability: string): Service => { + assertActivating("resolve"); + if (!(definition.requires ?? []).includes(capability)) { + throw new PluginUndeclaredCapabilityError({ capability, pluginId: definition.id }); + } + if (!capabilities.has(capability)) { + throw new PluginResolutionError({ capability, pluginId: definition.id }); + } + return capabilities.get(capability) as Service; }, - ), - ); - for (const finalizer of finalizers) { - const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe( - Effect.catch((error) => - Effect.sync(() => { - cleanupErrors.push(error); - }), + register: (slot, contribution) => { + assertActivating("register"); + const values = contributions.get(slot) ?? []; + values.push(Object.freeze({ id: contribution.id, label: contribution.label })); + contributions.set(slot, values); + }, + onDispose: (finalizer) => { + assertActivating("onDispose"); + finalizers.push(finalizer); + }, + }; + + const activationExit = yield* Effect.exit( + restore( + invokePluginCallback( + "activate", + definition.id, + () => definition.activate(context), + () => { + activating = false; + }, + ), ), ); - yield* Scope.addFinalizer(scope, finalizerEffect); - } + for (const finalizer of finalizers) { + const finalizerEffect = invokePluginCallback( + "finalizer", + definition.id, + finalizer, + ).pipe( + Effect.catch((error) => + Effect.sync(() => { + cleanupErrors.push(error); + }), + ), + ); + yield* Scope.addFinalizer(scope, finalizerEffect); + } - if (Exit.isFailure(activationExit)) { - const failures = yield* closePlugins([plugin], false); - yield* reportCleanupErrors("rollback", failures); - return yield* Effect.failCause(activationExit.cause); - } - return plugin; - }); + if (Exit.isFailure(activationExit)) { + const failures = yield* closePlugins([plugin], false); + yield* reportCleanupErrors("rollback", failures); + return yield* Effect.failCause(activationExit.cause); + } + return plugin; + }), + ); const reconcileEffect = ( definitions: ReadonlyArray, @@ -406,46 +414,52 @@ export const make = (options: PluginRuntimeOptions = {}) => } } - const staged = new Map(); - const candidateExit = yield* Effect.exit( + return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - for (const definition of plan.definitions) { - if (!affected.has(definition.id)) continue; - const plugin = yield* activatePlugin(definition, capabilities); - staged.set(definition.id, plugin); - for (const [capability, service] of Object.entries(definition.provides ?? {})) { - capabilities.set(capability, service); - } + const staged = new Map(); + const candidateExit = yield* Effect.exit( + restore( + Effect.gen(function* () { + for (const definition of plan.definitions) { + if (!affected.has(definition.id)) continue; + const plugin = yield* activatePlugin(definition, capabilities); + staged.set(definition.id, plugin); + for (const [capability, service] of Object.entries(definition.provides ?? {})) { + capabilities.set(capability, service); + } + } + + const nextPlugins: Array = []; + for (const definition of plan.definitions) { + const plugin = staged.get(definition.id) ?? currentById.get(definition.id); + if (plugin === undefined) { + return yield* new PluginStagingError({ pluginId: definition.id }); + } + nextPlugins.push(plugin); + } + return { + plugins: nextPlugins, + snapshot: snapshotOf(nextPlugins, plan.blocked), + }; + }), + ), + ); + if (Exit.isFailure(candidateExit)) { + const failures = yield* closePlugins([...staged.values()], false); + yield* reportCleanupErrors("rollback", failures); + return yield* Effect.failCause(candidateExit.cause); } - const nextPlugins: Array = []; - for (const definition of plan.definitions) { - const plugin = staged.get(definition.id) ?? currentById.get(definition.id); - if (plugin === undefined) { - return yield* new PluginStagingError({ pluginId: definition.id }); - } - nextPlugins.push(plugin); + const previous = current.plugins.filter((plugin) => affected.has(plugin.definition.id)); + current = candidateExit.value; + for (const plugin of staged.values()) { + yield* reportLifecycle("activate", plugin.definition.id); } - return { - plugins: nextPlugins, - snapshot: snapshotOf(nextPlugins, plan.blocked), - }; + const failures = yield* closePlugins(previous, true); + yield* reportCleanupErrors("retire", failures); + return current.snapshot; }), ); - if (Exit.isFailure(candidateExit)) { - const failures = yield* closePlugins([...staged.values()], false); - yield* reportCleanupErrors("rollback", failures); - return yield* Effect.failCause(candidateExit.cause); - } - - const previous = current.plugins.filter((plugin) => affected.has(plugin.definition.id)); - current = candidateExit.value; - for (const plugin of staged.values()) { - yield* reportLifecycle("activate", plugin.definition.id); - } - const failures = yield* closePlugins(previous, true); - yield* reportCleanupErrors("retire", failures); - return current.snapshot; }); const disposeEffect = (): Effect.Effect => @@ -485,14 +499,14 @@ export const make = (options: PluginRuntimeOptions = {}) => yield* Effect.addFinalizer(() => runTransition("dispose", disposeEffect).pipe( - Effect.catch((error) => - "failures" in error - ? reportCleanupErrors( - "retire", - error.failures.map(({ cause, pluginId }) => ({ error: cause, pluginId })), - ) - : Effect.void, - ), + Effect.catchTags({ + PluginRuntimeCleanupError: (error) => + reportCleanupErrors( + "retire", + error.failures.map(({ cause, pluginId }) => ({ error: cause, pluginId })), + ), + PluginRuntimeReentrancyError: () => Effect.void, + }), ), ); diff --git a/packages/plugin-runtime/test/runtime.test.ts b/packages/plugin-runtime/test/runtime.test.ts index 1a7ed0d6f87f..5fe72822156a 100644 --- a/packages/plugin-runtime/test/runtime.test.ts +++ b/packages/plugin-runtime/test/runtime.test.ts @@ -8,10 +8,10 @@ import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; import type { PluginDefinition, PluginRuntimeOptions } from "../src/contract.ts"; -import { layer, make, PluginRuntime } from "../src/runtime.ts"; +import * as PluginRuntime from "../src/runtime.ts"; import { defineRuntimeContract } from "./runtimeContract.ts"; -const makeTestRuntime = (options: PluginRuntimeOptions = {}) => make(options); +const makeTestRuntime = (options: PluginRuntimeOptions = {}) => PluginRuntime.make(options); defineRuntimeContract("plugin runtime", makeTestRuntime); @@ -70,7 +70,7 @@ describe("plugin runtime layer", () => { let disposed = false; const lifecycle: Array = []; - yield* PluginRuntime.use((runtime) => + yield* PluginRuntime.PluginRuntime.use((runtime) => runtime.reconcile([ { id: "acme.layer-owned", @@ -84,7 +84,7 @@ describe("plugin runtime layer", () => { ]), ).pipe( Effect.provide( - layer({ + PluginRuntime.layer({ onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), }), ), @@ -108,12 +108,12 @@ describe("plugin runtime layer", () => { const lifecycle: Array = []; const runtimeScope = yield* Scope.make("sequential"); const services = yield* Layer.buildWithScope( - layer({ + PluginRuntime.layer({ onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), }), runtimeScope, ); - const runtime = Context.get(services, PluginRuntime); + const runtime = Context.get(services, PluginRuntime.PluginRuntime); const reconcileFiber = yield* Effect.forkChild( runtime.reconcile([ { @@ -207,7 +207,8 @@ describe("plugin runtime interruption", () => { let interruptCompleted = false; let lateRegistrationSucceeded = false; let lateFailure: unknown; - let finalizerRan = false; + let earlyFinalizerRan = false; + let lateFinalizerRan = false; let releaseActivation!: () => void; const activationGate = new Promise((resolve) => { releaseActivation = resolve; @@ -219,11 +220,14 @@ describe("plugin runtime interruption", () => { id: "acme.interrupted-activation", version: "1.0.0", async activate(context) { + context.onDispose(() => { + earlyFinalizerRan = true; + }); markActivationStarted(); await activationGate; try { context.onDispose(() => { - finalizerRan = true; + lateFinalizerRan = true; }); lateRegistrationSucceeded = true; } catch (error) { @@ -242,7 +246,9 @@ describe("plugin runtime interruption", () => { Effect.ensuring(Effect.sync(() => (interruptCompleted = true))), ), ); - yield* Effect.yieldNow; + for (let attempt = 0; attempt < 10; attempt += 1) { + yield* Effect.yieldNow; + } expect(interruptCompleted).toBe(true); releaseActivation(); @@ -254,7 +260,8 @@ describe("plugin runtime interruption", () => { expect(lateRegistrationSucceeded).toBe(false); expect(lateFailure).toBeInstanceOf(Error); - expect(finalizerRan).toBe(false); + expect(earlyFinalizerRan).toBe(true); + expect(lateFinalizerRan).toBe(false); }), ), ); From 72d3a0a87eb6d9999c86696e2b887746feee29b8 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:12:15 +0000 Subject: [PATCH 05/45] fix: harden interrupted plugin callbacks Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- packages/plugin-runtime/src/runtime.ts | 21 ++++++++------ packages/plugin-runtime/test/runtime.test.ts | 30 +++++++++++++++++++- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/plugin-runtime/src/runtime.ts b/packages/plugin-runtime/src/runtime.ts index 370711453bf2..a381d0b0bcc0 100644 --- a/packages/plugin-runtime/src/runtime.ts +++ b/packages/plugin-runtime/src/runtime.ts @@ -281,30 +281,33 @@ export const make = (options: PluginRuntimeOptions = {}) => onSettled?: () => void, ): Effect.Effect => { const callbackState: PluginCallbackContext = { active: true, callback, pluginId }; - let settled = false; - const settle = () => { - if (settled) return; - settled = true; - callbackState.active = false; + let expired = false; + const expire = () => { + if (expired) return; + expired = true; onSettled?.(); }; + const settleCallback = () => { + callbackState.active = false; + expire(); + }; return Effect.tryPromise({ try: async () => { try { const result = callbackContext.run(callbackState, invoke); if (typeof result === "object" && result !== null && "then" in result) { - return await Promise.resolve(result).finally(settle); + return await Promise.resolve(result).finally(settleCallback); } - settle(); + settleCallback(); return result; } catch (error) { - settle(); + settleCallback(); throw error; } }, catch: (cause) => new PluginCallbackError({ callback, cause, pluginId }), - }).pipe(Effect.ensuring(Effect.sync(settle))); + }).pipe(Effect.ensuring(Effect.sync(expire))); }; const activatePlugin = ( diff --git a/packages/plugin-runtime/test/runtime.test.ts b/packages/plugin-runtime/test/runtime.test.ts index 5fe72822156a..8d3d81dee47a 100644 --- a/packages/plugin-runtime/test/runtime.test.ts +++ b/packages/plugin-runtime/test/runtime.test.ts @@ -1,5 +1,6 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vite-plus/test"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -7,12 +8,28 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; -import type { PluginDefinition, PluginRuntimeOptions } from "../src/contract.ts"; +import type { + PluginDefinition, + PluginRuntimeOptions, + PluginRuntimeSnapshot, +} from "../src/contract.ts"; import * as PluginRuntime from "../src/runtime.ts"; import { defineRuntimeContract } from "./runtimeContract.ts"; const makeTestRuntime = (options: PluginRuntimeOptions = {}) => PluginRuntime.make(options); +const makeEffectCallback = () => { + let unsafeResume: (effect: Effect.Effect) => void = () => { + throw new Error("effect callback has not started"); + }; + return { + await: Effect.callback((resume) => { + unsafeResume = resume; + }), + resume: (effect: Effect.Effect) => unsafeResume(effect), + } as const; +}; + defineRuntimeContract("plugin runtime", makeTestRuntime); describe("plugin runtime errors", () => { @@ -214,6 +231,9 @@ describe("plugin runtime interruption", () => { releaseActivation = resolve; }); const runtime = yield* makeTestRuntime(); + const nested = makeEffectCallback, never>(); + const nestedFiber = yield* Effect.forkChild(nested.await); + yield* Effect.yieldNow; const reconcileFiber = yield* Effect.forkChild( runtime.reconcile([ { @@ -233,6 +253,7 @@ describe("plugin runtime interruption", () => { } catch (error) { lateFailure = error; } + nested.resume(Effect.exit(runtime.reconcile([]))); }, }, ]), @@ -256,12 +277,19 @@ describe("plugin runtime interruption", () => { yield* Effect.callback((resume) => { queueMicrotask(() => resume(Effect.void)); }); + const nestedExit = yield* Fiber.join(nestedFiber); yield* runtime.dispose; expect(lateRegistrationSucceeded).toBe(false); expect(lateFailure).toBeInstanceOf(Error); expect(earlyFinalizerRan).toBe(true); expect(lateFinalizerRan).toBe(false); + expect(Exit.isFailure(nestedExit)).toBe(true); + if (Exit.isFailure(nestedExit)) { + expect(Cause.squash(nestedExit.cause)).toMatchObject({ + _tag: "PluginRuntimeReentrancyError", + }); + } }), ), ); From 596bd97d026896c3e7d02986ba51dd99f1f4d14a Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:36:38 +0000 Subject: [PATCH 06/45] feat: add plugin command catalog Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../threads/ComposerCommandPopover.tsx | 14 +- .../features/threads/NewTaskDraftScreen.tsx | 70 ++- .../src/features/threads/ThreadComposer.tsx | 34 +- .../threads/plugin-command-menu.test.ts | 27 ++ .../features/threads/plugin-command-menu.ts | 25 ++ .../threads/use-mobile-plugin-commands.ts | 57 +++ apps/server/package.json | 1 + apps/server/src/auth/RpcAuthorization.test.ts | 12 + apps/server/src/auth/RpcAuthorization.ts | 3 + .../src/plugins/PluginCommandCatalog.test.ts | 184 ++++++++ .../src/plugins/PluginCommandCatalog.ts | 217 ++++++++++ apps/server/src/server.test.ts | 30 ++ apps/server/src/ws.ts | 19 +- .../components/CommandPalette.logic.test.ts | 48 ++- .../src/components/CommandPalette.logic.ts | 29 ++ apps/web/src/components/CommandPalette.tsx | 60 ++- packages/client-runtime/src/rpc/client.ts | 1 + packages/client-runtime/src/state/server.ts | 13 + packages/contracts/src/index.ts | 1 + packages/contracts/src/pluginCommands.test.ts | 68 +++ packages/contracts/src/pluginCommands.ts | 64 +++ packages/contracts/src/rpc.ts | 40 ++ packages/plugin-runtime/README.md | 8 + packages/plugin-runtime/src/contract.ts | 14 +- packages/plugin-runtime/src/index.ts | 1 + packages/plugin-runtime/src/runtime.ts | 299 +++++++++++-- .../plugin-runtime/test/contributions.test.ts | 403 ++++++++++++++++++ pnpm-lock.yaml | 5 + 28 files changed, 1703 insertions(+), 44 deletions(-) create mode 100644 apps/mobile/src/features/threads/plugin-command-menu.test.ts create mode 100644 apps/mobile/src/features/threads/plugin-command-menu.ts create mode 100644 apps/mobile/src/features/threads/use-mobile-plugin-commands.ts create mode 100644 apps/server/src/plugins/PluginCommandCatalog.test.ts create mode 100644 apps/server/src/plugins/PluginCommandCatalog.ts create mode 100644 packages/contracts/src/pluginCommands.test.ts create mode 100644 packages/contracts/src/pluginCommands.ts create mode 100644 packages/plugin-runtime/test/contributions.test.ts diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 0eea51719521..f8dad3a476d6 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -1,5 +1,9 @@ import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; -import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; +import type { + PluginCommand, + ServerProviderSkill, + ServerProviderSlashCommand, +} from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { memo } from "react"; import { Pressable, ScrollView, View, type ViewStyle } from "react-native"; @@ -37,6 +41,13 @@ export type ComposerCommandItem = readonly skill: ServerProviderSkill; readonly label: string; readonly description: string; + } + | { + readonly id: string; + readonly type: "plugin-command"; + readonly command: PluginCommand; + readonly label: string; + readonly description: string; }; interface ComposerCommandPopoverProps { @@ -65,6 +76,7 @@ function itemIcon(item: ComposerCommandItem) { switch (item.type) { case "slash-command": case "provider-slash-command": + case "plugin-command": return "terminal" as const; case "skill": return "cube" as const; diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f5beb69c938..598d702a37cf 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -5,7 +5,7 @@ import { useNavigation, usePreventRemove, } from "@react-navigation/native"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { KeyboardController, @@ -22,7 +22,11 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; +import { + ComposerEditor, + type ComposerEditorHandle, + type ComposerEditorSelection, +} from "../../components/ComposerEditor"; import { ComposerInlineControl, ComposerToolbarButton, @@ -66,6 +70,10 @@ import { resolveNewTaskWorkspaceLabel, } from "./new-task-context-presentation"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; +import { detectComposerTrigger, replaceTextRange } from "@t3tools/shared/composerTrigger"; +import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { buildMobilePluginCommandItems } from "./plugin-command-menu"; +import { useMobilePluginCommands } from "./use-mobile-plugin-commands"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; @@ -126,6 +134,49 @@ export function NewTaskDraftScreen(props: { const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [composerSelection, setComposerSelection] = useState(() => ({ + start: flow.prompt.length, + end: flow.prompt.length, + })); + const { commands: pluginCommands, execute: executePluginCommand } = useMobilePluginCommands( + selectedProject?.environmentId ?? null, + ); + const pluginCommandTrigger = useMemo(() => { + const trigger = detectComposerTrigger(flow.prompt, composerSelection.end); + return trigger?.kind === "slash-command" ? trigger : null; + }, [composerSelection.end, flow.prompt]); + const pluginCommandItems = useMemo(() => { + if (pluginCommandTrigger === null) return []; + return buildMobilePluginCommandItems(pluginCommands, pluginCommandTrigger.query); + }, [pluginCommandTrigger, pluginCommands]); + const handleComposerSelectionChange = useCallback((selection: ComposerEditorSelection) => { + setComposerSelection(selection); + }, []); + const handlePluginCommandSelect = useCallback( + (item: ComposerCommandItem) => { + if (item.type !== "plugin-command" || pluginCommandTrigger === null) return; + const result = replaceTextRange( + flow.prompt, + pluginCommandTrigger.rangeStart, + pluginCommandTrigger.rangeEnd, + "", + ); + setComposerSelection({ start: result.cursor, end: result.cursor }); + flow.setPrompt(result.text); + void executePluginCommand(item.command); + }, + [executePluginCommand, flow, pluginCommandTrigger], + ); + useEffect(() => { + const end = flow.prompt.length; + setComposerSelection((selection) => { + const start = Math.min(selection.start, end); + const selectionEnd = Math.min(selection.end, end); + return start === selection.start && selectionEnd === selection.end + ? selection + : { start, end: selectionEnd }; + }); + }, [flow.prompt.length]); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: promptInputRef, isEditorFocused: isComposerFocused, @@ -817,7 +868,9 @@ export function NewTaskDraftScreen(props: { scrollEnabled value={flow.prompt} skills={flow.selectedProviderSkills} + selection={composerSelection} onChangeText={flow.setPrompt} + onSelectionChange={handleComposerSelectionChange} onFocus={() => setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} @@ -955,9 +1008,20 @@ export function NewTaskDraftScreen(props: { ); const composerDock = ( - + {workspaceControls} + {pluginCommandTrigger !== null && pluginCommandItems.length > 0 ? ( + + + + ) : null} + ({ @@ -418,6 +424,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ]; const builtIn = allBuiltIn.filter((item) => item.command.includes(q)); + const pluginCommandItems = buildMobilePluginCommandItems(pluginCommands, q); + const providerCommands: ComposerCommandItem[] = []; for (const cmd of selectedProviderStatus?.slashCommands ?? []) { if (!cmd.name.toLowerCase().includes(q)) continue; @@ -430,7 +438,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); } - return [...builtIn, ...providerCommands]; + return [...builtIn, ...pluginCommandItems, ...providerCommands]; } if (composerTrigger.kind === "skill") { @@ -531,7 +539,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); + }, [composerTrigger, pathSearch.entries, pluginCommands, selectedProviderStatus]); // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; @@ -581,6 +589,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer return; } + if (item.type === "plugin-command") { + const command = item.command; + const result = replaceTextRange( + draftMessage, + composerTrigger.rangeStart, + composerTrigger.rangeEnd, + "", + ); + setComposerSelection({ start: result.cursor, end: result.cursor }); + onChangeDraftMessage(result.text); + void executePluginCommand(command); + return; + } + let replacement = ""; if (item.type === "path") { replacement = `${serializeComposerFileLink(item.path)} `; @@ -601,7 +623,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); }, - [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], + [ + composerTrigger, + draftMessage, + executePluginCommand, + onChangeDraftMessage, + onUpdateInteractionMode, + ], ); // ── Model menu ─────────────────────────────────────────── diff --git a/apps/mobile/src/features/threads/plugin-command-menu.test.ts b/apps/mobile/src/features/threads/plugin-command-menu.test.ts new file mode 100644 index 000000000000..a75d52f222ef --- /dev/null +++ b/apps/mobile/src/features/threads/plugin-command-menu.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildMobilePluginCommandItems } from "./plugin-command-menu"; + +describe("buildMobilePluginCommandItems", () => { + it("renders only matching mobile command contributions", () => { + const items = buildMobilePluginCommandItems( + [ + { + id: "plugin.mobile-status", + label: "Check status", + description: "Verify the runtime", + surfaces: ["mobile"], + }, + { + id: "plugin.web-only", + label: "Web only", + surfaces: ["web"], + }, + ], + "status", + ); + + expect(items.map((item) => item.id)).toEqual(["plugin-command:plugin.mobile-status"]); + expect(items[0]?.type).toBe("plugin-command"); + }); +}); diff --git a/apps/mobile/src/features/threads/plugin-command-menu.ts b/apps/mobile/src/features/threads/plugin-command-menu.ts new file mode 100644 index 000000000000..f27ec2e874d4 --- /dev/null +++ b/apps/mobile/src/features/threads/plugin-command-menu.ts @@ -0,0 +1,25 @@ +import type { PluginCommand } from "@t3tools/contracts"; + +import type { ComposerCommandItem } from "./ComposerCommandPopover"; + +export function buildMobilePluginCommandItems( + commands: ReadonlyArray, + query: string, +): ComposerCommandItem[] { + const normalizedQuery = query.toLowerCase(); + return commands + .filter( + (command) => + command.surfaces.includes("mobile") && + `${command.label} ${command.description ?? ""} ${command.id}` + .toLowerCase() + .includes(normalizedQuery), + ) + .map((command) => ({ + id: `plugin-command:${command.id}`, + type: "plugin-command" as const, + command, + label: command.label, + description: command.description ?? "Plugin command", + })); +} diff --git a/apps/mobile/src/features/threads/use-mobile-plugin-commands.ts b/apps/mobile/src/features/threads/use-mobile-plugin-commands.ts new file mode 100644 index 000000000000..eefcbe96e750 --- /dev/null +++ b/apps/mobile/src/features/threads/use-mobile-plugin-commands.ts @@ -0,0 +1,57 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, PluginCommand, PluginCommandCatalog } from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import * as Option from "effect/Option"; +import { useCallback } from "react"; +import { Alert } from "react-native"; + +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; + +const EMPTY_PLUGIN_COMMAND_CATALOG: PluginCommandCatalog = { commands: [], generation: 0 }; +const EMPTY_PLUGIN_COMMAND_CATALOG_ATOM = Atom.make( + AsyncResult.success(EMPTY_PLUGIN_COMMAND_CATALOG), +); + +export function useMobilePluginCommands(environmentId: EnvironmentId | null) { + const catalogResult = useAtomValue( + environmentId === null + ? EMPTY_PLUGIN_COMMAND_CATALOG_ATOM + : serverEnvironment.pluginCommands({ environmentId, input: {} }), + ); + const catalog = + Option.getOrNull(AsyncResult.value(catalogResult)) ?? EMPTY_PLUGIN_COMMAND_CATALOG; + const invokePluginCommand = useAtomCommand(serverEnvironment.invokePluginCommand, { + reportFailure: false, + reportDefect: false, + }); + const execute = useCallback( + async (command: PluginCommand): Promise => { + if (environmentId === null) return; + const result = await invokePluginCommand({ + environmentId, + input: { generation: catalog.generation, id: command.id }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + Alert.alert( + "Command failed", + error instanceof Error ? error.message : "The plugin command could not be completed.", + ); + return; + } + Alert.alert(command.label, result.value.message); + }, + [catalog.generation, environmentId, invokePluginCommand], + ); + + return { + commands: catalog.commands, + execute, + } as const; +} diff --git a/apps/server/package.json b/apps/server/package.json index eb4dc7dd35ec..9f3915e79e26 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -38,6 +38,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-runtime": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 790be9386e6e..34e3f5b111a2 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -37,6 +37,18 @@ describe("RPC authorization scopes", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope); }); + it("allows command discovery with read scope and invocation with operate scope", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.pluginCommandsList)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.subscribePluginCommands)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.pluginCommandsInvoke)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { // The candidate list is a read like the detail beside it, and asking somebody for a review is // a write like every other pull request operation. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..ad5adc1e859c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -50,6 +50,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, + [WS_METHODS.pluginCommandsList]: AuthOrchestrationReadScope, + [WS_METHODS.pluginCommandsInvoke]: AuthOrchestrationOperateScope, + [WS_METHODS.subscribePluginCommands]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, diff --git a/apps/server/src/plugins/PluginCommandCatalog.test.ts b/apps/server/src/plugins/PluginCommandCatalog.test.ts new file mode 100644 index 000000000000..2115e2d7705f --- /dev/null +++ b/apps/server/src/plugins/PluginCommandCatalog.test.ts @@ -0,0 +1,184 @@ +import { it } from "@effect/vitest"; +import { describe, expect } from "vite-plus/test"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import type { PluginDefinition } from "@t3tools/plugin-runtime"; + +import { PluginCommandCatalog, layer, registerPluginCommand } from "./PluginCommandCatalog.ts"; + +const TestLayer = layer; + +const testPlugin = (input: { + readonly fail?: boolean; + readonly message: string; + readonly onDispose?: () => void | Promise; + readonly version: string; +}): PluginDefinition => ({ + id: "acme.command-plugin", + version: input.version, + activate(context) { + if (input.fail === true) throw new Error("activation failed"); + if (input.onDispose !== undefined) context.onDispose(input.onDispose); + registerPluginCommand(context, { + command: { + id: "acme.hello", + label: "Say hello", + description: "Return a greeting from the trusted test plugin.", + surfaces: ["web", "desktop", "mobile"], + }, + handler: Effect.succeed({ message: input.message, tone: "success" }), + }); + }, +}); + +describe("plugin command catalog", () => { + it.effect("lists and invokes the trusted built-in command", () => + Effect.gen(function* () { + const catalog = yield* PluginCommandCatalog; + const listed = yield* catalog.list; + const streamed = yield* Stream.runHead(catalog.changes); + + expect(listed.commands.map((command) => command.id)).toContain("t3.plugin-runtime.status"); + expect(Object.isFrozen(listed)).toBe(true); + expect(Object.isFrozen(listed.commands)).toBe(true); + expect(Object.isFrozen(listed.commands[0])).toBe(true); + expect(Object.isFrozen(listed.commands[0]?.surfaces)).toBe(true); + expect(Option.getOrNull(streamed)).toEqual(listed); + expect( + yield* catalog.invoke({ + generation: listed.generation, + id: "t3.plugin-runtime.status", + }), + ).toEqual({ message: "Plugin runtime is active.", tone: "success" }); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("keeps the committed command and handler when replacement activation fails", () => + Effect.gen(function* () { + const catalog = yield* PluginCommandCatalog; + const first = yield* catalog.reconcile([ + testPlugin({ message: "hello one", version: "1.0.0" }), + ]); + const failed = yield* Effect.exit( + catalog.reconcile([testPlugin({ fail: true, message: "hello two", version: "2.0.0" })]), + ); + + expect(Exit.isFailure(failed)).toBe(true); + expect(yield* catalog.list).toEqual(first); + expect(yield* catalog.invoke({ generation: first.generation, id: "acme.hello" })).toEqual({ + message: "hello one", + tone: "success", + }); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("does not republish an unchanged command catalog", () => + Effect.gen(function* () { + const catalog = yield* PluginCommandCatalog; + const definition = testPlugin({ message: "hello one", version: "1.0.0" }); + + const first = yield* catalog.reconcile([definition]); + const second = yield* catalog.reconcile([definition]); + + expect(second).toBe(first); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("rolls back invalid command metadata before publishing a generation", () => + Effect.gen(function* () { + const catalog = yield* PluginCommandCatalog; + const first = yield* catalog.list; + const invalid: PluginDefinition = { + id: "acme.invalid-command-plugin", + version: "1.0.0", + activate(context) { + context.register( + "commands", + { + id: "acme.invalid", + label: "Invalid command", + data: { surfaces: ["server"] }, + }, + Effect.succeed({ message: "invalid", tone: "success" as const }), + ); + }, + }; + + const failed = yield* Effect.exit(catalog.reconcile([invalid])); + + const shadowedIdentity: PluginDefinition = { + id: "acme.shadowed-command-plugin", + version: "1.0.0", + activate(context) { + context.register( + "commands", + { + id: "acme.registered", + label: "Registered command", + data: { + id: "acme.advertised", + label: "Advertised command", + surfaces: ["web"], + }, + }, + Effect.succeed({ message: "shadowed", tone: "success" as const }), + ); + }, + }; + const shadowed = yield* Effect.exit(catalog.reconcile([shadowedIdentity])); + + expect(Exit.isFailure(failed)).toBe(true); + expect(Exit.isFailure(shadowed)).toBe(true); + expect(yield* catalog.list).toBe(first); + expect( + yield* catalog.invoke({ + generation: first.generation, + id: "t3.plugin-runtime.status", + }), + ).toEqual({ message: "Plugin runtime is active.", tone: "success" }); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("publishes a committed runtime generation before reporting interruption", () => + Effect.gen(function* () { + const catalog = yield* PluginCommandCatalog; + let markRetirementStarted!: () => void; + let releaseRetirement!: () => void; + const retirementStarted = new Promise((resolve) => { + markRetirementStarted = resolve; + }); + const retirementGate = new Promise((resolve) => { + releaseRetirement = resolve; + }); + const first = yield* catalog.reconcile([ + testPlugin({ + message: "hello one", + onDispose: async () => { + markRetirementStarted(); + await retirementGate; + }, + version: "1.0.0", + }), + ]); + const replacement = yield* Effect.forkChild( + catalog.reconcile([testPlugin({ message: "hello two", version: "2.0.0" })]), + ); + yield* Effect.promise(() => retirementStarted); + const interruption = yield* Effect.forkChild(Fiber.interrupt(replacement)); + yield* Effect.yieldNow; + releaseRetirement(); + yield* Fiber.join(interruption); + + const second = yield* catalog.list; + expect(second.generation).toBe(first.generation + 1); + expect(yield* catalog.invoke({ generation: second.generation, id: "acme.hello" })).toEqual({ + message: "hello two", + tone: "success", + }); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/plugins/PluginCommandCatalog.ts b/apps/server/src/plugins/PluginCommandCatalog.ts new file mode 100644 index 000000000000..4e299fecc5fe --- /dev/null +++ b/apps/server/src/plugins/PluginCommandCatalog.ts @@ -0,0 +1,217 @@ +import { + PluginCommand as PluginCommandSchema, + type PluginCommand, + type PluginCommandCatalog as PluginCommandCatalogSnapshot, + PluginCommandCatalogChangedError, + type PluginCommandInvocationResult, + PluginCommandInvocationError, + type PluginCommandInvokeInput, + PluginCommandNotFoundError, +} from "@t3tools/contracts"; +import type { + Contribution, + PluginActivationContext, + PluginDefinition, + PluginRuntimeSnapshot, +} from "@t3tools/plugin-runtime"; +import { PluginRuntime } from "@t3tools/plugin-runtime"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +const COMMAND_SLOT = "commands"; +const decodePluginCommand = Schema.decodeUnknownSync(PluginCommandSchema); +const decodePluginCommandEffect = Schema.decodeUnknownEffect(PluginCommandSchema); +const isContributionGenerationError = Schema.is(PluginRuntime.PluginContributionGenerationError); +const isContributionNotFoundError = Schema.is(PluginRuntime.PluginContributionNotFoundError); + +const commandInputFromContribution = (entry: Contribution) => { + const data = typeof entry.data === "object" && entry.data !== null ? entry.data : {}; + if (Object.hasOwn(data, "id") || Object.hasOwn(data, "label")) { + throw new TypeError("Plugin command metadata cannot override its registered id or label"); + } + return { ...data, id: entry.id, label: entry.label }; +}; + +const validateCommandSnapshot = (snapshot: PluginRuntimeSnapshot): void => { + for (const entry of snapshot.contributions[COMMAND_SLOT] ?? []) { + decodePluginCommand(commandInputFromContribution(entry)); + } +}; + +export class PluginCommandExecutionError extends Schema.TaggedErrorClass()( + "PluginCommandExecutionError", + { cause: Schema.Defect() }, +) {} + +type PluginCommandHandler = Effect.Effect< + PluginCommandInvocationResult, + PluginCommandExecutionError +>; + +export class PluginCommandDefinitionError extends Schema.TaggedErrorClass()( + "PluginCommandDefinitionError", + { message: Schema.String }, +) {} + +export interface PluginCommandRegistration { + readonly command: PluginCommand; + readonly handler: PluginCommandHandler; +} + +export const registerPluginCommand = ( + context: PluginActivationContext, + registration: PluginCommandRegistration, +): void => { + const command = decodePluginCommand(registration.command); + const { description, surfaces } = command; + context.register( + COMMAND_SLOT, + { + id: command.id, + label: command.label, + data: { + ...(description === undefined ? {} : { description }), + surfaces, + }, + }, + registration.handler, + ); +}; + +const builtInPlugin: PluginDefinition = { + id: "t3.plugin-runtime.commands", + version: "1.0.0", + activate(context) { + registerPluginCommand(context, { + command: { + id: "t3.plugin-runtime.status", + label: "Check plugin runtime", + description: "Verify that the environment plugin runtime is responding.", + surfaces: ["web", "desktop", "mobile"], + }, + handler: Effect.succeed({ + message: "Plugin runtime is active.", + tone: "success", + }), + }); + }, +}; + +const catalogFromRuntime = Effect.fn("PluginCommandCatalog.catalogFromRuntime")(function* ( + runtime: PluginRuntime.PluginRuntime["Service"], +) { + const snapshot = yield* runtime.contributions(COMMAND_SLOT); + const commands = yield* Effect.forEach(snapshot.entries, (entry) => + decodePluginCommandEffect(commandInputFromContribution(entry)).pipe( + Effect.mapError( + () => + new PluginCommandDefinitionError({ + message: `Plugin command ${entry.id} has invalid declarative metadata.`, + }), + ), + ), + ); + const frozenCommands = commands.map((command) => + Object.freeze({ ...command, surfaces: Object.freeze([...command.surfaces]) }), + ); + return Object.freeze({ + commands: Object.freeze(frozenCommands), + generation: snapshot.generation, + }) satisfies PluginCommandCatalogSnapshot; +}); + +export class PluginCommandCatalog extends Context.Service< + PluginCommandCatalog, + { + readonly list: Effect.Effect; + readonly changes: Stream.Stream; + readonly invoke: ( + input: PluginCommandInvokeInput, + ) => Effect.Effect< + PluginCommandInvocationResult, + PluginCommandCatalogChangedError | PluginCommandInvocationError | PluginCommandNotFoundError + >; + readonly reconcile: ( + definitions: ReadonlyArray, + ) => Effect.Effect< + PluginCommandCatalogSnapshot, + PluginCommandDefinitionError | PluginRuntime.PluginRuntimeReconcileError + >; + } +>()("t3/plugins/PluginCommandCatalog") {} + +export const make = Effect.gen(function* () { + const runtime = yield* PluginRuntime.PluginRuntime; + const state = yield* SubscriptionRef.make({ + commands: [], + generation: 0, + }); + + const reconcile = Effect.fn("PluginCommandCatalog.reconcile")( + (definitions: ReadonlyArray) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const transitionExit = yield* Effect.exit( + restore(runtime.reconcile([builtInPlugin, ...definitions])), + ); + const catalog = yield* catalogFromRuntime(runtime); + const previous = yield* SubscriptionRef.get(state); + const published = + previous.generation === catalog.generation + ? previous + : yield* SubscriptionRef.set(state, catalog).pipe(Effect.as(catalog)); + if (Exit.isFailure(transitionExit)) { + return yield* Effect.failCause(transitionExit.cause); + } + return published; + }), + ), + ); + + yield* reconcile([]); + + const invoke = Effect.fn("PluginCommandCatalog.invoke")(function* ( + input: PluginCommandInvokeInput, + ) { + return yield* runtime + .useContribution< + PluginCommandHandler, + PluginCommandInvocationResult, + PluginCommandExecutionError, + never + >(COMMAND_SLOT, input.id, input.generation, (handler) => handler) + .pipe( + Effect.mapError((error) => { + if (isContributionGenerationError(error)) { + return new PluginCommandCatalogChangedError({ + actualGeneration: error.actual, + expectedGeneration: error.expected, + }); + } + if (isContributionNotFoundError(error)) { + return new PluginCommandNotFoundError({ id: input.id }); + } + return new PluginCommandInvocationError({ + id: input.id, + message: "Plugin command failed.", + }); + }), + ); + }); + + return PluginCommandCatalog.of({ + changes: SubscriptionRef.changes(state), + invoke, + list: SubscriptionRef.get(state), + reconcile, + }); +}); + +export const layer = Layer.effect(PluginCommandCatalog, make).pipe( + Layer.provide(PluginRuntime.layer({ validateSnapshot: validateCommandSnapshot })), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 89f903c4f895..c94982291951 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4606,6 +4606,36 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("lists, subscribes to, and invokes plugin commands over websocket rpc", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const listed = yield* client[WS_METHODS.pluginCommandsList]({}); + const streamed = yield* client[WS_METHODS.subscribePluginCommands]({}).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + const invoked = yield* client[WS_METHODS.pluginCommandsInvoke]({ + generation: listed.generation, + id: "t3.plugin-runtime.status", + }); + return { invoked, listed, streamed }; + }), + ), + ); + + assert.deepEqual(result.streamed, result.listed); + assert.deepEqual(result.invoked, { + message: "Plugin runtime is active.", + tone: "success", + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => Effect.gen(function* () { const nextProviders = [ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ebcf65e4b47c..a82975680ceb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -65,6 +65,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; +import * as PluginCommandCatalog from "./plugins/PluginCommandCatalog.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { projectActivityEvent, @@ -352,6 +353,7 @@ function toAuthAccessStreamEvent( const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], + pluginCommands: PluginCommandCatalog.PluginCommandCatalog["Service"], ) => WsRpcGroup.toLayer( Effect.gen(function* () { @@ -1445,6 +1447,14 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", }), + [WS_METHODS.pluginCommandsList]: (_input) => + observeRpcEffect(WS_METHODS.pluginCommandsList, pluginCommands.list, { + "rpc.aggregate": "pluginCommands", + }), + [WS_METHODS.pluginCommandsInvoke]: (input) => + observeRpcEffect(WS_METHODS.pluginCommandsInvoke, pluginCommands.invoke(input), { + "rpc.aggregate": "pluginCommands", + }), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, @@ -2294,6 +2304,10 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "server" }, ), + [WS_METHODS.subscribePluginCommands]: (_input) => + observeRpcStream(WS_METHODS.subscribePluginCommands, pluginCommands.changes, { + "rpc.aggregate": "pluginCommands", + }), }); }), ); @@ -2301,6 +2315,7 @@ const makeWsRpcLayer = ( export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const pluginCommands = yield* PluginCommandCatalog.PluginCommandCatalog; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; return HttpRouter.add( @@ -2322,7 +2337,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session, previewAutomationBroker).pipe( + makeWsRpcLayer(session, previewAutomationBroker, pluginCommands).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), @@ -2366,4 +2381,4 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), ); }), -); +).pipe(Layer.provide(PluginCommandCatalog.layer.pipe(Layer.orDie))); diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 9bae9c58a977..85cbf7b1a870 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -1,17 +1,63 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type PluginCommand, +} from "@t3tools/contracts"; import type { Thread } from "../types"; import { browseInputEndPaddingClass, buildBrowseGroups, + buildPluginCommandActionItems, buildThreadActionItems, enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, reduceCommandPaletteUiState, + resolvePluginCommandEnvironmentId, type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("buildPluginCommandActionItems", () => { + it("renders only commands for the current host surface and routes execution", async () => { + const run = vi.fn(async () => undefined); + const commands: ReadonlyArray = [ + { + id: "plugin.status", + label: "Check plugin status", + description: "Verify the runtime.", + surfaces: ["web", "desktop"], + }, + { + id: "plugin.mobile", + label: "Mobile only", + surfaces: ["mobile"], + }, + ]; + + const items = buildPluginCommandActionItems({ commands, icon: null, run, surface: "web" }); + + expect(items.map((item) => item.value)).toEqual(["plugin-command:plugin.status"]); + expect(items[0]?.description).toBe("Verify the runtime."); + await items[0]?.run(); + expect(run).toHaveBeenCalledWith(commands[0]); + }); +}); + +describe("resolvePluginCommandEnvironmentId", () => { + it("prefers an active remote draft over the primary environment", () => { + expect( + resolvePluginCommandEnvironmentId({ + activeDraftEnvironmentId: EnvironmentId.make("remote"), + activeThreadEnvironmentId: null, + primaryEnvironmentId: EnvironmentId.make("primary"), + }), + ).toBe("remote"); + }); +}); + describe("browseInputEndPaddingClass", () => { it("reserves the widest space for the create action", () => { expect( diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 1fddb4f92f4a..0abb2389adb4 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,6 +1,9 @@ import { + type EnvironmentId, type FilesystemBrowseEntry, type KeybindingCommand, + type PluginCommand, + type PluginCommandSurface, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem"; @@ -107,6 +110,32 @@ export interface CommandPaletteActionItem extends CommandPaletteItem { readonly run: () => Promise; } +export function buildPluginCommandActionItems(input: { + readonly commands: ReadonlyArray; + readonly icon: ReactNode; + readonly run: (command: PluginCommand) => Promise; + readonly surface: PluginCommandSurface; +}): CommandPaletteActionItem[] { + return input.commands + .filter((command) => command.surfaces.includes(input.surface)) + .map((command) => ({ + kind: "action", + value: `plugin-command:${command.id}`, + searchTerms: [command.label, command.description ?? "", command.id, "plugin"], + title: command.label, + ...(command.description === undefined ? {} : { description: command.description }), + icon: input.icon, + run: async () => input.run(command), + })); +} + +export const resolvePluginCommandEnvironmentId = (input: { + readonly activeDraftEnvironmentId: EnvironmentId | null; + readonly activeThreadEnvironmentId: EnvironmentId | null; + readonly primaryEnvironmentId: EnvironmentId | null; +}): EnvironmentId | null => + input.activeThreadEnvironmentId ?? input.activeDraftEnvironmentId ?? input.primaryEnvironmentId; + export interface CommandPaletteSubmenuItem extends CommandPaletteItem { readonly kind: "submenu"; readonly addonIcon: ReactNode; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4a90f1a50343..87cdcc61c298 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -24,6 +24,8 @@ import { type DesktopWslState, type EnvironmentId, type FilesystemBrowseResult, + type PluginCommand, + type PluginCommandCatalog, type ProjectId, type SourceControlDiscoveryResult, type SourceControlProviderKind, @@ -41,6 +43,7 @@ import { LinkIcon, MessageSquareIcon, PaletteIcon, + PuzzleIcon, ServerIcon, SettingsIcon, SquarePenIcon, @@ -59,6 +62,7 @@ import { type ReactNode, } from "react"; import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; @@ -107,6 +111,7 @@ import { browseInputEndPaddingClass, buildBrowseGroups, buildProjectActionItems, + buildPluginCommandActionItems, buildRootGroups, buildThreadActionItems, enumerateCommandPaletteItems, @@ -121,6 +126,7 @@ import { ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, reduceCommandPaletteUiState, + resolvePluginCommandEnvironmentId, type SearchOverlayMode, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; @@ -138,7 +144,11 @@ import { ThreadCommandSubtitle, } from "./ThreadCommandSubtitle"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; -import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; +import { + primaryServerKeybindingsAtom, + primaryServerProvidersAtom, + serverEnvironment, +} from "../state/server"; import { deriveProviderInstanceEntries, resolveDefaultProviderModelSelection, @@ -161,6 +171,10 @@ import { import type { Project } from "../types"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +const EMPTY_PLUGIN_COMMAND_CATALOG: PluginCommandCatalog = { commands: [], generation: 0 }; +const EMPTY_PLUGIN_COMMAND_CATALOG_ATOM = Atom.make( + AsyncResult.success(EMPTY_PLUGIN_COMMAND_CATALOG), +).pipe(Atom.withLabel("plugin-commands:empty")); function projectFavicon(project: Project) { return ( @@ -595,6 +609,21 @@ function OpenCommandPaletteDialog(props: { const primaryEnvironmentId = usePrimaryEnvironmentId(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); + const pluginCommandEnvironmentId = resolvePluginCommandEnvironmentId({ + activeDraftEnvironmentId: activeDraftThread?.environmentId ?? null, + activeThreadEnvironmentId: activeThread?.environmentId ?? null, + primaryEnvironmentId, + }); + const pluginCommandCatalogResult = useAtomValue( + pluginCommandEnvironmentId === null + ? EMPTY_PLUGIN_COMMAND_CATALOG_ATOM + : serverEnvironment.pluginCommands({ environmentId: pluginCommandEnvironmentId, input: {} }), + ); + const pluginCommandCatalog = + Option.getOrNull(AsyncResult.value(pluginCommandCatalogResult)) ?? EMPTY_PLUGIN_COMMAND_CATALOG; + const invokePluginCommand = useAtomCommand(serverEnvironment.invokePluginCommand, { + reportFailure: false, + }); const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); @@ -1658,6 +1687,35 @@ function OpenCommandPaletteDialog(props: { }); } + if (pluginCommandEnvironmentId !== null) { + const commandEnvironmentId = pluginCommandEnvironmentId; + actionItems.push( + ...buildPluginCommandActionItems({ + commands: pluginCommandCatalog.commands, + icon: , + surface: + typeof window !== "undefined" && window.desktopBridge !== undefined ? "desktop" : "web", + run: async (command: PluginCommand) => { + const result = await invokePluginCommand({ + environmentId: commandEnvironmentId, + input: { generation: pluginCommandCatalog.generation, id: command.id }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + toastManager.add( + stackedThreadToast({ + type: result.value.tone === "success" ? "success" : "info", + title: command.label, + description: result.value.message, + }), + ); + }, + }), + ); + } + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index bfe57a6c0dd5..f2588aad7901 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -50,6 +50,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribePreviewEvents | typeof WS_METHODS.subscribeDiscoveredLocalServers | typeof WS_METHODS.subscribeResourceTelemetry + | typeof WS_METHODS.subscribePluginCommands | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.terminalAttach; diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..2d310911e5bb 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -702,6 +702,11 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.subscribeResourceTelemetry, idleTtlMs: 0, }), + pluginCommands: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:plugin-commands", + tag: WS_METHODS.subscribePluginCommands, + idleTtlMs: 0, + }), resourceTelemetryHistory: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:resource-telemetry-history", tag: WS_METHODS.serverGetResourceTelemetryHistory, @@ -731,6 +736,14 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + invokePluginCommand: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:invoke-plugin-command", + tag: WS_METHODS.pluginCommandsInvoke, + concurrency: { + mode: "serial", + key: ({ environmentId }) => environmentId, + }, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..48d921596fb5 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -19,6 +19,7 @@ export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; export * from "./pullRequest.ts"; +export * from "./pluginCommands.ts"; export * from "./orchestration.ts"; export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; diff --git a/packages/contracts/src/pluginCommands.test.ts b/packages/contracts/src/pluginCommands.test.ts new file mode 100644 index 000000000000..4cbdeecf8548 --- /dev/null +++ b/packages/contracts/src/pluginCommands.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + PluginCommandCatalog, + PluginCommandInvokeInput, + PluginCommandInvocationResult, +} from "./pluginCommands.ts"; +import { WS_METHODS, WsRpcGroup } from "./rpc.ts"; + +const decodeCatalog = Schema.decodeUnknownEffect(PluginCommandCatalog); +const decodeInvokeInput = Schema.decodeUnknownSync(PluginCommandInvokeInput); +const decodeInvocationResult = Schema.decodeUnknownSync(PluginCommandInvocationResult); + +describe("plugin command contracts", () => { + it.effect("decodes a multi-surface command catalog", () => + Effect.gen(function* () { + const catalog = yield* decodeCatalog({ + generation: 3, + commands: [ + { + id: "t3.runtime-status", + label: "Check plugin runtime", + description: "Verify that the environment plugin runtime is responding.", + surfaces: ["web", "desktop", "mobile"], + }, + ], + }); + + expect(catalog.generation).toBe(3); + expect(catalog.commands[0]?.surfaces).toEqual(["web", "desktop", "mobile"]); + }), + ); + + it.effect("rejects unknown command surfaces", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + decodeCatalog({ + generation: 1, + commands: [{ id: "acme.command", label: "Command", surfaces: ["server"] }], + }), + ); + expect(exit._tag).toBe("Failure"); + }), + ); + + it("defines generation-bound invocation schemas", () => { + expect( + decodeInvokeInput({ + generation: 4, + id: "acme.command", + }), + ).toEqual({ generation: 4, id: "acme.command" }); + expect( + decodeInvocationResult({ + message: "Command completed.", + tone: "success", + }), + ).toEqual({ message: "Command completed.", tone: "success" }); + }); + + it("registers fixed list, invoke, and subscribe rpc methods", () => { + expect(WsRpcGroup.requests.has(WS_METHODS.pluginCommandsList)).toBe(true); + expect(WsRpcGroup.requests.has(WS_METHODS.pluginCommandsInvoke)).toBe(true); + expect(WsRpcGroup.requests.has(WS_METHODS.subscribePluginCommands)).toBe(true); + }); +}); diff --git a/packages/contracts/src/pluginCommands.ts b/packages/contracts/src/pluginCommands.ts new file mode 100644 index 000000000000..dad5681813bd --- /dev/null +++ b/packages/contracts/src/pluginCommands.ts @@ -0,0 +1,64 @@ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const PluginCommandId = TrimmedNonEmptyString.check(Schema.isMaxLength(200)); +export type PluginCommandId = typeof PluginCommandId.Type; + +export const PluginCommandSurface = Schema.Literals(["web", "desktop", "mobile"]); +export type PluginCommandSurface = typeof PluginCommandSurface.Type; + +export const PluginCommand = Schema.Struct({ + id: PluginCommandId, + label: TrimmedNonEmptyString.check(Schema.isMaxLength(120)), + description: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(500))), + surfaces: Schema.Array(PluginCommandSurface).check(Schema.isMinLength(1)), +}); +export type PluginCommand = typeof PluginCommand.Type; + +export const PluginCommandCatalog = Schema.Struct({ + generation: NonNegativeInt, + commands: Schema.Array(PluginCommand), +}); +export type PluginCommandCatalog = typeof PluginCommandCatalog.Type; + +export const PluginCommandInvokeInput = Schema.Struct({ + generation: NonNegativeInt, + id: PluginCommandId, +}); +export type PluginCommandInvokeInput = typeof PluginCommandInvokeInput.Type; + +export const PluginCommandInvocationResult = Schema.Struct({ + message: TrimmedNonEmptyString.check(Schema.isMaxLength(500)), + tone: Schema.Literals(["info", "success"]), +}); +export type PluginCommandInvocationResult = typeof PluginCommandInvocationResult.Type; + +export class PluginCommandNotFoundError extends Schema.TaggedErrorClass()( + "PluginCommandNotFoundError", + { id: PluginCommandId }, +) { + override get message(): string { + return `Plugin command not found: ${this.id}`; + } +} + +export class PluginCommandCatalogChangedError extends Schema.TaggedErrorClass()( + "PluginCommandCatalogChangedError", + { + actualGeneration: NonNegativeInt, + expectedGeneration: NonNegativeInt, + }, +) { + override get message(): string { + return `Plugin command catalog changed from generation ${this.expectedGeneration} to ${this.actualGeneration}`; + } +} + +export class PluginCommandInvocationError extends Schema.TaggedErrorClass()( + "PluginCommandInvocationError", + { + id: PluginCommandId, + message: TrimmedNonEmptyString.check(Schema.isMaxLength(500)), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..ae48e7cbd22b 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -67,6 +67,14 @@ import { OrchestrationGetWorkflowScriptError, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { + PluginCommandCatalog, + PluginCommandCatalogChangedError, + PluginCommandInvocationError, + PluginCommandInvocationResult, + PluginCommandInvokeInput, + PluginCommandNotFoundError, +} from "./pluginCommands.ts"; import { PullRequestActionInput, PullRequestActivity, @@ -274,6 +282,10 @@ export const WS_METHODS = { serverGetBackgroundPolicy: "server.getBackgroundPolicy", serverGetUsageSummary: "server.getUsageSummary", + // Plugin command methods + pluginCommandsList: "pluginCommands.list", + pluginCommandsInvoke: "pluginCommands.invoke", + // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -313,6 +325,7 @@ export const WS_METHODS = { subscribeAuthAccess: "subscribeAuthAccess", subscribeBackgroundPolicy: "subscribeBackgroundPolicy", subscribeResourceTelemetry: "subscribeResourceTelemetry", + subscribePluginCommands: "subscribePluginCommands", } as const; export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -339,6 +352,23 @@ export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), }); +export const WsPluginCommandsListRpc = Rpc.make(WS_METHODS.pluginCommandsList, { + payload: Schema.Struct({}), + success: PluginCommandCatalog, + error: EnvironmentAuthorizationError, +}); + +export const WsPluginCommandsInvokeRpc = Rpc.make(WS_METHODS.pluginCommandsInvoke, { + payload: PluginCommandInvokeInput, + success: PluginCommandInvocationResult, + error: Schema.Union([ + PluginCommandCatalogChangedError, + PluginCommandInvocationError, + PluginCommandNotFoundError, + EnvironmentAuthorizationError, + ]), +}); + export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** @@ -982,9 +1012,18 @@ export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeReso stream: true, }); +export const WsSubscribePluginCommandsRpc = Rpc.make(WS_METHODS.subscribePluginCommands, { + payload: Schema.Struct({}), + success: PluginCommandCatalog, + error: EnvironmentAuthorizationError, + stream: true, +}); + export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, + WsPluginCommandsListRpc, + WsPluginCommandsInvokeRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, @@ -1074,6 +1113,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeAuthAccessRpc, WsSubscribeBackgroundPolicyRpc, WsSubscribeResourceTelemetryRpc, + WsSubscribePluginCommandsRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetWorkflowScriptRpc, WsOrchestrationGetTurnDiffRpc, diff --git a/packages/plugin-runtime/README.md b/packages/plugin-runtime/README.md index d377361a28ff..bf73ab7b04cb 100644 --- a/packages/plugin-runtime/README.md +++ b/packages/plugin-runtime/README.md @@ -5,3 +5,11 @@ internal effect service for t3 product plugins. it uses a deterministic, stack-safe reconciliation planner and one effect child scope per active plugin. `PluginRuntime` is provided through `layer()`, so the composing effect scope owns shutdown. updates stage changed plugins and their dependents, publish contributions atomically, and roll back without replacing the live composition when activation fails. cordis is not a dependency. a pure-only executor was rejected because plugin lifetimes and async cleanup should be owned by effect scopes. + +## contributions + +plugins register detached declarative metadata and an optional host-only live value. snapshots and `contributions(slot)` expose only frozen metadata. executable values never cross the rpc boundary. + +`contributions(slot)` returns the committed generation with its entries. hosts must pass that generation to `useContribution(...)`; stale callers fail instead of invoking a handler from a different composition. invocation, reconciliation, and shutdown share the same transition authority, so a plugin scope cannot retire while its contribution is running. + +contribution ids are unique within each slot across the active composition. duplicate ids fail candidate validation and leave the previous generation live. diff --git a/packages/plugin-runtime/src/contract.ts b/packages/plugin-runtime/src/contract.ts index 248bdba167a8..84d5055fd5be 100644 --- a/packages/plugin-runtime/src/contract.ts +++ b/packages/plugin-runtime/src/contract.ts @@ -1,6 +1,7 @@ -export interface Contribution { +export interface Contribution { readonly id: string; readonly label: string; + readonly data?: Data; } export interface PluginDefinition { @@ -13,10 +14,18 @@ export interface PluginDefinition { export interface PluginActivationContext { readonly resolve: (capability: string) => Service; - readonly register: (slot: string, contribution: Contribution) => void; + readonly register: { + (slot: string, contribution: Contribution): void; + (slot: string, contribution: Contribution, value: Value): void; + }; readonly onDispose: (finalizer: () => void | Promise) => void; } +export interface PluginRuntimeContributionSnapshot { + readonly generation: number; + readonly entries: ReadonlyArray; +} + export interface PluginRuntimeSnapshot { readonly active: ReadonlyArray; readonly blocked: Readonly>>; @@ -24,6 +33,7 @@ export interface PluginRuntimeSnapshot { } export interface PluginRuntimeOptions { + readonly validateSnapshot?: (snapshot: PluginRuntimeSnapshot) => void; readonly onLifecycle?: (event: { readonly phase: "activate" | "deactivate"; readonly pluginId: string; diff --git a/packages/plugin-runtime/src/index.ts b/packages/plugin-runtime/src/index.ts index 133b22ad3811..21e731b191c1 100644 --- a/packages/plugin-runtime/src/index.ts +++ b/packages/plugin-runtime/src/index.ts @@ -2,6 +2,7 @@ export type { Contribution, PluginActivationContext, PluginDefinition, + PluginRuntimeContributionSnapshot, PluginRuntimeOptions, PluginRuntimeSnapshot, } from "./contract.ts"; diff --git a/packages/plugin-runtime/src/runtime.ts b/packages/plugin-runtime/src/runtime.ts index a381d0b0bcc0..7e8624ace5cd 100644 --- a/packages/plugin-runtime/src/runtime.ts +++ b/packages/plugin-runtime/src/runtime.ts @@ -6,6 +6,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Scheduler from "effect/Scheduler"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; @@ -13,6 +14,7 @@ import type { Contribution, PluginActivationContext, PluginDefinition, + PluginRuntimeContributionSnapshot, PluginRuntimeOptions, PluginRuntimeSnapshot, } from "./contract.ts"; @@ -23,20 +25,27 @@ import { type PluginPlanningError, } from "./planner.ts"; +interface LiveContribution { + readonly contribution: Contribution; + readonly value: unknown; +} + interface LivePlugin { readonly definition: PluginDefinition; readonly scope: Scope.Closeable; - readonly contributions: ReadonlyMap>; + readonly contributions: ReadonlyMap>; readonly cleanupErrors: Array; } interface LiveComposition { + readonly generation: number; readonly plugins: ReadonlyArray; readonly snapshot: PluginRuntimeSnapshot; } -type RuntimeOperation = "reconcile" | "dispose"; -type PluginCallback = "activate" | "finalizer"; +type RuntimeOperation = "reconcile" | "dispose" | "invoke"; +type PluginLifecycleCallback = "activate" | "finalizer"; +type PluginCallback = PluginLifecycleCallback | "contribution"; interface PluginCallbackContext { active: boolean; @@ -44,6 +53,15 @@ interface PluginCallbackContext { readonly pluginId: string; } +interface PluginEffectCallbackContext extends PluginCallbackContext { + readonly runtime: object; +} + +class PluginEffectCallback extends Context.Reference( + "@t3tools/plugin-runtime/runtime/PluginEffectCallback", + { defaultValue: () => undefined }, +) {} + interface CleanupFailure { readonly error: unknown; readonly pluginId: string; @@ -101,9 +119,23 @@ class PluginStagingError extends Schema.TaggedErrorClass()( } } +export class PluginDuplicateContributionError extends Schema.TaggedErrorClass()( + "PluginDuplicateContributionError", + { + firstPluginId: Schema.String, + id: Schema.String, + secondPluginId: Schema.String, + slot: Schema.String, + }, +) { + override get message(): string { + return `Duplicate plugin contribution ${this.slot}/${this.id} from ${this.firstPluginId} and ${this.secondPluginId}`; + } +} + class PluginRuntimeDisposedError extends Schema.TaggedErrorClass()( "PluginRuntimeDisposedError", - { operation: Schema.Literals(["reconcile", "dispose"]) }, + { operation: Schema.Literals(["reconcile", "dispose", "invoke"]) }, ) { override get message(): string { return `Plugin runtime is disposed; cannot ${this.operation}`; @@ -113,8 +145,8 @@ class PluginRuntimeDisposedError extends Schema.TaggedErrorClass()( "PluginRuntimeReentrancyError", { - callback: Schema.Literals(["activate", "finalizer"]), - operation: Schema.Literals(["reconcile", "dispose"]), + callback: Schema.Literals(["activate", "contribution", "finalizer"]), + operation: Schema.Literals(["reconcile", "dispose", "invoke"]), pluginId: Schema.String, }, ) { @@ -142,12 +174,47 @@ class PluginRuntimeCleanupError extends Schema.TaggedErrorClass()( + "PluginSnapshotValidationError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Plugin contribution snapshot validation failed"; + } +} + export type PluginRuntimeDisposeError = PluginRuntimeCleanupError | PluginRuntimeReentrancyError; +export class PluginContributionGenerationError extends Schema.TaggedErrorClass()( + "PluginContributionGenerationError", + { actual: Schema.Int, expected: Schema.Int }, +) { + override get message(): string { + return `Plugin contribution generation changed from ${this.expected} to ${this.actual}`; + } +} + +export class PluginContributionNotFoundError extends Schema.TaggedErrorClass()( + "PluginContributionNotFoundError", + { id: Schema.String, slot: Schema.String }, +) { + override get message(): string { + return `Plugin contribution not found: ${this.slot}/${this.id}`; + } +} + +export type PluginRuntimeContributionError = + | PluginContributionGenerationError + | PluginContributionNotFoundError + | PluginRuntimeDisposedError + | PluginRuntimeReentrancyError; + export class PluginRuntime extends Context.Service< PluginRuntime, { @@ -155,6 +222,13 @@ export class PluginRuntime extends Context.Service< definitions: ReadonlyArray, ) => Effect.Effect; readonly snapshot: Effect.Effect; + readonly contributions: (slot: string) => Effect.Effect; + readonly useContribution: ( + slot: string, + id: string, + generation: number, + use: (value: Value) => Effect.Effect, + ) => Effect.Effect; readonly dispose: Effect.Effect; } >()("@t3tools/plugin-runtime/runtime/PluginRuntime") {} @@ -162,6 +236,30 @@ export class PluginRuntime extends Context.Service< const createNullPrototypeRecord = (): Record => Object.create(null) as Record; +const sameStringRecord = ( + left: Readonly>>, + right: Readonly>>, +): boolean => { + const leftKeys = Object.keys(left); + if (leftKeys.length !== Object.keys(right).length) return false; + return leftKeys.every((key) => left[key] === right[key]); +}; + +const deepFreeze = (value: Value): Value => { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; + for (const nested of Object.values(value)) deepFreeze(nested); + return Object.freeze(value); +}; + +const detachContribution = (contribution: Contribution): Contribution => + Object.freeze({ + id: contribution.id, + label: contribution.label, + ...(contribution.data === undefined + ? {} + : { data: deepFreeze(structuredClone(contribution.data)) }), + }); + const snapshotDefinitions = ( definitions: ReadonlyArray, ): ReadonlyArray => @@ -198,9 +296,7 @@ const snapshotOf = ( const values = contributions[slot] ?? []; contributions[slot] = Object.freeze([ ...values, - ...registrations.map((registration) => - Object.freeze({ id: registration.id, label: registration.label }), - ), + ...registrations.map((registration) => registration.contribution), ]); } } @@ -211,11 +307,39 @@ const snapshotOf = ( }); }; +const validateUniqueContributions = ( + plugins: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const owners = new Map>(); + for (const plugin of plugins) { + for (const [slot, registrations] of plugin.contributions) { + const slotOwners = owners.get(slot) ?? new Map(); + owners.set(slot, slotOwners); + for (const registration of registrations) { + const id = registration.contribution.id; + const firstPluginId = slotOwners.get(id); + if (firstPluginId !== undefined) { + return yield* new PluginDuplicateContributionError({ + firstPluginId, + id, + secondPluginId: plugin.definition.id, + slot, + }); + } + slotOwners.set(id, plugin.definition.id); + } + } + } + }); + export const make = (options: PluginRuntimeOptions = {}) => Effect.gen(function* () { const parentScope = yield* Effect.scope; const transitionSemaphore = yield* Semaphore.make(1); - let current: LiveComposition = { plugins: [], snapshot: emptySnapshot() }; + const baseScheduler = yield* Scheduler.Scheduler; + const runtimeIdentity = {}; + let current: LiveComposition = { generation: 0, plugins: [], snapshot: emptySnapshot() }; let disposalStarted = false; let disposed = false; const callbackContext = new NodeAsyncHooks.AsyncLocalStorage(); @@ -275,7 +399,7 @@ export const make = (options: PluginRuntimeOptions = {}) => }); const invokePluginCallback = ( - callback: PluginCallback, + callback: PluginLifecycleCallback, pluginId: string, invoke: () => Result | PromiseLike, onSettled?: () => void, @@ -317,7 +441,7 @@ export const make = (options: PluginRuntimeOptions = {}) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const scope = yield* Scope.fork(parentScope, "sequential"); - const contributions = new Map>(); + const contributions = new Map>(); const cleanupErrors: Array = []; const finalizers: Array<() => void | Promise> = []; const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors }; @@ -339,10 +463,18 @@ export const make = (options: PluginRuntimeOptions = {}) => } return capabilities.get(capability) as Service; }, - register: (slot, contribution) => { + register: ( + slot: string, + contribution: Contribution, + ...registeredValues: [] | [unknown] + ) => { assertActivating("register"); const values = contributions.get(slot) ?? []; - values.push(Object.freeze({ id: contribution.id, label: contribution.label })); + const detachedContribution = detachContribution(contribution); + values.push({ + contribution: detachedContribution, + value: registeredValues.length === 0 ? detachedContribution : registeredValues[0], + }); contributions.set(slot, values); }, onDispose: (finalizer) => { @@ -406,6 +538,9 @@ export const make = (options: PluginRuntimeOptions = {}) => current.plugins.map((plugin) => plugin.definition), plan.definitions, ); + if (affected.size === 0 && sameStringRecord(current.snapshot.blocked, plan.blocked)) { + return current.snapshot; + } const currentById = new Map( current.plugins.map((plugin) => [plugin.definition.id, plugin]), ); @@ -440,9 +575,16 @@ export const make = (options: PluginRuntimeOptions = {}) => } nextPlugins.push(plugin); } + yield* validateUniqueContributions(nextPlugins); + const snapshot = snapshotOf(nextPlugins, plan.blocked); + yield* Effect.try({ + try: () => options.validateSnapshot?.(snapshot), + catch: (cause) => new PluginSnapshotValidationError({ cause }), + }); return { + generation: current.generation + 1, plugins: nextPlugins, - snapshot: snapshotOf(nextPlugins, plan.blocked), + snapshot, }; }), ), @@ -472,7 +614,11 @@ export const make = (options: PluginRuntimeOptions = {}) => disposalStarted = true; const previous = current.plugins; const failures = [...(yield* closePlugins(previous, true))]; - current = { plugins: [], snapshot: emptySnapshot() }; + current = { + generation: current.generation + 1, + plugins: [], + snapshot: emptySnapshot(), + }; disposed = true; if (failures.length > 0) { return yield* new PluginRuntimeCleanupError({ @@ -482,24 +628,101 @@ export const make = (options: PluginRuntimeOptions = {}) => }), ); - const runTransition = ( + const runTransition = ( operation: RuntimeOperation, - effect: () => Effect.Effect, - ): Effect.Effect => - Effect.suspend(() => { - const callback = callbackContext.getStore(); - if (callback?.active === true) { - return Effect.fail( - new PluginRuntimeReentrancyError({ - callback: callback.callback, - operation, - pluginId: callback.pluginId, - }), - ); - } - return transitionSemaphore.withPermits(1)(effect()); + effect: () => Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const effectCallback = yield* PluginEffectCallback; + return yield* Effect.suspend( + () => { + const asyncCallback = callbackContext.getStore(); + const callback = + effectCallback?.runtime === runtimeIdentity ? effectCallback : asyncCallback; + if (callback?.active === true) { + return Effect.fail( + new PluginRuntimeReentrancyError({ + callback: callback.callback, + operation, + pluginId: callback.pluginId, + }), + ); + } + return transitionSemaphore.withPermits(1)(effect()); + }, + ); }); + const useContribution = ( + slot: string, + id: string, + generation: number, + use: (value: Value) => Effect.Effect, + ): Effect.Effect => + runTransition("invoke", () => + Effect.suspend< + Success, + | Failure + | PluginContributionGenerationError + | PluginContributionNotFoundError + | PluginRuntimeDisposedError, + Requirements + >(() => { + if (disposalStarted) { + return Effect.fail(new PluginRuntimeDisposedError({ operation: "invoke" })); + } + if (current.generation !== generation) { + return Effect.fail( + new PluginContributionGenerationError({ + actual: current.generation, + expected: generation, + }), + ); + } + for (const plugin of current.plugins) { + const registration = (plugin.contributions.get(slot) ?? []).find( + (candidate) => candidate.contribution.id === id, + ); + if (registration !== undefined) { + const contributionState: PluginEffectCallbackContext = { + active: true, + callback: "contribution", + pluginId: plugin.definition.id, + runtime: runtimeIdentity, + }; + const contributionScheduler: Scheduler.Scheduler = { + executionMode: baseScheduler.executionMode, + shouldYield: (fiber) => baseScheduler.shouldYield(fiber), + makeDispatcher: () => { + const dispatcher = baseScheduler.makeDispatcher(); + return { + flush: () => dispatcher.flush(), + scheduleTask: (task, priority) => + dispatcher.scheduleTask( + () => callbackContext.run(contributionState, task), + priority, + ), + }; + }, + }; + return Effect.yieldNow.pipe( + Effect.andThen(Effect.suspend(() => use(registration.value as Value))), + Effect.provideService(PluginEffectCallback, contributionState), + Effect.provideService(Scheduler.Scheduler, contributionScheduler), + Effect.onExit((exit) => + Effect.sync(() => { + if (!(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause))) { + contributionState.active = false; + } + }), + ), + ); + } + } + return Effect.fail(new PluginContributionNotFoundError({ id, slot })); + }), + ); + yield* Effect.addFinalizer(() => runTransition("dispose", disposeEffect).pipe( Effect.catchTags({ @@ -524,6 +747,20 @@ export const make = (options: PluginRuntimeOptions = {}) => return runTransition("reconcile", () => reconcileEffect(desired)); }, snapshot: Effect.sync(() => current.snapshot), + contributions: (slot) => + Effect.sync(() => + Object.freeze({ + generation: current.generation, + entries: Object.freeze( + current.plugins.flatMap((plugin) => + (plugin.contributions.get(slot) ?? []).map( + (registration) => registration.contribution, + ), + ), + ), + }), + ), + useContribution, dispose: runTransition("dispose", disposeEffect), } satisfies PluginRuntime["Service"]; }); diff --git a/packages/plugin-runtime/test/contributions.test.ts b/packages/plugin-runtime/test/contributions.test.ts new file mode 100644 index 000000000000..82c8a2b22b98 --- /dev/null +++ b/packages/plugin-runtime/test/contributions.test.ts @@ -0,0 +1,403 @@ +import { it } from "@effect/vitest"; +import { describe, expect } from "vite-plus/test"; +import * as NodeTimersPromises from "node:timers/promises"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; + +import * as PluginRuntime from "../src/runtime.ts"; +import type { PluginDefinition, PluginRuntimeSnapshot } from "../src/contract.ts"; + +describe("plugin runtime live contributions", () => { + it.effect("invokes the handler from the listed committed generation", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* PluginRuntime.make(); + yield* runtime.reconcile([ + { + id: "acme.commands", + version: "1.0.0", + activate(context) { + context.register( + "commands", + { + id: "acme.hello", + label: "Say hello", + data: { surfaces: ["web", "desktop", "mobile"] }, + }, + (name: string) => Effect.succeed(`hello ${name}`), + ); + }, + }, + ]); + + const catalog = yield* runtime.contributions("commands"); + expect(catalog.generation).toBe(1); + expect(catalog.entries).toEqual([ + { + id: "acme.hello", + label: "Say hello", + data: { surfaces: ["web", "desktop", "mobile"] }, + }, + ]); + expect(Object.isFrozen(catalog.entries[0]?.data)).toBe(true); + + const greeting = yield* runtime.useContribution( + "commands", + "acme.hello", + catalog.generation, + (handler: (name: string) => Effect.Effect) => handler("t3"), + ); + expect(greeting).toBe("hello t3"); + }), + ), + ); + + it.effect("rejects invocation from a stale catalog generation", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* PluginRuntime.make(); + const definition = (version: string) => ({ + id: "acme.commands", + version, + activate( + context: Parameters[0][number]["activate"]>[0], + ) { + context.register("commands", { id: "acme.hello", label: `Say hello ${version}` }, () => + Effect.succeed(version), + ); + }, + }); + + yield* runtime.reconcile([definition("1.0.0")]); + const firstCatalog = yield* runtime.contributions("commands"); + yield* runtime.reconcile([definition("2.0.0")]); + + const staleExit = yield* Effect.exit( + runtime.useContribution( + "commands", + "acme.hello", + firstCatalog.generation, + (handler: () => Effect.Effect) => handler(), + ), + ); + expect(Exit.isFailure(staleExit)).toBe(true); + if (Exit.isFailure(staleExit)) { + expect(Cause.squash(staleExit.cause)).toMatchObject({ + _tag: "PluginContributionGenerationError", + actual: 2, + expected: 1, + }); + } + }), + ), + ); + + it.effect("rejects duplicate contribution ids before publishing", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* PluginRuntime.make(); + const plugin = (id: string, label: string) => ({ + id, + version: "1.0.0", + activate( + context: Parameters[0][number]["activate"]>[0], + ) { + context.register("commands", { id: "acme.duplicate", label }, () => Effect.void); + }, + }); + + const reconcileExit = yield* Effect.exit( + runtime.reconcile([ + plugin("acme.first", "First command"), + plugin("acme.second", "Second command"), + ]), + ); + expect(Exit.isFailure(reconcileExit)).toBe(true); + if (Exit.isFailure(reconcileExit)) { + expect(Cause.squash(reconcileExit.cause)).toMatchObject({ + _tag: "PluginDuplicateContributionError", + id: "acme.duplicate", + slot: "commands", + }); + } + expect((yield* runtime.contributions("commands")).entries).toEqual([]); + }), + ), + ); + + it.effect("rejects runtime reentrancy from a contribution handler", () => + Effect.gen(function* () { + const runtime = yield* PluginRuntime.make(); + const definition: PluginDefinition = { + id: "acme.commands", + version: "1.0.0", + activate(context) { + context.register("commands", { id: "status", label: "Status" }, () => + runtime.reconcile([]), + ); + }, + }; + yield* runtime.reconcile([definition]); + const catalog = yield* runtime.contributions("commands"); + + const result = yield* runtime + .useContribution< + () => ReturnType, + PluginRuntimeSnapshot, + PluginRuntime.PluginRuntimeReconcileError, + never + >("commands", "status", catalog.generation, (handler) => handler()) + .pipe(Effect.exit, Effect.timeoutOption(Duration.millis(100))); + + expect(Option.isSome(result)).toBe(true); + if (Option.isSome(result) && Exit.isFailure(result.value)) { + const error = Cause.squash(result.value.cause); + expect(error).toMatchObject({ + _tag: "PluginRuntimeReentrancyError", + callback: "contribution", + operation: "reconcile", + }); + } + }), + ); + + it.effect("rejects contribution reentrancy through a fresh effect runtime", () => + Effect.gen(function* () { + const runtime = yield* PluginRuntime.make(); + const definition: PluginDefinition = { + id: "acme.commands", + version: "1.0.0", + activate(context) { + context.register( + "commands", + { id: "status", label: "Status" }, + Effect.promise(async () => { + // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- the regression is specifically a fresh-runtime bridge from plugin code + const nested = Effect.runPromiseExit(runtime.reconcile([])).then((exit) => ({ + exit, + kind: "nested" as const, + })); + const timeout = NodeTimersPromises.setTimeout(50, { kind: "timeout" as const }); + return Promise.race([nested, timeout]); + }), + ); + }, + }; + yield* runtime.reconcile([definition]); + const catalog = yield* runtime.contributions("commands"); + + const result = yield* runtime.useContribution( + "commands", + "status", + catalog.generation, + ( + handler: Effect.Effect< + | { readonly kind: "timeout" } + | { + readonly exit: Exit.Exit< + PluginRuntimeSnapshot, + PluginRuntime.PluginRuntimeReconcileError + >; + readonly kind: "nested"; + } + >, + ) => handler, + ); + + expect(result.kind).toBe("nested"); + if (result.kind === "nested") { + expect(Exit.isFailure(result.exit)).toBe(true); + if (Exit.isFailure(result.exit)) { + expect(Cause.squash(result.exit.cause)).toMatchObject({ + _tag: "PluginRuntimeReentrancyError", + callback: "contribution", + operation: "reconcile", + }); + } + } + }), + ); + + it.effect("installs contribution context before calling the host consumer", () => + Effect.gen(function* () { + const runtime = yield* PluginRuntime.make(); + const definition: PluginDefinition = { + id: "acme.commands", + version: "1.0.0", + activate(context) { + context.register("commands", { id: "status", label: "Status" }, Effect.void); + }, + }; + yield* runtime.reconcile([definition]); + const catalog = yield* runtime.contributions("commands"); + + const result = yield* runtime.useContribution( + "commands", + "status", + catalog.generation, + () => { + // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- verifies context before the host callback returns its Effect + const nested = Effect.runPromiseExit(runtime.reconcile([])).then((exit) => ({ + exit, + kind: "nested" as const, + })); + const timeout = NodeTimersPromises.setTimeout(50, { kind: "timeout" as const }); + return Effect.promise(() => Promise.race([nested, timeout])); + }, + ); + + expect(result.kind).toBe("nested"); + if (result.kind === "nested") { + expect(Exit.isFailure(result.exit)).toBe(true); + if (Exit.isFailure(result.exit)) { + expect(Cause.squash(result.exit.cause)).toMatchObject({ + _tag: "PluginRuntimeReentrancyError", + callback: "contribution", + operation: "reconcile", + }); + } + } + }), + ); + + it.effect("keeps interrupted asynchronous contribution descendants reentrancy guarded", () => + Effect.gen(function* () { + const runtime = yield* PluginRuntime.make(); + let markStarted!: () => void; + let releaseHandler!: () => void; + let resolveNested!: ( + exit: Exit.Exit, + ) => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const handlerGate = new Promise((resolve) => { + releaseHandler = resolve; + }); + const nestedResult = new Promise< + Exit.Exit + >((resolve) => { + resolveNested = resolve; + }); + const definition: PluginDefinition = { + id: "acme.commands", + version: "1.0.0", + activate(context) { + context.register( + "commands", + { id: "status", label: "Status" }, + Effect.promise(async () => { + markStarted(); + await handlerGate; + // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- verifies an interrupted native promise descendant cannot reenter + resolveNested(await Effect.runPromiseExit(runtime.reconcile([]))); + }), + ); + }, + }; + yield* runtime.reconcile([definition]); + const catalog = yield* runtime.contributions("commands"); + const invocation = yield* Effect.forkChild( + runtime.useContribution( + "commands", + "status", + catalog.generation, + (handler: Effect.Effect) => handler, + ), + ); + yield* Effect.promise(() => started); + yield* Fiber.interrupt(invocation); + releaseHandler(); + const nestedExit = yield* Effect.promise(() => nestedResult); + + expect(Exit.isFailure(nestedExit)).toBe(true); + if (Exit.isFailure(nestedExit)) { + expect(Cause.squash(nestedExit.cause)).toMatchObject({ + _tag: "PluginRuntimeReentrancyError", + callback: "contribution", + operation: "reconcile", + }); + } + expect((yield* runtime.contributions("commands")).generation).toBe(catalog.generation); + }), + ); + + it.effect("keeps the contribution generation stable for an unchanged composition", () => + Effect.gen(function* () { + const runtime = yield* PluginRuntime.make(); + const definition: PluginDefinition = { + id: "acme.commands", + version: "1.0.0", + activate(context) { + context.register("commands", { id: "status", label: "Status" }, Effect.void); + }, + }; + + yield* runtime.reconcile([definition]); + const first = yield* runtime.contributions("commands"); + yield* runtime.reconcile([definition]); + const second = yield* runtime.contributions("commands"); + + expect(second.generation).toBe(first.generation); + expect(second.entries).toEqual(first.entries); + }), + ); + + it.effect("uses detached metadata as the default live contribution value", () => + Effect.gen(function* () { + const runtime = yield* PluginRuntime.make(); + const contribution = { id: "status", label: "Status", data: { source: "original" } }; + const definition: PluginDefinition = { + id: "acme.commands", + version: "1.0.0", + activate(context) { + context.register("commands", contribution); + }, + }; + + yield* runtime.reconcile([definition]); + const catalog = yield* runtime.contributions("commands"); + contribution.id = "mutated"; + contribution.label = "Mutated"; + contribution.data.source = "mutated"; + const used = yield* runtime.useContribution( + "commands", + "status", + catalog.generation, + (value: typeof contribution) => Effect.succeed(value), + ); + + expect(used).toEqual({ id: "status", label: "Status", data: { source: "original" } }); + expect(Object.isFrozen(used)).toBe(true); + expect(Object.isFrozen(used.data)).toBe(true); + }), + ); + + it.effect("preserves an explicitly undefined live contribution value", () => + Effect.gen(function* () { + const runtime = yield* PluginRuntime.make(); + const definition: PluginDefinition = { + id: "acme.commands", + version: "1.0.0", + activate(context) { + context.register("commands", { id: "status", label: "Status" }, undefined); + }, + }; + + yield* runtime.reconcile([definition]); + const catalog = yield* runtime.contributions("commands"); + const isUndefined = yield* runtime.useContribution( + "commands", + "status", + catalog.generation, + (value: undefined) => Effect.succeed(value === undefined), + ); + + expect(isUndefined).toBe(true); + }), + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5a93e957a53..10def78621b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -489,6 +489,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts + '@t3tools/plugin-runtime': + specifier: workspace:* + version: link:../../packages/plugin-runtime '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared @@ -5200,10 +5203,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yuuang/ffi-rs-android-arm64@1.3.2': resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} From 12b8e9e8fe2596e4db2d1d3df9ba63160800aeee Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:51:26 +0000 Subject: [PATCH 07/45] fix: harden plugin command catalog Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../features/threads/NewTaskDraftScreen.tsx | 18 ++++--- .../threads/plugin-command-menu.test.ts | 14 ++++- .../features/threads/plugin-command-menu.ts | 14 +++++ packages/plugin-runtime/README.md | 2 +- packages/plugin-runtime/src/contract.ts | 10 +++- packages/plugin-runtime/src/runtime.ts | 54 ++++++++++++++++--- .../plugin-runtime/test/contributions.test.ts | 45 ++++++++++++++++ scripts/release-smoke.ts | 1 + 8 files changed, 140 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 598d702a37cf..d7aba9b92f7d 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -72,7 +72,10 @@ import { import { useIncomingShare } from "../sharing/IncomingShareProvider"; import { detectComposerTrigger, replaceTextRange } from "@t3tools/shared/composerTrigger"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; -import { buildMobilePluginCommandItems } from "./plugin-command-menu"; +import { + buildMobilePluginCommandItems, + reconcileComposerSelectionForTextChange, +} from "./plugin-command-menu"; import { useMobilePluginCommands } from "./use-mobile-plugin-commands"; function NewTaskWorkspaceIcon(props: { @@ -138,6 +141,7 @@ export function NewTaskDraftScreen(props: { start: flow.prompt.length, end: flow.prompt.length, })); + const previousPromptLengthRef = useRef(flow.prompt.length); const { commands: pluginCommands, execute: executePluginCommand } = useMobilePluginCommands( selectedProject?.environmentId ?? null, ); @@ -169,13 +173,11 @@ export function NewTaskDraftScreen(props: { ); useEffect(() => { const end = flow.prompt.length; - setComposerSelection((selection) => { - const start = Math.min(selection.start, end); - const selectionEnd = Math.min(selection.end, end); - return start === selection.start && selectionEnd === selection.end - ? selection - : { start, end: selectionEnd }; - }); + const previousEnd = previousPromptLengthRef.current; + previousPromptLengthRef.current = end; + setComposerSelection((selection) => + reconcileComposerSelectionForTextChange(selection, previousEnd, end), + ); }, [flow.prompt.length]); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: promptInputRef, diff --git a/apps/mobile/src/features/threads/plugin-command-menu.test.ts b/apps/mobile/src/features/threads/plugin-command-menu.test.ts index a75d52f222ef..5cc6b4e8cd30 100644 --- a/apps/mobile/src/features/threads/plugin-command-menu.test.ts +++ b/apps/mobile/src/features/threads/plugin-command-menu.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildMobilePluginCommandItems } from "./plugin-command-menu"; +import { + buildMobilePluginCommandItems, + reconcileComposerSelectionForTextChange, +} from "./plugin-command-menu"; describe("buildMobilePluginCommandItems", () => { it("renders only matching mobile command contributions", () => { @@ -25,3 +28,12 @@ describe("buildMobilePluginCommandItems", () => { expect(items[0]?.type).toBe("plugin-command"); }); }); + +describe("reconcileComposerSelectionForTextChange", () => { + it("moves an end-positioned caret after prompt hydration", () => { + expect(reconcileComposerSelectionForTextChange({ start: 0, end: 0 }, 0, 14)).toEqual({ + start: 14, + end: 14, + }); + }); +}); diff --git a/apps/mobile/src/features/threads/plugin-command-menu.ts b/apps/mobile/src/features/threads/plugin-command-menu.ts index f27ec2e874d4..ebde34cd7d7b 100644 --- a/apps/mobile/src/features/threads/plugin-command-menu.ts +++ b/apps/mobile/src/features/threads/plugin-command-menu.ts @@ -2,6 +2,20 @@ import type { PluginCommand } from "@t3tools/contracts"; import type { ComposerCommandItem } from "./ComposerCommandPopover"; +export function reconcileComposerSelectionForTextChange( + selection: { readonly start: number; readonly end: number }, + previousLength: number, + nextLength: number, +): { readonly start: number; readonly end: number } { + if (selection.start === previousLength && selection.end === previousLength) { + return { start: nextLength, end: nextLength }; + } + return { + start: Math.min(selection.start, nextLength), + end: Math.min(selection.end, nextLength), + }; +} + export function buildMobilePluginCommandItems( commands: ReadonlyArray, query: string, diff --git a/packages/plugin-runtime/README.md b/packages/plugin-runtime/README.md index bf73ab7b04cb..a6c2216ab57b 100644 --- a/packages/plugin-runtime/README.md +++ b/packages/plugin-runtime/README.md @@ -8,7 +8,7 @@ cordis is not a dependency. a pure-only executor was rejected because plugin lif ## contributions -plugins register detached declarative metadata and an optional host-only live value. snapshots and `contributions(slot)` expose only frozen metadata. executable values never cross the rpc boundary. +plugins register detached, deeply frozen, json-compatible declarative metadata and an optional host-only live value. snapshots and `contributions(slot)` expose only frozen metadata. executable values never cross the rpc boundary. `contributions(slot)` returns the committed generation with its entries. hosts must pass that generation to `useContribution(...)`; stale callers fail instead of invoking a handler from a different composition. invocation, reconciliation, and shutdown share the same transition authority, so a plugin scope cannot retire while its contribution is running. diff --git a/packages/plugin-runtime/src/contract.ts b/packages/plugin-runtime/src/contract.ts index 84d5055fd5be..ae0df5fc5094 100644 --- a/packages/plugin-runtime/src/contract.ts +++ b/packages/plugin-runtime/src/contract.ts @@ -1,4 +1,12 @@ -export interface Contribution { +export type ContributionData = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: ContributionData }; + +export interface Contribution { readonly id: string; readonly label: string; readonly data?: Data; diff --git a/packages/plugin-runtime/src/runtime.ts b/packages/plugin-runtime/src/runtime.ts index 7e8624ace5cd..fb8ca7095947 100644 --- a/packages/plugin-runtime/src/runtime.ts +++ b/packages/plugin-runtime/src/runtime.ts @@ -12,6 +12,7 @@ import * as Scope from "effect/Scope"; import type { Contribution, + ContributionData, PluginActivationContext, PluginDefinition, PluginRuntimeContributionSnapshot, @@ -245,19 +246,58 @@ const sameStringRecord = ( return leftKeys.every((key) => left[key] === right[key]); }; -const deepFreeze = (value: Value): Value => { - if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; - for (const nested of Object.values(value)) deepFreeze(nested); - return Object.freeze(value); +const cloneContributionData = ( + value: ContributionData, + ancestors = new WeakSet(), +): ContributionData => { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("Contribution data numbers must be finite"); + return value; + } + if (typeof value !== "object") { + throw new TypeError("Contribution data must contain only JSON-compatible values"); + } + if (ancestors.has(value)) throw new TypeError("Contribution data cannot contain cycles"); + ancestors.add(value); + try { + if (Array.isArray(value)) { + const clone: Array = []; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) { + throw new TypeError("Contribution data arrays cannot contain holes"); + } + clone.push(cloneContributionData(value[index]!, ancestors)); + } + return Object.freeze(clone); + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Contribution data objects must use a plain or null prototype"); + } + const clone = createNullPrototypeRecord(); + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string") { + throw new TypeError("Contribution data objects cannot use symbol keys"); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || descriptor.enumerable !== true || !("value" in descriptor)) { + throw new TypeError("Contribution data objects must use enumerable data properties"); + } + clone[key] = cloneContributionData(descriptor.value as ContributionData, ancestors); + } + return Object.freeze(clone); + } finally { + ancestors.delete(value); + } }; const detachContribution = (contribution: Contribution): Contribution => Object.freeze({ id: contribution.id, label: contribution.label, - ...(contribution.data === undefined - ? {} - : { data: deepFreeze(structuredClone(contribution.data)) }), + ...(contribution.data === undefined ? {} : { data: cloneContributionData(contribution.data) }), }); const snapshotDefinitions = ( diff --git a/packages/plugin-runtime/test/contributions.test.ts b/packages/plugin-runtime/test/contributions.test.ts index 82c8a2b22b98..0f6a51a531ca 100644 --- a/packages/plugin-runtime/test/contributions.test.ts +++ b/packages/plugin-runtime/test/contributions.test.ts @@ -347,6 +347,51 @@ describe("plugin runtime live contributions", () => { }), ); + it.effect("rejects non-declarative contribution data without replacing the composition", () => + Effect.gen(function* () { + const runtime = yield* PluginRuntime.make(); + const stable: PluginDefinition = { + id: "acme.commands", + version: "1.0.0", + activate(context) { + context.register("commands", { id: "status", label: "Status" }, Effect.void); + }, + }; + yield* runtime.reconcile([stable]); + const committed = yield* runtime.contributions("commands"); + const circular: { self?: unknown } = {}; + circular.self = circular; + const invalidValues: ReadonlyArray = [ + circular, + new Map([["status", true]]), + new Uint8Array([1]), + ]; + + for (const [index, data] of invalidValues.entries()) { + const failed = yield* Effect.exit( + runtime.reconcile([ + { + id: "acme.commands", + version: `2.0.${index}`, + activate(context) { + context.register("commands", { + id: "status", + label: "Status", + data: data as never, + }); + }, + }, + ]), + ); + expect(Exit.isFailure(failed)).toBe(true); + if (Exit.isFailure(failed)) { + expect(Cause.squash(failed.cause)).toMatchObject({ _tag: "PluginCallbackError" }); + } + expect((yield* runtime.contributions("commands")).generation).toBe(committed.generation); + } + }), + ); + it.effect("uses detached metadata as the default live contribution value", () => Effect.gen(function* () { const runtime = yield* PluginRuntime.make(); diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 45d2dd436b6a..9fea3458e785 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -26,6 +26,7 @@ const workspaceFiles = [ "oxlint-plugin-t3code/package.json", "packages/client-runtime/package.json", "packages/contracts/package.json", + "packages/plugin-runtime/package.json", "packages/shared/package.json", "packages/ssh/package.json", "packages/tailscale/package.json", From ceb364010385d64fddcde73017d1c9746bd61d4c Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:56:07 +0000 Subject: [PATCH 08/45] fix: address plugin command review findings Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/features/threads/NewTaskDraftScreen.tsx | 4 +++- .../features/threads/plugin-command-menu.test.ts | 7 +++++++ .../src/features/threads/plugin-command-menu.ts | 5 +++++ apps/server/src/plugins/PluginCommandCatalog.ts | 16 ++++++++-------- packages/contracts/src/pluginCommands.ts | 1 + 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index d7aba9b92f7d..912f5c923900 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -74,6 +74,7 @@ import { detectComposerTrigger, replaceTextRange } from "@t3tools/shared/compose import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; import { buildMobilePluginCommandItems, + isCollapsedComposerSelection, reconcileComposerSelectionForTextChange, } from "./plugin-command-menu"; import { useMobilePluginCommands } from "./use-mobile-plugin-commands"; @@ -146,9 +147,10 @@ export function NewTaskDraftScreen(props: { selectedProject?.environmentId ?? null, ); const pluginCommandTrigger = useMemo(() => { + if (!isCollapsedComposerSelection(composerSelection)) return null; const trigger = detectComposerTrigger(flow.prompt, composerSelection.end); return trigger?.kind === "slash-command" ? trigger : null; - }, [composerSelection.end, flow.prompt]); + }, [composerSelection, flow.prompt]); const pluginCommandItems = useMemo(() => { if (pluginCommandTrigger === null) return []; return buildMobilePluginCommandItems(pluginCommands, pluginCommandTrigger.query); diff --git a/apps/mobile/src/features/threads/plugin-command-menu.test.ts b/apps/mobile/src/features/threads/plugin-command-menu.test.ts index 5cc6b4e8cd30..60599983546b 100644 --- a/apps/mobile/src/features/threads/plugin-command-menu.test.ts +++ b/apps/mobile/src/features/threads/plugin-command-menu.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildMobilePluginCommandItems, + isCollapsedComposerSelection, reconcileComposerSelectionForTextChange, } from "./plugin-command-menu"; @@ -37,3 +38,9 @@ describe("reconcileComposerSelectionForTextChange", () => { }); }); }); + +describe("isCollapsedComposerSelection", () => { + it("rejects a highlighted range", () => { + expect(isCollapsedComposerSelection({ start: 0, end: 7 })).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/plugin-command-menu.ts b/apps/mobile/src/features/threads/plugin-command-menu.ts index ebde34cd7d7b..422cc1ebe1b2 100644 --- a/apps/mobile/src/features/threads/plugin-command-menu.ts +++ b/apps/mobile/src/features/threads/plugin-command-menu.ts @@ -2,6 +2,11 @@ import type { PluginCommand } from "@t3tools/contracts"; import type { ComposerCommandItem } from "./ComposerCommandPopover"; +export const isCollapsedComposerSelection = (selection: { + readonly start: number; + readonly end: number; +}): boolean => selection.start === selection.end; + export function reconcileComposerSelectionForTextChange( selection: { readonly start: number; readonly end: number }, previousLength: number, diff --git a/apps/server/src/plugins/PluginCommandCatalog.ts b/apps/server/src/plugins/PluginCommandCatalog.ts index 4e299fecc5fe..acc1eb2ec7cc 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.ts @@ -55,8 +55,12 @@ type PluginCommandHandler = Effect.Effect< export class PluginCommandDefinitionError extends Schema.TaggedErrorClass()( "PluginCommandDefinitionError", - { message: Schema.String }, -) {} + { cause: Schema.Defect(), id: Schema.String }, +) { + override get message(): string { + return `Plugin command ${this.id} has invalid declarative metadata.`; + } +} export interface PluginCommandRegistration { readonly command: PluginCommand; @@ -108,12 +112,7 @@ const catalogFromRuntime = Effect.fn("PluginCommandCatalog.catalogFromRuntime")( const snapshot = yield* runtime.contributions(COMMAND_SLOT); const commands = yield* Effect.forEach(snapshot.entries, (entry) => decodePluginCommandEffect(commandInputFromContribution(entry)).pipe( - Effect.mapError( - () => - new PluginCommandDefinitionError({ - message: `Plugin command ${entry.id} has invalid declarative metadata.`, - }), - ), + Effect.mapError((cause) => new PluginCommandDefinitionError({ cause, id: entry.id })), ), ); const frozenCommands = commands.map((command) => @@ -197,6 +196,7 @@ export const make = Effect.gen(function* () { return new PluginCommandNotFoundError({ id: input.id }); } return new PluginCommandInvocationError({ + cause: error, id: input.id, message: "Plugin command failed.", }); diff --git a/packages/contracts/src/pluginCommands.ts b/packages/contracts/src/pluginCommands.ts index dad5681813bd..dec2081a304a 100644 --- a/packages/contracts/src/pluginCommands.ts +++ b/packages/contracts/src/pluginCommands.ts @@ -58,6 +58,7 @@ export class PluginCommandCatalogChangedError extends Schema.TaggedErrorClass()( "PluginCommandInvocationError", { + cause: Schema.Defect(), id: PluginCommandId, message: TrimmedNonEmptyString.check(Schema.isMaxLength(500)), }, From 4c1b5a69d8721aa52c278acefd8b62c81da59c40 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:04:06 +0000 Subject: [PATCH 09/45] fix: serialize plugin catalog publication Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/plugins/PluginCommandCatalog.test.ts | 81 +++++++++++++++---- .../src/plugins/PluginCommandCatalog.ts | 36 +++++---- 2 files changed, 87 insertions(+), 30 deletions(-) diff --git a/apps/server/src/plugins/PluginCommandCatalog.test.ts b/apps/server/src/plugins/PluginCommandCatalog.test.ts index 2115e2d7705f..0028e6b372e7 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.test.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.test.ts @@ -8,9 +8,7 @@ import * as Stream from "effect/Stream"; import type { PluginDefinition } from "@t3tools/plugin-runtime"; -import { PluginCommandCatalog, layer, registerPluginCommand } from "./PluginCommandCatalog.ts"; - -const TestLayer = layer; +import * as PluginCommandCatalog from "./PluginCommandCatalog.ts"; const testPlugin = (input: { readonly fail?: boolean; @@ -23,7 +21,7 @@ const testPlugin = (input: { activate(context) { if (input.fail === true) throw new Error("activation failed"); if (input.onDispose !== undefined) context.onDispose(input.onDispose); - registerPluginCommand(context, { + PluginCommandCatalog.registerPluginCommand(context, { command: { id: "acme.hello", label: "Say hello", @@ -38,7 +36,7 @@ const testPlugin = (input: { describe("plugin command catalog", () => { it.effect("lists and invokes the trusted built-in command", () => Effect.gen(function* () { - const catalog = yield* PluginCommandCatalog; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; const listed = yield* catalog.list; const streamed = yield* Stream.runHead(catalog.changes); @@ -54,12 +52,12 @@ describe("plugin command catalog", () => { id: "t3.plugin-runtime.status", }), ).toEqual({ message: "Plugin runtime is active.", tone: "success" }); - }).pipe(Effect.provide(TestLayer)), + }).pipe(Effect.provide(PluginCommandCatalog.layer)), ); it.effect("keeps the committed command and handler when replacement activation fails", () => Effect.gen(function* () { - const catalog = yield* PluginCommandCatalog; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; const first = yield* catalog.reconcile([ testPlugin({ message: "hello one", version: "1.0.0" }), ]); @@ -73,24 +71,24 @@ describe("plugin command catalog", () => { message: "hello one", tone: "success", }); - }).pipe(Effect.provide(TestLayer)), + }).pipe(Effect.provide(PluginCommandCatalog.layer)), ); it.effect("does not republish an unchanged command catalog", () => Effect.gen(function* () { - const catalog = yield* PluginCommandCatalog; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; const definition = testPlugin({ message: "hello one", version: "1.0.0" }); const first = yield* catalog.reconcile([definition]); const second = yield* catalog.reconcile([definition]); expect(second).toBe(first); - }).pipe(Effect.provide(TestLayer)), + }).pipe(Effect.provide(PluginCommandCatalog.layer)), ); it.effect("rolls back invalid command metadata before publishing a generation", () => Effect.gen(function* () { - const catalog = yield* PluginCommandCatalog; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; const first = yield* catalog.list; const invalid: PluginDefinition = { id: "acme.invalid-command-plugin", @@ -140,12 +138,67 @@ describe("plugin command catalog", () => { id: "t3.plugin-runtime.status", }), ).toEqual({ message: "Plugin runtime is active.", tone: "success" }); - }).pipe(Effect.provide(TestLayer)), + }).pipe(Effect.provide(PluginCommandCatalog.layer)), + ); + + it.effect("serializes runtime reconciliation through catalog publication", () => + Effect.gen(function* () { + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + let markActivationStarted!: () => void; + let releaseActivation!: () => void; + let markSecondActivationStarted!: () => void; + const activationStarted = new Promise((resolve) => { + markActivationStarted = resolve; + }); + const activationGate = new Promise((resolve) => { + releaseActivation = resolve; + }); + const secondActivationStarted = new Promise((resolve) => { + markSecondActivationStarted = resolve; + }); + const firstPlugin: PluginDefinition = { + id: "acme.first-command-plugin", + version: "1.0.0", + activate(context) { + markActivationStarted(); + return activationGate.then(() => { + PluginCommandCatalog.registerPluginCommand(context, { + command: { id: "acme.first", label: "First", surfaces: ["web"] }, + handler: Effect.succeed({ message: "first", tone: "success" }), + }); + }); + }, + }; + const secondPlugin: PluginDefinition = { + id: "acme.second-command-plugin", + version: "1.0.0", + activate(context) { + markSecondActivationStarted(); + PluginCommandCatalog.registerPluginCommand(context, { + command: { id: "acme.second", label: "Second", surfaces: ["web"] }, + handler: Effect.succeed({ message: "second", tone: "success" }), + }); + }, + }; + + const firstFiber = yield* Effect.forkChild(catalog.reconcile([firstPlugin])); + yield* Effect.promise(() => activationStarted); + const secondFiber = yield* Effect.forkChild(catalog.reconcile([secondPlugin])); + yield* Effect.yieldNow; + releaseActivation(); + + const first = yield* Fiber.join(firstFiber); + yield* Effect.promise(() => secondActivationStarted); + const generationSeenBySecondActivation = (yield* catalog.list).generation; + const second = yield* Fiber.join(secondFiber); + expect(generationSeenBySecondActivation).toBe(first.generation); + expect(yield* catalog.list).toBe(second); + }).pipe(Effect.provide(PluginCommandCatalog.layer)), ); it.effect("publishes a committed runtime generation before reporting interruption", () => Effect.gen(function* () { - const catalog = yield* PluginCommandCatalog; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; let markRetirementStarted!: () => void; let releaseRetirement!: () => void; const retirementStarted = new Promise((resolve) => { @@ -179,6 +232,6 @@ describe("plugin command catalog", () => { message: "hello two", tone: "success", }); - }).pipe(Effect.provide(TestLayer)), + }).pipe(Effect.provide(PluginCommandCatalog.layer)), ); }); diff --git a/apps/server/src/plugins/PluginCommandCatalog.ts b/apps/server/src/plugins/PluginCommandCatalog.ts index acc1eb2ec7cc..0a7cb4a9338a 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.ts @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import type * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -150,25 +151,28 @@ export const make = Effect.gen(function* () { commands: [], generation: 0, }); + const reconcileSemaphore = yield* Semaphore.make(1); const reconcile = Effect.fn("PluginCommandCatalog.reconcile")( (definitions: ReadonlyArray) => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const transitionExit = yield* Effect.exit( - restore(runtime.reconcile([builtInPlugin, ...definitions])), - ); - const catalog = yield* catalogFromRuntime(runtime); - const previous = yield* SubscriptionRef.get(state); - const published = - previous.generation === catalog.generation - ? previous - : yield* SubscriptionRef.set(state, catalog).pipe(Effect.as(catalog)); - if (Exit.isFailure(transitionExit)) { - return yield* Effect.failCause(transitionExit.cause); - } - return published; - }), + reconcileSemaphore.withPermits(1)( + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const transitionExit = yield* Effect.exit( + restore(runtime.reconcile([builtInPlugin, ...definitions])), + ); + const catalog = yield* catalogFromRuntime(runtime); + const previous = yield* SubscriptionRef.get(state); + const published = + previous.generation === catalog.generation + ? previous + : yield* SubscriptionRef.set(state, catalog).pipe(Effect.as(catalog)); + if (Exit.isFailure(transitionExit)) { + return yield* Effect.failCause(transitionExit.cause); + } + return published; + }), + ), ), ); From 0422cae8890305c35c8e5a25a8e105aea5290090 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:57:39 +0000 Subject: [PATCH 10/45] feat: add plugin package lifecycle Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- apps/server/src/auth/RpcAuthorization.ts | 4 + .../src/plugins/PluginPackageManager.test.ts | 558 ++++++++++++++++ .../src/plugins/PluginPackageManager.ts | 616 ++++++++++++++++++ .../provider/Layers/ProviderRegistry.test.ts | 5 + .../makeManagedServerProvider.test.ts | 1 + apps/server/src/server.test.ts | 25 + apps/server/src/serverSettings.ts | 39 +- apps/server/src/ws.ts | 27 +- examples/plugins/runtime-status/README.md | 13 + examples/plugins/runtime-status/index.mjs | 14 + .../plugins/runtime-status/t3-plugin.json | 13 + packages/contracts/src/index.ts | 1 + packages/contracts/src/pluginPackages.test.ts | 67 ++ packages/contracts/src/pluginPackages.ts | 77 +++ packages/contracts/src/rpc.ts | 37 ++ packages/contracts/src/settings.test.ts | 13 + packages/contracts/src/settings.ts | 4 + packages/plugin-runtime/src/manifest.ts | 9 +- packages/plugin-runtime/test/manifest.test.ts | 13 +- 19 files changed, 1516 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/plugins/PluginPackageManager.test.ts create mode 100644 apps/server/src/plugins/PluginPackageManager.ts create mode 100644 examples/plugins/runtime-status/README.md create mode 100644 examples/plugins/runtime-status/index.mjs create mode 100644 examples/plugins/runtime-status/t3-plugin.json create mode 100644 packages/contracts/src/pluginPackages.test.ts create mode 100644 packages/contracts/src/pluginPackages.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index ad5adc1e859c..3b13a0de651f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -52,6 +52,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.pluginCommandsList]: AuthOrchestrationReadScope, [WS_METHODS.pluginCommandsInvoke]: AuthOrchestrationOperateScope, + [WS_METHODS.pluginPackagesStatus]: AuthOrchestrationReadScope, + [WS_METHODS.pluginPackagesEnable]: AuthOrchestrationOperateScope, + [WS_METHODS.pluginPackagesDisable]: AuthOrchestrationOperateScope, + [WS_METHODS.pluginPackagesReload]: AuthOrchestrationOperateScope, [WS_METHODS.subscribePluginCommands]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, diff --git a/apps/server/src/plugins/PluginPackageManager.test.ts b/apps/server/src/plugins/PluginPackageManager.test.ts new file mode 100644 index 000000000000..c6d9c106efd6 --- /dev/null +++ b/apps/server/src/plugins/PluginPackageManager.test.ts @@ -0,0 +1,558 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ServerSettingsError } from "@t3tools/contracts"; +import { it } from "@effect/vitest"; +import { expect } from "vite-plus/test"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import { PluginManifest } from "@t3tools/plugin-runtime/manifest"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as PluginCommandCatalog from "./PluginCommandCatalog.ts"; +import * as PluginPackageManager from "./PluginPackageManager.ts"; + +const packageId = "com.acme.runtime-status"; +const commandId = "acme.runtime-status"; + +const manifest = { + manifestVersion: 1, + id: packageId, + version: "1.0.0", + apiVersion: 1, + entrypoints: { server: "./index.mjs" }, + capabilities: ["t3.commands@1"], + contributes: { commands: [commandId] }, +} as const; + +const encodeManifest = Schema.encodeSync(Schema.fromJsonString(PluginManifest)); +const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.String)); +const decodePersistedEnabledPlugins = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ enabledPluginIds: Schema.optional(Schema.Array(Schema.String)) }), + ), +); + +const pluginSource = (disposalFile: string, message = "External plugin runtime is active.") => ` +import { appendFile } from "node:fs/promises"; + +export default function activate(api) { + api.registerCommand( + { + id: "${commandId}", + label: "External runtime status", + description: "Report status from an external local plugin package.", + surfaces: ["web", "desktop", "mobile"] + }, + () => ({ message: ${encodeJsonString(message)}, tone: "success" }) + ); + api.onDispose(() => appendFile(${encodeJsonString(disposalFile)}, "disposed\\n")); +} +`; + +const pluginSourceWithHelper = ` +import { message } from "./message.mjs"; + +export default function activate(api) { + api.registerCommand( + { + id: "${commandId}", + label: "External runtime status", + surfaces: ["web", "desktop", "mobile"] + }, + () => ({ message, tone: "success" }) + ); +} +`; + +const pluginSourceWithRetirementGate = (startedSymbol: string, releaseSymbol: string) => ` +export default function activate(api) { + api.registerCommand( + { + id: "${commandId}", + label: "External runtime status", + surfaces: ["web", "desktop", "mobile"] + }, + () => ({ message: "retirement gate", tone: "success" }) + ); + api.onDispose(() => new Promise((resolve) => { + const markStarted = Reflect.get(globalThis, Symbol.for(${encodeJsonString(startedSymbol)})); + if (typeof markStarted === "function") markStarted(); + Reflect.set(globalThis, Symbol.for(${encodeJsonString(releaseSymbol)}), resolve); + })); +} +`; + +const pluginSourceWithCleanupFailure = ` +export default function activate(api) { + api.registerCommand( + { + id: "${commandId}", + label: "External runtime status", + surfaces: ["web", "desktop", "mobile"] + }, + () => ({ message: "cleanup failure", tone: "success" }) + ); + api.onDispose(() => { throw new Error("cleanup exploded"); }); +} +`; + +interface EnvironmentLayerOptions { + readonly persistenceFailures?: { remaining: number }; +} + +const makeEnvironmentLayer = (baseDir: string, options?: EnvironmentLayerOptions) => { + const configLayer = Layer.fresh(ServerConfig.layerTest(process.cwd(), baseDir)); + const liveSettingsLayer = ServerSettings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(configLayer), + ); + const persistenceFailures = options?.persistenceFailures; + const settingsLayer = + persistenceFailures === undefined + ? liveSettingsLayer + : Layer.effect( + ServerSettings.ServerSettingsService, + Effect.gen(function* () { + const live = yield* ServerSettings.ServerSettingsService; + return ServerSettings.ServerSettingsService.of({ + ...live, + setEnabledPluginIds: (ids) => + Effect.suspend(() => { + if (persistenceFailures.remaining > 0) { + persistenceFailures.remaining -= 1; + return Effect.fail( + new ServerSettingsError({ + cause: new Error("injected persistence failure"), + operation: "write-file", + settingsPath: `${baseDir}/userdata/settings.json`, + }), + ); + } + return live.setEnabledPluginIds(ids); + }), + }); + }), + ).pipe(Layer.provide(liveSettingsLayer)); + + return PluginPackageManager.layer.pipe( + Layer.provideMerge(PluginCommandCatalog.layer), + Layer.provideMerge(settingsLayer), + Layer.provideMerge(configLayer), + ); +}; + +const useEnvironment = ( + baseDir: string, + effect: Effect.Effect< + A, + E, + PluginPackageManager.PluginPackageManager | PluginCommandCatalog.PluginCommandCatalog + >, + options?: EnvironmentLayerOptions, +) => Effect.scoped(effect.pipe(Effect.provide(makeEnvironmentLayer(baseDir, options)))); + +it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { + it.effect("loads the committed external runtime-status example without rebuilding", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-example-test-", + }); + const exampleId = "com.t3code.runtime-status-example"; + const exampleCommandId = "example.runtime-status"; + yield* fileSystem.makeDirectory(`${baseDir}/userdata/plugins`, { recursive: true }); + yield* fileSystem.copy( + path.resolve(import.meta.dirname, "../../../../examples/plugins/runtime-status"), + `${baseDir}/userdata/plugins/${exampleId}`, + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + yield* manager.enable(exampleId); + const listed = yield* catalog.list; + expect( + yield* catalog.invoke({ generation: listed.generation, id: exampleCommandId }), + ).toEqual({ message: "external plugin runtime is active.", tone: "success" }); + }), + ); + }), + ); + + it.effect("discovers, enables, restarts, and cleanly disables an external package", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-test-", + }); + const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; + yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest(manifest), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + pluginSource(`${packageDirectory}/disposed.log`), + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + + expect(yield* manager.status).toMatchObject({ + packages: [{ id: packageId, enabled: false, state: "disabled" }], + }); + + expect(yield* manager.enable(packageId)).toMatchObject({ + packages: [{ id: packageId, enabled: true, state: "active" }], + }); + const listed = yield* catalog.list; + expect(listed.commands.map((command) => command.id)).toContain(commandId); + expect(yield* catalog.invoke({ generation: listed.generation, id: commandId })).toEqual({ + message: "External plugin runtime is active.", + tone: "success", + }); + }), + ); + + expect( + decodePersistedEnabledPlugins( + yield* fileSystem.readFileString(`${baseDir}/userdata/settings.json`), + ).enabledPluginIds, + ).toEqual([packageId]); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + + expect(yield* manager.status).toMatchObject({ + packages: [{ id: packageId, enabled: true, state: "active" }], + }); + expect((yield* catalog.list).commands.map((command) => command.id)).toContain(commandId); + + expect(yield* manager.disable(packageId)).toMatchObject({ + packages: [{ id: packageId, enabled: false, state: "disabled" }], + }); + expect((yield* catalog.list).commands.map((command) => command.id)).not.toContain( + commandId, + ); + }), + ); + + const persisted = yield* fileSystem.readFileString(`${baseDir}/userdata/settings.json`); + expect(decodePersistedEnabledPlugins(persisted).enabledPluginIds ?? []).toEqual([]); + expect(yield* fileSystem.readFileString(`${packageDirectory}/disposed.log`)).toBe( + "disposed\ndisposed\n", + ); + }), + ); + + it.effect("keeps the previous generation when import or activation fails during reload", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-rollback-test-", + }); + const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; + yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest(manifest), + ); + yield* fileSystem.writeFileString(`${packageDirectory}/index.mjs`, pluginSourceWithHelper); + yield* fileSystem.writeFileString( + `${packageDirectory}/message.mjs`, + 'export const message = "generation one";\n', + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + yield* manager.enable(packageId); + const committed = yield* catalog.list; + const manifestV2 = { ...manifest, version: "2.0.0" as const }; + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest(manifestV2), + ); + + yield* fileSystem.writeFileString(`${packageDirectory}/index.mjs`, "export default ("); + expect((yield* Effect.exit(manager.reload(packageId)))._tag).toBe("Failure"); + expect(yield* catalog.list).toBe(committed); + expect( + yield* catalog.invoke({ generation: committed.generation, id: commandId }), + ).toEqual({ message: "generation one", tone: "success" }); + + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + "export default function activate() { throw new Error('activation failed') }", + ); + expect((yield* Effect.exit(manager.reload(packageId)))._tag).toBe("Failure"); + expect(yield* catalog.list).toBe(committed); + expect(yield* manager.status).toMatchObject({ + packages: [{ id: packageId, version: "1.0.0", enabled: true, state: "error" }], + }); + + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + pluginSourceWithHelper, + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/message.mjs`, + 'export const message = "generation two";\n', + ); + yield* manager.reload(packageId); + expect(yield* manager.status).toMatchObject({ + packages: [{ id: packageId, version: "2.0.0", enabled: true, state: "active" }], + }); + const reloaded = yield* catalog.list; + expect(reloaded.generation).toBeGreaterThan(committed.generation); + expect(yield* catalog.invoke({ generation: reloaded.generation, id: commandId })).toEqual( + { + message: "generation two", + tone: "success", + }, + ); + }), + ); + }), + ); + + it.effect("rejects symbolic links before importing a trusted local package", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-symlink-test-", + }); + const sourceDirectory = `${baseDir}/linked-package-source`; + const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; + yield* fileSystem.makeDirectory(sourceDirectory, { recursive: true }); + yield* fileSystem.makeDirectory(`${baseDir}/userdata/plugins`, { recursive: true }); + yield* fileSystem.writeFileString( + `${sourceDirectory}/t3-plugin.json`, + encodeManifest(manifest), + ); + yield* fileSystem.writeFileString( + `${sourceDirectory}/index.mjs`, + pluginSource(`${sourceDirectory}/disposed.log`), + ); + yield* fileSystem.symlink(sourceDirectory, packageDirectory); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + expect((yield* Effect.exit(manager.enable(packageId)))._tag).toBe("Failure"); + expect((yield* catalog.list).commands.map((command) => command.id)).not.toContain( + commandId, + ); + expect(yield* manager.status).toMatchObject({ + packages: [{ id: packageId, enabled: false, state: "error" }], + }); + }), + ); + }), + ); + + it.effect("keeps runtime and persisted enablement aligned when settings writes fail", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-persistence-test-", + }); + const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; + yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest(manifest), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + pluginSource(`${packageDirectory}/disposed.log`), + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + expect((yield* Effect.exit(manager.enable(packageId)))._tag).toBe("Failure"); + expect((yield* catalog.list).commands.map((command) => command.id)).not.toContain( + commandId, + ); + expect(yield* manager.status).toMatchObject({ + packages: [{ id: packageId, enabled: false }], + }); + }), + { persistenceFailures: { remaining: 1 } }, + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + yield* manager.enable(packageId); + }), + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + expect((yield* Effect.exit(manager.disable(packageId)))._tag).toBe("Failure"); + expect((yield* catalog.list).commands.map((command) => command.id)).toContain(commandId); + expect(yield* manager.status).toMatchObject({ + packages: [{ id: packageId, enabled: true, state: "active" }], + }); + }), + { persistenceFailures: { remaining: 1 } }, + ); + }), + ); + + it.effect("reports an invalid local manifest without blocking the package service", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-invalid-test-", + }); + const invalidDirectory = `${baseDir}/userdata/plugins/broken-package`; + yield* fileSystem.makeDirectory(invalidDirectory, { recursive: true }); + yield* fileSystem.writeFileString(`${invalidDirectory}/t3-plugin.json`, "{}"); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + expect(yield* manager.status).toMatchObject({ + errors: [{ directory: "broken-package" }], + packages: [], + }); + }), + ); + }), + ); + + it.effect("reports cleanup failures after disabling the committed package", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-cleanup-test-", + }); + const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; + yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest(manifest), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + pluginSourceWithCleanupFailure, + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + yield* manager.enable(packageId); + expect(yield* manager.disable(packageId)).toMatchObject({ + packages: [ + { + id: packageId, + enabled: false, + state: "error", + error: "cleanup exploded", + }, + ], + }); + expect((yield* catalog.list).commands.map((command) => command.id)).not.toContain( + commandId, + ); + }), + ); + }), + ); + + it.effect("finishes disable bookkeeping when interrupted after the runtime commits", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-interruption-test-", + }); + const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; + const startedSymbol = `t3.test.plugin.retirement.started.${baseDir}`; + const releaseSymbol = `t3.test.plugin.retirement.${baseDir}`; + let markRetirementStarted!: () => void; + const retirementStarted = new Promise((resolve) => { + markRetirementStarted = resolve; + }); + Reflect.set(globalThis, Symbol.for(startedSymbol), markRetirementStarted); + yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest(manifest), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + pluginSourceWithRetirementGate(startedSymbol, releaseSymbol), + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + yield* manager.enable(packageId); + const disabling = yield* Effect.forkChild(manager.disable(packageId)); + yield* Effect.promise(() => retirementStarted); + + const interrupting = yield* Effect.forkChild(Fiber.interrupt(disabling)); + yield* Effect.yieldNow; + const release = Reflect.get(globalThis, Symbol.for(releaseSymbol)); + expect(release).toBeTypeOf("function"); + if (typeof release === "function") release(); + yield* Fiber.join(interrupting); + expect( + yield* fileSystem.exists(`${baseDir}/userdata/plugin-cache/${packageId}/0`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("1 millis"), + until: (exists) => !exists, + }), + Effect.timeout("2 seconds"), + ), + ).toBe(false); + Reflect.deleteProperty(globalThis, Symbol.for(startedSymbol)); + Reflect.deleteProperty(globalThis, Symbol.for(releaseSymbol)); + + expect(yield* manager.status).toMatchObject({ + packages: [{ id: packageId, enabled: false, state: "disabled" }], + }); + expect((yield* catalog.list).commands.map((command) => command.id)).not.toContain( + commandId, + ); + }), + ); + const persisted = yield* fileSystem.readFileString(`${baseDir}/userdata/settings.json`); + expect(decodePersistedEnabledPlugins(persisted).enabledPluginIds ?? []).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts new file mode 100644 index 000000000000..d0c1fe035248 --- /dev/null +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -0,0 +1,616 @@ +import * as NodeURL from "node:url"; + +import { + PluginCommandInvocationResult, + PluginPackageNotFoundError, + PluginPackageOperationError, + type PluginPackageDiscoveryError, + type PluginPackageOperation, + type PluginPackageStatus, + type PluginPackageStatusSnapshot, +} from "@t3tools/contracts"; +import type { PluginActivationContext, PluginDefinition } from "@t3tools/plugin-runtime"; +import { + PluginManifest, + type PluginManifest as PluginManifestType, +} from "@t3tools/plugin-runtime/manifest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as PluginCommandCatalog from "./PluginCommandCatalog.ts"; + +const MANIFEST_FILE_NAME = "t3-plugin.json"; +const COMMAND_CAPABILITY = "t3.commands@1"; + +interface DiscoveredPackage { + readonly directory: string; + readonly manifest: PluginManifestType; +} + +interface DiscoveryResult { + readonly errors: ReadonlyArray; + readonly packages: ReadonlyMap; +} + +interface LoadedDefinition { + readonly cacheDirectory: string; + readonly definition: PluginDefinition; + readonly retired: Promise; +} + +export interface PluginPackageApi { + readonly onDispose: (cleanup: () => void | Promise) => void; + readonly registerCommand: ( + command: { + readonly id: string; + readonly label: string; + readonly description?: string; + readonly surfaces: ReadonlyArray<"web" | "desktop" | "mobile">; + }, + handler: () => unknown | Promise, + ) => void; +} + +type PluginPackageActivator = (api: PluginPackageApi) => void | Promise; + +const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); +const decodeInvocationResult = Schema.decodeUnknownEffect(PluginCommandInvocationResult); + +const detailFromUnknown = (error: unknown): string => { + const detail = error instanceof Error ? error.message : String(error); + const trimmed = detail.trim(); + return (trimmed.length === 0 ? "unknown error" : trimmed).slice(0, 2_000); +}; + +const operationError = ( + operation: PluginPackageOperation, + error: unknown, + id?: string, +): PluginPackageOperationError => + new PluginPackageOperationError({ + ...(id === undefined ? {} : { id }), + operation, + detail: detailFromUnknown(error), + }); + +const makeDefinition = ( + discovered: DiscoveredPackage, + activatePackage: PluginPackageActivator, + onRetired: () => void, + onCleanupError: (error: unknown) => void, +): PluginDefinition => { + const declaredCommands = new Set(discovered.manifest.contributes?.commands ?? []); + + return { + id: discovered.manifest.id, + version: discovered.manifest.version, + activate(context: PluginActivationContext) { + context.onDispose(onRetired); + const api: PluginPackageApi = { + onDispose(cleanup) { + context.onDispose(async () => { + try { + await cleanup(); + } catch (error) { + onCleanupError(error); + throw error; + } + }); + }, + registerCommand(command, handler) { + if (!discovered.manifest.capabilities.includes(COMMAND_CAPABILITY)) { + throw new Error(`Manifest does not declare capability ${COMMAND_CAPABILITY}`); + } + if (!declaredCommands.has(command.id)) { + throw new Error(`Command ${command.id} is not declared in the manifest`); + } + PluginCommandCatalog.registerPluginCommand(context, { + command, + handler: Effect.tryPromise({ + try: async () => handler(), + catch: (cause) => new PluginCommandCatalog.PluginCommandExecutionError({ cause }), + }).pipe( + Effect.flatMap((result) => + decodeInvocationResult(result).pipe( + Effect.mapError( + (cause) => new PluginCommandCatalog.PluginCommandExecutionError({ cause }), + ), + ), + ), + ), + }); + }, + }; + return activatePackage(api); + }, + }; +}; + +export const make = Effect.fn("PluginPackageManager.make")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const settings = yield* ServerSettings.ServerSettingsService; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + const semaphore = yield* Semaphore.make(1); + const pluginsDirectory = path.join(config.stateDir, "plugins"); + const pluginCacheDirectory = path.join(config.stateDir, "plugin-cache"); + const activeDefinitions = new Map(); + const activeCacheDirectories = new Map(); + const activeManifests = new Map(); + const activeRetirements = new Map>(); + const packageErrors = new Map(); + let loadSequence = 0; + + const removeCacheDirectory = (directory: string) => + fileSystem + .remove(directory, { recursive: true, force: true }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to remove local plugin package cache", { directory, error }), + ), + ); + + const validatePackageTree = Effect.fn("PluginPackageManager.validatePackageTree")(function* ( + discovered: DiscoveredPackage, + operation: PluginPackageOperation, + ) { + const canonicalPluginsDirectory = yield* fileSystem + .realPath(pluginsDirectory) + .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id))); + const relativeRoot = path.relative(pluginsDirectory, discovered.directory); + const pending: Array = [ + [discovered.directory, path.resolve(canonicalPluginsDirectory, relativeRoot)], + ]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) continue; + const [lexical, expectedCanonical] = current; + const canonical = yield* fileSystem + .realPath(lexical) + .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id))); + if (path.normalize(canonical) !== path.normalize(expectedCanonical)) { + return yield* operationError( + operation, + "symbolic links are not supported in trusted local plugin packages", + discovered.manifest.id, + ); + } + const info = yield* fileSystem + .stat(lexical) + .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id))); + if (info.type !== "Directory") continue; + const entries = yield* fileSystem + .readDirectory(lexical) + .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id))); + for (const entry of entries) { + pending.push([path.join(lexical, entry), path.join(expectedCanonical, entry)]); + } + } + }); + + const discover = Effect.fn("PluginPackageManager.discover")(function* ( + operation: PluginPackageOperation, + ) { + yield* fileSystem + .makeDirectory(pluginsDirectory, { recursive: true }) + .pipe(Effect.mapError((error) => operationError(operation, error))); + const entries = yield* fileSystem + .readDirectory(pluginsDirectory) + .pipe(Effect.mapError((error) => operationError(operation, error))); + const discovered = new Map(); + const errors: Array = []; + + for (const entry of [...entries].sort()) { + const directory = path.join(pluginsDirectory, entry); + const manifestPath = path.join(directory, MANIFEST_FILE_NAME); + if ( + !(yield* fileSystem + .exists(manifestPath) + .pipe(Effect.mapError((error) => operationError(operation, error)))) + ) + continue; + + const decoded = yield* Effect.exit( + fileSystem.readFileString(manifestPath).pipe(Effect.flatMap(decodeManifestJson)), + ); + if (decoded._tag === "Failure") { + errors.push({ directory: entry, error: detailFromUnknown(decoded.cause) }); + continue; + } + const packageManifest = decoded.value; + if (packageManifest.entrypoints.server === undefined) { + errors.push({ directory: entry, error: "manifest must define entrypoints.server" }); + continue; + } + if (discovered.has(packageManifest.id)) { + errors.push({ directory: entry, error: `duplicate package id ${packageManifest.id}` }); + continue; + } + discovered.set(packageManifest.id, { directory, manifest: packageManifest }); + } + + return { errors, packages: discovered } satisfies DiscoveryResult; + }); + + const loadDefinition = Effect.fn("PluginPackageManager.loadDefinition")(function* ( + discovered: DiscoveredPackage, + operation: PluginPackageOperation, + ) { + const serverEntrypoint = discovered.manifest.entrypoints.server; + if (serverEntrypoint === undefined) { + return yield* operationError( + operation, + "manifest must define entrypoints.server", + discovered.manifest.id, + ); + } + yield* validatePackageTree(discovered, operation); + const sourceEntrypointPath = path.resolve(discovered.directory, serverEntrypoint); + const relativeEntrypoint = path.relative(discovered.directory, sourceEntrypointPath); + if ( + relativeEntrypoint === ".." || + relativeEntrypoint.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeEntrypoint) + ) { + return yield* operationError( + operation, + "entrypoint escapes the package directory", + discovered.manifest.id, + ); + } + + const cacheDirectory = path.join( + pluginCacheDirectory, + discovered.manifest.id, + String(loadSequence++), + ); + yield* fileSystem + .makeDirectory(path.dirname(cacheDirectory), { recursive: true }) + .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id))); + const copied = yield* Effect.exit( + fileSystem + .copy(discovered.directory, cacheDirectory) + .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id))), + ); + if (copied._tag === "Failure") { + yield* removeCacheDirectory(cacheDirectory); + return yield* Effect.failCause(copied.cause); + } + const entrypointPath = path.resolve(cacheDirectory, serverEntrypoint); + + const loaded = yield* Effect.exit( + Effect.gen(function* () { + const moduleUrl = NodeURL.pathToFileURL(entrypointPath); + const module = yield* Effect.tryPromise({ + try: () => import(/* @vite-ignore */ moduleUrl.href) as Promise>, + catch: (cause) => operationError(operation, cause, discovered.manifest.id), + }); + if (typeof module.default !== "function") { + return yield* operationError( + operation, + "server entrypoint must export a default activation function", + discovered.manifest.id, + ); + } + return module.default as PluginPackageActivator; + }), + ); + if (loaded._tag === "Failure") { + yield* removeCacheDirectory(cacheDirectory); + return yield* Effect.failCause(loaded.cause); + } + + let markRetired: () => void = () => {}; + const retired = new Promise((resolve) => { + markRetired = resolve; + }); + return { + cacheDirectory, + definition: makeDefinition(discovered, loaded.value, markRetired, (error) => { + packageErrors.set(discovered.manifest.id, detailFromUnknown(error)); + }), + retired, + } satisfies LoadedDefinition; + }); + + const definitionList = (replacement?: readonly [string, PluginDefinition | undefined]) => { + const definitions = new Map(activeDefinitions); + if (replacement !== undefined) { + const [id, definition] = replacement; + if (definition === undefined) definitions.delete(id); + else definitions.set(id, definition); + } + return [...definitions.values()].sort((left, right) => left.id.localeCompare(right.id)); + }; + + const readEnabledIds = settings.getSettings.pipe( + Effect.map((current) => new Set(current.enabledPluginIds)), + ); + + const persistEnabledIds = ( + ids: ReadonlySet, + operation: PluginPackageOperation, + id?: string, + ) => + settings.setEnabledPluginIds([...ids].sort()).pipe( + Effect.mapError((error) => operationError(operation, error, id)), + Effect.asVoid, + ); + + const statusUnlocked = Effect.fn("PluginPackageManager.status")(function* ( + operation: PluginPackageOperation, + ): Effect.fn.Return { + const [discovery, enabledIds] = yield* Effect.all([discover(operation), readEnabledIds], { + concurrency: "unbounded", + }).pipe(Effect.mapError((error) => operationError(operation, error))); + const discovered = discovery.packages; + const errors = [...discovery.errors]; + const packages: Array = []; + const packageIds = new Set([...discovered.keys(), ...activeManifests.keys()]); + + for (const id of [...packageIds].sort()) { + const activeManifest = activeManifests.get(id); + const packageManifest = activeManifest ?? discovered.get(id)?.manifest; + if (packageManifest === undefined) continue; + const enabled = enabledIds.has(id); + const active = activeDefinitions.has(id); + const error = + packageErrors.get(id) ?? (enabled && !active ? "enabled package is not active" : undefined); + packages.push({ + id: packageManifest.id, + version: packageManifest.version, + apiVersion: packageManifest.apiVersion, + enabled, + state: error !== undefined ? "error" : active ? "active" : "disabled", + capabilities: [...packageManifest.capabilities], + contributions: { commands: [...(packageManifest.contributes?.commands ?? [])] }, + ...(error === undefined ? {} : { error }), + }); + } + + for (const id of [...enabledIds].sort()) { + if (!discovered.has(id)) { + errors.push({ directory: id, error: "enabled package was not discovered" }); + } + } + + return { errors, packages }; + }); + + const transition = Effect.fn("PluginPackageManager.transition")( + (operation: "enable" | "reload", id: string) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const discovery = yield* restore(discover(operation)); + const pluginPackage = discovery.packages.get(id); + if (pluginPackage === undefined) return yield* new PluginPackageNotFoundError({ id }); + const enabledIds = yield* restore( + readEnabledIds.pipe(Effect.mapError((error) => operationError(operation, error, id))), + ); + if (operation === "reload" && !enabledIds.has(id)) { + return yield* operationError(operation, "package is not enabled", id); + } + if (operation === "enable" && activeDefinitions.has(id) && enabledIds.has(id)) { + packageErrors.delete(id); + return yield* statusUnlocked(operation); + } + packageErrors.delete(id); + + const previousEnabledIds = new Set(enabledIds); + const previousCacheDirectory = activeCacheDirectories.get(id); + const previousRetirement = activeRetirements.get(id); + const loaded = yield* restore(loadDefinition(pluginPackage, operation)); + if (operation === "enable") { + enabledIds.add(id); + const persisted = yield* Effect.exit(persistEnabledIds(enabledIds, operation, id)); + if (persisted._tag === "Failure") { + yield* removeCacheDirectory(loaded.cacheDirectory); + return yield* Effect.failCause(persisted.cause); + } + } + const previousCatalog = yield* catalog.list; + const reconciled = yield* Effect.exit( + restore( + catalog + .reconcile(definitionList([id, loaded.definition])) + .pipe(Effect.mapError((error) => operationError(operation, error, id))), + ), + ); + if (reconciled._tag === "Failure") { + const currentCatalog = yield* catalog.list; + if (currentCatalog.generation === previousCatalog.generation) { + if (operation === "enable") { + const rolledBack = yield* Effect.exit( + persistEnabledIds(previousEnabledIds, operation, id), + ); + if (rolledBack._tag === "Failure") { + yield* removeCacheDirectory(loaded.cacheDirectory); + return yield* operationError( + operation, + `${detailFromUnknown(reconciled.cause)}; failed to restore enabled package settings: ${detailFromUnknown(rolledBack.cause)}`, + id, + ); + } + } + yield* removeCacheDirectory(loaded.cacheDirectory); + return yield* Effect.failCause(reconciled.cause); + } + } + + activeDefinitions.set(id, loaded.definition); + activeCacheDirectories.set(id, loaded.cacheDirectory); + activeManifests.set(id, pluginPackage.manifest); + activeRetirements.set(id, loaded.retired); + if (previousCacheDirectory !== undefined) { + if (reconciled._tag === "Failure" && previousRetirement !== undefined) { + yield* Effect.promise(() => previousRetirement); + } + yield* removeCacheDirectory(previousCacheDirectory); + } + if (reconciled._tag === "Failure") return yield* Effect.failCause(reconciled.cause); + return yield* statusUnlocked(operation); + }), + ), + ); + + const disableUnlocked = Effect.fn("PluginPackageManager.disable")((id: string) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const discovery = yield* restore(discover("disable")); + const enabledIds = yield* restore( + readEnabledIds.pipe(Effect.mapError((error) => operationError("disable", error, id))), + ); + if (!discovery.packages.has(id) && !enabledIds.has(id) && !activeDefinitions.has(id)) { + return yield* new PluginPackageNotFoundError({ id }); + } + packageErrors.delete(id); + + const previousEnabledIds = new Set(enabledIds); + enabledIds.delete(id); + const persisted = yield* Effect.exit(persistEnabledIds(enabledIds, "disable", id)); + if (persisted._tag === "Failure") return yield* Effect.failCause(persisted.cause); + const previousCatalog = yield* catalog.list; + const reconciled = yield* Effect.exit( + restore( + catalog + .reconcile(definitionList([id, undefined])) + .pipe(Effect.mapError((error) => operationError("disable", error, id))), + ), + ); + if (reconciled._tag === "Failure") { + const currentCatalog = yield* catalog.list; + if (currentCatalog.generation === previousCatalog.generation) { + const rolledBack = yield* Effect.exit( + persistEnabledIds(previousEnabledIds, "disable", id), + ); + if (rolledBack._tag === "Failure") { + return yield* operationError( + "disable", + `${detailFromUnknown(reconciled.cause)}; failed to restore enabled package settings: ${detailFromUnknown(rolledBack.cause)}`, + id, + ); + } + return yield* Effect.failCause(reconciled.cause); + } + } + + activeDefinitions.delete(id); + activeManifests.delete(id); + const cacheDirectory = activeCacheDirectories.get(id); + const retirement = activeRetirements.get(id); + activeCacheDirectories.delete(id); + activeRetirements.delete(id); + if (cacheDirectory !== undefined) { + if (reconciled._tag === "Failure" && retirement !== undefined) { + yield* Effect.promise(() => retirement); + } + yield* removeCacheDirectory(cacheDirectory); + } + if (reconciled._tag === "Failure") return yield* Effect.failCause(reconciled.cause); + return yield* statusUnlocked("disable"); + }), + ), + ); + + yield* settings.start.pipe(Effect.mapError((error) => operationError("status", error))); + yield* fileSystem + .remove(pluginCacheDirectory, { recursive: true, force: true }) + .pipe(Effect.mapError((error) => operationError("status", error))); + yield* fileSystem + .makeDirectory(pluginCacheDirectory, { recursive: true }) + .pipe(Effect.mapError((error) => operationError("status", error))); + yield* fileSystem + .makeDirectory(pluginsDirectory, { recursive: true }) + .pipe(Effect.mapError((error) => operationError("status", error))); + + const startupDiscovery = yield* discover("status"); + for (const error of startupDiscovery.errors) { + yield* Effect.logWarning("Invalid local plugin package", error); + } + const startupEnabledIds = yield* readEnabledIds.pipe( + Effect.mapError((error) => operationError("status", error)), + ); + for (const id of [...startupEnabledIds].sort()) { + const pluginPackage = startupDiscovery.packages.get(id); + if (pluginPackage === undefined) { + yield* Effect.logWarning("Enabled local plugin package was not discovered", { id }); + continue; + } + const startup = yield* Effect.exit( + Effect.gen(function* () { + const loaded = yield* loadDefinition(pluginPackage, "status"); + const reconciled = yield* Effect.exit( + catalog + .reconcile(definitionList([id, loaded.definition])) + .pipe(Effect.mapError((error) => operationError("status", error, id))), + ); + if (reconciled._tag === "Failure") { + yield* removeCacheDirectory(loaded.cacheDirectory); + return yield* Effect.failCause(reconciled.cause); + } + activeDefinitions.set(id, loaded.definition); + activeCacheDirectories.set(id, loaded.cacheDirectory); + activeManifests.set(id, pluginPackage.manifest); + activeRetirements.set(id, loaded.retired); + }), + ); + if (startup._tag === "Failure") { + const detail = detailFromUnknown(startup.cause); + packageErrors.set(id, detail); + yield* Effect.logWarning("Failed to activate enabled local plugin package", { id, detail }); + } + } + + yield* Effect.addFinalizer(() => + semaphore.withPermits(1)( + Effect.gen(function* () { + const shutdown = yield* Effect.exit(catalog.reconcile([])); + if (shutdown._tag === "Failure") { + yield* Effect.logWarning("Failed to retire local plugin packages during shutdown", { + error: detailFromUnknown(shutdown.cause), + }); + } + for (const [id, error] of packageErrors) { + yield* Effect.logWarning("Local plugin package reported a shutdown error", { id, error }); + } + yield* removeCacheDirectory(pluginCacheDirectory); + }), + ), + ); + + return { + status: semaphore.withPermits(1)(statusUnlocked("status")), + enable: (id: string) => + semaphore.withPermits(1)( + transition("enable", id).pipe( + Effect.tapError((error) => + Effect.sync(() => packageErrors.set(id, detailFromUnknown(error))), + ), + ), + ), + disable: (id: string) => semaphore.withPermits(1)(disableUnlocked(id)), + reload: (id: string) => + semaphore.withPermits(1)( + transition("reload", id).pipe( + Effect.tapError((error) => + Effect.sync(() => packageErrors.set(id, detailFromUnknown(error))), + ), + ), + ), + } as const; +}); + +export class PluginPackageManager extends Context.Service< + PluginPackageManager, + Effect.Success> +>()("t3/plugins/PluginPackageManager") {} + +export const layer = Layer.effect(PluginPackageManager, make()); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9a72ea83d3c0..8656198b261f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -324,6 +324,11 @@ function makeMutableServerSettingsService( yield* PubSub.publish(changes, next); return next; }), + setEnabledPluginIds: (ids) => + Ref.updateAndGet(settingsRef, (current) => ({ + ...current, + enabledPluginIds: [...ids], + })), get streamChanges() { return Stream.fromPubSub(changes); }, diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 5bfd3e14cfd7..e638c6bf3e10 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -266,6 +266,7 @@ describe("makeManagedServerProvider", () => { ready: Effect.void, getSettings: Ref.get(serverSettingsRef), updateSettings: () => Effect.die(new Error("unused in this test")), + setEnabledPluginIds: () => Effect.die(new Error("unused in this test")), streamChanges: Stream.empty, subscribeChanges: PubSub.subscribe(serverSettingsChanges).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c94982291951..585c9598008b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4636,6 +4636,31 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes plugin package status and lifecycle errors over websocket rpc", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const status = yield* client[WS_METHODS.pluginPackagesStatus]({}); + const missing = yield* Effect.flip( + client[WS_METHODS.pluginPackagesEnable]({ id: "com.acme.missing" }), + ); + return { missing, status }; + }), + ), + ); + + assert.deepEqual(result.status, { errors: [], packages: [] }); + assert.deepInclude(result.missing, { + _tag: "PluginPackageNotFoundError", + id: "com.acme.missing", + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => Effect.gen(function* () { const nextProviders = [ diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 1bf37335271b..8b150fc44e8c 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -16,6 +16,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, type ModelSelection, + type PluginPackageId, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, ProviderDriverKind, @@ -178,6 +179,11 @@ export class ServerSettingsService extends Context.Service< patch: ServerSettingsPatch, ) => Effect.Effect; + /** Replace the internal environment-scoped plugin enablement set. */ + readonly setEnabledPluginIds: ( + ids: ReadonlyArray, + ) => Effect.Effect; + /** Stream of settings change events. */ readonly streamChanges: Stream.Stream; @@ -220,6 +226,11 @@ const makeTest = (overrides: DeepPartial = {}) => Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), Effect.map(resolveTextGenerationProvider), ), + setEnabledPluginIds: (ids) => + Ref.updateAndGet(currentSettingsRef, (current) => ({ + ...current, + enabledPluginIds: [...ids], + })).pipe(Effect.map(resolveTextGenerationProvider)), streamChanges: Stream.empty, subscribeChanges: Effect.succeed(Stream.empty), } satisfies ServerSettingsService["Service"]; @@ -617,21 +628,12 @@ const make = Effect.gen(function* () { yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); }); - return { - start, - ready: Deferred.await(startedDeferred), - getSettings: getSettingsFromCache.pipe( - Effect.flatMap(materializeProviderEnvironmentSecrets), - Effect.map(resolveTextGenerationProvider), - ), - updateSettings: (patch) => + const mutateSettings = Effect.fn("ServerSettings.mutateSettings")( + (mutate: (current: ServerSettings) => ServerSettings) => writeSemaphore.withPermits(1)( Effect.gen(function* () { const current = yield* getSettingsFromCache; - const nextPersisted = yield* persistProviderEnvironmentSecrets( - current, - applyServerSettingsPatch(current, patch), - ); + const nextPersisted = yield* persistProviderEnvironmentSecrets(current, mutate(current)); const next = yield* normalizeServerSettings(nextPersisted); yield* writeSettingsAtomically(next); yield* Cache.set(settingsCache, cacheKey, next); @@ -640,6 +642,19 @@ const make = Effect.gen(function* () { return resolveTextGenerationProvider(materialized); }), ), + ); + + return { + start, + ready: Deferred.await(startedDeferred), + getSettings: getSettingsFromCache.pipe( + Effect.flatMap(materializeProviderEnvironmentSecrets), + Effect.map(resolveTextGenerationProvider), + ), + updateSettings: (patch) => + mutateSettings((current) => applyServerSettingsPatch(current, patch)), + setEnabledPluginIds: (ids) => + mutateSettings((current) => ({ ...current, enabledPluginIds: [...ids] })), get streamChanges() { return materializeChanges(Stream.fromPubSub(changesPubSub)); }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a82975680ceb..f702acf5e272 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -66,6 +66,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import * as PluginCommandCatalog from "./plugins/PluginCommandCatalog.ts"; +import * as PluginPackageManager from "./plugins/PluginPackageManager.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { projectActivityEvent, @@ -354,6 +355,7 @@ const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], pluginCommands: PluginCommandCatalog.PluginCommandCatalog["Service"], + pluginPackages: PluginPackageManager.PluginPackageManager["Service"], ) => WsRpcGroup.toLayer( Effect.gen(function* () { @@ -1455,6 +1457,22 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pluginCommandsInvoke, pluginCommands.invoke(input), { "rpc.aggregate": "pluginCommands", }), + [WS_METHODS.pluginPackagesStatus]: (_input) => + observeRpcEffect(WS_METHODS.pluginPackagesStatus, pluginPackages.status, { + "rpc.aggregate": "pluginPackages", + }), + [WS_METHODS.pluginPackagesEnable]: (input) => + observeRpcEffect(WS_METHODS.pluginPackagesEnable, pluginPackages.enable(input.id), { + "rpc.aggregate": "pluginPackages", + }), + [WS_METHODS.pluginPackagesDisable]: (input) => + observeRpcEffect(WS_METHODS.pluginPackagesDisable, pluginPackages.disable(input.id), { + "rpc.aggregate": "pluginPackages", + }), + [WS_METHODS.pluginPackagesReload]: (input) => + observeRpcEffect(WS_METHODS.pluginPackagesReload, pluginPackages.reload(input.id), { + "rpc.aggregate": "pluginPackages", + }), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, @@ -2316,6 +2334,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const pluginCommands = yield* PluginCommandCatalog.PluginCommandCatalog; + const pluginPackages = yield* PluginPackageManager.PluginPackageManager; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; return HttpRouter.add( @@ -2337,7 +2356,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session, previewAutomationBroker, pluginCommands).pipe( + makeWsRpcLayer(session, previewAutomationBroker, pluginCommands, pluginPackages).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), @@ -2381,4 +2400,8 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), ); }), -).pipe(Layer.provide(PluginCommandCatalog.layer.pipe(Layer.orDie))); +).pipe( + Layer.provide( + PluginPackageManager.layer.pipe(Layer.provideMerge(PluginCommandCatalog.layer), Layer.orDie), + ), +); diff --git a/examples/plugins/runtime-status/README.md b/examples/plugins/runtime-status/README.md new file mode 100644 index 000000000000..c6303c30471a --- /dev/null +++ b/examples/plugins/runtime-status/README.md @@ -0,0 +1,13 @@ +# runtime status example plugin + +this is the minimal trusted local plugin package used to prove the package lifecycle. plugins run in the server process with the server's full permissions, so only install code you trust. + +copy this directory to: + +```text +~/.t3/userdata/plugins/com.t3code.runtime-status-example +``` + +t3 code discovers the package without a rebuild. use `pluginPackages.status` to inspect it and `pluginPackages.enable` with the manifest id to enable it for that environment. `pluginPackages.reload` re-evaluates the entrypoint, and `pluginPackages.disable` removes its contributions. once enabled, `example.runtime-status` appears on web, desktop, and mobile command surfaces. + +local packages run in the server process and are fully trusted. marketplace distribution, signing, sandboxing, and renderer code are not part of this mvp. diff --git a/examples/plugins/runtime-status/index.mjs b/examples/plugins/runtime-status/index.mjs new file mode 100644 index 000000000000..2a387328b331 --- /dev/null +++ b/examples/plugins/runtime-status/index.mjs @@ -0,0 +1,14 @@ +export default function activate(api) { + api.registerCommand( + { + id: "example.runtime-status", + label: "external runtime status", + description: "report status from an external local plugin package.", + surfaces: ["web", "desktop", "mobile"], + }, + () => ({ + message: "external plugin runtime is active.", + tone: "success", + }), + ); +} diff --git a/examples/plugins/runtime-status/t3-plugin.json b/examples/plugins/runtime-status/t3-plugin.json new file mode 100644 index 000000000000..1a770791d620 --- /dev/null +++ b/examples/plugins/runtime-status/t3-plugin.json @@ -0,0 +1,13 @@ +{ + "manifestVersion": 1, + "id": "com.t3code.runtime-status-example", + "version": "1.0.0", + "apiVersion": 1, + "entrypoints": { + "server": "./index.mjs" + }, + "capabilities": ["t3.commands@1"], + "contributes": { + "commands": ["example.runtime-status"] + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 48d921596fb5..a5e5a24703eb 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -20,6 +20,7 @@ export * from "./vcs.ts"; export * from "./sourceControl.ts"; export * from "./pullRequest.ts"; export * from "./pluginCommands.ts"; +export * from "./pluginPackages.ts"; export * from "./orchestration.ts"; export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; diff --git a/packages/contracts/src/pluginPackages.test.ts b/packages/contracts/src/pluginPackages.test.ts new file mode 100644 index 000000000000..8cac2dc4733b --- /dev/null +++ b/packages/contracts/src/pluginPackages.test.ts @@ -0,0 +1,67 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { PluginPackageActionInput, PluginPackageStatusSnapshot } from "./pluginPackages.ts"; +import { WS_METHODS, WsRpcGroup } from "./rpc.ts"; + +const decodeStatus = Schema.decodeUnknownSync(PluginPackageStatusSnapshot); +const decodeAction = Schema.decodeUnknownSync(PluginPackageActionInput); + +describe("plugin package contracts", () => { + it("decodes environment package status", () => { + expect( + decodeStatus({ + errors: [], + packages: [ + { + id: "com.acme.runtime-status", + version: "1.0.0", + apiVersion: 1, + enabled: true, + state: "active", + capabilities: ["t3.commands@1"], + contributions: { commands: ["acme.runtime-status"] }, + }, + ], + }), + ).toEqual({ + errors: [], + packages: [ + { + id: "com.acme.runtime-status", + version: "1.0.0", + apiVersion: 1, + enabled: true, + state: "active", + capabilities: ["t3.commands@1"], + contributions: { commands: ["acme.runtime-status"] }, + }, + ], + }); + }); + + it("reports invalid discovered package directories without inventing an id", () => { + expect( + decodeStatus({ + errors: [{ directory: "broken-package", error: "manifest api version is unsupported" }], + packages: [], + }), + ).toEqual({ + errors: [{ directory: "broken-package", error: "manifest api version is unsupported" }], + packages: [], + }); + }); + + it("rejects malformed package ids and action payloads", () => { + expect(() => decodeAction({ id: "runtime-status" })).toThrow(); + expect(() => decodeAction({ id: `com.${"a".repeat(252)}` })).toThrow(); + expect(() => decodeAction({ id: "com.acme.runtime-status", extra: true })).toThrow(); + }); + + it("registers fixed status and lifecycle rpc methods", () => { + expect(WsRpcGroup.requests.has(WS_METHODS.pluginPackagesStatus)).toBe(true); + expect(WsRpcGroup.requests.has(WS_METHODS.pluginPackagesEnable)).toBe(true); + expect(WsRpcGroup.requests.has(WS_METHODS.pluginPackagesDisable)).toBe(true); + expect(WsRpcGroup.requests.has(WS_METHODS.pluginPackagesReload)).toBe(true); + }); +}); diff --git a/packages/contracts/src/pluginPackages.ts b/packages/contracts/src/pluginPackages.ts new file mode 100644 index 000000000000..723141ce3299 --- /dev/null +++ b/packages/contracts/src/pluginPackages.ts @@ -0,0 +1,77 @@ +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const PluginPackageId = Schema.String.check( + Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/), + Schema.isMaxLength(255), +); +export type PluginPackageId = typeof PluginPackageId.Type; + +export const PluginPackageCapability = Schema.String.check( + Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@[1-9]\d*$/), +); +export type PluginPackageCapability = typeof PluginPackageCapability.Type; + +export const PluginPackageState = Schema.Literals(["disabled", "active", "error"]); +export type PluginPackageState = typeof PluginPackageState.Type; + +export const PluginPackageContributions = Schema.Struct({ + commands: Schema.Array(PluginPackageId), +}); +export type PluginPackageContributions = typeof PluginPackageContributions.Type; + +export const PluginPackageStatus = Schema.Struct({ + id: PluginPackageId, + version: TrimmedNonEmptyString, + apiVersion: Schema.Literal(1), + enabled: Schema.Boolean, + state: PluginPackageState, + capabilities: Schema.Array(PluginPackageCapability), + contributions: PluginPackageContributions, + error: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(2_000))), +}); +export type PluginPackageStatus = typeof PluginPackageStatus.Type; + +export const PluginPackageDiscoveryError = Schema.Struct({ + directory: TrimmedNonEmptyString.check(Schema.isMaxLength(255), Schema.isPattern(/^[^/\\]+$/)), + error: TrimmedNonEmptyString.check(Schema.isMaxLength(2_000)), +}); +export type PluginPackageDiscoveryError = typeof PluginPackageDiscoveryError.Type; + +export const PluginPackageStatusSnapshot = Schema.Struct({ + errors: Schema.Array(PluginPackageDiscoveryError), + packages: Schema.Array(PluginPackageStatus), +}); +export type PluginPackageStatusSnapshot = typeof PluginPackageStatusSnapshot.Type; + +export const PluginPackageActionInput = Schema.Struct({ + id: PluginPackageId, +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +export type PluginPackageActionInput = typeof PluginPackageActionInput.Type; + +export const PluginPackageOperation = Schema.Literals(["status", "enable", "disable", "reload"]); +export type PluginPackageOperation = typeof PluginPackageOperation.Type; + +export class PluginPackageNotFoundError extends Schema.TaggedErrorClass()( + "PluginPackageNotFoundError", + { id: PluginPackageId }, +) { + override get message(): string { + return `Plugin package not found: ${this.id}`; + } +} + +export class PluginPackageOperationError extends Schema.TaggedErrorClass()( + "PluginPackageOperationError", + { + id: Schema.optional(PluginPackageId), + operation: PluginPackageOperation, + detail: TrimmedNonEmptyString.check(Schema.isMaxLength(2_000)), + }, +) { + override get message(): string { + const packageName = this.id === undefined ? "plugin packages" : `plugin package ${this.id}`; + return `${this.operation} failed for ${packageName}: ${this.detail}`; + } +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ae48e7cbd22b..babbef28e321 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -75,6 +75,12 @@ import { PluginCommandInvokeInput, PluginCommandNotFoundError, } from "./pluginCommands.ts"; +import { + PluginPackageActionInput, + PluginPackageNotFoundError, + PluginPackageOperationError, + PluginPackageStatusSnapshot, +} from "./pluginPackages.ts"; import { PullRequestActionInput, PullRequestActivity, @@ -286,6 +292,12 @@ export const WS_METHODS = { pluginCommandsList: "pluginCommands.list", pluginCommandsInvoke: "pluginCommands.invoke", + // Plugin package lifecycle methods + pluginPackagesStatus: "pluginPackages.status", + pluginPackagesEnable: "pluginPackages.enable", + pluginPackagesDisable: "pluginPackages.disable", + pluginPackagesReload: "pluginPackages.reload", + // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -369,6 +381,27 @@ export const WsPluginCommandsInvokeRpc = Rpc.make(WS_METHODS.pluginCommandsInvok ]), }); +export const WsPluginPackagesStatusRpc = Rpc.make(WS_METHODS.pluginPackagesStatus, { + payload: Schema.Struct({}), + success: PluginPackageStatusSnapshot, + error: Schema.Union([PluginPackageOperationError, EnvironmentAuthorizationError]), +}); + +const pluginPackageActionRpc = (method: Method) => + Rpc.make(method, { + payload: PluginPackageActionInput, + success: PluginPackageStatusSnapshot, + error: Schema.Union([ + PluginPackageNotFoundError, + PluginPackageOperationError, + EnvironmentAuthorizationError, + ]), + }); + +export const WsPluginPackagesEnableRpc = pluginPackageActionRpc(WS_METHODS.pluginPackagesEnable); +export const WsPluginPackagesDisableRpc = pluginPackageActionRpc(WS_METHODS.pluginPackagesDisable); +export const WsPluginPackagesReloadRpc = pluginPackageActionRpc(WS_METHODS.pluginPackagesReload); + export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** @@ -1024,6 +1057,10 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsPluginCommandsListRpc, WsPluginCommandsInvokeRpc, + WsPluginPackagesStatusRpc, + WsPluginPackagesEnableRpc, + WsPluginPackagesDisableRpc, + WsPluginPackagesReloadRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 46a4d25ac303..06718f935e73 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -231,6 +231,19 @@ describe("ServerSettings worktree defaults", () => { }); }); +describe("ServerSettings enabled plugin packages", () => { + it("defaults to no enabled packages without exposing lifecycle state in public patches", () => { + expect(decodeServerSettings({}).enabledPluginIds).toEqual([]); + expect( + decodeServerSettings({ enabledPluginIds: ["com.acme.runtime-status"] }).enabledPluginIds, + ).toEqual(["com.acme.runtime-status"]); + expect(() => decodeServerSettings({ enabledPluginIds: ["runtime-status"] })).toThrow(); + expect(decodeServerSettingsPatch({ enabledPluginIds: ["com.acme.runtime-status"] })).toEqual( + {}, + ); + }); +}); + describe("ServerSettings.sourceControlWritingStyle", () => { it("defaults all style settings for legacy configs", () => { const settings = decodeServerSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 96ee5b85c05a..3d1ff3dbaa97 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -10,6 +10,7 @@ import { ProviderOptionSelections, } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; +import { PluginPackageId } from "./pluginPackages.ts"; import { DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, @@ -628,6 +629,9 @@ export const ServerSettings = Schema.Struct({ newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + enabledPluginIds: Schema.Array(PluginPackageId).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( diff --git a/packages/plugin-runtime/src/manifest.ts b/packages/plugin-runtime/src/manifest.ts index 47421d2e3c46..fcdcbf2dd97d 100644 --- a/packages/plugin-runtime/src/manifest.ts +++ b/packages/plugin-runtime/src/manifest.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; const NamespacedId = Schema.String.check( Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/), + Schema.isMaxLength(255), ); const SemanticVersion = Schema.String.check( @@ -25,22 +26,22 @@ const ContributionCatalog = Schema.Struct({ }); export const PluginManifest = Schema.Struct({ + manifestVersion: Schema.Literal(1), id: NamespacedId, version: SemanticVersion, - engines: Schema.Struct({ - t3: Schema.String.check(Schema.isNonEmpty()), - }), + apiVersion: Schema.Literal(1), surfaces: Schema.optional(Schema.Array(Schema.Literals(["web", "desktop", "mobile"]))), entrypoints: Schema.Struct({ server: Schema.optional(RelativeEntrypoint), web: Schema.optional(RelativeEntrypoint), desktop: Schema.optional(RelativeEntrypoint), }).annotate({ parseOptions: { onExcessProperty: "error" } }), + capabilities: Schema.Array(CapabilityId), requires: Schema.optional(Schema.Array(CapabilityId)), optional: Schema.optional(Schema.Array(CapabilityId)), provides: Schema.optional(Schema.Array(CapabilityId)), permissions: Schema.optional(Schema.Array(Permission)), contributes: ContributionCatalog, -}); +}).annotate({ parseOptions: { onExcessProperty: "error" } }); export type PluginManifest = typeof PluginManifest.Type; diff --git a/packages/plugin-runtime/test/manifest.test.ts b/packages/plugin-runtime/test/manifest.test.ts index 98380a5b7732..88d52433ea67 100644 --- a/packages/plugin-runtime/test/manifest.test.ts +++ b/packages/plugin-runtime/test/manifest.test.ts @@ -6,13 +6,15 @@ import { PluginManifest } from "../src/manifest.ts"; const decodeManifest = Schema.decodeUnknownSync(PluginManifest); const validManifest = { + manifestVersion: 1, id: "com.acme.linear", version: "1.2.0", - engines: { t3: "^0.1.0" }, + apiVersion: 1, entrypoints: { server: "./dist/server.js", web: "./dist/web.js", }, + capabilities: ["t3.commands@1"], requires: ["t3.commands@1", "t3.secrets@1"], provides: ["com.acme.linear@1"], permissions: ["network:https://api.linear.app", "secrets:linear-token"], @@ -24,12 +26,19 @@ const validManifest = { }; describe("PluginManifest", () => { - it("decodes a namespaced multi-surface plugin manifest", () => { + it("decodes a versioned namespaced multi-surface plugin manifest", () => { expect(decodeManifest(validManifest)).toEqual(validManifest); }); + it("rejects unsupported manifest and api versions", () => { + expect(() => decodeManifest({ ...validManifest, manifestVersion: 2 })).toThrow(); + expect(() => decodeManifest({ ...validManifest, apiVersion: 2 })).toThrow(); + expect(() => decodeManifest({ ...validManifest, engines: { t3: "^0.1.0" } })).toThrow(); + }); + it("rejects unnamespaced plugin and contribution ids", () => { expect(() => decodeManifest({ ...validManifest, id: "linear" })).toThrow(); + expect(() => decodeManifest({ ...validManifest, id: `com.${"a".repeat(252)}` })).toThrow(); expect(() => decodeManifest({ ...validManifest, From 69e4b122bc37fdd094ce17a3918da0b22467e378 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:07:27 +0000 Subject: [PATCH 11/45] fix: align plugin lifecycle effect errors --- .../src/plugins/PluginPackageManager.ts | 49 ++++++++++++++----- packages/contracts/src/pluginPackages.test.ts | 20 +++++++- packages/contracts/src/pluginPackages.ts | 6 ++- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index d0c1fe035248..fe9f67053bb9 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -62,6 +62,7 @@ type PluginPackageActivator = (api: PluginPackageApi) => void | Promise; const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); const decodeInvocationResult = Schema.decodeUnknownEffect(PluginCommandInvocationResult); +const isPluginPackageOperationError = Schema.is(PluginPackageOperationError); const detailFromUnknown = (error: unknown): string => { const detail = error instanceof Error ? error.message : String(error); @@ -73,12 +74,14 @@ const operationError = ( operation: PluginPackageOperation, error: unknown, id?: string, -): PluginPackageOperationError => - new PluginPackageOperationError({ +): PluginPackageOperationError => { + if (isPluginPackageOperationError(error)) return error; + return new PluginPackageOperationError({ ...(id === undefined ? {} : { id }), operation, - detail: detailFromUnknown(error), + ...(typeof error === "string" ? { detail: error } : { cause: error }), }); +}; const makeDefinition = ( discovered: DiscoveredPackage, @@ -133,6 +136,31 @@ const makeDefinition = ( }; }; +export class PluginPackageManager extends Context.Service< + PluginPackageManager, + { + readonly status: Effect.Effect; + readonly enable: ( + id: string, + ) => Effect.Effect< + PluginPackageStatusSnapshot, + PluginPackageNotFoundError | PluginPackageOperationError + >; + readonly disable: ( + id: string, + ) => Effect.Effect< + PluginPackageStatusSnapshot, + PluginPackageNotFoundError | PluginPackageOperationError + >; + readonly reload: ( + id: string, + ) => Effect.Effect< + PluginPackageStatusSnapshot, + PluginPackageNotFoundError | PluginPackageOperationError + >; + } +>()("t3/plugins/PluginPackageManager") {} + export const make = Effect.fn("PluginPackageManager.make")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -348,9 +376,13 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { const statusUnlocked = Effect.fn("PluginPackageManager.status")(function* ( operation: PluginPackageOperation, ): Effect.fn.Return { - const [discovery, enabledIds] = yield* Effect.all([discover(operation), readEnabledIds], { - concurrency: "unbounded", - }).pipe(Effect.mapError((error) => operationError(operation, error))); + const [discovery, enabledIds] = yield* Effect.all( + [ + discover(operation), + readEnabledIds.pipe(Effect.mapError((error) => operationError(operation, error))), + ], + { concurrency: "unbounded" }, + ); const discovered = discovery.packages; const errors = [...discovery.errors]; const packages: Array = []; @@ -608,9 +640,4 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { } as const; }); -export class PluginPackageManager extends Context.Service< - PluginPackageManager, - Effect.Success> ->()("t3/plugins/PluginPackageManager") {} - export const layer = Layer.effect(PluginPackageManager, make()); diff --git a/packages/contracts/src/pluginPackages.test.ts b/packages/contracts/src/pluginPackages.test.ts index 8cac2dc4733b..9a883b0a0a7b 100644 --- a/packages/contracts/src/pluginPackages.test.ts +++ b/packages/contracts/src/pluginPackages.test.ts @@ -1,7 +1,11 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { PluginPackageActionInput, PluginPackageStatusSnapshot } from "./pluginPackages.ts"; +import { + PluginPackageActionInput, + PluginPackageOperationError, + PluginPackageStatusSnapshot, +} from "./pluginPackages.ts"; import { WS_METHODS, WsRpcGroup } from "./rpc.ts"; const decodeStatus = Schema.decodeUnknownSync(PluginPackageStatusSnapshot); @@ -58,6 +62,20 @@ describe("plugin package contracts", () => { expect(() => decodeAction({ id: "com.acme.runtime-status", extra: true })).toThrow(); }); + it("preserves operation causes without putting failure text in the stable message", () => { + const cause = new Error("disk exploded"); + const error = new PluginPackageOperationError({ cause, operation: "enable" }); + expect(error.cause).toBe(cause); + expect(error.message).toBe("enable failed for plugin packages"); + expect( + new PluginPackageOperationError({ + detail: "package is not enabled", + id: "com.acme.runtime-status", + operation: "reload", + }).message, + ).toBe("reload failed for plugin package com.acme.runtime-status: package is not enabled"); + }); + it("registers fixed status and lifecycle rpc methods", () => { expect(WsRpcGroup.requests.has(WS_METHODS.pluginPackagesStatus)).toBe(true); expect(WsRpcGroup.requests.has(WS_METHODS.pluginPackagesEnable)).toBe(true); diff --git a/packages/contracts/src/pluginPackages.ts b/packages/contracts/src/pluginPackages.ts index 723141ce3299..45d6eece3eb1 100644 --- a/packages/contracts/src/pluginPackages.ts +++ b/packages/contracts/src/pluginPackages.ts @@ -67,11 +67,13 @@ export class PluginPackageOperationError extends Schema.TaggedErrorClass Date: Thu, 20 Aug 2026 00:19:21 +0000 Subject: [PATCH 12/45] fix: keep plugin status failures scoped --- .../src/plugins/PluginPackageManager.test.ts | 15 ++++- .../src/plugins/PluginPackageManager.ts | 61 ++++++++++--------- 2 files changed, 47 insertions(+), 29 deletions(-) diff --git a/apps/server/src/plugins/PluginPackageManager.test.ts b/apps/server/src/plugins/PluginPackageManager.test.ts index c6d9c106efd6..f8f88fde74b8 100644 --- a/apps/server/src/plugins/PluginPackageManager.test.ts +++ b/apps/server/src/plugins/PluginPackageManager.test.ts @@ -211,6 +211,7 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { const manager = yield* PluginPackageManager.PluginPackageManager; const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + expect((yield* Effect.exit(manager.reload(packageId)))._tag).toBe("Failure"); expect(yield* manager.status).toMatchObject({ packages: [{ id: packageId, enabled: false, state: "disabled" }], }); @@ -306,7 +307,19 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { expect((yield* Effect.exit(manager.reload(packageId)))._tag).toBe("Failure"); expect(yield* catalog.list).toBe(committed); expect(yield* manager.status).toMatchObject({ - packages: [{ id: packageId, version: "1.0.0", enabled: true, state: "error" }], + packages: [ + { + id: packageId, + version: "1.0.0", + enabled: true, + state: "error", + error: "activation failed", + }, + ], + }); + yield* manager.enable(packageId); + expect(yield* manager.status).toMatchObject({ + packages: [{ id: packageId, state: "error", error: "activation failed" }], }); yield* fileSystem.writeFileString( diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index fe9f67053bb9..21d0a128f118 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -14,6 +14,7 @@ import { PluginManifest, type PluginManifest as PluginManifestType, } from "@t3tools/plugin-runtime/manifest"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -65,11 +66,22 @@ const decodeInvocationResult = Schema.decodeUnknownEffect(PluginCommandInvocatio const isPluginPackageOperationError = Schema.is(PluginPackageOperationError); const detailFromUnknown = (error: unknown): string => { + if (isPluginPackageOperationError(error)) { + if (error.detail !== undefined) return error.detail; + if (error.cause !== undefined) return detailFromUnknown(error.cause); + } + if (typeof error === "object" && error !== null && "cause" in error) { + const cause = error.cause; + if (cause !== undefined && cause !== error) return detailFromUnknown(cause); + } const detail = error instanceof Error ? error.message : String(error); const trimmed = detail.trim(); return (trimmed.length === 0 ? "unknown error" : trimmed).slice(0, 2_000); }; +const detailFromCause = (cause: Cause.Cause): string => + detailFromUnknown(Cause.squash(cause)); + const operationError = ( operation: PluginPackageOperation, error: unknown, @@ -431,7 +443,6 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { return yield* operationError(operation, "package is not enabled", id); } if (operation === "enable" && activeDefinitions.has(id) && enabledIds.has(id)) { - packageErrors.delete(id); return yield* statusUnlocked(operation); } packageErrors.delete(id); @@ -439,11 +450,17 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { const previousEnabledIds = new Set(enabledIds); const previousCacheDirectory = activeCacheDirectories.get(id); const previousRetirement = activeRetirements.get(id); - const loaded = yield* restore(loadDefinition(pluginPackage, operation)); + const loadedExit = yield* Effect.exit(restore(loadDefinition(pluginPackage, operation))); + if (loadedExit._tag === "Failure") { + packageErrors.set(id, detailFromCause(loadedExit.cause)); + return yield* Effect.failCause(loadedExit.cause); + } + const loaded = loadedExit.value; if (operation === "enable") { enabledIds.add(id); const persisted = yield* Effect.exit(persistEnabledIds(enabledIds, operation, id)); if (persisted._tag === "Failure") { + packageErrors.set(id, detailFromCause(persisted.cause)); yield* removeCacheDirectory(loaded.cacheDirectory); return yield* Effect.failCause(persisted.cause); } @@ -464,14 +481,16 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { persistEnabledIds(previousEnabledIds, operation, id), ); if (rolledBack._tag === "Failure") { + packageErrors.set(id, detailFromCause(reconciled.cause)); yield* removeCacheDirectory(loaded.cacheDirectory); - return yield* operationError( - operation, - `${detailFromUnknown(reconciled.cause)}; failed to restore enabled package settings: ${detailFromUnknown(rolledBack.cause)}`, + yield* Effect.logWarning("Failed to restore enabled package settings", { id, - ); + error: rolledBack.cause, + }); + return yield* Effect.failCause(reconciled.cause); } } + packageErrors.set(id, detailFromCause(reconciled.cause)); yield* removeCacheDirectory(loaded.cacheDirectory); return yield* Effect.failCause(reconciled.cause); } @@ -524,11 +543,11 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { persistEnabledIds(previousEnabledIds, "disable", id), ); if (rolledBack._tag === "Failure") { - return yield* operationError( - "disable", - `${detailFromUnknown(reconciled.cause)}; failed to restore enabled package settings: ${detailFromUnknown(rolledBack.cause)}`, + yield* Effect.logWarning("Failed to restore enabled package settings", { id, - ); + error: rolledBack.cause, + }); + return yield* Effect.failCause(reconciled.cause); } return yield* Effect.failCause(reconciled.cause); } @@ -595,7 +614,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { }), ); if (startup._tag === "Failure") { - const detail = detailFromUnknown(startup.cause); + const detail = detailFromCause(startup.cause); packageErrors.set(id, detail); yield* Effect.logWarning("Failed to activate enabled local plugin package", { id, detail }); } @@ -607,7 +626,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { const shutdown = yield* Effect.exit(catalog.reconcile([])); if (shutdown._tag === "Failure") { yield* Effect.logWarning("Failed to retire local plugin packages during shutdown", { - error: detailFromUnknown(shutdown.cause), + error: detailFromCause(shutdown.cause), }); } for (const [id, error] of packageErrors) { @@ -620,23 +639,9 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { return { status: semaphore.withPermits(1)(statusUnlocked("status")), - enable: (id: string) => - semaphore.withPermits(1)( - transition("enable", id).pipe( - Effect.tapError((error) => - Effect.sync(() => packageErrors.set(id, detailFromUnknown(error))), - ), - ), - ), + enable: (id: string) => semaphore.withPermits(1)(transition("enable", id)), disable: (id: string) => semaphore.withPermits(1)(disableUnlocked(id)), - reload: (id: string) => - semaphore.withPermits(1)( - transition("reload", id).pipe( - Effect.tapError((error) => - Effect.sync(() => packageErrors.set(id, detailFromUnknown(error))), - ), - ), - ), + reload: (id: string) => semaphore.withPermits(1)(transition("reload", id)), } as const; }); From 0ebe3fe9c8bdeb580fbbbbd6e019b2dd7d45f16a Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:24:32 +0000 Subject: [PATCH 13/45] chore: rerun flaky ci From 7809007b3cac23f6703ec959982efece98a5902b Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:29:17 +0000 Subject: [PATCH 14/45] refactor: defer mobile plugin commands Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../threads/ComposerCommandPopover.tsx | 14 +--- .../features/threads/NewTaskDraftScreen.tsx | 74 +------------------ .../src/features/threads/ThreadComposer.tsx | 34 +-------- .../threads/plugin-command-menu.test.ts | 46 ------------ .../features/threads/plugin-command-menu.ts | 44 ----------- .../threads/use-mobile-plugin-commands.ts | 57 -------------- 6 files changed, 7 insertions(+), 262 deletions(-) delete mode 100644 apps/mobile/src/features/threads/plugin-command-menu.test.ts delete mode 100644 apps/mobile/src/features/threads/plugin-command-menu.ts delete mode 100644 apps/mobile/src/features/threads/use-mobile-plugin-commands.ts diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index f8dad3a476d6..0eea51719521 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -1,9 +1,5 @@ import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; -import type { - PluginCommand, - ServerProviderSkill, - ServerProviderSlashCommand, -} from "@t3tools/contracts"; +import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { memo } from "react"; import { Pressable, ScrollView, View, type ViewStyle } from "react-native"; @@ -41,13 +37,6 @@ export type ComposerCommandItem = readonly skill: ServerProviderSkill; readonly label: string; readonly description: string; - } - | { - readonly id: string; - readonly type: "plugin-command"; - readonly command: PluginCommand; - readonly label: string; - readonly description: string; }; interface ComposerCommandPopoverProps { @@ -76,7 +65,6 @@ function itemIcon(item: ComposerCommandItem) { switch (item.type) { case "slash-command": case "provider-slash-command": - case "plugin-command": return "terminal" as const; case "skill": return "cube" as const; diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 912f5c923900..8f5beb69c938 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -5,7 +5,7 @@ import { useNavigation, usePreventRemove, } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { KeyboardController, @@ -22,11 +22,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { - ComposerEditor, - type ComposerEditorHandle, - type ComposerEditorSelection, -} from "../../components/ComposerEditor"; +import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { ComposerInlineControl, ComposerToolbarButton, @@ -70,14 +66,6 @@ import { resolveNewTaskWorkspaceLabel, } from "./new-task-context-presentation"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; -import { detectComposerTrigger, replaceTextRange } from "@t3tools/shared/composerTrigger"; -import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; -import { - buildMobilePluginCommandItems, - isCollapsedComposerSelection, - reconcileComposerSelectionForTextChange, -} from "./plugin-command-menu"; -import { useMobilePluginCommands } from "./use-mobile-plugin-commands"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; @@ -138,49 +126,6 @@ export function NewTaskDraftScreen(props: { const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); - const [composerSelection, setComposerSelection] = useState(() => ({ - start: flow.prompt.length, - end: flow.prompt.length, - })); - const previousPromptLengthRef = useRef(flow.prompt.length); - const { commands: pluginCommands, execute: executePluginCommand } = useMobilePluginCommands( - selectedProject?.environmentId ?? null, - ); - const pluginCommandTrigger = useMemo(() => { - if (!isCollapsedComposerSelection(composerSelection)) return null; - const trigger = detectComposerTrigger(flow.prompt, composerSelection.end); - return trigger?.kind === "slash-command" ? trigger : null; - }, [composerSelection, flow.prompt]); - const pluginCommandItems = useMemo(() => { - if (pluginCommandTrigger === null) return []; - return buildMobilePluginCommandItems(pluginCommands, pluginCommandTrigger.query); - }, [pluginCommandTrigger, pluginCommands]); - const handleComposerSelectionChange = useCallback((selection: ComposerEditorSelection) => { - setComposerSelection(selection); - }, []); - const handlePluginCommandSelect = useCallback( - (item: ComposerCommandItem) => { - if (item.type !== "plugin-command" || pluginCommandTrigger === null) return; - const result = replaceTextRange( - flow.prompt, - pluginCommandTrigger.rangeStart, - pluginCommandTrigger.rangeEnd, - "", - ); - setComposerSelection({ start: result.cursor, end: result.cursor }); - flow.setPrompt(result.text); - void executePluginCommand(item.command); - }, - [executePluginCommand, flow, pluginCommandTrigger], - ); - useEffect(() => { - const end = flow.prompt.length; - const previousEnd = previousPromptLengthRef.current; - previousPromptLengthRef.current = end; - setComposerSelection((selection) => - reconcileComposerSelectionForTextChange(selection, previousEnd, end), - ); - }, [flow.prompt.length]); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: promptInputRef, isEditorFocused: isComposerFocused, @@ -872,9 +817,7 @@ export function NewTaskDraftScreen(props: { scrollEnabled value={flow.prompt} skills={flow.selectedProviderSkills} - selection={composerSelection} onChangeText={flow.setPrompt} - onSelectionChange={handleComposerSelectionChange} onFocus={() => setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} @@ -1012,20 +955,9 @@ export function NewTaskDraftScreen(props: { ); const composerDock = ( - + {workspaceControls} - {pluginCommandTrigger !== null && pluginCommandItems.length > 0 ? ( - - - - ) : null} - ({ @@ -424,8 +418,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ]; const builtIn = allBuiltIn.filter((item) => item.command.includes(q)); - const pluginCommandItems = buildMobilePluginCommandItems(pluginCommands, q); - const providerCommands: ComposerCommandItem[] = []; for (const cmd of selectedProviderStatus?.slashCommands ?? []) { if (!cmd.name.toLowerCase().includes(q)) continue; @@ -438,7 +430,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); } - return [...builtIn, ...pluginCommandItems, ...providerCommands]; + return [...builtIn, ...providerCommands]; } if (composerTrigger.kind === "skill") { @@ -539,7 +531,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, pluginCommands, selectedProviderStatus]); + }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; @@ -589,20 +581,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer return; } - if (item.type === "plugin-command") { - const command = item.command; - const result = replaceTextRange( - draftMessage, - composerTrigger.rangeStart, - composerTrigger.rangeEnd, - "", - ); - setComposerSelection({ start: result.cursor, end: result.cursor }); - onChangeDraftMessage(result.text); - void executePluginCommand(command); - return; - } - let replacement = ""; if (item.type === "path") { replacement = `${serializeComposerFileLink(item.path)} `; @@ -623,13 +601,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); }, - [ - composerTrigger, - draftMessage, - executePluginCommand, - onChangeDraftMessage, - onUpdateInteractionMode, - ], + [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], ); // ── Model menu ─────────────────────────────────────────── diff --git a/apps/mobile/src/features/threads/plugin-command-menu.test.ts b/apps/mobile/src/features/threads/plugin-command-menu.test.ts deleted file mode 100644 index 60599983546b..000000000000 --- a/apps/mobile/src/features/threads/plugin-command-menu.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - buildMobilePluginCommandItems, - isCollapsedComposerSelection, - reconcileComposerSelectionForTextChange, -} from "./plugin-command-menu"; - -describe("buildMobilePluginCommandItems", () => { - it("renders only matching mobile command contributions", () => { - const items = buildMobilePluginCommandItems( - [ - { - id: "plugin.mobile-status", - label: "Check status", - description: "Verify the runtime", - surfaces: ["mobile"], - }, - { - id: "plugin.web-only", - label: "Web only", - surfaces: ["web"], - }, - ], - "status", - ); - - expect(items.map((item) => item.id)).toEqual(["plugin-command:plugin.mobile-status"]); - expect(items[0]?.type).toBe("plugin-command"); - }); -}); - -describe("reconcileComposerSelectionForTextChange", () => { - it("moves an end-positioned caret after prompt hydration", () => { - expect(reconcileComposerSelectionForTextChange({ start: 0, end: 0 }, 0, 14)).toEqual({ - start: 14, - end: 14, - }); - }); -}); - -describe("isCollapsedComposerSelection", () => { - it("rejects a highlighted range", () => { - expect(isCollapsedComposerSelection({ start: 0, end: 7 })).toBe(false); - }); -}); diff --git a/apps/mobile/src/features/threads/plugin-command-menu.ts b/apps/mobile/src/features/threads/plugin-command-menu.ts deleted file mode 100644 index 422cc1ebe1b2..000000000000 --- a/apps/mobile/src/features/threads/plugin-command-menu.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { PluginCommand } from "@t3tools/contracts"; - -import type { ComposerCommandItem } from "./ComposerCommandPopover"; - -export const isCollapsedComposerSelection = (selection: { - readonly start: number; - readonly end: number; -}): boolean => selection.start === selection.end; - -export function reconcileComposerSelectionForTextChange( - selection: { readonly start: number; readonly end: number }, - previousLength: number, - nextLength: number, -): { readonly start: number; readonly end: number } { - if (selection.start === previousLength && selection.end === previousLength) { - return { start: nextLength, end: nextLength }; - } - return { - start: Math.min(selection.start, nextLength), - end: Math.min(selection.end, nextLength), - }; -} - -export function buildMobilePluginCommandItems( - commands: ReadonlyArray, - query: string, -): ComposerCommandItem[] { - const normalizedQuery = query.toLowerCase(); - return commands - .filter( - (command) => - command.surfaces.includes("mobile") && - `${command.label} ${command.description ?? ""} ${command.id}` - .toLowerCase() - .includes(normalizedQuery), - ) - .map((command) => ({ - id: `plugin-command:${command.id}`, - type: "plugin-command" as const, - command, - label: command.label, - description: command.description ?? "Plugin command", - })); -} diff --git a/apps/mobile/src/features/threads/use-mobile-plugin-commands.ts b/apps/mobile/src/features/threads/use-mobile-plugin-commands.ts deleted file mode 100644 index eefcbe96e750..000000000000 --- a/apps/mobile/src/features/threads/use-mobile-plugin-commands.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, PluginCommand, PluginCommandCatalog } from "@t3tools/contracts"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import * as Option from "effect/Option"; -import { useCallback } from "react"; -import { Alert } from "react-native"; - -import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; - -const EMPTY_PLUGIN_COMMAND_CATALOG: PluginCommandCatalog = { commands: [], generation: 0 }; -const EMPTY_PLUGIN_COMMAND_CATALOG_ATOM = Atom.make( - AsyncResult.success(EMPTY_PLUGIN_COMMAND_CATALOG), -); - -export function useMobilePluginCommands(environmentId: EnvironmentId | null) { - const catalogResult = useAtomValue( - environmentId === null - ? EMPTY_PLUGIN_COMMAND_CATALOG_ATOM - : serverEnvironment.pluginCommands({ environmentId, input: {} }), - ); - const catalog = - Option.getOrNull(AsyncResult.value(catalogResult)) ?? EMPTY_PLUGIN_COMMAND_CATALOG; - const invokePluginCommand = useAtomCommand(serverEnvironment.invokePluginCommand, { - reportFailure: false, - reportDefect: false, - }); - const execute = useCallback( - async (command: PluginCommand): Promise => { - if (environmentId === null) return; - const result = await invokePluginCommand({ - environmentId, - input: { generation: catalog.generation, id: command.id }, - }); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - Alert.alert( - "Command failed", - error instanceof Error ? error.message : "The plugin command could not be completed.", - ); - return; - } - Alert.alert(command.label, result.value.message); - }, - [catalog.generation, environmentId, invokePluginCommand], - ); - - return { - commands: catalog.commands, - execute, - } as const; -} From 890e002c053deb17fcf0b8726813b51a17415916 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:41:27 +0000 Subject: [PATCH 15/45] feat: add plugin management settings Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../settings/PluginsSettings.test.tsx | 245 +++++++++++++++ .../components/settings/PluginsSettings.tsx | 288 ++++++++++++++++++ .../settings/SettingsSidebarNav.tsx | 2 + .../src/components/settings/settingsSearch.ts | 7 + apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/settings.plugins.tsx | 11 + packages/client-runtime/src/state/server.ts | 24 ++ 7 files changed, 598 insertions(+) create mode 100644 apps/web/src/components/settings/PluginsSettings.test.tsx create mode 100644 apps/web/src/components/settings/PluginsSettings.tsx create mode 100644 apps/web/src/routes/settings.plugins.tsx diff --git a/apps/web/src/components/settings/PluginsSettings.test.tsx b/apps/web/src/components/settings/PluginsSettings.test.tsx new file mode 100644 index 000000000000..cfdcd112e269 --- /dev/null +++ b/apps/web/src/components/settings/PluginsSettings.test.tsx @@ -0,0 +1,245 @@ +import type { ReactElement } from "react"; +import { EnvironmentId, type PluginPackageStatusSnapshot } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { visitElements } from "../../test/reactElementTree"; +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +const environmentId = EnvironmentId.make("primary"); + +const atoms = vi.hoisted(() => ({ + status: Symbol("pluginPackagesStatus"), + enable: Symbol("pluginPackagesEnable"), + disable: Symbol("pluginPackagesDisable"), + reload: Symbol("pluginPackagesReload"), +})); + +const query = vi.hoisted(() => ({ + data: null as PluginPackageStatusSnapshot | null, + error: null as string | null, + isPending: false, + refresh: vi.fn(), +})); + +const commands = vi.hoisted(() => ({ + enable: vi.fn(), + disable: vi.fn(), + reload: vi.fn(), +})); + +const access = vi.hoisted(() => ({ + value: "granted" as "granted" | "denied" | "pending", +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useMemo: reactHookHarness.useMemo, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("../../state/environments", () => ({ + usePrimaryEnvironmentId: () => environmentId, +})); + +vi.mock("../../environments/primary", () => ({ + usePrimarySessionState: () => ({ data: null, error: null, isPending: false }), +})); + +vi.mock("../../env", () => ({ isElectron: false })); + +vi.mock("./ProviderSettingsPanel.logic", () => ({ + resolvePrimaryOperateAccess: () => access.value, +})); + +vi.mock("../../state/query", () => ({ + useEnvironmentQuery: () => query, +})); + +vi.mock("../../state/server", () => ({ + serverEnvironment: { + pluginPackages: () => atoms.status, + enablePluginPackage: atoms.enable, + disablePluginPackage: atoms.disable, + reloadPluginPackage: atoms.reload, + }, +})); + +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (command: symbol) => { + if (command === atoms.enable) return commands.enable; + if (command === atoms.disable) return commands.disable; + return commands.reload; + }, +})); + +import { PluginsSettingsPanel } from "./PluginsSettings"; + +const snapshot: PluginPackageStatusSnapshot = { + errors: [{ directory: "broken-package", error: "manifest is invalid" }], + packages: [ + { + id: "com.acme.active", + version: "1.2.3", + apiVersion: 1, + enabled: true, + state: "active", + capabilities: ["t3.commands@1"], + contributions: { commands: ["acme.active.run"] }, + }, + { + id: "com.acme.disabled", + version: "2.0.0", + apiVersion: 1, + enabled: false, + state: "disabled", + capabilities: ["t3.commands@1"], + contributions: { commands: [] }, + }, + ], +}; + +function renderPanel(): ReactElement> { + hooks.beginRender(); + return PluginsSettingsPanel() as ReactElement>; +} + +function renderPackageRow( + panel: ReactElement>, + id: string, +): ReactElement> { + const row = visitElements( + panel, + (element) => (element.props.pluginPackage as { readonly id?: string } | undefined)?.id === id, + ); + expect(row).not.toBeNull(); + const render = row?.type as ( + props: Record, + ) => ReactElement>; + return render(row?.props ?? {}); +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("PluginsSettingsPanel", () => { + beforeEach(() => { + hooks.reset(); + access.value = "granted"; + query.data = snapshot; + query.error = null; + query.isPending = false; + query.refresh.mockReset(); + commands.enable.mockReset().mockResolvedValue({ _tag: "Success", value: snapshot }); + commands.disable.mockReset().mockResolvedValue({ _tag: "Success", value: snapshot }); + commands.reload.mockReset().mockResolvedValue({ _tag: "Success", value: snapshot }); + }); + + it("shows package state, package errors, and environment-scoped actions", () => { + const panel = renderPanel(); + const activeRow = renderPackageRow(panel, "com.acme.active"); + const disabledRow = renderPackageRow(panel, "com.acme.disabled"); + + expect( + visitElements( + activeRow, + (element) => element.props["aria-label"] === "Disable com.acme.active", + ), + ).not.toBeNull(); + expect( + visitElements( + disabledRow, + (element) => element.props["aria-label"] === "Enable com.acme.disabled", + ), + ).not.toBeNull(); + expect( + visitElements( + activeRow, + (element) => element.props["aria-label"] === "Reload com.acme.active", + ), + ).not.toBeNull(); + expect( + visitElements(panel, (element) => element.props["data-plugin-error"] === "broken-package"), + ).not.toBeNull(); + }); + + it("routes disable, enable, reload, and refresh to the primary environment", async () => { + const panel = renderPanel(); + const activeRow = renderPackageRow(panel, "com.acme.active"); + const disabledRow = renderPackageRow(panel, "com.acme.disabled"); + const disable = visitElements( + activeRow, + (element) => element.props["aria-label"] === "Disable com.acme.active", + ); + const enable = visitElements( + disabledRow, + (element) => element.props["aria-label"] === "Enable com.acme.disabled", + ); + const reload = visitElements( + activeRow, + (element) => element.props["aria-label"] === "Reload com.acme.active", + ); + const refresh = visitElements( + panel, + (element) => element.props["aria-label"] === "Refresh plugins", + ); + + (disable?.props.onCheckedChange as ((checked: boolean) => void) | undefined)?.(false); + (enable?.props.onCheckedChange as ((checked: boolean) => void) | undefined)?.(true); + (reload?.props.onClick as (() => void) | undefined)?.(); + (refresh?.props.onClick as (() => void) | undefined)?.(); + await flushPromises(); + + expect(commands.disable).toHaveBeenCalledWith({ + environmentId, + input: { id: "com.acme.active" }, + }); + expect(commands.enable).toHaveBeenCalledWith({ + environmentId, + input: { id: "com.acme.disabled" }, + }); + expect(commands.reload).toHaveBeenCalledWith({ + environmentId, + input: { id: "com.acme.active" }, + }); + expect(query.refresh).toHaveBeenCalledTimes(4); + }); + + it("keeps an empty environment actionable", () => { + query.data = { packages: [], errors: [] }; + const panel = renderPanel(); + + expect( + visitElements(panel, (element) => element.props["data-plugin-empty"] === true), + ).not.toBeNull(); + expect( + visitElements(panel, (element) => element.props["aria-label"] === "Refresh plugins"), + ).not.toBeNull(); + }); + + it("shows package status without offering writes when the session is read only", () => { + access.value = "denied"; + const panel = renderPanel(); + const activeRow = renderPackageRow(panel, "com.acme.active"); + const disable = visitElements( + activeRow, + (element) => element.props["aria-label"] === "Disable com.acme.active", + ); + + expect(disable?.props.disabled).toBe(true); + expect( + visitElements(panel, (element) => element.props["data-plugin-read-only"] === true), + ).not.toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx new file mode 100644 index 000000000000..4c4be4e94153 --- /dev/null +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -0,0 +1,288 @@ +import type { PluginPackageStatus } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + BlocksIcon, + CircleAlertIcon, + FolderCodeIcon, + RefreshCwIcon, + RotateCwIcon, + ShieldAlertIcon, +} from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; + +import { isElectron } from "../../env"; +import { usePrimarySessionState } from "../../environments/primary"; +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Spinner } from "../ui/spinner"; +import { Switch } from "../ui/switch"; +import { toastManager } from "../ui/toast"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; +import { resolvePrimaryOperateAccess } from "./ProviderSettingsPanel.logic"; +import { searchableSetting } from "./settingsSearch"; + +const statePresentation = { + active: { label: "Active", variant: "success" }, + disabled: { label: "Disabled", variant: "secondary" }, + error: { label: "Error", variant: "error" }, +} as const; + +type PackageAction = "enable" | "disable" | "reload"; + +function actionFailureMessage(action: PackageAction, error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) return error.message; + return `The plugin could not be ${action === "reload" ? "reloaded" : `${action}d`}.`; +} + +function PluginPackageRow({ + pluginPackage, + pendingAction, + readOnly, + onEnabledChange, + onReload, +}: { + readonly pluginPackage: PluginPackageStatus; + readonly pendingAction: PackageAction | null; + readonly readOnly: boolean; + readonly onEnabledChange: (enabled: boolean) => void; + readonly onReload: () => void; +}) { + const state = statePresentation[pluginPackage.state]; + const commands = pluginPackage.contributions.commands; + const busy = pendingAction !== null; + const status = ( +
+ {state.label} + v{pluginPackage.version} + {pluginPackage.capabilities.map((capability) => ( + + {capability} + + ))} +
+ ); + + return ( + {pluginPackage.id}} + description={ + commands.length === 0 + ? "No command contributions" + : `${commands.length} command${commands.length === 1 ? "" : "s"}: ${commands.join(", ")}` + } + status={status} + className="border border-border/60 bg-card/35" + control={ +
+ {pluginPackage.enabled ? ( + + ) : null} + +
+ } + > + {pluginPackage.error ? ( + + + {pluginPackage.error} + + ) : null} +
+ ); +} + +export function PluginsSettingsPanel() { + const environmentId = usePrimaryEnvironmentId(); + const primarySession = usePrimarySessionState(); + const operateAccess = resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: isElectron, + session: primarySession.data, + isPending: primarySession.isPending, + hasError: primarySession.error !== null, + }); + const readOnly = operateAccess !== "granted"; + const status = useEnvironmentQuery( + environmentId === null ? null : serverEnvironment.pluginPackages({ environmentId, input: {} }), + ); + const enablePlugin = useAtomCommand(serverEnvironment.enablePluginPackage, { + reportFailure: false, + }); + const disablePlugin = useAtomCommand(serverEnvironment.disablePluginPackage, { + reportFailure: false, + }); + const reloadPlugin = useAtomCommand(serverEnvironment.reloadPluginPackage, { + reportFailure: false, + }); + const [pending, setPending] = useState<{ + readonly id: string; + readonly action: PackageAction; + } | null>(null); + const packages = useMemo( + () => [...(status.data?.packages ?? [])].sort((left, right) => left.id.localeCompare(right.id)), + [status.data?.packages], + ); + + const runAction = useCallback( + (pluginPackage: PluginPackageStatus, action: PackageAction) => { + if (environmentId === null || pending !== null || readOnly) return; + setPending({ id: pluginPackage.id, action }); + const command = + action === "enable" ? enablePlugin : action === "disable" ? disablePlugin : reloadPlugin; + void (async () => { + const result = await command({ + environmentId, + input: { id: pluginPackage.id }, + }); + setPending(null); + if (result._tag === "Success") { + status.refresh(); + return; + } + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: `Could not ${action} plugin`, + description: actionFailureMessage(action, error), + }); + } + })(); + }, + [disablePlugin, enablePlugin, environmentId, pending, readOnly, reloadPlugin, status], + ); + + const countLabel = `${packages.length} ${packages.length === 1 ? "plugin" : "plugins"}`; + + return ( + + } + headerAction={ +
+ {countLabel} + +
+ } + > + + + Trusted local code + + Plugins run inside this environment's server process with its filesystem and network + access. Only install code you trust. + + + + {operateAccess === "denied" ? ( + + + Limited permissions + + This session can inspect plugins, but it cannot enable, disable, or reload them. + + + ) : null} + + {status.error ? ( + + + Could not load plugins + {status.error} + + ) : null} + + {status.data?.errors.map((error) => ( + + + {error.directory} + {error.error} + + ))} + + {status.isPending && status.data === null ? ( +
+ + Loading plugins +
+ ) : null} + + {!status.isPending && status.error === null && packages.length === 0 ? ( +
+ +

No plugins found

+

+ Add a trusted plugin package to this environment's userdata/plugins directory, then + refresh this page. +

+
+ ) : null} + +
+ {packages.map((pluginPackage) => ( + + runAction(pluginPackage, enabled ? "enable" : "disable") + } + onReload={() => runAction(pluginPackage, "reload")} + /> + ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 5a5978487ac3..95217e859f6a 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -15,6 +15,7 @@ import { GitBranchIcon, KeyboardIcon, Link2Icon, + PackageIcon, PaletteIcon, SearchIcon, Settings2Icon, @@ -50,6 +51,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/appearance": PaletteIcon, "/settings/keybindings": KeyboardIcon, "/settings/providers": BotIcon, + "/settings/plugins": PackageIcon, "/settings/integrations": BlocksIcon, "/settings/source-control": GitBranchIcon, "/settings/connections": Link2Icon, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e49f77a834eb..e21c7f32cb90 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -5,6 +5,7 @@ export type SettingsPath = | "/settings/appearance" | "/settings/keybindings" | "/settings/providers" + | "/settings/plugins" | "/settings/integrations" | "/settings/source-control" | "/settings/connections" @@ -29,6 +30,7 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/appearance": "Appearance", "/settings/keybindings": "Keybindings", "/settings/providers": "Providers", + "/settings/plugins": "Plugins", "/settings/integrations": "Integrations", "/settings/source-control": "Source Control", "/settings/connections": "Connections", @@ -197,6 +199,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Providers", to: "/settings/providers", }, + { + id: "plugins", + title: "Plugins", + to: "/settings/plugins", + }, { id: "agent-browser-access", title: "Agent browser access", diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f7c47ace6840..6c82317d91a7 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsPluginsRouteImport } from './routes/settings.plugins' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' @@ -69,6 +70,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) +const SettingsPluginsRoute = SettingsPluginsRouteImport.update({ + id: '/plugins', + path: '/plugins', + getParentRoute: () => SettingsRoute, +} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -147,6 +153,7 @@ export interface FileRoutesByFullPath { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/plugins': typeof SettingsPluginsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -167,6 +174,7 @@ export interface FileRoutesByTo { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/plugins': typeof SettingsPluginsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute @@ -190,6 +198,7 @@ export interface FileRoutesById { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/plugins': typeof SettingsPluginsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute @@ -214,6 +223,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/plugins' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' @@ -234,6 +244,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/plugins' | '/settings/providers' | '/settings/source-control' | '/' @@ -256,6 +267,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/plugins' | '/settings/providers' | '/settings/source-control' | '/_chat/' @@ -331,6 +343,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } + '/settings/plugins': { + id: '/settings/plugins' + path: '/plugins' + fullPath: '/settings/plugins' + preLoaderRoute: typeof SettingsPluginsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -442,6 +461,7 @@ interface SettingsRouteChildren { SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsPluginsRoute: typeof SettingsPluginsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -454,6 +474,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsGeneralRoute: SettingsGeneralRoute, SettingsIntegrationsRoute: SettingsIntegrationsRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsPluginsRoute: SettingsPluginsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } diff --git a/apps/web/src/routes/settings.plugins.tsx b/apps/web/src/routes/settings.plugins.tsx new file mode 100644 index 000000000000..4ca76435d82e --- /dev/null +++ b/apps/web/src/routes/settings.plugins.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { PluginsSettingsPanel } from "../components/settings/PluginsSettings"; + +function SettingsPluginsRoute() { + return ; +} + +export const Route = createFileRoute("/settings/plugins")({ + component: SettingsPluginsRoute, +}); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 2d310911e5bb..b4d237f510da 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -465,6 +465,7 @@ export function createServerEnvironmentAtoms( }, ) { const configScheduler = createAtomCommandScheduler(); + const pluginPackageScheduler = createAtomCommandScheduler(); // Updates stay serial end-to-end, but only their handoff phase occupies the config lane. const updateScheduler = createAtomCommandScheduler(); const configConcurrency = { @@ -707,6 +708,11 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.subscribePluginCommands, idleTtlMs: 0, }), + pluginPackages: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:plugin-packages", + tag: WS_METHODS.pluginPackagesStatus, + staleTimeMs: 1_000, + }), resourceTelemetryHistory: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:resource-telemetry-history", tag: WS_METHODS.serverGetResourceTelemetryHistory, @@ -744,6 +750,24 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + enablePluginPackage: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:enable-plugin-package", + tag: WS_METHODS.pluginPackagesEnable, + scheduler: pluginPackageScheduler, + concurrency: configConcurrency, + }), + disablePluginPackage: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:disable-plugin-package", + tag: WS_METHODS.pluginPackagesDisable, + scheduler: pluginPackageScheduler, + concurrency: configConcurrency, + }), + reloadPluginPackage: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:reload-plugin-package", + tag: WS_METHODS.pluginPackagesReload, + scheduler: pluginPackageScheduler, + concurrency: configConcurrency, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, From 52a541fbfd2fe67d3d445534d8ff232ae6f1f927 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:48:21 +0000 Subject: [PATCH 16/45] fix: disable plugin actions during mutations --- .../settings/PluginsSettings.test.tsx | 20 +++++++++++++++++++ .../components/settings/PluginsSettings.tsx | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/PluginsSettings.test.tsx b/apps/web/src/components/settings/PluginsSettings.test.tsx index cfdcd112e269..e18e1783a314 100644 --- a/apps/web/src/components/settings/PluginsSettings.test.tsx +++ b/apps/web/src/components/settings/PluginsSettings.test.tsx @@ -242,4 +242,24 @@ describe("PluginsSettingsPanel", () => { visitElements(panel, (element) => element.props["data-plugin-read-only"] === true), ).not.toBeNull(); }); + + it("disables every package action while one lifecycle mutation is pending", () => { + commands.disable.mockReturnValue(new Promise(() => undefined)); + const panel = renderPanel(); + const activeRow = renderPackageRow(panel, "com.acme.active"); + const disable = visitElements( + activeRow, + (element) => element.props["aria-label"] === "Disable com.acme.active", + ); + (disable?.props.onCheckedChange as ((checked: boolean) => void) | undefined)?.(false); + + const pendingPanel = renderPanel(); + const disabledRow = renderPackageRow(pendingPanel, "com.acme.disabled"); + const enable = visitElements( + disabledRow, + (element) => element.props["aria-label"] === "Enable com.acme.disabled", + ); + + expect(enable?.props.disabled).toBe(true); + }); }); diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index 4c4be4e94153..8625dfe3eaf9 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -271,7 +271,7 @@ export function PluginsSettingsPanel() { Date: Sun, 23 Aug 2026 15:54:06 +0000 Subject: [PATCH 17/45] refactor: use shared plugin empty state --- .../components/settings/PluginsSettings.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index 8625dfe3eaf9..aaf7fc674250 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -22,6 +22,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { Spinner } from "../ui/spinner"; import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; @@ -253,17 +254,18 @@ export function PluginsSettingsPanel() { ) : null} {!status.isPending && status.error === null && packages.length === 0 ? ( -
- -

No plugins found

-

- Add a trusted plugin package to this environment's userdata/plugins directory, then - refresh this page. -

-
+ + + + + + No plugins found + + Add a trusted plugin package to this environment's userdata/plugins directory, then + refresh this page. + + + ) : null}
From 6a85281c4bc20fb2666e159c14d0ff81b24f7124 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:02:49 +0000 Subject: [PATCH 18/45] fix: align plugin settings header actions --- .../components/settings/PluginsSettings.tsx | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index aaf7fc674250..79ebf71a2df8 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -4,7 +4,6 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { - BlocksIcon, CircleAlertIcon, FolderCodeIcon, RefreshCwIcon, @@ -26,6 +25,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from ".. import { Spinner } from "../ui/spinner"; import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { resolvePrimaryOperateAccess } from "./ProviderSettingsPanel.logic"; import { searchableSetting } from "./settingsSearch"; @@ -185,24 +185,30 @@ export function PluginsSettingsPanel() { } headerAction={
{countLabel} - + + + {status.isPending ? ( + + ) : ( + + )} + + } + /> + Refresh plugins +
} > From 376ea51fbdc134ac7d40a18c18acc90122ed3d08 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:26:49 +0000 Subject: [PATCH 19/45] fix: align plugin loading state --- apps/web/src/components/settings/PluginsSettings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index 79ebf71a2df8..c332c8d1f6be 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -253,10 +253,10 @@ export function PluginsSettingsPanel() { ))} {status.isPending && status.data === null ? ( -
+ Loading plugins -
+ ) : null} {!status.isPending && status.error === null && packages.length === 0 ? ( From 691c59280b482d8a02dd1b8ab7a8c00bb53f2106 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:35:23 +0000 Subject: [PATCH 20/45] fix: preserve plugin command error context --- apps/server/src/plugins/PluginCommandCatalog.test.ts | 9 +++++++++ apps/server/src/plugins/PluginCommandCatalog.ts | 10 +++++++--- apps/server/src/plugins/PluginPackageManager.ts | 9 +++++++-- packages/contracts/src/pluginCommands.test.ts | 10 ++++++++++ packages/contracts/src/pluginCommands.ts | 7 +++++-- 5 files changed, 38 insertions(+), 7 deletions(-) diff --git a/apps/server/src/plugins/PluginCommandCatalog.test.ts b/apps/server/src/plugins/PluginCommandCatalog.test.ts index 0028e6b372e7..257715c7c5c6 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.test.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.test.ts @@ -34,6 +34,15 @@ const testPlugin = (input: { }); describe("plugin command catalog", () => { + it("keeps command identity on execution errors", () => { + const error = new PluginCommandCatalog.PluginCommandExecutionError({ + cause: new Error("handler failed"), + id: "acme.hello", + }); + + expect(error.message).toBe("Plugin command acme.hello failed during execution."); + }); + it.effect("lists and invokes the trusted built-in command", () => Effect.gen(function* () { const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; diff --git a/apps/server/src/plugins/PluginCommandCatalog.ts b/apps/server/src/plugins/PluginCommandCatalog.ts index 0a7cb4a9338a..911165c66d7f 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.ts @@ -3,6 +3,7 @@ import { type PluginCommand, type PluginCommandCatalog as PluginCommandCatalogSnapshot, PluginCommandCatalogChangedError, + PluginCommandId, type PluginCommandInvocationResult, PluginCommandInvocationError, type PluginCommandInvokeInput, @@ -46,8 +47,12 @@ const validateCommandSnapshot = (snapshot: PluginRuntimeSnapshot): void => { export class PluginCommandExecutionError extends Schema.TaggedErrorClass()( "PluginCommandExecutionError", - { cause: Schema.Defect() }, -) {} + { cause: Schema.Defect(), id: PluginCommandId }, +) { + override get message(): string { + return `Plugin command ${this.id} failed during execution.`; + } +} type PluginCommandHandler = Effect.Effect< PluginCommandInvocationResult, @@ -202,7 +207,6 @@ export const make = Effect.gen(function* () { return new PluginCommandInvocationError({ cause: error, id: input.id, - message: "Plugin command failed.", }); }), ); diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index 21d0a128f118..09581fd948a1 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -130,12 +130,17 @@ const makeDefinition = ( command, handler: Effect.tryPromise({ try: async () => handler(), - catch: (cause) => new PluginCommandCatalog.PluginCommandExecutionError({ cause }), + catch: (cause) => + new PluginCommandCatalog.PluginCommandExecutionError({ cause, id: command.id }), }).pipe( Effect.flatMap((result) => decodeInvocationResult(result).pipe( Effect.mapError( - (cause) => new PluginCommandCatalog.PluginCommandExecutionError({ cause }), + (cause) => + new PluginCommandCatalog.PluginCommandExecutionError({ + cause, + id: command.id, + }), ), ), ), diff --git a/packages/contracts/src/pluginCommands.test.ts b/packages/contracts/src/pluginCommands.test.ts index 4cbdeecf8548..85eaa55f5998 100644 --- a/packages/contracts/src/pluginCommands.test.ts +++ b/packages/contracts/src/pluginCommands.test.ts @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; import { PluginCommandCatalog, PluginCommandInvokeInput, + PluginCommandInvocationError, PluginCommandInvocationResult, } from "./pluginCommands.ts"; import { WS_METHODS, WsRpcGroup } from "./rpc.ts"; @@ -60,6 +61,15 @@ describe("plugin command contracts", () => { ).toEqual({ message: "Command completed.", tone: "success" }); }); + it("derives invocation failure messages from the command id", () => { + const error = new PluginCommandInvocationError({ + cause: new Error("handler failed"), + id: "acme.command", + }); + + expect(error.message).toBe("Plugin command failed: acme.command"); + }); + it("registers fixed list, invoke, and subscribe rpc methods", () => { expect(WsRpcGroup.requests.has(WS_METHODS.pluginCommandsList)).toBe(true); expect(WsRpcGroup.requests.has(WS_METHODS.pluginCommandsInvoke)).toBe(true); diff --git a/packages/contracts/src/pluginCommands.ts b/packages/contracts/src/pluginCommands.ts index dec2081a304a..5d814be97b32 100644 --- a/packages/contracts/src/pluginCommands.ts +++ b/packages/contracts/src/pluginCommands.ts @@ -60,6 +60,9 @@ export class PluginCommandInvocationError extends Schema.TaggedErrorClass Date: Sun, 23 Aug 2026 16:51:37 +0000 Subject: [PATCH 21/45] fix: isolate plugin package failures --- .../src/plugins/PluginPackageManager.test.ts | 45 +++++++++++++++++-- .../src/plugins/PluginPackageManager.ts | 21 ++++++++- apps/server/src/ws.ts | 4 +- .../settings/PluginsSettings.test.tsx | 15 +++++++ .../components/settings/PluginsSettings.tsx | 5 ++- 5 files changed, 81 insertions(+), 9 deletions(-) diff --git a/apps/server/src/plugins/PluginPackageManager.test.ts b/apps/server/src/plugins/PluginPackageManager.test.ts index f8f88fde74b8..2b701d2be93c 100644 --- a/apps/server/src/plugins/PluginPackageManager.test.ts +++ b/apps/server/src/plugins/PluginPackageManager.test.ts @@ -104,6 +104,7 @@ export default function activate(api) { interface EnvironmentLayerOptions { readonly persistenceFailures?: { remaining: number }; + readonly startupFailure?: boolean; } const makeEnvironmentLayer = (baseDir: string, options?: EnvironmentLayerOptions) => { @@ -113,8 +114,9 @@ const makeEnvironmentLayer = (baseDir: string, options?: EnvironmentLayerOptions Layer.provideMerge(configLayer), ); const persistenceFailures = options?.persistenceFailures; + const startupFailure = options?.startupFailure === true; const settingsLayer = - persistenceFailures === undefined + persistenceFailures === undefined && !startupFailure ? liveSettingsLayer : Layer.effect( ServerSettings.ServerSettingsService, @@ -122,9 +124,18 @@ const makeEnvironmentLayer = (baseDir: string, options?: EnvironmentLayerOptions const live = yield* ServerSettings.ServerSettingsService; return ServerSettings.ServerSettingsService.of({ ...live, + start: startupFailure + ? Effect.fail( + new ServerSettingsError({ + cause: new Error("injected startup failure"), + operation: "read-file", + settingsPath: `${baseDir}/userdata/settings.json`, + }), + ) + : live.start, setEnabledPluginIds: (ids) => Effect.suspend(() => { - if (persistenceFailures.remaining > 0) { + if (persistenceFailures !== undefined && persistenceFailures.remaining > 0) { persistenceFailures.remaining -= 1; return Effect.fail( new ServerSettingsError({ @@ -158,6 +169,31 @@ const useEnvironment = ( ) => Effect.scoped(effect.pipe(Effect.provide(makeEnvironmentLayer(baseDir, options)))); it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { + it.effect("keeps the environment available when package manager startup fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-startup-failure-test-", + }); + + const exit = yield* Effect.exit( + useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + return yield* Effect.exit(manager.status); + }), + { startupFailure: true }, + ), + ); + + expect(exit._tag).toBe("Success"); + if (exit._tag === "Success") { + expect(exit.value._tag).toBe("Failure"); + } + }), + ); + it.effect("loads the committed external runtime-status example without rebuilding", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -455,10 +491,13 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { baseDir, Effect.gen(function* () { const manager = yield* PluginPackageManager.PluginPackageManager; - expect(yield* manager.status).toMatchObject({ + const status = yield* manager.status; + expect(status).toMatchObject({ errors: [{ directory: "broken-package" }], packages: [], }); + expect(status.errors[0]?.error).toContain("manifestVersion"); + expect(status.errors[0]?.error).not.toContain("Cause(["); }), ); }), diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index 09581fd948a1..0085c8d3b83a 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -267,7 +267,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { fileSystem.readFileString(manifestPath).pipe(Effect.flatMap(decodeManifestJson)), ); if (decoded._tag === "Failure") { - errors.push({ directory: entry, error: detailFromUnknown(decoded.cause) }); + errors.push({ directory: entry, error: detailFromCause(decoded.cause) }); continue; } const packageManifest = decoded.value; @@ -650,4 +650,21 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { } as const; }); -export const layer = Layer.effect(PluginPackageManager, make()); +const unavailableService = (error: PluginPackageOperationError) => + PluginPackageManager.of({ + status: Effect.fail(error), + enable: () => Effect.fail(error), + disable: () => Effect.fail(error), + reload: () => Effect.fail(error), + }); + +export const layer = Layer.effect( + PluginPackageManager, + make().pipe( + Effect.catch((error) => + Effect.logWarning("Local plugin package manager failed to start", { + error: detailFromUnknown(error), + }).pipe(Effect.as(unavailableService(error))), + ), + ), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c257e5a51406..14f82fb27b40 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2508,7 +2508,5 @@ export const websocketRpcRouteLayer = Layer.unwrap( ); }), ).pipe( - Layer.provide( - PluginPackageManager.layer.pipe(Layer.provideMerge(PluginCommandCatalog.layer), Layer.orDie), - ), + Layer.provide(PluginPackageManager.layer.pipe(Layer.provideMerge(PluginCommandCatalog.layer))), ); diff --git a/apps/web/src/components/settings/PluginsSettings.test.tsx b/apps/web/src/components/settings/PluginsSettings.test.tsx index e18e1783a314..a8a08cb37009 100644 --- a/apps/web/src/components/settings/PluginsSettings.test.tsx +++ b/apps/web/src/components/settings/PluginsSettings.test.tsx @@ -228,6 +228,21 @@ describe("PluginsSettingsPanel", () => { ).not.toBeNull(); }); + it("does not claim no plugins were found when discovery reported a broken package", () => { + query.data = { + packages: [], + errors: [{ directory: "broken-package", error: "manifest is invalid" }], + }; + const panel = renderPanel(); + + expect( + visitElements(panel, (element) => element.props["data-plugin-error"] === "broken-package"), + ).not.toBeNull(); + expect( + visitElements(panel, (element) => element.props["data-plugin-empty"] === true), + ).toBeNull(); + }); + it("shows package status without offering writes when the session is read only", () => { access.value = "denied"; const panel = renderPanel(); diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index c332c8d1f6be..4ce4ad93e3eb 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -259,7 +259,10 @@ export function PluginsSettingsPanel() { ) : null} - {!status.isPending && status.error === null && packages.length === 0 ? ( + {!status.isPending && + status.error === null && + packages.length === 0 && + (status.data?.errors.length ?? 0) === 0 ? ( From 0d0a498c44514e18381546e3cd6c758ba9cf8562 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Sun, 23 Aug 2026 23:59:53 +0530 Subject: [PATCH 22/45] fix(web): refresh plugin status after failed lifecycle actions A failed enable, disable, or reload still changes the server-side snapshot (package errors are set or cleared), but the settings panel only refreshed on success, leaving stale rows until a manual refresh. Refresh after any completed non-interrupted action and cover both outcomes with tests. --- .../settings/PluginsSettings.test.tsx | 47 +++++++++++++++++++ .../components/settings/PluginsSettings.tsx | 1 + 2 files changed, 48 insertions(+) diff --git a/apps/web/src/components/settings/PluginsSettings.test.tsx b/apps/web/src/components/settings/PluginsSettings.test.tsx index a8a08cb37009..d47d8f5e0a27 100644 --- a/apps/web/src/components/settings/PluginsSettings.test.tsx +++ b/apps/web/src/components/settings/PluginsSettings.test.tsx @@ -1,5 +1,7 @@ import type { ReactElement } from "react"; import { EnvironmentId, type PluginPackageStatusSnapshot } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { visitElements } from "../../test/reactElementTree"; @@ -82,6 +84,12 @@ vi.mock("../../state/use-atom-command", () => ({ }, })); +vi.mock("../ui/toast", () => ({ + toastManager: { add: vi.fn() }, +})); + +import { toastManager } from "../ui/toast"; + import { PluginsSettingsPanel } from "./PluginsSettings"; const snapshot: PluginPackageStatusSnapshot = { @@ -141,6 +149,7 @@ describe("PluginsSettingsPanel", () => { query.error = null; query.isPending = false; query.refresh.mockReset(); + vi.mocked(toastManager.add).mockReset(); commands.enable.mockReset().mockResolvedValue({ _tag: "Success", value: snapshot }); commands.disable.mockReset().mockResolvedValue({ _tag: "Success", value: snapshot }); commands.reload.mockReset().mockResolvedValue({ _tag: "Success", value: snapshot }); @@ -216,6 +225,44 @@ describe("PluginsSettingsPanel", () => { expect(query.refresh).toHaveBeenCalledTimes(4); }); + it("refreshes status and reports a toast when a lifecycle action fails", async () => { + commands.reload.mockResolvedValue( + AsyncResult.failure(Cause.fail(new Error("reload exploded"))), + ); + const panel = renderPanel(); + const activeRow = renderPackageRow(panel, "com.acme.active"); + const reload = visitElements( + activeRow, + (element) => element.props["aria-label"] === "Reload com.acme.active", + ); + (reload?.props.onClick as (() => void) | undefined)?.(); + await flushPromises(); + + expect(commands.reload).toHaveBeenCalledWith({ + environmentId, + input: { id: "com.acme.active" }, + }); + expect(query.refresh).toHaveBeenCalledTimes(1); + expect(toastManager.add).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", title: "Could not reload plugin" }), + ); + }); + + it("does not refresh or toast when a lifecycle action is interrupted", async () => { + commands.disable.mockResolvedValue(AsyncResult.failure(Cause.interrupt(1))); + const panel = renderPanel(); + const activeRow = renderPackageRow(panel, "com.acme.active"); + const disable = visitElements( + activeRow, + (element) => element.props["aria-label"] === "Disable com.acme.active", + ); + (disable?.props.onCheckedChange as ((checked: boolean) => void) | undefined)?.(false); + await flushPromises(); + + expect(query.refresh).not.toHaveBeenCalled(); + expect(toastManager.add).not.toHaveBeenCalled(); + }); + it("keeps an empty environment actionable", () => { query.data = { packages: [], errors: [] }; const panel = renderPanel(); diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index 4ce4ad93e3eb..7175d873daa9 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -168,6 +168,7 @@ export function PluginsSettingsPanel() { } if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); + status.refresh(); toastManager.add({ type: "error", title: `Could not ${action} plugin`, From 3ccb065a608ec9babdc319aa26b0cc0ca394ae9b Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:30:25 +0000 Subject: [PATCH 23/45] fix: address plugin review findings Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/plugins/PluginCommandCatalog.test.ts | 64 ++++++++++++++++++- .../src/plugins/PluginCommandCatalog.ts | 48 +++++++------- .../settings/PluginsSettings.test.tsx | 4 +- .../components/settings/PluginsSettings.tsx | 3 +- packages/contracts/src/pluginPackages.test.ts | 19 ++++++ packages/contracts/src/pluginPackages.ts | 3 +- packages/plugin-runtime/src/manifest.ts | 7 +- packages/plugin-runtime/test/manifest.test.ts | 12 ++++ 8 files changed, 130 insertions(+), 30 deletions(-) diff --git a/apps/server/src/plugins/PluginCommandCatalog.test.ts b/apps/server/src/plugins/PluginCommandCatalog.test.ts index 257715c7c5c6..170708b05bb9 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.test.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.test.ts @@ -6,7 +6,7 @@ import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; -import type { PluginDefinition } from "@t3tools/plugin-runtime"; +import { PluginRuntime, type PluginDefinition } from "@t3tools/plugin-runtime"; import * as PluginCommandCatalog from "./PluginCommandCatalog.ts"; @@ -243,4 +243,66 @@ describe("plugin command catalog", () => { }); }).pipe(Effect.provide(PluginCommandCatalog.layer)), ); + + it.effect("does not invoke against a runtime generation before publishing its catalog", () => + Effect.gen(function* () { + let generation = 0; + let blockPublication = false; + let markPublicationStarted!: () => void; + let releasePublication!: () => void; + const publicationStarted = new Promise((resolve) => { + markPublicationStarted = resolve; + }); + const publicationGate = new Promise((resolve) => { + releasePublication = resolve; + }); + const runtime = PluginRuntime.PluginRuntime.of({ + reconcile: () => + Effect.sync(() => { + generation += 1; + return { active: [], blocked: {}, contributions: {} }; + }), + snapshot: Effect.succeed({ active: [], blocked: {}, contributions: {} }), + contributions: () => + Effect.suspend(() => { + if (!blockPublication) { + return Effect.succeed({ generation, entries: [] }); + } + return Effect.promise(() => { + markPublicationStarted(); + return publicationGate; + }).pipe(Effect.as({ generation, entries: [] })); + }), + useContribution: (_slot, id, expectedGeneration) => + Effect.fail( + expectedGeneration === generation + ? new PluginRuntime.PluginContributionNotFoundError({ id, slot: "commands" }) + : new PluginRuntime.PluginContributionGenerationError({ + actual: generation, + expected: expectedGeneration, + }), + ), + dispose: Effect.void, + }); + const catalog = yield* PluginCommandCatalog.make.pipe( + Effect.provideService(PluginRuntime.PluginRuntime, runtime), + ); + const first = yield* catalog.list; + blockPublication = true; + const replacement = yield* Effect.forkChild(catalog.reconcile([])); + yield* Effect.promise(() => publicationStarted); + const invocation = yield* Effect.forkChild( + Effect.exit(catalog.invoke({ generation: first.generation, id: "acme.hello" })), + ); + yield* Effect.yieldNow; + const waitedForPublication = invocation.pollUnsafe() === undefined; + releasePublication(); + + const second = yield* Fiber.join(replacement); + const invocationExit = yield* Fiber.join(invocation); + expect(waitedForPublication).toBe(true); + expect(yield* catalog.list).toBe(second); + expect(Exit.isFailure(invocationExit)).toBe(true); + }), + ); }); diff --git a/apps/server/src/plugins/PluginCommandCatalog.ts b/apps/server/src/plugins/PluginCommandCatalog.ts index 911165c66d7f..df317ab25be7 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.ts @@ -186,30 +186,32 @@ export const make = Effect.gen(function* () { const invoke = Effect.fn("PluginCommandCatalog.invoke")(function* ( input: PluginCommandInvokeInput, ) { - return yield* runtime - .useContribution< - PluginCommandHandler, - PluginCommandInvocationResult, - PluginCommandExecutionError, - never - >(COMMAND_SLOT, input.id, input.generation, (handler) => handler) - .pipe( - Effect.mapError((error) => { - if (isContributionGenerationError(error)) { - return new PluginCommandCatalogChangedError({ - actualGeneration: error.actual, - expectedGeneration: error.expected, + return yield* reconcileSemaphore.withPermits(1)( + runtime + .useContribution< + PluginCommandHandler, + PluginCommandInvocationResult, + PluginCommandExecutionError, + never + >(COMMAND_SLOT, input.id, input.generation, (handler) => handler) + .pipe( + Effect.mapError((error) => { + if (isContributionGenerationError(error)) { + return new PluginCommandCatalogChangedError({ + actualGeneration: error.actual, + expectedGeneration: error.expected, + }); + } + if (isContributionNotFoundError(error)) { + return new PluginCommandNotFoundError({ id: input.id }); + } + return new PluginCommandInvocationError({ + cause: error, + id: input.id, }); - } - if (isContributionNotFoundError(error)) { - return new PluginCommandNotFoundError({ id: input.id }); - } - return new PluginCommandInvocationError({ - cause: error, - id: input.id, - }); - }), - ); + }), + ), + ); }); return PluginCommandCatalog.of({ diff --git a/apps/web/src/components/settings/PluginsSettings.test.tsx b/apps/web/src/components/settings/PluginsSettings.test.tsx index d47d8f5e0a27..e7399e284ced 100644 --- a/apps/web/src/components/settings/PluginsSettings.test.tsx +++ b/apps/web/src/components/settings/PluginsSettings.test.tsx @@ -248,7 +248,7 @@ describe("PluginsSettingsPanel", () => { ); }); - it("does not refresh or toast when a lifecycle action is interrupted", async () => { + it("refreshes status without a toast when a lifecycle action is interrupted", async () => { commands.disable.mockResolvedValue(AsyncResult.failure(Cause.interrupt(1))); const panel = renderPanel(); const activeRow = renderPackageRow(panel, "com.acme.active"); @@ -259,7 +259,7 @@ describe("PluginsSettingsPanel", () => { (disable?.props.onCheckedChange as ((checked: boolean) => void) | undefined)?.(false); await flushPromises(); - expect(query.refresh).not.toHaveBeenCalled(); + expect(query.refresh).toHaveBeenCalledTimes(1); expect(toastManager.add).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index 7175d873daa9..2d2fdce40d2c 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -162,13 +162,12 @@ export function PluginsSettingsPanel() { input: { id: pluginPackage.id }, }); setPending(null); + status.refresh(); if (result._tag === "Success") { - status.refresh(); return; } if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); - status.refresh(); toastManager.add({ type: "error", title: `Could not ${action} plugin`, diff --git a/packages/contracts/src/pluginPackages.test.ts b/packages/contracts/src/pluginPackages.test.ts index 9a883b0a0a7b..5dd44ed5a994 100644 --- a/packages/contracts/src/pluginPackages.test.ts +++ b/packages/contracts/src/pluginPackages.test.ts @@ -62,6 +62,25 @@ describe("plugin package contracts", () => { expect(() => decodeAction({ id: "com.acme.runtime-status", extra: true })).toThrow(); }); + it("rejects declared command ids that cannot be invoked", () => { + expect(() => + decodeStatus({ + errors: [], + packages: [ + { + id: "com.acme.runtime-status", + version: "1.0.0", + apiVersion: 1, + enabled: false, + state: "disabled", + capabilities: ["t3.commands@1"], + contributions: { commands: [`acme.${"x".repeat(196)}`] }, + }, + ], + }), + ).toThrow(); + }); + it("preserves operation causes without putting failure text in the stable message", () => { const cause = new Error("disk exploded"); const error = new PluginPackageOperationError({ cause, operation: "enable" }); diff --git a/packages/contracts/src/pluginPackages.ts b/packages/contracts/src/pluginPackages.ts index 45d6eece3eb1..f2d874a49610 100644 --- a/packages/contracts/src/pluginPackages.ts +++ b/packages/contracts/src/pluginPackages.ts @@ -1,6 +1,7 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PluginCommandId } from "./pluginCommands.ts"; export const PluginPackageId = Schema.String.check( Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/), @@ -17,7 +18,7 @@ export const PluginPackageState = Schema.Literals(["disabled", "active", "error" export type PluginPackageState = typeof PluginPackageState.Type; export const PluginPackageContributions = Schema.Struct({ - commands: Schema.Array(PluginPackageId), + commands: Schema.Array(PluginCommandId), }); export type PluginPackageContributions = typeof PluginPackageContributions.Type; diff --git a/packages/plugin-runtime/src/manifest.ts b/packages/plugin-runtime/src/manifest.ts index fcdcbf2dd97d..460f21ec8894 100644 --- a/packages/plugin-runtime/src/manifest.ts +++ b/packages/plugin-runtime/src/manifest.ts @@ -5,6 +5,11 @@ const NamespacedId = Schema.String.check( Schema.isMaxLength(255), ); +const CommandId = Schema.String.check( + Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/), + Schema.isMaxLength(200), +); + const SemanticVersion = Schema.String.check( Schema.isPattern( /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, @@ -19,7 +24,7 @@ const RelativeEntrypoint = Schema.String.check( const Permission = Schema.String.check(Schema.isPattern(/^[a-z][a-z-]*:.+$/)); const ContributionCatalog = Schema.Struct({ - commands: Schema.optional(Schema.Array(NamespacedId)), + commands: Schema.optional(Schema.Array(CommandId)), settings: Schema.optional(Schema.Array(NamespacedId)), views: Schema.optional(Schema.Array(NamespacedId)), mobileCards: Schema.optional(Schema.Array(NamespacedId)), diff --git a/packages/plugin-runtime/test/manifest.test.ts b/packages/plugin-runtime/test/manifest.test.ts index 88d52433ea67..8ba884db6910 100644 --- a/packages/plugin-runtime/test/manifest.test.ts +++ b/packages/plugin-runtime/test/manifest.test.ts @@ -47,6 +47,18 @@ describe("PluginManifest", () => { ).toThrow(); }); + it("rejects command ids longer than the invocation contract", () => { + expect(() => + decodeManifest({ + ...validManifest, + contributes: { + ...validManifest.contributes, + commands: [`acme.${"x".repeat(196)}`], + }, + }), + ).toThrow(); + }); + it("rejects malformed versions and capability ids", () => { expect(() => decodeManifest({ ...validManifest, version: "next" })).toThrow(); expect(() => decodeManifest({ ...validManifest, version: "01.2.3" })).toThrow(); From ea5647fa3d41bcd60bc3971ddf6ef0c703d7ace6 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:43:18 +0000 Subject: [PATCH 24/45] docs: correct plugin command surfaces Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- examples/plugins/runtime-status/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/plugins/runtime-status/README.md b/examples/plugins/runtime-status/README.md index c6303c30471a..90b0bb2c2cb9 100644 --- a/examples/plugins/runtime-status/README.md +++ b/examples/plugins/runtime-status/README.md @@ -8,6 +8,6 @@ copy this directory to: ~/.t3/userdata/plugins/com.t3code.runtime-status-example ``` -t3 code discovers the package without a rebuild. use `pluginPackages.status` to inspect it and `pluginPackages.enable` with the manifest id to enable it for that environment. `pluginPackages.reload` re-evaluates the entrypoint, and `pluginPackages.disable` removes its contributions. once enabled, `example.runtime-status` appears on web, desktop, and mobile command surfaces. +t3 code discovers the package without a rebuild. use `pluginPackages.status` to inspect it and `pluginPackages.enable` with the manifest id to enable it for that environment. `pluginPackages.reload` re-evaluates the entrypoint, and `pluginPackages.disable` removes its contributions. once enabled, `example.runtime-status` appears in the web and desktop command palettes. local packages run in the server process and are fully trusted. marketplace distribution, signing, sandboxing, and renderer code are not part of this mvp. From 8dd04ec1ffdc9f0a09ab48698d4972865e1b0092 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:26:35 +0000 Subject: [PATCH 25/45] feat: finish plugin dependency resolution Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/plugins/PluginCommandCatalog.ts | 2 + .../src/plugins/PluginPackageManager.test.ts | 97 ++++++++++++++++ .../src/plugins/PluginPackageManager.ts | 32 +++++- .../settings/PluginsSettings.test.tsx | 22 ++++ .../components/settings/PluginsSettings.tsx | 1 + packages/contracts/src/pluginPackages.test.ts | 22 ++++ packages/contracts/src/pluginPackages.ts | 2 +- packages/plugin-runtime/README.md | 6 + packages/plugin-runtime/src/contract.ts | 2 + packages/plugin-runtime/src/planner.ts | 107 +++++++++++++++++- packages/plugin-runtime/src/runtime.ts | 16 ++- .../plugin-runtime/test/runtimeContract.ts | 99 ++++++++++++++++ 12 files changed, 399 insertions(+), 9 deletions(-) diff --git a/apps/server/src/plugins/PluginCommandCatalog.ts b/apps/server/src/plugins/PluginCommandCatalog.ts index df317ab25be7..f7c382d4de98 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.ts @@ -134,6 +134,7 @@ export class PluginCommandCatalog extends Context.Service< PluginCommandCatalog, { readonly list: Effect.Effect; + readonly composition: Effect.Effect; readonly changes: Stream.Stream; readonly invoke: ( input: PluginCommandInvokeInput, @@ -216,6 +217,7 @@ export const make = Effect.gen(function* () { return PluginCommandCatalog.of({ changes: SubscriptionRef.changes(state), + composition: runtime.snapshot, invoke, list: SubscriptionRef.get(state), reconcile, diff --git a/apps/server/src/plugins/PluginPackageManager.test.ts b/apps/server/src/plugins/PluginPackageManager.test.ts index 2b701d2be93c..48d70d11c98f 100644 --- a/apps/server/src/plugins/PluginPackageManager.test.ts +++ b/apps/server/src/plugins/PluginPackageManager.test.ts @@ -55,6 +55,19 @@ export default function activate(api) { } `; +const commandPluginSource = (id: string, label: string) => ` +export default function activate(api) { + api.registerCommand( + { + id: ${encodeJsonString(id)}, + label: ${encodeJsonString(label)}, + surfaces: ["web", "desktop"] + }, + () => ({ message: ${encodeJsonString(label)}, tone: "success" }) + ); +} +`; + const pluginSourceWithHelper = ` import { message } from "./message.mjs"; @@ -298,6 +311,90 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { }), ); + it.effect("maps package dependencies into deterministic activation and blocked status", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-dependency-test-", + }); + const providerId = "com.acme.database"; + const consumerId = "com.acme.issues"; + const providerCommandId = "acme.database.status"; + const consumerCommandId = "acme.issues.create"; + const databaseCapability = "acme.database@1"; + const providerManifest = { + ...manifest, + id: providerId, + provides: [databaseCapability], + contributes: { commands: [providerCommandId] }, + } as const; + const consumerManifest = { + ...manifest, + id: consumerId, + requires: [databaseCapability], + contributes: { commands: [consumerCommandId] }, + } as const; + for (const [id, packageManifest, source] of [ + [providerId, providerManifest, commandPluginSource(providerCommandId, "database provider")], + [consumerId, consumerManifest, commandPluginSource(consumerCommandId, "issues consumer")], + ] as const) { + const directory = `${baseDir}/userdata/plugins/${id}`; + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString( + `${directory}/t3-plugin.json`, + encodeManifest(packageManifest), + ); + yield* fileSystem.writeFileString(`${directory}/index.mjs`, source); + } + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + + const blocked = yield* manager.enable(consumerId); + expect(blocked.packages.find(({ id }) => id === consumerId)).toMatchObject({ + enabled: true, + state: "blocked", + error: `Missing dependency: ${databaseCapability}`, + }); + expect((yield* catalog.list).commands.map(({ id }) => id)).not.toContain( + consumerCommandId, + ); + + const active = yield* manager.enable(providerId); + expect(active.packages.find(({ id }) => id === providerId)).toMatchObject({ + enabled: true, + state: "active", + }); + expect(active.packages.find(({ id }) => id === consumerId)).toMatchObject({ + enabled: true, + state: "active", + }); + const commandIds = (yield* catalog.list).commands.map(({ id }) => id); + expect(commandIds.indexOf(providerCommandId)).toBeLessThan( + commandIds.indexOf(consumerCommandId), + ); + + const providerDisabled = yield* manager.disable(providerId); + expect(providerDisabled.packages.find(({ id }) => id === providerId)).toMatchObject({ + enabled: false, + state: "disabled", + }); + expect(providerDisabled.packages.find(({ id }) => id === consumerId)).toMatchObject({ + enabled: true, + state: "blocked", + error: `Missing dependency: ${databaseCapability}`, + }); + const remainingCommandIds = (yield* catalog.list).commands.map(({ id }) => id); + expect(remainingCommandIds).not.toContain(providerCommandId); + expect(remainingCommandIds).not.toContain(consumerCommandId); + }), + ); + }), + ); + it.effect("keeps the previous generation when import or activation fails during reload", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index 0085c8d3b83a..f3c11c3f5f04 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -102,10 +102,19 @@ const makeDefinition = ( onCleanupError: (error: unknown) => void, ): PluginDefinition => { const declaredCommands = new Set(discovered.manifest.contributes?.commands ?? []); + const providedCapabilities = Object.fromEntries( + (discovered.manifest.provides ?? []).map((capability) => [ + capability, + Object.freeze({ capability, packageId: discovered.manifest.id }), + ]), + ); return { id: discovered.manifest.id, version: discovered.manifest.version, + requires: [...(discovered.manifest.requires ?? [])], + optional: [...(discovered.manifest.optional ?? [])], + provides: providedCapabilities, activate(context: PluginActivationContext) { context.onDispose(onRetired); const api: PluginPackageApi = { @@ -393,10 +402,11 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { const statusUnlocked = Effect.fn("PluginPackageManager.status")(function* ( operation: PluginPackageOperation, ): Effect.fn.Return { - const [discovery, enabledIds] = yield* Effect.all( + const [discovery, enabledIds, composition] = yield* Effect.all( [ discover(operation), readEnabledIds.pipe(Effect.mapError((error) => operationError(operation, error))), + catalog.composition, ], { concurrency: "unbounded" }, ); @@ -410,15 +420,29 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { const packageManifest = activeManifest ?? discovered.get(id)?.manifest; if (packageManifest === undefined) continue; const enabled = enabledIds.has(id); - const active = activeDefinitions.has(id); + const active = composition.active.includes(id); + const blocked = composition.blocked[id]; + const packageError = packageErrors.get(id); const error = - packageErrors.get(id) ?? (enabled && !active ? "enabled package is not active" : undefined); + packageError ?? + blocked ?? + (enabled && !active ? "enabled package is not active" : undefined); + const state = + packageError !== undefined + ? "error" + : blocked !== undefined + ? "blocked" + : error !== undefined + ? "error" + : active + ? "active" + : "disabled"; packages.push({ id: packageManifest.id, version: packageManifest.version, apiVersion: packageManifest.apiVersion, enabled, - state: error !== undefined ? "error" : active ? "active" : "disabled", + state, capabilities: [...packageManifest.capabilities], contributions: { commands: [...(packageManifest.contributes?.commands ?? [])] }, ...(error === undefined ? {} : { error }), diff --git a/apps/web/src/components/settings/PluginsSettings.test.tsx b/apps/web/src/components/settings/PluginsSettings.test.tsx index e7399e284ced..373269c0b2b0 100644 --- a/apps/web/src/components/settings/PluginsSettings.test.tsx +++ b/apps/web/src/components/settings/PluginsSettings.test.tsx @@ -183,6 +183,28 @@ describe("PluginsSettingsPanel", () => { ).not.toBeNull(); }); + it("presents dependency-blocked packages distinctly from activation errors", () => { + query.data = { + errors: [], + packages: [ + { + ...snapshot.packages[0]!, + state: "blocked", + error: "Missing dependency: acme.database@1", + }, + ], + }; + const panel = renderPanel(); + const blockedRow = renderPackageRow(panel, "com.acme.active"); + + expect( + visitElements( + blockedRow, + (element) => element.props.variant === "warning" && element.props.children === "Blocked", + ), + ).not.toBeNull(); + }); + it("routes disable, enable, reload, and refresh to the primary environment", async () => { const panel = renderPanel(); const activeRow = renderPackageRow(panel, "com.acme.active"); diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index 2d2fdce40d2c..cbad4fc4b91e 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -33,6 +33,7 @@ import { searchableSetting } from "./settingsSearch"; const statePresentation = { active: { label: "Active", variant: "success" }, disabled: { label: "Disabled", variant: "secondary" }, + blocked: { label: "Blocked", variant: "warning" }, error: { label: "Error", variant: "error" }, } as const; diff --git a/packages/contracts/src/pluginPackages.test.ts b/packages/contracts/src/pluginPackages.test.ts index 5dd44ed5a994..657df0389528 100644 --- a/packages/contracts/src/pluginPackages.test.ts +++ b/packages/contracts/src/pluginPackages.test.ts @@ -56,6 +56,28 @@ describe("plugin package contracts", () => { }); }); + it("decodes an enabled package blocked by dependency resolution", () => { + expect( + decodeStatus({ + errors: [], + packages: [ + { + id: "com.acme.issues", + version: "1.0.0", + apiVersion: 1, + enabled: true, + state: "blocked", + capabilities: ["t3.commands@1"], + contributions: { commands: ["acme.issues.create"] }, + error: "Missing dependency: acme.database@1", + }, + ], + }), + ).toMatchObject({ + packages: [{ id: "com.acme.issues", state: "blocked" }], + }); + }); + it("rejects malformed package ids and action payloads", () => { expect(() => decodeAction({ id: "runtime-status" })).toThrow(); expect(() => decodeAction({ id: `com.${"a".repeat(252)}` })).toThrow(); diff --git a/packages/contracts/src/pluginPackages.ts b/packages/contracts/src/pluginPackages.ts index f2d874a49610..7cca50dedecc 100644 --- a/packages/contracts/src/pluginPackages.ts +++ b/packages/contracts/src/pluginPackages.ts @@ -14,7 +14,7 @@ export const PluginPackageCapability = Schema.String.check( ); export type PluginPackageCapability = typeof PluginPackageCapability.Type; -export const PluginPackageState = Schema.Literals(["disabled", "active", "error"]); +export const PluginPackageState = Schema.Literals(["disabled", "active", "blocked", "error"]); export type PluginPackageState = typeof PluginPackageState.Type; export const PluginPackageContributions = Schema.Struct({ diff --git a/packages/plugin-runtime/README.md b/packages/plugin-runtime/README.md index a6c2216ab57b..73ea3a9aa778 100644 --- a/packages/plugin-runtime/README.md +++ b/packages/plugin-runtime/README.md @@ -6,6 +6,12 @@ it uses a deterministic, stack-safe reconciliation planner and one effect child cordis is not a dependency. a pure-only executor was rejected because plugin lifetimes and async cleanup should be owned by effect scopes. +## dependencies + +`requires` capabilities block activation when no active provider exists. `optional` capabilities order an available provider before the consumer but do not block activation when absent; optional edges that would create a cycle are ignored deterministically. required cycles and duplicate capability providers reject before publication. + +activation code reads required capabilities with `resolve(...)` and optional capabilities with `resolveOptional(...)`. undeclared access fails activation. changing, adding, or removing a provider restarts its required and optional dependents while unrelated plugin scopes remain live. + ## contributions plugins register detached, deeply frozen, json-compatible declarative metadata and an optional host-only live value. snapshots and `contributions(slot)` expose only frozen metadata. executable values never cross the rpc boundary. diff --git a/packages/plugin-runtime/src/contract.ts b/packages/plugin-runtime/src/contract.ts index ae0df5fc5094..7c626cea3a1d 100644 --- a/packages/plugin-runtime/src/contract.ts +++ b/packages/plugin-runtime/src/contract.ts @@ -16,12 +16,14 @@ export interface PluginDefinition { readonly id: string; readonly version: string; readonly requires?: ReadonlyArray; + readonly optional?: ReadonlyArray; readonly provides?: Readonly>; readonly activate: (context: PluginActivationContext) => void | Promise; } export interface PluginActivationContext { readonly resolve: (capability: string) => Service; + readonly resolveOptional: (capability: string) => Service | undefined; readonly register: { (slot: string, contribution: Contribution): void; (slot: string, contribution: Contribution, value: Value): void; diff --git a/packages/plugin-runtime/src/planner.ts b/packages/plugin-runtime/src/planner.ts index 94f5c7a7d080..698e8e9f5bd3 100644 --- a/packages/plugin-runtime/src/planner.ts +++ b/packages/plugin-runtime/src/planner.ts @@ -50,6 +50,108 @@ export const isPluginPlanningError = Schema.is(PluginPlanningError); const createNullPrototypeRecord = (): Record => Object.create(null) as Record; +const orderWithOptionalDependencies = ( + definitions: ReadonlyArray, + providersByCapability: ReadonlyMap, +): ReadonlyArray => { + const activeById = new Map(definitions.map((definition) => [definition.id, definition])); + const originalIndex = new Map(definitions.map((definition, index) => [definition.id, index])); + const outgoing = new Map(definitions.map((definition) => [definition.id, new Set()])); + const indegree = new Map(definitions.map((definition) => [definition.id, 0])); + const addEdge = (providerId: string, consumerId: string): boolean => { + const consumers = outgoing.get(providerId); + if (consumers === undefined || consumers.has(consumerId)) return false; + consumers.add(consumerId); + indegree.set(consumerId, (indegree.get(consumerId) ?? 0) + 1); + return true; + }; + const hasPath = (start: string, target: string): boolean => { + const pending = [start]; + const visited = new Set(); + while (pending.length > 0) { + const id = pending.pop(); + if (id === undefined || visited.has(id)) continue; + if (id === target) return true; + visited.add(id); + pending.push(...(outgoing.get(id) ?? [])); + } + return false; + }; + + for (const definition of definitions) { + for (const capability of definition.requires ?? []) { + const provider = providersByCapability.get(capability); + if (provider !== undefined && activeById.has(provider.id)) { + addEdge(provider.id, definition.id); + } + } + } + let optionalEdgeAdded = false; + for (const definition of definitions) { + for (const capability of [...(definition.optional ?? [])].sort()) { + const provider = providersByCapability.get(capability); + if ( + provider === undefined || + !activeById.has(provider.id) || + provider.id === definition.id || + hasPath(definition.id, provider.id) + ) { + continue; + } + optionalEdgeAdded = addEdge(provider.id, definition.id) || optionalEdgeAdded; + } + } + + if (!optionalEdgeAdded) return definitions; + + const byOriginalOrder = (left: string, right: string) => + (originalIndex.get(left) ?? 0) - (originalIndex.get(right) ?? 0); + const ready: Array = []; + const pushReady = (id: string) => { + ready.push(id); + for (let index = ready.length - 1; index > 0; ) { + const parent = Math.floor((index - 1) / 2); + if (byOriginalOrder(ready[parent]!, ready[index]!) <= 0) break; + [ready[parent], ready[index]] = [ready[index]!, ready[parent]!]; + index = parent; + } + }; + const popReady = (): string | undefined => { + const first = ready[0]; + const last = ready.pop(); + if (first === undefined || last === undefined || ready.length === 0) return first; + ready[0] = last; + for (let index = 0; ; ) { + const left = index * 2 + 1; + const right = left + 1; + if (left >= ready.length) break; + const smallest = + right < ready.length && byOriginalOrder(ready[right]!, ready[left]!) < 0 ? right : left; + if (byOriginalOrder(ready[index]!, ready[smallest]!) <= 0) break; + [ready[index], ready[smallest]] = [ready[smallest]!, ready[index]!]; + index = smallest; + } + return first; + }; + for (const definition of definitions) { + if (indegree.get(definition.id) === 0) pushReady(definition.id); + } + const ordered: Array = []; + while (ready.length > 0) { + const id = popReady(); + if (id === undefined) break; + const definition = activeById.get(id); + if (definition === undefined) continue; + ordered.push(definition); + for (const consumerId of outgoing.get(id) ?? []) { + const remaining = (indegree.get(consumerId) ?? 0) - 1; + indegree.set(consumerId, remaining); + if (remaining === 0) pushReady(consumerId); + } + } + return ordered; +}; + export const planComposition = ( definitions: ReadonlyArray, ): PlannedComposition => { @@ -144,7 +246,7 @@ export const planComposition = ( } } - return { blocked, definitions: ordered }; + return { blocked, definitions: orderWithOptionalDependencies(ordered, providersByCapability) }; }; const sameStrings = (left: ReadonlyArray, right: ReadonlyArray): boolean => { @@ -163,6 +265,7 @@ const sameDefinition = (left: PluginDefinition, right: PluginDefinition): boolea return false; } if (!sameStrings(left.requires ?? [], right.requires ?? [])) return false; + if (!sameStrings(left.optional ?? [], right.optional ?? [])) return false; const leftProvides = left.provides ?? {}; const rightProvides = right.provides ?? {}; @@ -187,7 +290,7 @@ const dependentsByPlugin = (definitions: ReadonlyArray) => { } } for (const definition of definitions) { - for (const capability of definition.requires ?? []) { + for (const capability of [...(definition.requires ?? []), ...(definition.optional ?? [])]) { const providerId = providers.get(capability); if (providerId === undefined) continue; const values = dependents.get(providerId) ?? new Set(); diff --git a/packages/plugin-runtime/src/runtime.ts b/packages/plugin-runtime/src/runtime.ts index fb8ca7095947..f2078c2d77d4 100644 --- a/packages/plugin-runtime/src/runtime.ts +++ b/packages/plugin-runtime/src/runtime.ts @@ -89,7 +89,7 @@ class PluginUndeclaredCapabilityError extends Schema.TaggedErrorClass()( "PluginActivationContextExpiredError", { - method: Schema.Literals(["resolve", "register", "onDispose"]), + method: Schema.Literals(["resolve", "resolveOptional", "register", "onDispose"]), pluginId: Schema.String, }, ) { @@ -306,6 +306,8 @@ const snapshotDefinitions = ( definitions.map((definition) => { const requires = definition.requires === undefined ? undefined : Object.freeze([...definition.requires]); + const optional = + definition.optional === undefined ? undefined : Object.freeze([...definition.optional]); const provides = definition.provides === undefined ? undefined @@ -315,6 +317,7 @@ const snapshotDefinitions = ( version: definition.version, activate: definition.activate, ...(requires === undefined ? {} : { requires }), + ...(optional === undefined ? {} : { optional }), ...(provides === undefined ? {} : { provides }), }); }); @@ -486,7 +489,9 @@ export const make = (options: PluginRuntimeOptions = {}) => const finalizers: Array<() => void | Promise> = []; const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors }; let activating = true; - const assertActivating = (method: "resolve" | "register" | "onDispose") => { + const assertActivating = ( + method: "resolve" | "resolveOptional" | "register" | "onDispose", + ) => { if (!activating) { throw new PluginActivationContextExpiredError({ method, pluginId: definition.id }); } @@ -503,6 +508,13 @@ export const make = (options: PluginRuntimeOptions = {}) => } return capabilities.get(capability) as Service; }, + resolveOptional: (capability: string): Service | undefined => { + assertActivating("resolveOptional"); + if (!(definition.optional ?? []).includes(capability)) { + throw new PluginUndeclaredCapabilityError({ capability, pluginId: definition.id }); + } + return capabilities.get(capability) as Service | undefined; + }, register: ( slot: string, contribution: Contribution, diff --git a/packages/plugin-runtime/test/runtimeContract.ts b/packages/plugin-runtime/test/runtimeContract.ts index bdab09596d83..094b72fefd06 100644 --- a/packages/plugin-runtime/test/runtimeContract.ts +++ b/packages/plugin-runtime/test/runtimeContract.ts @@ -76,6 +76,19 @@ const consumer = (): PluginDefinition => ({ }, }); +const optionalConsumer = (): PluginDefinition => ({ + id: "acme.optional-issues", + version: "1.0.0", + optional: ["acme.database@1"], + activate(context) { + const database = context.resolveOptional<{ readonly name: string }>("acme.database@1"); + context.register("commands", { + id: "create-optional-issue", + label: database?.name ?? "database unavailable", + }); + }, +}); + export function defineRuntimeContract(name: string, createRuntime: TestPluginRuntimeFactory) { describe(name, () => { it.effect("activates providers before consumers regardless of manifest order", () => @@ -114,6 +127,50 @@ export function defineRuntimeContract(name: string, createRuntime: TestPluginRun ), ); + it.effect("orders available optional dependencies without blocking a missing one", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + const consumerDefinition = optionalConsumer(); + + const withoutProvider = yield* runtime.reconcile([consumerDefinition]); + expect(withoutProvider.active).toEqual(["acme.optional-issues"]); + expect(contributionLabels(withoutProvider, "commands")).toEqual(["database unavailable"]); + + const withProvider = yield* runtime.reconcile([consumerDefinition, provider()]); + expect(withProvider.active).toEqual(["acme.database", "acme.optional-issues"]); + expect(contributionLabels(withProvider, "commands")).toEqual(["database-1.0.0"]); + yield* runtime.dispose; + }), + ), + ); + + it.effect("drops optional ordering edges that would create a cycle", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + const first: PluginDefinition = { + id: "acme.first", + version: "1.0.0", + optional: ["acme.second@1"], + provides: { "acme.first@1": "first" }, + activate() {}, + }; + const second: PluginDefinition = { + id: "acme.second", + version: "1.0.0", + optional: ["acme.first@1"], + provides: { "acme.second@1": "second" }, + activate() {}, + }; + + const snapshot = yield* runtime.reconcile([first, second]); + expect(snapshot.active).toEqual(["acme.second", "acme.first"]); + yield* runtime.dispose; + }), + ), + ); + it.effect("deactivates dependents before providers", () => Effect.scoped( Effect.gen(function* () { @@ -927,6 +984,19 @@ export function defineRuntimeContract(name: string, createRuntime: TestPluginRun ); expect(failureMessage(undeclaredFailure)).toContain("did not declare"); expect((yield* runtime.snapshot).active).toEqual([]); + + const undeclaredOptionalConsumer: PluginDefinition = { + id: "acme.undeclared-optional-consumer", + version: "1.0.0", + activate(context) { + context.resolveOptional("acme.database@1"); + }, + }; + const undeclaredOptionalFailure = yield* failureOf( + runtime.reconcile([provider(), undeclaredOptionalConsumer]), + ); + expect(failureMessage(undeclaredOptionalFailure)).toContain("did not declare"); + expect((yield* runtime.snapshot).active).toEqual([]); yield* runtime.dispose; }), ), @@ -1024,6 +1094,35 @@ export function defineRuntimeContract(name: string, createRuntime: TestPluginRun ), ); + it.effect( + "rejects duplicate capability providers without disturbing the current composition", + () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* createRuntime(); + yield* runtime.reconcile([provider()]); + const first: PluginDefinition = { + id: "acme.first-provider", + version: "1.0.0", + provides: { "acme.shared@1": "first" }, + activate() {}, + }; + const second: PluginDefinition = { + id: "acme.second-provider", + version: "1.0.0", + provides: { "acme.shared@1": "second" }, + activate() {}, + }; + + expect(failureMessage(yield* failureOf(runtime.reconcile([first, second])))).toContain( + "Duplicate capability", + ); + expect((yield* runtime.snapshot).active).toEqual(["acme.database"]); + yield* runtime.dispose; + }), + ), + ); + it.effect("rejects dependency cycles without disturbing the current composition", () => Effect.scoped( Effect.gen(function* () { From 700d682d699ae49417cda5686c0a33ab3e822f6f Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:43:53 +0000 Subject: [PATCH 26/45] fix: harden plugin startup recovery --- .../src/plugins/PluginPackageManager.test.ts | 211 ++++++++++++++++++ .../src/plugins/PluginPackageManager.ts | 43 ++-- examples/plugins/runtime-status/README.md | 6 +- 3 files changed, 237 insertions(+), 23 deletions(-) diff --git a/apps/server/src/plugins/PluginPackageManager.test.ts b/apps/server/src/plugins/PluginPackageManager.test.ts index 48d70d11c98f..8137d4118df7 100644 --- a/apps/server/src/plugins/PluginPackageManager.test.ts +++ b/apps/server/src/plugins/PluginPackageManager.test.ts @@ -2,7 +2,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ServerSettingsError } from "@t3tools/contracts"; import { it } from "@effect/vitest"; import { expect } from "vite-plus/test"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -19,6 +21,8 @@ import * as PluginPackageManager from "./PluginPackageManager.ts"; const packageId = "com.acme.runtime-status"; const commandId = "acme.runtime-status"; +let testSymbolSequence = 0; +const nextTestSymbol = (prefix: string) => `${prefix}-${testSymbolSequence++}`; const manifest = { manifestVersion: 1, @@ -68,6 +72,49 @@ export default function activate(api) { } `; +const retryingCommandPluginSource = ( + id: string, + label: string, + attemptsSymbol: string, + failThroughAttempt = 1, +) => ` +export default function activate(api) { + const key = Symbol.for(${encodeJsonString(attemptsSymbol)}); + const attempts = (Reflect.get(globalThis, key) ?? 0) + 1; + Reflect.set(globalThis, key, attempts); + if (attempts <= ${String(failThroughAttempt)}) throw new Error("startup activation failed"); + api.registerCommand( + { + id: ${encodeJsonString(id)}, + label: ${encodeJsonString(label)}, + surfaces: ["web", "desktop"] + }, + () => ({ message: ${encodeJsonString(label)}, tone: "success" }) + ); +} +`; + +const gatedCommandPluginSource = ( + id: string, + gateEnabledSymbol: string, + startedSymbol: string, + releaseSymbol: string, +) => ` +export default async function activate(api) { + if (Reflect.get(globalThis, Symbol.for(${encodeJsonString(gateEnabledSymbol)})) === true) { + const markStarted = Reflect.get(globalThis, Symbol.for(${encodeJsonString(startedSymbol)})); + if (typeof markStarted === "function") markStarted(); + await new Promise((resolve) => { + Reflect.set(globalThis, Symbol.for(${encodeJsonString(releaseSymbol)}), resolve); + }); + } + api.registerCommand( + { id: ${encodeJsonString(id)}, label: "gated", surfaces: ["web"] }, + () => ({ message: "gated", tone: "success" }) + ); +} +`; + const pluginSourceWithHelper = ` import { message } from "./message.mjs"; @@ -207,6 +254,170 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { }), ); + it.effect("retries a transient package activation failure during startup", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-startup-retry-test-", + }); + const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; + const attemptsSymbol = nextTestSymbol("t3code-plugin-startup-retry"); + yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest(manifest), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + retryingCommandPluginSource(commandId, "startup retry", attemptsSymbol), + ); + Reflect.set(globalThis, Symbol.for(attemptsSymbol), 1); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + yield* manager.enable(packageId); + }), + ); + Reflect.set(globalThis, Symbol.for(attemptsSymbol), 0); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + expect(yield* manager.status).toMatchObject({ + packages: [{ id: packageId, state: "active" }], + }); + expect((yield* catalog.list).commands.map(({ id }) => id)).toContain(commandId); + }), + ); + expect(Reflect.get(globalThis, Symbol.for(attemptsSymbol))).toBe(2); + Reflect.deleteProperty(globalThis, Symbol.for(attemptsSymbol)); + }), + ); + + it.effect("continues startup after a persistent package failure", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-startup-isolation-test-", + }); + const failingId = "com.acme.a-failing"; + const workingId = "com.acme.z-working"; + const failingCommandId = "acme.failing.status"; + const workingCommandId = "acme.working.status"; + const attemptsSymbol = nextTestSymbol("t3code-plugin-startup-failing"); + for (const [id, declaredCommand] of [ + [failingId, failingCommandId], + [workingId, workingCommandId], + ] as const) { + const directory = `${baseDir}/userdata/plugins/${id}`; + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString( + `${directory}/t3-plugin.json`, + encodeManifest({ ...manifest, id, contributes: { commands: [declaredCommand] } }), + ); + yield* fileSystem.writeFileString( + `${directory}/index.mjs`, + id === failingId + ? retryingCommandPluginSource(declaredCommand, id, attemptsSymbol, 2) + : commandPluginSource(declaredCommand, id), + ); + } + Reflect.set(globalThis, Symbol.for(attemptsSymbol), 2); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + yield* manager.enable(failingId); + yield* manager.enable(workingId); + }), + ); + Reflect.set(globalThis, Symbol.for(attemptsSymbol), 0); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + const status = yield* manager.status; + expect(status.packages.find(({ id }) => id === failingId)).toMatchObject({ + enabled: true, + state: "error", + error: "startup activation failed", + }); + expect(status.packages.find(({ id }) => id === workingId)).toMatchObject({ + enabled: true, + state: "active", + }); + const commandIds = (yield* catalog.list).commands.map(({ id }) => id); + expect(commandIds).not.toContain(failingCommandId); + expect(commandIds).toContain(workingCommandId); + }), + ); + expect(Reflect.get(globalThis, Symbol.for(attemptsSymbol))).toBe(2); + Reflect.deleteProperty(globalThis, Symbol.for(attemptsSymbol)); + }), + ); + + it.effect("preserves interruption during startup activation", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-startup-interruption-test-", + }); + const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; + const gateEnabledSymbol = nextTestSymbol("t3code-plugin-startup-gate"); + const startedSymbol = `${gateEnabledSymbol}-started`; + const releaseSymbol = `${gateEnabledSymbol}-release`; + yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest(manifest), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + gatedCommandPluginSource(commandId, gateEnabledSymbol, startedSymbol, releaseSymbol), + ); + Reflect.set(globalThis, Symbol.for(gateEnabledSymbol), false); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + yield* manager.enable(packageId); + }), + ); + + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + Reflect.set(globalThis, Symbol.for(gateEnabledSymbol), true); + Reflect.set(globalThis, Symbol.for(startedSymbol), markStarted); + const startup = yield* Effect.forkChild( + useEnvironment(baseDir, Effect.asVoid(PluginPackageManager.PluginPackageManager)), + ); + yield* Effect.promise(() => started); + const interrupting = yield* Effect.forkChild(Fiber.interrupt(startup)); + yield* Effect.yieldNow; + const release = Reflect.get(globalThis, Symbol.for(releaseSymbol)); + expect(release).toBeTypeOf("function"); + if (typeof release === "function") release(); + yield* Fiber.join(interrupting); + const exit = yield* Fiber.await(startup); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true); + + for (const symbol of [gateEnabledSymbol, startedSymbol, releaseSymbol]) { + Reflect.deleteProperty(globalThis, Symbol.for(symbol)); + } + }), + ); + it.effect("loads the committed external runtime-status example without rebuilding", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index f3c11c3f5f04..566e157c1c2a 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -624,29 +624,30 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { yield* Effect.logWarning("Enabled local plugin package was not discovered", { id }); continue; } - const startup = yield* Effect.exit( - Effect.gen(function* () { - const loaded = yield* loadDefinition(pluginPackage, "status"); - const reconciled = yield* Effect.exit( - catalog - .reconcile(definitionList([id, loaded.definition])) - .pipe(Effect.mapError((error) => operationError("status", error, id))), - ); - if (reconciled._tag === "Failure") { - yield* removeCacheDirectory(loaded.cacheDirectory); - return yield* Effect.failCause(reconciled.cause); - } - activeDefinitions.set(id, loaded.definition); - activeCacheDirectories.set(id, loaded.cacheDirectory); - activeManifests.set(id, pluginPackage.manifest); - activeRetirements.set(id, loaded.retired); + yield* Effect.gen(function* () { + const loaded = yield* loadDefinition(pluginPackage, "status"); + const reconciled = yield* Effect.exit( + catalog + .reconcile(definitionList([id, loaded.definition])) + .pipe(Effect.mapError((error) => operationError("status", error, id))), + ); + if (reconciled._tag === "Failure") { + yield* removeCacheDirectory(loaded.cacheDirectory); + return yield* Effect.failCause(reconciled.cause); + } + activeDefinitions.set(id, loaded.definition); + activeCacheDirectories.set(id, loaded.cacheDirectory); + activeManifests.set(id, pluginPackage.manifest); + activeRetirements.set(id, loaded.retired); + }).pipe( + Effect.retry({ times: 1 }), + Effect.catchCause((cause) => { + if (Cause.hasInterrupts(cause)) return Effect.failCause(cause); + const detail = detailFromCause(cause); + packageErrors.set(id, detail); + return Effect.logWarning("Failed to activate enabled local plugin package", { id, detail }); }), ); - if (startup._tag === "Failure") { - const detail = detailFromCause(startup.cause); - packageErrors.set(id, detail); - yield* Effect.logWarning("Failed to activate enabled local plugin package", { id, detail }); - } } yield* Effect.addFinalizer(() => diff --git a/examples/plugins/runtime-status/README.md b/examples/plugins/runtime-status/README.md index 90b0bb2c2cb9..b7d41811ba35 100644 --- a/examples/plugins/runtime-status/README.md +++ b/examples/plugins/runtime-status/README.md @@ -2,12 +2,14 @@ this is the minimal trusted local plugin package used to prove the package lifecycle. plugins run in the server process with the server's full permissions, so only install code you trust. -copy this directory to: +copy this directory into the active environment's plugin directory: ```text -~/.t3/userdata/plugins/com.t3code.runtime-status-example +/userdata/plugins/com.t3code.runtime-status-example ``` +for a normal local install, `` is usually `~/.t3`. development servers use the worktree's `.t3` directory, and an explicit `T3CODE_HOME` or `--base-dir` uses that configured directory. + t3 code discovers the package without a rebuild. use `pluginPackages.status` to inspect it and `pluginPackages.enable` with the manifest id to enable it for that environment. `pluginPackages.reload` re-evaluates the entrypoint, and `pluginPackages.disable` removes its contributions. once enabled, `example.runtime-status` appears in the web and desktop command palettes. local packages run in the server process and are fully trusted. marketplace distribution, signing, sandboxing, and renderer code are not part of this mvp. From a8c3e8faf9a028480ac3048c57f10480eacb302a Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:53:08 +0000 Subject: [PATCH 27/45] feat: add plugin capability broker Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../PluginHostCapabilityBroker.test.ts | 193 +++++ .../src/plugins/PluginHostCapabilityBroker.ts | 690 ++++++++++++++++++ .../src/plugins/PluginPackageManager.test.ts | 138 +++- .../src/plugins/PluginPackageManager.ts | 107 ++- apps/server/src/ws.ts | 8 +- .../settings/PluginsSettings.test.tsx | 26 + .../components/settings/PluginsSettings.tsx | 20 +- examples/plugins/runtime-status/README.md | 18 +- packages/contracts/src/pluginPackages.test.ts | 29 + packages/contracts/src/pluginPackages.ts | 24 + packages/plugin-runtime/src/manifest.ts | 21 +- packages/plugin-runtime/test/manifest.test.ts | 12 +- 12 files changed, 1269 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/plugins/PluginHostCapabilityBroker.test.ts create mode 100644 apps/server/src/plugins/PluginHostCapabilityBroker.ts diff --git a/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts b/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts new file mode 100644 index 000000000000..814b797d7909 --- /dev/null +++ b/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts @@ -0,0 +1,193 @@ +import { it } from "@effect/vitest"; +import { expect } from "vite-plus/test"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { NodeServices } from "@effect/platform-node"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as PluginHostCapabilityBroker from "./PluginHostCapabilityBroker.ts"; + +const pluginId = "com.acme.data"; + +const makeLayer = (baseDir: string) => { + const configLayer = Layer.fresh(ServerConfig.layerTest(process.cwd(), baseDir)); + return PluginHostCapabilityBroker.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer.pipe(Layer.provideMerge(configLayer))), + Layer.provideMerge(configLayer), + ); +}; + +const useBroker = ( + baseDir: string, + effect: Effect.Effect, +) => Effect.scoped(effect.pipe(Effect.provide(makeLayer(baseDir)))); + +it.layer(NodeServices.layer)("plugin host capability broker", (it) => { + it.effect("requires an explicit persisted grant before opening host capabilities", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-host-grants-test-", + }); + const requested = ["settings:read-write", "state:read-write"]; + + const denied = yield* Effect.exit( + useBroker( + baseDir, + Effect.gen(function* () { + const broker = yield* PluginHostCapabilityBroker.PluginHostCapabilityBroker; + return yield* broker.open(pluginId, requested); + }), + ), + ); + expect(denied._tag).toBe("Failure"); + + yield* useBroker( + baseDir, + Effect.gen(function* () { + const broker = yield* PluginHostCapabilityBroker.PluginHostCapabilityBroker; + expect((yield* Effect.exit(broker.grant(pluginId, ["filesystem:/tmp"])))._tag).toBe( + "Failure", + ); + yield* broker.grant(pluginId, requested); + expect(yield* broker.granted(pluginId)).toEqual(requested); + }), + ); + + yield* useBroker( + baseDir, + Effect.gen(function* () { + const broker = yield* PluginHostCapabilityBroker.PluginHostCapabilityBroker; + expect(yield* broker.granted(pluginId)).toEqual(requested); + yield* broker.open(pluginId, requested); + }), + ); + }), + ); + + it.effect("keeps settings, state, and cache detached and isolated by plugin", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-host-data-test-", + }); + const permissions = ["settings:read-write", "state:read-write", "cache:read-write"]; + + yield* useBroker( + baseDir, + Effect.gen(function* () { + const broker = yield* PluginHostCapabilityBroker.PluginHostCapabilityBroker; + yield* broker.grant(pluginId, permissions); + yield* broker.grant("com.acme.other", permissions); + const api = yield* broker.open(pluginId, permissions); + const mutable = { nested: ["before"] }; + yield* api.settings.set("preferences", mutable); + mutable.nested[0] = "after"; + yield* api.state.set("cursor", { value: 7 }); + yield* api.cache.set("response", { ok: true }); + + expect(yield* api.settings.get("preferences")).toEqual({ nested: ["before"] }); + expect(yield* api.state.get("cursor")).toEqual({ value: 7 }); + expect(yield* api.cache.get("response")).toEqual({ ok: true }); + + const other = yield* broker.open("com.acme.other", permissions); + expect(yield* other.settings.get("preferences")).toBeUndefined(); + yield* api.cache.clear; + expect(yield* api.cache.get("response")).toBeUndefined(); + }), + ); + + yield* useBroker( + baseDir, + Effect.gen(function* () { + const broker = yield* PluginHostCapabilityBroker.PluginHostCapabilityBroker; + const api = yield* broker.open(pluginId, permissions); + expect(yield* api.settings.get("preferences")).toEqual({ nested: ["before"] }); + expect(yield* api.state.get("cursor")).toEqual({ value: 7 }); + expect(yield* api.cache.get("response")).toBeUndefined(); + }), + ); + }), + ); + + it.effect( + "brokers namespaced secrets and files while rejecting undeclared access and traversal", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-host-authority-test-", + }); + const permissions = ["secrets:api-token", "filesystem:data"]; + + yield* useBroker( + baseDir, + Effect.gen(function* () { + const broker = yield* PluginHostCapabilityBroker.PluginHostCapabilityBroker; + yield* broker.grant(pluginId, permissions); + const api = yield* broker.open(pluginId, permissions); + yield* api.secrets.set("api-token", "secret-value"); + expect(yield* api.secrets.get("api-token")).toBe("secret-value"); + expect((yield* Effect.exit(api.secrets.get("other")))._tag).toBe("Failure"); + + yield* api.files.writeText("nested/value.txt", "plugin-owned"); + expect(yield* api.files.readText("nested/value.txt")).toBe("plugin-owned"); + expect((yield* Effect.exit(api.files.readText("../outside.txt")))._tag).toBe("Failure"); + + const outside = path.join(baseDir, "outside.txt"); + const linked = path.join( + baseDir, + "userdata", + "plugin-data", + pluginId, + "files", + "linked.txt", + ); + yield* fileSystem.writeFileString(outside, "outside"); + yield* fileSystem.symlink(outside, linked); + expect((yield* Effect.exit(api.files.readText("linked.txt")))._tag).toBe("Failure"); + + const outsideDirectory = path.join(baseDir, "outside-directory"); + const escapedDirectory = path.join(path.dirname(linked), "escape"); + yield* fileSystem.makeDirectory(outsideDirectory); + yield* fileSystem.symlink(outsideDirectory, escapedDirectory); + expect((yield* Effect.exit(api.files.writeText("escape/leak.txt", "leak")))._tag).toBe( + "Failure", + ); + expect(yield* fileSystem.exists(path.join(outsideDirectory, "leak.txt"))).toBe(false); + }), + ); + }), + ); + + it.effect("limits network and process calls to declared targets", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-host-external-test-", + }); + const permissions = ["network:https://example.com", "process:node"]; + + yield* useBroker( + baseDir, + Effect.gen(function* () { + const broker = yield* PluginHostCapabilityBroker.PluginHostCapabilityBroker; + yield* broker.grant(pluginId, permissions); + const api = yield* broker.open(pluginId, permissions); + expect((yield* Effect.exit(api.network.fetchText("https://example.org")))._tag).toBe( + "Failure", + ); + const result = yield* api.process.run("node", ["-e", "process.stdout.write('ok')"]); + expect(result).toEqual({ exitCode: 0, stdout: "ok", stderr: "" }); + expect((yield* Effect.exit(api.process.run("sh", ["-c", "exit 0"])))._tag).toBe( + "Failure", + ); + }), + ); + }), + ); +}); diff --git a/apps/server/src/plugins/PluginHostCapabilityBroker.ts b/apps/server/src/plugins/PluginHostCapabilityBroker.ts new file mode 100644 index 000000000000..297f40dfc37b --- /dev/null +++ b/apps/server/src/plugins/PluginHostCapabilityBroker.ts @@ -0,0 +1,690 @@ +import { NodeHttpClient } from "@effect/platform-node"; +import { PluginHostPermission } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; + +const MAX_DATA_FILE_BYTES = 1_000_000; +const MAX_EXTERNAL_OUTPUT_BYTES = 1_000_000; +const EXTERNAL_OPERATION_TIMEOUT = "30 seconds"; +const pluginIdPattern = /^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/; +const dataKeyPattern = /^[a-z0-9][a-z0-9._-]{0,127}$/; +const processNamePattern = /^[A-Za-z0-9._+-]{1,128}$/; + +type JsonValue = Schema.Json; + +const StoreEntry = Schema.Struct({ key: Schema.String, value: Schema.Json }); +const StoreFile = Schema.Struct({ entries: Schema.Array(StoreEntry) }); +const GrantEntry = Schema.Struct({ + id: Schema.String, + permissions: Schema.Array(PluginHostPermission), +}); +const GrantFile = Schema.Struct({ grants: Schema.Array(GrantEntry) }); +const decodeStoreFile = Schema.decodeUnknownEffect(Schema.fromJsonString(StoreFile)); +const encodeStoreFile = Schema.encodeEffect(Schema.fromJsonString(StoreFile)); +const decodeGrantFile = Schema.decodeUnknownEffect(Schema.fromJsonString(GrantFile)); +const encodeGrantFile = Schema.encodeEffect(Schema.fromJsonString(GrantFile)); +const decodeJson = Schema.decodeUnknownEffect(Schema.Json); +const decodePermissions = Schema.decodeUnknownEffect(Schema.Array(PluginHostPermission)); + +export class PluginHostCapabilityError extends Schema.TaggedErrorClass()( + "PluginHostCapabilityError", + { + pluginId: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `${this.operation} failed for plugin ${this.pluginId}: ${this.detail}`; + } +} + +const isPluginHostCapabilityError = Schema.is(PluginHostCapabilityError); + +export interface PluginHostKeyValueStore { + readonly get: (key: string) => Effect.Effect; + readonly set: (key: string, value: unknown) => Effect.Effect; + readonly delete: (key: string) => Effect.Effect; + readonly clear: Effect.Effect; +} + +export interface PluginHostApi { + readonly settings: PluginHostKeyValueStore; + readonly state: PluginHostKeyValueStore; + readonly cache: PluginHostKeyValueStore; + readonly secrets: { + readonly get: (name: string) => Effect.Effect; + readonly set: (name: string, value: string) => Effect.Effect; + readonly delete: (name: string) => Effect.Effect; + }; + readonly files: { + readonly readText: (relativePath: string) => Effect.Effect; + readonly writeText: ( + relativePath: string, + contents: string, + ) => Effect.Effect; + readonly remove: (relativePath: string) => Effect.Effect; + }; + readonly network: { + readonly fetchText: (url: string) => Effect.Effect< + { + readonly status: number; + readonly headers: Readonly>; + readonly body: string; + }, + PluginHostCapabilityError + >; + }; + readonly process: { + readonly run: ( + command: string, + args?: ReadonlyArray, + ) => Effect.Effect< + { readonly exitCode: number; readonly stdout: string; readonly stderr: string }, + PluginHostCapabilityError + >; + }; +} + +export class PluginHostCapabilityBroker extends Context.Service< + PluginHostCapabilityBroker, + { + readonly granted: ( + pluginId: string, + ) => Effect.Effect, PluginHostCapabilityError>; + readonly snapshot: Effect.Effect< + ReadonlyMap>, + PluginHostCapabilityError + >; + readonly grant: ( + pluginId: string, + permissions: ReadonlyArray, + ) => Effect.Effect; + readonly open: ( + pluginId: string, + requestedPermissions: ReadonlyArray, + ) => Effect.Effect; + } +>()("t3/plugins/PluginHostCapabilityBroker") {} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const httpClient = yield* HttpClient.HttpClient; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const semaphore = yield* Semaphore.make(1); + const pluginDataRoot = path.join(serverConfig.stateDir, "plugin-data"); + const grantFilePath = path.join(pluginDataRoot, "grants.json"); + const textEncoder = new TextEncoder(); + const textDecoder = new TextDecoder(); + const atomicWrite = (filePath: string, contents: string) => + writeFileStringAtomically({ filePath, contents }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const fail = ( + pluginId: string, + operation: string, + detail: string, + cause?: unknown, + ): PluginHostCapabilityError => + new PluginHostCapabilityError({ + pluginId, + operation, + detail, + ...(cause === undefined ? {} : { cause }), + }); + + const validatePluginId = ( + pluginId: string, + operation: string, + ): Effect.Effect => + pluginIdPattern.test(pluginId) + ? Effect.void + : Effect.fail(fail(pluginId, operation, "invalid plugin id")); + + const validateKey = ( + pluginId: string, + operation: string, + key: string, + ): Effect.Effect => + dataKeyPattern.test(key) + ? Effect.void + : Effect.fail(fail(pluginId, operation, `invalid key ${key}`)); + + const requirePermission = ( + pluginId: string, + requested: ReadonlySet, + permission: string, + operation: string, + ): Effect.Effect => + requested.has(permission) + ? Effect.void + : Effect.fail(fail(pluginId, operation, `permission not declared: ${permission}`)); + + const readTextIfPresent = Effect.fn("PluginHostCapabilityBroker.readTextIfPresent")(function* ( + filePath: string, + pluginId: string, + operation: string, + ): Effect.fn.Return, PluginHostCapabilityError> { + const info = yield* fileSystem.stat(filePath).pipe( + Effect.map(Option.some), + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail( + fail(pluginId, operation, `could not stat ${path.basename(filePath)}`, cause), + ), + ), + ); + if (Option.isNone(info)) return Option.none(); + if (info.value.size > BigInt(MAX_DATA_FILE_BYTES)) { + return yield* fail(pluginId, operation, `${path.basename(filePath)} exceeds data limit`); + } + const contents = yield* fileSystem + .readFileString(filePath) + .pipe( + Effect.mapError((cause) => + fail(pluginId, operation, `could not read ${path.basename(filePath)}`, cause), + ), + ); + if (Buffer.byteLength(contents, "utf8") > MAX_DATA_FILE_BYTES) { + return yield* fail(pluginId, operation, `${path.basename(filePath)} exceeds data limit`); + } + return Option.some(contents); + }); + + const readGrants = Effect.fn("PluginHostCapabilityBroker.readGrants")(function* ( + pluginId: string, + operation: string, + ): Effect.fn.Return>, PluginHostCapabilityError> { + const contents = yield* readTextIfPresent(grantFilePath, pluginId, operation); + if (Option.isNone(contents)) return new Map(); + const decoded = yield* decodeGrantFile(contents.value).pipe( + Effect.mapError((cause) => fail(pluginId, operation, "plugin grant file is invalid", cause)), + ); + return new Map( + decoded.grants.map(({ id, permissions }) => [id, [...new Set(permissions)].sort()] as const), + ); + }); + + const writeGrants = Effect.fn("PluginHostCapabilityBroker.writeGrants")(function* ( + pluginId: string, + operation: string, + grants: ReadonlyMap>, + ): Effect.fn.Return { + const contents = yield* encodeGrantFile({ + grants: [...grants.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([id, permissions]) => ({ id, permissions: [...permissions].sort() })), + }).pipe( + Effect.mapError((cause) => + fail(pluginId, operation, "could not encode plugin grants", cause), + ), + ); + yield* atomicWrite(grantFilePath, contents).pipe( + Effect.mapError((cause) => + fail(pluginId, operation, "could not persist plugin grants", cause), + ), + ); + }); + + const snapshot: PluginHostCapabilityBroker["Service"]["snapshot"] = semaphore.withPermits(1)( + readGrants("", "granted"), + ); + + const granted: PluginHostCapabilityBroker["Service"]["granted"] = (pluginId) => + semaphore.withPermits(1)( + Effect.gen(function* () { + yield* validatePluginId(pluginId, "granted"); + const grants = yield* readGrants(pluginId, "granted"); + return grants.get(pluginId) ?? []; + }), + ); + + const grant: PluginHostCapabilityBroker["Service"]["grant"] = (pluginId, permissions) => + semaphore.withPermits(1)( + Effect.gen(function* () { + yield* validatePluginId(pluginId, "grant"); + const validatedPermissions = yield* decodePermissions(permissions).pipe( + Effect.mapError((cause) => + fail(pluginId, "grant", "plugin permissions are invalid", cause), + ), + ); + const grants = yield* readGrants(pluginId, "grant"); + grants.set(pluginId, [...new Set(validatedPermissions)].sort()); + yield* writeGrants(pluginId, "grant", grants); + }), + ); + + const makeStore = ( + pluginId: string, + requested: ReadonlySet, + name: "settings" | "state" | "cache", + ): PluginHostKeyValueStore => { + const permission = `${name}:read-write`; + const filePath = path.join(pluginDataRoot, pluginId, `${name}.json`); + const operation = `${name} data`; + + const read = Effect.gen(function* () { + const contents = yield* readTextIfPresent(filePath, pluginId, operation); + if (Option.isNone(contents)) return new Map(); + const decoded = yield* decodeStoreFile(contents.value).pipe( + Effect.mapError((cause) => fail(pluginId, operation, `${name} data is invalid`, cause)), + ); + return new Map(decoded.entries.map(({ key, value }) => [key, value])); + }); + + const write = Effect.fn(`PluginHostCapabilityBroker.${name}.write`)(function* ( + entries: ReadonlyMap, + ): Effect.fn.Return { + const contents = yield* encodeStoreFile({ + entries: [...entries.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => ({ key, value })), + }).pipe( + Effect.mapError((cause) => + fail(pluginId, operation, `could not encode ${name} data`, cause), + ), + ); + if (Buffer.byteLength(contents, "utf8") > MAX_DATA_FILE_BYTES) { + return yield* fail( + pluginId, + operation, + `${name} data exceeds ${MAX_DATA_FILE_BYTES} bytes`, + ); + } + yield* atomicWrite(filePath, contents).pipe( + Effect.mapError((cause) => + fail(pluginId, operation, `could not persist ${name} data`, cause), + ), + ); + }); + + const use = ( + effect: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + yield* requirePermission(pluginId, requested, permission, operation); + return yield* semaphore.withPermits(1)(effect); + }); + + return { + get: (key) => + use( + Effect.gen(function* () { + yield* validateKey(pluginId, operation, key); + const entries = yield* read; + return entries.get(key); + }), + ), + set: (key, value) => + use( + Effect.gen(function* () { + yield* validateKey(pluginId, operation, key); + const detached = yield* decodeJson(value).pipe( + Effect.mapError((cause) => + fail(pluginId, operation, "value must be JSON-compatible", cause), + ), + ); + const entries = yield* read; + entries.set(key, detached); + yield* write(entries); + }), + ), + delete: (key) => + use( + Effect.gen(function* () { + yield* validateKey(pluginId, operation, key); + const entries = yield* read; + entries.delete(key); + yield* write(entries); + }), + ), + clear: use(write(new Map())), + }; + }; + + const secretResourceName = (pluginId: string, name: string) => + `plugin-${Buffer.from(pluginId, "utf8").toString("base64url")}-${Buffer.from(name, "utf8").toString("base64url")}`; + + const resolveFilePath = ( + pluginId: string, + relativePath: string, + ): Effect.Effect => { + const root = path.join(pluginDataRoot, pluginId, "files"); + const resolved = path.resolve(root, relativePath); + const relative = path.relative(root, resolved); + return relativePath.length > 0 && + !path.isAbsolute(relativePath) && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) + ? Effect.succeed(resolved) + : Effect.fail(fail(pluginId, "filesystem", "path escapes plugin data directory")); + }; + + const isContained = (root: string, target: string): boolean => { + const relative = path.relative(root, target); + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); + }; + + const resolveExistingFilePath = Effect.fn("PluginHostCapabilityBroker.resolveExistingFilePath")( + function* ( + pluginId: string, + relativePath: string, + operation: string, + ): Effect.fn.Return { + const root = path.join(pluginDataRoot, pluginId, "files"); + const lexical = yield* resolveFilePath(pluginId, relativePath); + const [canonicalRoot, canonicalTarget] = yield* Effect.all( + [fileSystem.realPath(root), fileSystem.realPath(lexical)], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => + fail(pluginId, operation, `could not resolve ${relativePath}`, cause), + ), + ); + if (!isContained(canonicalRoot, canonicalTarget)) { + return yield* fail(pluginId, operation, "path escapes plugin data directory"); + } + if (path.normalize(lexical) !== path.normalize(canonicalTarget)) { + return yield* fail(pluginId, operation, "symbolic links are not allowed in plugin data"); + } + return canonicalTarget; + }, + ); + + const resolveWritableFilePath = Effect.fn("PluginHostCapabilityBroker.resolveWritableFilePath")( + function* ( + pluginId: string, + relativePath: string, + ): Effect.fn.Return { + const root = path.join(pluginDataRoot, pluginId, "files"); + const lexical = yield* resolveFilePath(pluginId, relativePath); + const parent = path.dirname(lexical); + yield* fileSystem + .makeDirectory(parent, { recursive: true }) + .pipe( + Effect.mapError((cause) => + fail(pluginId, "filesystem write", `could not create ${relativePath}`, cause), + ), + ); + const [canonicalRoot, canonicalParent] = yield* Effect.all( + [fileSystem.realPath(root), fileSystem.realPath(parent)], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => + fail(pluginId, "filesystem write", `could not resolve ${relativePath}`, cause), + ), + ); + if (!isContained(canonicalRoot, canonicalParent)) { + return yield* fail(pluginId, "filesystem write", "path escapes plugin data directory"); + } + if (path.normalize(parent) !== path.normalize(canonicalParent)) { + return yield* fail( + pluginId, + "filesystem write", + "symbolic links are not allowed in plugin data", + ); + } + return path.join(canonicalParent, path.basename(lexical)); + }, + ); + + const concatChunks = (chunks: Iterable): Uint8Array => { + const arrays = [...chunks]; + const length = arrays.reduce((total, chunk) => total + chunk.byteLength, 0); + const output = new Uint8Array(length); + let offset = 0; + for (const chunk of arrays) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; + }; + + const collectBounded = ( + pluginId: string, + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + Stream.runFoldEffect( + stream, + () => ({ bytes: 0, chunks: [] as Array }), + (collected, chunk) => { + const bytes = collected.bytes + chunk.byteLength; + if (bytes > MAX_EXTERNAL_OUTPUT_BYTES) { + return Effect.fail(fail(pluginId, operation, `${operation} output exceeds limit`)); + } + collected.chunks.push(chunk); + return Effect.succeed({ bytes, chunks: collected.chunks }); + }, + ).pipe( + Effect.map((collected) => concatChunks(collected.chunks)), + Effect.mapError((cause) => + isPluginHostCapabilityError(cause) + ? cause + : fail(pluginId, operation, `${operation} output failed`, cause), + ), + ); + + const open: PluginHostCapabilityBroker["Service"]["open"] = (pluginId, requestedPermissions) => + Effect.gen(function* () { + yield* validatePluginId(pluginId, "open"); + const persistedPermissions = yield* granted(pluginId); + const grantedSet = new Set(persistedPermissions); + const requested = new Set(requestedPermissions); + const missing = [...requested].filter((permission) => !grantedSet.has(permission)).sort(); + if (missing.length > 0) { + return yield* fail(pluginId, "open", `permission approval required: ${missing.join(", ")}`); + } + + yield* fileSystem + .makeDirectory(path.join(pluginDataRoot, pluginId), { recursive: true }) + .pipe( + Effect.mapError((cause) => + fail(pluginId, "open", "could not create plugin data directory", cause), + ), + ); + + const requireSecret = (name: string, operation: string) => + Effect.gen(function* () { + yield* validateKey(pluginId, operation, name); + yield* requirePermission(pluginId, requested, `secrets:${name}`, operation); + }); + const requireFiles = (operation: string) => + requirePermission(pluginId, requested, "filesystem:data", operation); + + return { + settings: makeStore(pluginId, requested, "settings"), + state: makeStore(pluginId, requested, "state"), + cache: makeStore(pluginId, requested, "cache"), + secrets: { + get: (name) => + Effect.gen(function* () { + yield* requireSecret(name, "secret read"); + const value = yield* secretStore + .get(secretResourceName(pluginId, name)) + .pipe( + Effect.mapError((cause) => + fail(pluginId, "secret read", `could not read ${name}`, cause), + ), + ); + return Option.isSome(value) ? textDecoder.decode(value.value) : undefined; + }), + set: (name, value) => + Effect.gen(function* () { + yield* requireSecret(name, "secret write"); + yield* secretStore + .set(secretResourceName(pluginId, name), textEncoder.encode(value)) + .pipe( + Effect.mapError((cause) => + fail(pluginId, "secret write", `could not write ${name}`, cause), + ), + ); + }), + delete: (name) => + Effect.gen(function* () { + yield* requireSecret(name, "secret delete"); + yield* secretStore + .remove(secretResourceName(pluginId, name)) + .pipe( + Effect.mapError((cause) => + fail(pluginId, "secret delete", `could not remove ${name}`, cause), + ), + ); + }), + }, + files: { + readText: (relativePath) => + Effect.gen(function* () { + yield* requireFiles("filesystem read"); + const filePath = yield* resolveExistingFilePath( + pluginId, + relativePath, + "filesystem read", + ); + return yield* fileSystem + .readFileString(filePath) + .pipe( + Effect.mapError((cause) => + fail(pluginId, "filesystem read", `could not read ${relativePath}`, cause), + ), + ); + }), + writeText: (relativePath, contents) => + Effect.gen(function* () { + yield* requireFiles("filesystem write"); + if (Buffer.byteLength(contents, "utf8") > MAX_DATA_FILE_BYTES) { + return yield* fail(pluginId, "filesystem write", "file exceeds plugin data limit"); + } + const filePath = yield* resolveWritableFilePath(pluginId, relativePath); + yield* atomicWrite(filePath, contents).pipe( + Effect.mapError((cause) => + fail(pluginId, "filesystem write", `could not write ${relativePath}`, cause), + ), + ); + }), + remove: (relativePath) => + Effect.gen(function* () { + yield* requireFiles("filesystem remove"); + const filePath = yield* resolveExistingFilePath( + pluginId, + relativePath, + "filesystem remove", + ); + yield* fileSystem + .remove(filePath, { force: true }) + .pipe( + Effect.mapError((cause) => + fail(pluginId, "filesystem remove", `could not remove ${relativePath}`, cause), + ), + ); + }), + }, + network: { + fetchText: (url) => + Effect.gen(function* () { + const parsed = yield* Effect.try({ + try: () => new URL(url), + catch: (cause) => fail(pluginId, "network fetch", "invalid URL", cause), + }); + yield* requirePermission( + pluginId, + requested, + `network:${parsed.origin}`, + "network fetch", + ); + const response = yield* httpClient.get(parsed).pipe( + Effect.timeout(EXTERNAL_OPERATION_TIMEOUT), + Effect.mapError((cause) => + fail(pluginId, "network fetch", `request failed for ${url}`, cause), + ), + ); + const body = textDecoder.decode( + yield* collectBounded(pluginId, "network fetch", response.stream).pipe( + Effect.timeout(EXTERNAL_OPERATION_TIMEOUT), + Effect.mapError((cause) => + isPluginHostCapabilityError(cause) + ? cause + : fail(pluginId, "network fetch", `response timed out for ${url}`, cause), + ), + ), + ); + return { status: response.status, headers: response.headers, body }; + }), + }, + process: { + run: (command, args = []) => + Effect.gen(function* () { + yield* requirePermission(pluginId, requested, `process:${command}`, "process run"); + if (!processNamePattern.test(command)) { + return yield* fail(pluginId, "process run", "invalid process name"); + } + const executable = command === "node" ? process.execPath : command; + const childCommand = ChildProcess.make(executable, [...args], { + cwd: path.join(pluginDataRoot, pluginId), + env: { PATH: process.env.PATH }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + shell: false, + killSignal: "SIGTERM", + forceKillAfter: "1 second", + }); + const result = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* childProcessSpawner.spawn(childCommand); + const [stdoutChunks, stderrChunks, exitCode] = yield* Effect.all( + [ + collectBounded(pluginId, "process stdout", handle.stdout), + collectBounded(pluginId, "process stderr", handle.stderr), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + return { + exitCode: exitCode as unknown as number, + stdout: textDecoder.decode(stdoutChunks), + stderr: textDecoder.decode(stderrChunks), + }; + }), + ).pipe( + Effect.timeout(EXTERNAL_OPERATION_TIMEOUT), + Effect.mapError((cause) => + isPluginHostCapabilityError(cause) + ? cause + : fail(pluginId, "process run", "process execution failed", cause), + ), + ); + return result; + }), + }, + } satisfies PluginHostApi; + }); + + yield* fileSystem.makeDirectory(pluginDataRoot, { recursive: true }); + + return PluginHostCapabilityBroker.of({ grant, granted, open, snapshot }); +}); + +export const layer = Layer.effect(PluginHostCapabilityBroker, make).pipe( + Layer.provide(NodeHttpClient.layerUndici), +); diff --git a/apps/server/src/plugins/PluginPackageManager.test.ts b/apps/server/src/plugins/PluginPackageManager.test.ts index 8137d4118df7..084f518b5b30 100644 --- a/apps/server/src/plugins/PluginPackageManager.test.ts +++ b/apps/server/src/plugins/PluginPackageManager.test.ts @@ -17,6 +17,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PluginCommandCatalog from "./PluginCommandCatalog.ts"; +import * as PluginHostCapabilityBroker from "./PluginHostCapabilityBroker.ts"; import * as PluginPackageManager from "./PluginPackageManager.ts"; const packageId = "com.acme.runtime-status"; @@ -72,6 +73,21 @@ export default function activate(api) { } `; +const statefulCommandPluginSource = (id: string) => ` +export default function activate(api) { + api.registerCommand( + { id: ${encodeJsonString(id)}, label: "Increment plugin state", surfaces: ["web"] }, + () => api.effect.flatMap(api.host.state.get("count"), (stored) => { + const next = typeof stored === "number" ? stored + 1 : 1; + return api.effect.flatMap( + api.host.state.set("count", next), + () => api.effect.succeed({ message: String(next), tone: "success" }) + ); + }) + ); +} +`; + const retryingCommandPluginSource = ( id: string, label: string, @@ -169,6 +185,10 @@ interface EnvironmentLayerOptions { const makeEnvironmentLayer = (baseDir: string, options?: EnvironmentLayerOptions) => { const configLayer = Layer.fresh(ServerConfig.layerTest(process.cwd(), baseDir)); + const capabilityBrokerLayer = PluginHostCapabilityBroker.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(configLayer), + ); const liveSettingsLayer = ServerSettings.layer.pipe( Layer.provide(ServerSecretStore.layer), Layer.provideMerge(configLayer), @@ -213,6 +233,7 @@ const makeEnvironmentLayer = (baseDir: string, options?: EnvironmentLayerOptions return PluginPackageManager.layer.pipe( Layer.provideMerge(PluginCommandCatalog.layer), + Layer.provideMerge(capabilityBrokerLayer), Layer.provideMerge(settingsLayer), Layer.provideMerge(configLayer), ); @@ -229,6 +250,119 @@ const useEnvironment = ( ) => Effect.scoped(effect.pipe(Effect.provide(makeEnvironmentLayer(baseDir, options)))); it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { + it.effect("grants declared host capabilities and preserves plugin-owned state", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-host-capability-test-", + }); + const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; + yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest({ ...manifest, permissions: ["state:read-write"] }), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + statefulCommandPluginSource(commandId), + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + const enabled = yield* manager.enable(packageId); + expect(enabled.packages[0]).toMatchObject({ + permissions: ["state:read-write"], + grantedPermissions: ["state:read-write"], + }); + const first = yield* catalog.list; + expect(yield* catalog.invoke({ generation: first.generation, id: commandId })).toEqual({ + message: "1", + tone: "success", + }); + }), + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + const restored = yield* catalog.list; + expect(yield* catalog.invoke({ generation: restored.generation, id: commandId })).toEqual( + { + message: "2", + tone: "success", + }, + ); + }), + ); + }), + ); + + it.effect("requires disable and re-enable before granting added host permissions", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-permission-escalation-test-", + }); + const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; + yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest(manifest), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + commandPluginSource(commandId, "old generation"), + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + yield* manager.enable(packageId); + const oldCatalog = yield* catalog.list; + + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest({ ...manifest, permissions: ["state:read-write"] }), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + statefulCommandPluginSource(commandId), + ); + + const reload = yield* Effect.exit(manager.reload(packageId)); + expect(reload._tag).toBe("Failure"); + expect(yield* catalog.list).toBe(oldCatalog); + expect(yield* manager.status).toMatchObject({ + packages: [ + { + state: "error", + permissions: ["state:read-write"], + grantedPermissions: [], + error: expect.stringContaining("permission approval required"), + }, + ], + }); + + yield* manager.disable(packageId); + yield* manager.enable(packageId); + const approved = yield* catalog.list; + expect(yield* catalog.invoke({ generation: approved.generation, id: commandId })).toEqual( + { + message: "1", + tone: "success", + }, + ); + }), + ); + }), + ); + it.effect("keeps the environment available when package manager startup fails", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -738,7 +872,7 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); yield* fileSystem.writeFileString( `${packageDirectory}/t3-plugin.json`, - encodeManifest(manifest), + encodeManifest({ ...manifest, permissions: ["state:read-write"] }), ); yield* fileSystem.writeFileString( `${packageDirectory}/index.mjs`, @@ -755,7 +889,7 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { commandId, ); expect(yield* manager.status).toMatchObject({ - packages: [{ id: packageId, enabled: false }], + packages: [{ id: packageId, enabled: false, grantedPermissions: [] }], }); }), { persistenceFailures: { remaining: 1 } }, diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index 566e157c1c2a..edf8665a7f29 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -26,6 +26,7 @@ import * as Semaphore from "effect/Semaphore"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PluginCommandCatalog from "./PluginCommandCatalog.ts"; +import * as PluginHostCapabilityBroker from "./PluginHostCapabilityBroker.ts"; const MANIFEST_FILE_NAME = "t3-plugin.json"; const COMMAND_CAPABILITY = "t3.commands@1"; @@ -47,6 +48,15 @@ interface LoadedDefinition { } export interface PluginPackageApi { + readonly host: PluginHostCapabilityBroker.PluginHostApi; + readonly effect: { + readonly succeed: (value: A) => Effect.Effect; + readonly map: (effect: Effect.Effect, f: (value: A) => B) => Effect.Effect; + readonly flatMap: ( + effect: Effect.Effect, + f: (value: A) => Effect.Effect, + ) => Effect.Effect; + }; readonly onDispose: (cleanup: () => void | Promise) => void; readonly registerCommand: ( command: { @@ -55,7 +65,7 @@ export interface PluginPackageApi { readonly description?: string; readonly surfaces: ReadonlyArray<"web" | "desktop" | "mobile">; }, - handler: () => unknown | Promise, + handler: () => unknown | Promise | Effect.Effect, ) => void; } @@ -98,6 +108,7 @@ const operationError = ( const makeDefinition = ( discovered: DiscoveredPackage, activatePackage: PluginPackageActivator, + host: PluginHostCapabilityBroker.PluginHostApi, onRetired: () => void, onCleanupError: (error: unknown) => void, ): PluginDefinition => { @@ -118,6 +129,12 @@ const makeDefinition = ( activate(context: PluginActivationContext) { context.onDispose(onRetired); const api: PluginPackageApi = { + host, + effect: { + succeed: Effect.succeed, + map: (effect, f) => Effect.map(effect, f), + flatMap: (effect, f) => Effect.flatMap(effect, f), + }, onDispose(cleanup) { context.onDispose(async () => { try { @@ -135,13 +152,40 @@ const makeDefinition = ( if (!declaredCommands.has(command.id)) { throw new Error(`Command ${command.id} is not declared in the manifest`); } + const invokeHandler: Effect.Effect< + unknown, + PluginCommandCatalog.PluginCommandExecutionError + > = Effect.try({ + try: handler, + catch: (cause) => + new PluginCommandCatalog.PluginCommandExecutionError({ cause, id: command.id }), + }).pipe( + Effect.flatMap((result) => { + if (Effect.isEffect(result)) { + const pluginEffect = result as Effect.Effect; + return pluginEffect.pipe( + Effect.mapError( + (cause) => + new PluginCommandCatalog.PluginCommandExecutionError({ + cause, + id: command.id, + }), + ), + ); + } + return Effect.tryPromise({ + try: () => Promise.resolve(result), + catch: (cause) => + new PluginCommandCatalog.PluginCommandExecutionError({ + cause, + id: command.id, + }), + }); + }), + ); PluginCommandCatalog.registerPluginCommand(context, { command, - handler: Effect.tryPromise({ - try: async () => handler(), - catch: (cause) => - new PluginCommandCatalog.PluginCommandExecutionError({ cause, id: command.id }), - }).pipe( + handler: invokeHandler.pipe( Effect.flatMap((result) => decodeInvocationResult(result).pipe( Effect.mapError( @@ -193,6 +237,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { const config = yield* ServerConfig.ServerConfig; const settings = yield* ServerSettings.ServerSettingsService; const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + const hostCapabilities = yield* PluginHostCapabilityBroker.PluginHostCapabilityBroker; const semaphore = yield* Semaphore.make(1); const pluginsDirectory = path.join(config.stateDir, "plugins"); const pluginCacheDirectory = path.join(config.stateDir, "plugin-cache"); @@ -298,6 +343,9 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { discovered: DiscoveredPackage, operation: PluginPackageOperation, ) { + const host = yield* hostCapabilities + .open(discovered.manifest.id, discovered.manifest.permissions ?? []) + .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id))); const serverEntrypoint = discovered.manifest.entrypoints.server; if (serverEntrypoint === undefined) { return yield* operationError( @@ -368,7 +416,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { }); return { cacheDirectory, - definition: makeDefinition(discovered, loaded.value, markRetired, (error) => { + definition: makeDefinition(discovered, loaded.value, host, markRetired, (error) => { packageErrors.set(discovered.manifest.id, detailFromUnknown(error)); }), retired, @@ -402,11 +450,14 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { const statusUnlocked = Effect.fn("PluginPackageManager.status")(function* ( operation: PluginPackageOperation, ): Effect.fn.Return { - const [discovery, enabledIds, composition] = yield* Effect.all( + const [discovery, enabledIds, composition, grantSnapshot] = yield* Effect.all( [ discover(operation), readEnabledIds.pipe(Effect.mapError((error) => operationError(operation, error))), catalog.composition, + hostCapabilities.snapshot.pipe( + Effect.mapError((error) => operationError(operation, error)), + ), ], { concurrency: "unbounded" }, ); @@ -417,8 +468,13 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { for (const id of [...packageIds].sort()) { const activeManifest = activeManifests.get(id); - const packageManifest = activeManifest ?? discovered.get(id)?.manifest; + const discoveredManifest = discovered.get(id)?.manifest; + const packageManifest = activeManifest ?? discoveredManifest; if (packageManifest === undefined) continue; + const requestedPermissions = [ + ...new Set((discoveredManifest ?? packageManifest).permissions ?? []), + ].sort(); + const grantedPermissionSet = new Set(grantSnapshot.get(id) ?? []); const enabled = enabledIds.has(id); const active = composition.active.includes(id); const blocked = composition.blocked[id]; @@ -444,6 +500,10 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { enabled, state, capabilities: [...packageManifest.capabilities], + permissions: requestedPermissions, + grantedPermissions: requestedPermissions.filter((permission) => + grantedPermissionSet.has(permission), + ), contributions: { commands: [...(packageManifest.contributes?.commands ?? [])] }, ...(error === undefined ? {} : { error }), }); @@ -477,11 +537,38 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { packageErrors.delete(id); const previousEnabledIds = new Set(enabledIds); + const previousGrants = yield* restore( + hostCapabilities + .granted(id) + .pipe(Effect.mapError((error) => operationError(operation, error, id))), + ); + if (operation === "enable") { + const granted = yield* Effect.exit( + restore( + hostCapabilities + .grant(id, pluginPackage.manifest.permissions ?? []) + .pipe(Effect.mapError((error) => operationError(operation, error, id))), + ), + ); + if (granted._tag === "Failure") { + packageErrors.set(id, detailFromCause(granted.cause)); + return yield* Effect.failCause(granted.cause); + } + } + const restorePreviousGrants = hostCapabilities.grant(id, previousGrants).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to restore plugin capability grants", { + id, + error: detailFromCause(cause), + }), + ), + ); const previousCacheDirectory = activeCacheDirectories.get(id); const previousRetirement = activeRetirements.get(id); const loadedExit = yield* Effect.exit(restore(loadDefinition(pluginPackage, operation))); if (loadedExit._tag === "Failure") { packageErrors.set(id, detailFromCause(loadedExit.cause)); + if (operation === "enable") yield* restorePreviousGrants; return yield* Effect.failCause(loadedExit.cause); } const loaded = loadedExit.value; @@ -490,6 +577,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { const persisted = yield* Effect.exit(persistEnabledIds(enabledIds, operation, id)); if (persisted._tag === "Failure") { packageErrors.set(id, detailFromCause(persisted.cause)); + yield* restorePreviousGrants; yield* removeCacheDirectory(loaded.cacheDirectory); return yield* Effect.failCause(persisted.cause); } @@ -509,6 +597,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { const rolledBack = yield* Effect.exit( persistEnabledIds(previousEnabledIds, operation, id), ); + yield* restorePreviousGrants; if (rolledBack._tag === "Failure") { packageErrors.set(id, detailFromCause(reconciled.cause)); yield* removeCacheDirectory(loaded.cacheDirectory); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 14f82fb27b40..d2ef743d433c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -69,6 +69,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import * as PluginCommandCatalog from "./plugins/PluginCommandCatalog.ts"; +import * as PluginHostCapabilityBroker from "./plugins/PluginHostCapabilityBroker.ts"; import * as PluginPackageManager from "./plugins/PluginPackageManager.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { @@ -2508,5 +2509,10 @@ export const websocketRpcRouteLayer = Layer.unwrap( ); }), ).pipe( - Layer.provide(PluginPackageManager.layer.pipe(Layer.provideMerge(PluginCommandCatalog.layer))), + Layer.provide( + PluginPackageManager.layer.pipe( + Layer.provideMerge(PluginCommandCatalog.layer), + Layer.provideMerge(PluginHostCapabilityBroker.layer), + ), + ), ); diff --git a/apps/web/src/components/settings/PluginsSettings.test.tsx b/apps/web/src/components/settings/PluginsSettings.test.tsx index 373269c0b2b0..4c2d61520a31 100644 --- a/apps/web/src/components/settings/PluginsSettings.test.tsx +++ b/apps/web/src/components/settings/PluginsSettings.test.tsx @@ -102,6 +102,8 @@ const snapshot: PluginPackageStatusSnapshot = { enabled: true, state: "active", capabilities: ["t3.commands@1"], + permissions: ["state:read-write", "network:https://api.acme.test"], + grantedPermissions: ["state:read-write"], contributions: { commands: ["acme.active.run"] }, }, { @@ -111,6 +113,8 @@ const snapshot: PluginPackageStatusSnapshot = { enabled: false, state: "disabled", capabilities: ["t3.commands@1"], + permissions: ["filesystem:data"], + grantedPermissions: [], contributions: { commands: [] }, }, ], @@ -205,6 +209,28 @@ describe("PluginsSettingsPanel", () => { ).not.toBeNull(); }); + it("shows granted and pending host permissions", () => { + const panel = renderPanel(); + const activeRow = renderPackageRow(panel, "com.acme.active"); + + expect( + visitElements( + activeRow, + (element) => + element.props["data-plugin-permission"] === "state:read-write" && + element.props["data-granted"] === true, + ), + ).not.toBeNull(); + expect( + visitElements( + activeRow, + (element) => + element.props["data-plugin-permission"] === "network:https://api.acme.test" && + element.props["data-granted"] === false, + ), + ).not.toBeNull(); + }); + it("routes disable, enable, reload, and refresh to the primary environment", async () => { const panel = renderPanel(); const activeRow = renderPackageRow(panel, "com.acme.active"); diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index cbad4fc4b91e..acf856a79044 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -59,6 +59,7 @@ function PluginPackageRow({ }) { const state = statePresentation[pluginPackage.state]; const commands = pluginPackage.contributions.commands; + const grantedPermissions = new Set(pluginPackage.grantedPermissions); const busy = pendingAction !== null; const status = (
@@ -69,6 +70,20 @@ function PluginPackageRow({ {capability} ))} + {pluginPackage.permissions.map((permission) => { + const granted = grantedPermissions.has(permission); + return ( + + {permission} + {granted ? " granted" : " approval required"} + + ); + })}
); @@ -217,8 +232,9 @@ export function PluginsSettingsPanel() { Trusted local code - Plugins run inside this environment's server process with its filesystem and network - access. Only install code you trust. + Host APIs enforce declared grants and keep plugin data namespaced. Plugins still run + inside this environment's server process and can bypass those APIs with Node until + execution isolation lands. Only install code you trust. diff --git a/examples/plugins/runtime-status/README.md b/examples/plugins/runtime-status/README.md index b7d41811ba35..42633d9d6e4f 100644 --- a/examples/plugins/runtime-status/README.md +++ b/examples/plugins/runtime-status/README.md @@ -10,6 +10,22 @@ copy this directory into the active environment's plugin directory: for a normal local install, `` is usually `~/.t3`. development servers use the worktree's `.t3` directory, and an explicit `T3CODE_HOME` or `--base-dir` uses that configured directory. -t3 code discovers the package without a rebuild. use `pluginPackages.status` to inspect it and `pluginPackages.enable` with the manifest id to enable it for that environment. `pluginPackages.reload` re-evaluates the entrypoint, and `pluginPackages.disable` removes its contributions. once enabled, `example.runtime-status` appears in the web and desktop command palettes. +t3 code discovers the package without a rebuild. use `pluginPackages.status` to inspect it and `pluginPackages.enable` with the manifest id to enable it for that environment. enabling a plugin grants the host permissions currently declared in its manifest. a reload that adds permissions is rejected until the plugin is disabled and enabled again. `pluginPackages.reload` re-evaluates the entrypoint, and `pluginPackages.disable` removes its contributions. once enabled, `example.runtime-status` appears in the web and desktop command palettes. + +## host capabilities and plugin-owned data + +manifest permissions are explicit, bounded grants: + +- `settings:read-write`, `state:read-write`, and `cache:read-write` expose detached JSON key/value stores. +- `secrets:` exposes one plugin-namespaced secret. +- `filesystem:data` exposes text files under the plugin's own data directory and rejects traversal and symlink escapes. +- `network:https://host` allows text responses from one HTTPS origin. +- `process:` allows one exact executable name with no shell, a minimal environment, timeout, and output limits. + +plugin data lives under the active environment's `plugin-data//` directory. settings and state survive reloads and restarts. cache is separately clearable. secrets use the server secret store and never share names with another plugin. + +host operations return Effect values. command handlers may return those values directly and compose them with `api.effect.succeed`, `api.effect.map`, and `api.effect.flatMap`. synchronous and Promise handlers remain supported. + +these APIs define the boundary that isolated workers will use later. packages still run in the server process today, so trusted code can bypass the broker by importing Node APIs. local packages run in the server process and are fully trusted. marketplace distribution, signing, sandboxing, and renderer code are not part of this mvp. diff --git a/packages/contracts/src/pluginPackages.test.ts b/packages/contracts/src/pluginPackages.test.ts index 657df0389528..033c34254277 100644 --- a/packages/contracts/src/pluginPackages.test.ts +++ b/packages/contracts/src/pluginPackages.test.ts @@ -24,6 +24,8 @@ describe("plugin package contracts", () => { enabled: true, state: "active", capabilities: ["t3.commands@1"], + permissions: ["state:read-write", "network:https://api.acme.test"], + grantedPermissions: ["state:read-write"], contributions: { commands: ["acme.runtime-status"] }, }, ], @@ -38,6 +40,8 @@ describe("plugin package contracts", () => { enabled: true, state: "active", capabilities: ["t3.commands@1"], + permissions: ["state:read-write", "network:https://api.acme.test"], + grantedPermissions: ["state:read-write"], contributions: { commands: ["acme.runtime-status"] }, }, ], @@ -68,6 +72,8 @@ describe("plugin package contracts", () => { enabled: true, state: "blocked", capabilities: ["t3.commands@1"], + permissions: [], + grantedPermissions: [], contributions: { commands: ["acme.issues.create"] }, error: "Missing dependency: acme.database@1", }, @@ -84,6 +90,27 @@ describe("plugin package contracts", () => { expect(() => decodeAction({ id: "com.acme.runtime-status", extra: true })).toThrow(); }); + it("rejects unsupported host permissions in package status", () => { + expect(() => + decodeStatus({ + errors: [], + packages: [ + { + id: "com.acme.runtime-status", + version: "1.0.0", + apiVersion: 1, + enabled: false, + state: "disabled", + capabilities: [], + permissions: ["filesystem:/tmp"], + grantedPermissions: [], + contributions: { commands: [] }, + }, + ], + }), + ).toThrow(); + }); + it("rejects declared command ids that cannot be invoked", () => { expect(() => decodeStatus({ @@ -96,6 +123,8 @@ describe("plugin package contracts", () => { enabled: false, state: "disabled", capabilities: ["t3.commands@1"], + permissions: [], + grantedPermissions: [], contributions: { commands: [`acme.${"x".repeat(196)}`] }, }, ], diff --git a/packages/contracts/src/pluginPackages.ts b/packages/contracts/src/pluginPackages.ts index 7cca50dedecc..f5b1938188ca 100644 --- a/packages/contracts/src/pluginPackages.ts +++ b/packages/contracts/src/pluginPackages.ts @@ -14,6 +14,28 @@ export const PluginPackageCapability = Schema.String.check( ); export type PluginPackageCapability = typeof PluginPackageCapability.Type; +export const PluginHostPermission = Schema.Union([ + Schema.Literals([ + "settings:read-write", + "state:read-write", + "cache:read-write", + "filesystem:data", + ]), + Schema.String.check( + Schema.isPattern(/^secrets:[a-z0-9][a-z0-9._-]{0,127}$/), + Schema.isMaxLength(136), + ), + Schema.String.check( + Schema.isPattern(/^network:https:\/\/[A-Za-z0-9.-]+(?::[1-9]\d{0,4})?$/), + Schema.isMaxLength(255), + ), + Schema.String.check( + Schema.isPattern(/^process:[A-Za-z0-9._+-]{1,128}$/), + Schema.isMaxLength(136), + ), +]); +export type PluginHostPermission = typeof PluginHostPermission.Type; + export const PluginPackageState = Schema.Literals(["disabled", "active", "blocked", "error"]); export type PluginPackageState = typeof PluginPackageState.Type; @@ -29,6 +51,8 @@ export const PluginPackageStatus = Schema.Struct({ enabled: Schema.Boolean, state: PluginPackageState, capabilities: Schema.Array(PluginPackageCapability), + permissions: Schema.Array(PluginHostPermission), + grantedPermissions: Schema.Array(PluginHostPermission), contributions: PluginPackageContributions, error: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(2_000))), }); diff --git a/packages/plugin-runtime/src/manifest.ts b/packages/plugin-runtime/src/manifest.ts index 460f21ec8894..9c04aa0c8115 100644 --- a/packages/plugin-runtime/src/manifest.ts +++ b/packages/plugin-runtime/src/manifest.ts @@ -21,7 +21,26 @@ const CapabilityId = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@ const RelativeEntrypoint = Schema.String.check( Schema.isPattern(/^\.\/(?!(?:\.\.(?:\/|$)|.*\/\.\.(?:\/|$)))[A-Za-z0-9_./-]+$/), ); -const Permission = Schema.String.check(Schema.isPattern(/^[a-z][a-z-]*:.+$/)); +const Permission = Schema.Union([ + Schema.Literals([ + "settings:read-write", + "state:read-write", + "cache:read-write", + "filesystem:data", + ]), + Schema.String.check( + Schema.isPattern(/^secrets:[a-z0-9][a-z0-9._-]{0,127}$/), + Schema.isMaxLength(136), + ), + Schema.String.check( + Schema.isPattern(/^network:https:\/\/[A-Za-z0-9.-]+(?::[1-9]\d{0,4})?$/), + Schema.isMaxLength(255), + ), + Schema.String.check( + Schema.isPattern(/^process:[A-Za-z0-9._+-]{1,128}$/), + Schema.isMaxLength(136), + ), +]); const ContributionCatalog = Schema.Struct({ commands: Schema.optional(Schema.Array(CommandId)), diff --git a/packages/plugin-runtime/test/manifest.test.ts b/packages/plugin-runtime/test/manifest.test.ts index 8ba884db6910..4f98a93a200d 100644 --- a/packages/plugin-runtime/test/manifest.test.ts +++ b/packages/plugin-runtime/test/manifest.test.ts @@ -59,7 +59,7 @@ describe("PluginManifest", () => { ).toThrow(); }); - it("rejects malformed versions and capability ids", () => { + it("rejects malformed versions, capability ids, and host permissions", () => { expect(() => decodeManifest({ ...validManifest, version: "next" })).toThrow(); expect(() => decodeManifest({ ...validManifest, version: "01.2.3" })).toThrow(); expect(() => decodeManifest({ ...validManifest, version: "1.2.3-.." })).toThrow(); @@ -67,6 +67,16 @@ describe("PluginManifest", () => { "1.2.3+build.7", ); expect(() => decodeManifest({ ...validManifest, requires: ["t3.commands"] })).toThrow(); + for (const permission of [ + "settings:read", + "filesystem:/tmp", + "network:file:///tmp/secret", + "process:../sh", + "secrets:UPPERCASE", + "unknown:anything", + ]) { + expect(() => decodeManifest({ ...validManifest, permissions: [permission] })).toThrow(); + } }); it("rejects entrypoints that escape the plugin directory", () => { From 3741acb24f9d9bc27c9600f250f8a9423f66f169 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:00:44 +0000 Subject: [PATCH 28/45] fix: harden plugin capability boundaries Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../PluginHostCapabilityBroker.test.ts | 8 ++--- .../src/plugins/PluginHostCapabilityBroker.ts | 31 +++++++++++++++++++ packages/contracts/src/pluginPackages.test.ts | 2 +- packages/contracts/src/pluginPackages.ts | 2 +- packages/plugin-runtime/src/manifest.ts | 2 +- packages/plugin-runtime/test/manifest.test.ts | 1 + 6 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts b/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts index 814b797d7909..a3c8c034bf72 100644 --- a/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts +++ b/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts @@ -155,10 +155,10 @@ it.layer(NodeServices.layer)("plugin host capability broker", (it) => { const escapedDirectory = path.join(path.dirname(linked), "escape"); yield* fileSystem.makeDirectory(outsideDirectory); yield* fileSystem.symlink(outsideDirectory, escapedDirectory); - expect((yield* Effect.exit(api.files.writeText("escape/leak.txt", "leak")))._tag).toBe( - "Failure", - ); - expect(yield* fileSystem.exists(path.join(outsideDirectory, "leak.txt"))).toBe(false); + expect( + (yield* Effect.exit(api.files.writeText("escape/new/sub/leak.txt", "leak")))._tag, + ).toBe("Failure"); + expect(yield* fileSystem.exists(path.join(outsideDirectory, "new"))).toBe(false); }), ); }), diff --git a/apps/server/src/plugins/PluginHostCapabilityBroker.ts b/apps/server/src/plugins/PluginHostCapabilityBroker.ts index 297f40dfc37b..d4f96cfecc1d 100644 --- a/apps/server/src/plugins/PluginHostCapabilityBroker.ts +++ b/apps/server/src/plugins/PluginHostCapabilityBroker.ts @@ -416,9 +416,40 @@ const make = Effect.gen(function* () { pluginId: string, relativePath: string, ): Effect.fn.Return { + const pluginRoot = path.join(pluginDataRoot, pluginId); const root = path.join(pluginDataRoot, pluginId, "files"); const lexical = yield* resolveFilePath(pluginId, relativePath); const parent = path.dirname(lexical); + let existingAncestor = parent; + while (existingAncestor !== pluginRoot) { + const exists = yield* fileSystem + .exists(existingAncestor) + .pipe( + Effect.mapError((cause) => + fail(pluginId, "filesystem write", `could not inspect ${relativePath}`, cause), + ), + ); + if (exists) break; + existingAncestor = path.dirname(existingAncestor); + } + const [canonicalPluginRoot, canonicalAncestor] = yield* Effect.all( + [fileSystem.realPath(pluginRoot), fileSystem.realPath(existingAncestor)], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => + fail(pluginId, "filesystem write", `could not resolve ${relativePath}`, cause), + ), + ); + if ( + !isContained(canonicalPluginRoot, canonicalAncestor) || + path.normalize(existingAncestor) !== path.normalize(canonicalAncestor) + ) { + return yield* fail( + pluginId, + "filesystem write", + "symbolic links are not allowed in plugin data", + ); + } yield* fileSystem .makeDirectory(parent, { recursive: true }) .pipe( diff --git a/packages/contracts/src/pluginPackages.test.ts b/packages/contracts/src/pluginPackages.test.ts index 033c34254277..e82a0c67fe34 100644 --- a/packages/contracts/src/pluginPackages.test.ts +++ b/packages/contracts/src/pluginPackages.test.ts @@ -102,7 +102,7 @@ describe("plugin package contracts", () => { enabled: false, state: "disabled", capabilities: [], - permissions: ["filesystem:/tmp"], + permissions: ["network:https://example.com:443"], grantedPermissions: [], contributions: { commands: [] }, }, diff --git a/packages/contracts/src/pluginPackages.ts b/packages/contracts/src/pluginPackages.ts index f5b1938188ca..7e7c2aad257d 100644 --- a/packages/contracts/src/pluginPackages.ts +++ b/packages/contracts/src/pluginPackages.ts @@ -26,7 +26,7 @@ export const PluginHostPermission = Schema.Union([ Schema.isMaxLength(136), ), Schema.String.check( - Schema.isPattern(/^network:https:\/\/[A-Za-z0-9.-]+(?::[1-9]\d{0,4})?$/), + Schema.isPattern(/^network:https:\/\/[A-Za-z0-9.-]+(?::(?!443$)[1-9]\d{0,4})?$/), Schema.isMaxLength(255), ), Schema.String.check( diff --git a/packages/plugin-runtime/src/manifest.ts b/packages/plugin-runtime/src/manifest.ts index 9c04aa0c8115..bfb17c6e009d 100644 --- a/packages/plugin-runtime/src/manifest.ts +++ b/packages/plugin-runtime/src/manifest.ts @@ -33,7 +33,7 @@ const Permission = Schema.Union([ Schema.isMaxLength(136), ), Schema.String.check( - Schema.isPattern(/^network:https:\/\/[A-Za-z0-9.-]+(?::[1-9]\d{0,4})?$/), + Schema.isPattern(/^network:https:\/\/[A-Za-z0-9.-]+(?::(?!443$)[1-9]\d{0,4})?$/), Schema.isMaxLength(255), ), Schema.String.check( diff --git a/packages/plugin-runtime/test/manifest.test.ts b/packages/plugin-runtime/test/manifest.test.ts index 4f98a93a200d..534137321426 100644 --- a/packages/plugin-runtime/test/manifest.test.ts +++ b/packages/plugin-runtime/test/manifest.test.ts @@ -71,6 +71,7 @@ describe("PluginManifest", () => { "settings:read", "filesystem:/tmp", "network:file:///tmp/secret", + "network:https://example.com:443", "process:../sh", "secrets:UPPERCASE", "unknown:anything", From f8e2498e299d2f2aa3e193bd41e2ffbee2d71295 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:09:42 +0000 Subject: [PATCH 29/45] fix: bound plugin broker inputs Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../PluginHostCapabilityBroker.test.ts | 5 +++ .../src/plugins/PluginHostCapabilityBroker.ts | 32 +++++++++++++-- packages/contracts/src/pluginPackages.test.ts | 41 +++++++++++-------- packages/contracts/src/pluginPackages.ts | 4 +- packages/plugin-runtime/src/manifest.ts | 4 +- packages/plugin-runtime/test/manifest.test.ts | 1 + 6 files changed, 63 insertions(+), 24 deletions(-) diff --git a/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts b/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts index a3c8c034bf72..72c48edae2ee 100644 --- a/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts +++ b/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts @@ -137,6 +137,11 @@ it.layer(NodeServices.layer)("plugin host capability broker", (it) => { yield* api.files.writeText("nested/value.txt", "plugin-owned"); expect(yield* api.files.readText("nested/value.txt")).toBe("plugin-owned"); expect((yield* Effect.exit(api.files.readText("../outside.txt")))._tag).toBe("Failure"); + yield* fileSystem.writeFileString( + path.join(baseDir, "userdata", "plugin-data", pluginId, "files", "oversized.txt"), + "x".repeat(1_000_001), + ); + expect((yield* Effect.exit(api.files.readText("oversized.txt")))._tag).toBe("Failure"); const outside = path.join(baseDir, "outside.txt"); const linked = path.join( diff --git a/apps/server/src/plugins/PluginHostCapabilityBroker.ts b/apps/server/src/plugins/PluginHostCapabilityBroker.ts index d4f96cfecc1d..158f9840c70a 100644 --- a/apps/server/src/plugins/PluginHostCapabilityBroker.ts +++ b/apps/server/src/plugins/PluginHostCapabilityBroker.ts @@ -121,7 +121,7 @@ export class PluginHostCapabilityBroker extends Context.Service< } >()("t3/plugins/PluginHostCapabilityBroker") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; @@ -592,13 +592,27 @@ const make = Effect.gen(function* () { relativePath, "filesystem read", ); - return yield* fileSystem + const info = yield* fileSystem + .stat(filePath) + .pipe( + Effect.mapError((cause) => + fail(pluginId, "filesystem read", `could not inspect ${relativePath}`, cause), + ), + ); + if (info.size > BigInt(MAX_DATA_FILE_BYTES)) { + return yield* fail(pluginId, "filesystem read", "file exceeds plugin data limit"); + } + const contents = yield* fileSystem .readFileString(filePath) .pipe( Effect.mapError((cause) => fail(pluginId, "filesystem read", `could not read ${relativePath}`, cause), ), ); + if (Buffer.byteLength(contents, "utf8") > MAX_DATA_FILE_BYTES) { + return yield* fail(pluginId, "filesystem read", "file exceeds plugin data limit"); + } + return contents; }), writeText: (relativePath, contents) => Effect.gen(function* () { @@ -643,19 +657,29 @@ const make = Effect.gen(function* () { `network:${parsed.origin}`, "network fetch", ); + // The Undici dispatcher client does not follow redirects. Reject every redirect response + // so a future client change cannot widen this origin grant. const response = yield* httpClient.get(parsed).pipe( Effect.timeout(EXTERNAL_OPERATION_TIMEOUT), Effect.mapError((cause) => - fail(pluginId, "network fetch", `request failed for ${url}`, cause), + fail(pluginId, "network fetch", `request failed for ${parsed.origin}`, cause), ), ); + if (response.status >= 300 && response.status < 400) { + return yield* fail(pluginId, "network fetch", "redirect responses are not allowed"); + } const body = textDecoder.decode( yield* collectBounded(pluginId, "network fetch", response.stream).pipe( Effect.timeout(EXTERNAL_OPERATION_TIMEOUT), Effect.mapError((cause) => isPluginHostCapabilityError(cause) ? cause - : fail(pluginId, "network fetch", `response timed out for ${url}`, cause), + : fail( + pluginId, + "network fetch", + `response timed out for ${parsed.origin}`, + cause, + ), ), ), ); diff --git a/packages/contracts/src/pluginPackages.test.ts b/packages/contracts/src/pluginPackages.test.ts index e82a0c67fe34..157bfab46d96 100644 --- a/packages/contracts/src/pluginPackages.test.ts +++ b/packages/contracts/src/pluginPackages.test.ts @@ -91,24 +91,29 @@ describe("plugin package contracts", () => { }); it("rejects unsupported host permissions in package status", () => { - expect(() => - decodeStatus({ - errors: [], - packages: [ - { - id: "com.acme.runtime-status", - version: "1.0.0", - apiVersion: 1, - enabled: false, - state: "disabled", - capabilities: [], - permissions: ["network:https://example.com:443"], - grantedPermissions: [], - contributions: { commands: [] }, - }, - ], - }), - ).toThrow(); + for (const permission of [ + "network:https://example.com:443", + "network:https://example.com:99999", + ]) { + expect(() => + decodeStatus({ + errors: [], + packages: [ + { + id: "com.acme.runtime-status", + version: "1.0.0", + apiVersion: 1, + enabled: false, + state: "disabled", + capabilities: [], + permissions: [permission], + grantedPermissions: [], + contributions: { commands: [] }, + }, + ], + }), + ).toThrow(); + } }); it("rejects declared command ids that cannot be invoked", () => { diff --git a/packages/contracts/src/pluginPackages.ts b/packages/contracts/src/pluginPackages.ts index 7e7c2aad257d..39079f30a267 100644 --- a/packages/contracts/src/pluginPackages.ts +++ b/packages/contracts/src/pluginPackages.ts @@ -26,7 +26,9 @@ export const PluginHostPermission = Schema.Union([ Schema.isMaxLength(136), ), Schema.String.check( - Schema.isPattern(/^network:https:\/\/[A-Za-z0-9.-]+(?::(?!443$)[1-9]\d{0,4})?$/), + Schema.isPattern( + /^network:https:\/\/[A-Za-z0-9.-]+(?::(?!443$)(?:[1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))?$/, + ), Schema.isMaxLength(255), ), Schema.String.check( diff --git a/packages/plugin-runtime/src/manifest.ts b/packages/plugin-runtime/src/manifest.ts index bfb17c6e009d..3f7c31854ef3 100644 --- a/packages/plugin-runtime/src/manifest.ts +++ b/packages/plugin-runtime/src/manifest.ts @@ -33,7 +33,9 @@ const Permission = Schema.Union([ Schema.isMaxLength(136), ), Schema.String.check( - Schema.isPattern(/^network:https:\/\/[A-Za-z0-9.-]+(?::(?!443$)[1-9]\d{0,4})?$/), + Schema.isPattern( + /^network:https:\/\/[A-Za-z0-9.-]+(?::(?!443$)(?:[1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))?$/, + ), Schema.isMaxLength(255), ), Schema.String.check( diff --git a/packages/plugin-runtime/test/manifest.test.ts b/packages/plugin-runtime/test/manifest.test.ts index 534137321426..0a90d1282cba 100644 --- a/packages/plugin-runtime/test/manifest.test.ts +++ b/packages/plugin-runtime/test/manifest.test.ts @@ -72,6 +72,7 @@ describe("PluginManifest", () => { "filesystem:/tmp", "network:file:///tmp/secret", "network:https://example.com:443", + "network:https://example.com:99999", "process:../sh", "secrets:UPPERCASE", "unknown:anything", From 99646b6e1d7a5998e7a00fe40221ffa83b760617 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:18:15 +0000 Subject: [PATCH 30/45] fix: contain plugin permission badges Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/components/settings/PluginsSettings.test.tsx | 12 +++++++++++- apps/web/src/components/settings/PluginsSettings.tsx | 12 ++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/PluginsSettings.test.tsx b/apps/web/src/components/settings/PluginsSettings.test.tsx index 4c2d61520a31..e73bc3180818 100644 --- a/apps/web/src/components/settings/PluginsSettings.test.tsx +++ b/apps/web/src/components/settings/PluginsSettings.test.tsx @@ -213,6 +213,14 @@ describe("PluginsSettingsPanel", () => { const panel = renderPanel(); const activeRow = renderPackageRow(panel, "com.acme.active"); + expect( + visitElements( + activeRow, + (element) => + element.props.title === "t3.commands@1" && + element.props.className === "min-w-0 max-w-full", + ), + ).not.toBeNull(); expect( visitElements( activeRow, @@ -226,7 +234,9 @@ describe("PluginsSettingsPanel", () => { activeRow, (element) => element.props["data-plugin-permission"] === "network:https://api.acme.test" && - element.props["data-granted"] === false, + element.props["data-granted"] === false && + element.props.className === "min-w-0 max-w-full" && + element.props.title === "network:https://api.acme.test approval required", ), ).not.toBeNull(); }); diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index acf856a79044..333d1fae299e 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -66,8 +66,8 @@ function PluginPackageRow({ {state.label} v{pluginPackage.version} {pluginPackage.capabilities.map((capability) => ( - - {capability} + + {capability} ))} {pluginPackage.permissions.map((permission) => { @@ -76,11 +76,15 @@ function PluginPackageRow({ - {permission} - {granted ? " granted" : " approval required"} + + {permission} + {granted ? " granted" : " approval required"} + ); })} From 1229672373dab145c2c1ef82990f8f083e6f8c52 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:36:52 +0000 Subject: [PATCH 31/45] feat: isolate plugin execution Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- apps/server/package.json | 2 +- .../src/plugins/PluginPackageManager.test.ts | 216 ++++-- .../src/plugins/PluginPackageManager.ts | 238 +++---- .../src/plugins/PluginWorkerProtocol.test.ts | 58 ++ .../src/plugins/PluginWorkerProtocol.ts | 177 +++++ .../src/plugins/PluginWorkerRuntime.mjs | 254 +++++++ .../plugins/PluginWorkerSupervisor.test.ts | 338 +++++++++ .../src/plugins/PluginWorkerSupervisor.ts | 664 ++++++++++++++++++ apps/server/src/ws.ts | 2 + .../settings/PluginsSettings.test.tsx | 34 + .../components/settings/PluginsSettings.tsx | 15 +- examples/plugins/runtime-status/README.md | 6 +- packages/contracts/src/pluginPackages.test.ts | 10 + packages/contracts/src/pluginPackages.ts | 22 +- 14 files changed, 1823 insertions(+), 213 deletions(-) create mode 100644 apps/server/src/plugins/PluginWorkerProtocol.test.ts create mode 100644 apps/server/src/plugins/PluginWorkerProtocol.ts create mode 100644 apps/server/src/plugins/PluginWorkerRuntime.mjs create mode 100644 apps/server/src/plugins/PluginWorkerSupervisor.test.ts create mode 100644 apps/server/src/plugins/PluginWorkerSupervisor.ts diff --git a/apps/server/package.json b/apps/server/package.json index 9f3915e79e26..a4bf48e0260c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,7 +16,7 @@ "type": "module", "scripts": { "dev": "node --watch src/bin.ts", - "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean", + "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean && vp pack src/plugins/PluginWorkerRuntime.mjs --out-dir dist --no-clean", "start": "node dist/bin.mjs", "typecheck": "tsgo --noEmit", "test": "vp test run" diff --git a/apps/server/src/plugins/PluginPackageManager.test.ts b/apps/server/src/plugins/PluginPackageManager.test.ts index 084f518b5b30..287b94a95410 100644 --- a/apps/server/src/plugins/PluginPackageManager.test.ts +++ b/apps/server/src/plugins/PluginPackageManager.test.ts @@ -1,7 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ServerSettingsError } from "@t3tools/contracts"; import { it } from "@effect/vitest"; -import { expect } from "vite-plus/test"; +import { expect, vi } from "vite-plus/test"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -9,8 +9,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { PluginManifest } from "@t3tools/plugin-runtime/manifest"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -19,11 +19,27 @@ import * as ServerSettings from "../serverSettings.ts"; import * as PluginCommandCatalog from "./PluginCommandCatalog.ts"; import * as PluginHostCapabilityBroker from "./PluginHostCapabilityBroker.ts"; import * as PluginPackageManager from "./PluginPackageManager.ts"; +import * as PluginWorkerSupervisor from "./PluginWorkerSupervisor.ts"; const packageId = "com.acme.runtime-status"; const commandId = "acme.runtime-status"; -let testSymbolSequence = 0; -const nextTestSymbol = (prefix: string) => `${prefix}-${testSymbolSequence++}`; + +const waitForFile = (fileSystem: FileSystem.FileSystem, filePath: string, expected: boolean) => + Effect.gen(function* () { + let observed: boolean | undefined; + const observer = yield* Effect.forkChild( + Effect.forever( + fileSystem.exists(filePath).pipe( + Effect.tap((exists) => Effect.sync(() => void (observed = exists))), + Effect.flatMap(() => Effect.yieldNow), + ), + ), + ); + yield* Effect.promise(() => + vi.waitFor(() => expect(observed).toBe(expected), { interval: 5, timeout: 2_000 }), + ); + yield* Fiber.interrupt(observer); + }); const manifest = { manifestVersion: 1, @@ -88,16 +104,30 @@ export default function activate(api) { } `; +const crashOnceCommandPluginSource = (id: string) => ` +export default function activate(api) { + api.registerCommand( + { id: ${encodeJsonString(id)}, label: "Crash once", surfaces: ["web"] }, + () => api.effect.flatMap(api.host.state.get("crashed"), (crashed) => { + if (crashed === true) return api.effect.succeed({ message: "recovered", tone: "success" }); + return api.effect.flatMap(api.host.state.set("crashed", true), () => process.exit(23)); + }) + ); +} +`; + const retryingCommandPluginSource = ( id: string, label: string, - attemptsSymbol: string, + attemptsFile: string, failThroughAttempt = 1, ) => ` +import { readFileSync, writeFileSync } from "node:fs"; export default function activate(api) { - const key = Symbol.for(${encodeJsonString(attemptsSymbol)}); - const attempts = (Reflect.get(globalThis, key) ?? 0) + 1; - Reflect.set(globalThis, key, attempts); + let attempts = 0; + try { attempts = Number(readFileSync(${encodeJsonString(attemptsFile)}, "utf8")); } catch {} + attempts += 1; + writeFileSync(${encodeJsonString(attemptsFile)}, String(attempts)); if (attempts <= ${String(failThroughAttempt)}) throw new Error("startup activation failed"); api.registerCommand( { @@ -112,17 +142,17 @@ export default function activate(api) { const gatedCommandPluginSource = ( id: string, - gateEnabledSymbol: string, - startedSymbol: string, - releaseSymbol: string, + gateFile: string, + startedFile: string, + releaseFile: string, ) => ` +import { existsSync, writeFileSync } from "node:fs"; export default async function activate(api) { - if (Reflect.get(globalThis, Symbol.for(${encodeJsonString(gateEnabledSymbol)})) === true) { - const markStarted = Reflect.get(globalThis, Symbol.for(${encodeJsonString(startedSymbol)})); - if (typeof markStarted === "function") markStarted(); - await new Promise((resolve) => { - Reflect.set(globalThis, Symbol.for(${encodeJsonString(releaseSymbol)}), resolve); - }); + if (existsSync(${encodeJsonString(gateFile)})) { + writeFileSync(${encodeJsonString(startedFile)}, "started"); + while (!existsSync(${encodeJsonString(releaseFile)})) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } } api.registerCommand( { id: ${encodeJsonString(id)}, label: "gated", surfaces: ["web"] }, @@ -146,7 +176,8 @@ export default function activate(api) { } `; -const pluginSourceWithRetirementGate = (startedSymbol: string, releaseSymbol: string) => ` +const pluginSourceWithRetirementGate = (startedFile: string, releaseFile: string) => ` +import { existsSync, writeFileSync } from "node:fs"; export default function activate(api) { api.registerCommand( { @@ -156,11 +187,12 @@ export default function activate(api) { }, () => ({ message: "retirement gate", tone: "success" }) ); - api.onDispose(() => new Promise((resolve) => { - const markStarted = Reflect.get(globalThis, Symbol.for(${encodeJsonString(startedSymbol)})); - if (typeof markStarted === "function") markStarted(); - Reflect.set(globalThis, Symbol.for(${encodeJsonString(releaseSymbol)}), resolve); - })); + api.onDispose(async () => { + writeFileSync(${encodeJsonString(startedFile)}, "started"); + while (!existsSync(${encodeJsonString(releaseFile)})) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + }); } `; @@ -234,6 +266,7 @@ const makeEnvironmentLayer = (baseDir: string, options?: EnvironmentLayerOptions return PluginPackageManager.layer.pipe( Layer.provideMerge(PluginCommandCatalog.layer), Layer.provideMerge(capabilityBrokerLayer), + Layer.provideMerge(PluginWorkerSupervisor.layer), Layer.provideMerge(settingsLayer), Layer.provideMerge(configLayer), ); @@ -301,6 +334,57 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { }), ); + it.effect("reports worker restart health after an isolated plugin crash", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-worker-crash-test-", + }); + const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; + yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest({ ...manifest, permissions: ["state:read-write"] }), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + crashOnceCommandPluginSource(commandId), + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + yield* manager.enable(packageId); + const listed = yield* catalog.list; + expect( + (yield* Effect.exit(catalog.invoke({ generation: listed.generation, id: commandId }))) + ._tag, + ).toBe("Failure"); + expect(yield* manager.status).toMatchObject({ + packages: [ + { + state: "restarting", + runtimeState: "restarting", + error: expect.stringContaining("worker exited"), + }, + ], + }); + + yield* TestClock.adjust("1 second"); + expect(yield* catalog.invoke({ generation: listed.generation, id: commandId })).toEqual({ + message: "recovered", + tone: "success", + }); + expect(yield* manager.status).toMatchObject({ + packages: [{ state: "active", runtimeState: "running", restartCount: 0 }], + }); + }), + ); + }), + ); + it.effect("requires disable and re-enable before granting added host permissions", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -395,7 +479,7 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { prefix: "t3code-plugin-package-startup-retry-test-", }); const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; - const attemptsSymbol = nextTestSymbol("t3code-plugin-startup-retry"); + const attemptsFile = `${baseDir}/startup-attempts.txt`; yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); yield* fileSystem.writeFileString( `${packageDirectory}/t3-plugin.json`, @@ -403,9 +487,9 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { ); yield* fileSystem.writeFileString( `${packageDirectory}/index.mjs`, - retryingCommandPluginSource(commandId, "startup retry", attemptsSymbol), + retryingCommandPluginSource(commandId, "startup retry", attemptsFile), ); - Reflect.set(globalThis, Symbol.for(attemptsSymbol), 1); + yield* fileSystem.writeFileString(attemptsFile, "1"); yield* useEnvironment( baseDir, @@ -414,7 +498,7 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { yield* manager.enable(packageId); }), ); - Reflect.set(globalThis, Symbol.for(attemptsSymbol), 0); + yield* fileSystem.writeFileString(attemptsFile, "0"); yield* useEnvironment( baseDir, @@ -427,8 +511,7 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { expect((yield* catalog.list).commands.map(({ id }) => id)).toContain(commandId); }), ); - expect(Reflect.get(globalThis, Symbol.for(attemptsSymbol))).toBe(2); - Reflect.deleteProperty(globalThis, Symbol.for(attemptsSymbol)); + expect(yield* fileSystem.readFileString(attemptsFile)).toBe("2"); }), ); @@ -442,7 +525,7 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { const workingId = "com.acme.z-working"; const failingCommandId = "acme.failing.status"; const workingCommandId = "acme.working.status"; - const attemptsSymbol = nextTestSymbol("t3code-plugin-startup-failing"); + const attemptsFile = `${baseDir}/failing-attempts.txt`; for (const [id, declaredCommand] of [ [failingId, failingCommandId], [workingId, workingCommandId], @@ -456,11 +539,11 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { yield* fileSystem.writeFileString( `${directory}/index.mjs`, id === failingId - ? retryingCommandPluginSource(declaredCommand, id, attemptsSymbol, 2) + ? retryingCommandPluginSource(declaredCommand, id, attemptsFile, 2) : commandPluginSource(declaredCommand, id), ); } - Reflect.set(globalThis, Symbol.for(attemptsSymbol), 2); + yield* fileSystem.writeFileString(attemptsFile, "2"); yield* useEnvironment( baseDir, @@ -470,7 +553,7 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { yield* manager.enable(workingId); }), ); - Reflect.set(globalThis, Symbol.for(attemptsSymbol), 0); + yield* fileSystem.writeFileString(attemptsFile, "0"); yield* useEnvironment( baseDir, @@ -492,8 +575,7 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { expect(commandIds).toContain(workingCommandId); }), ); - expect(Reflect.get(globalThis, Symbol.for(attemptsSymbol))).toBe(2); - Reflect.deleteProperty(globalThis, Symbol.for(attemptsSymbol)); + expect(yield* fileSystem.readFileString(attemptsFile)).toBe("2"); }), ); @@ -504,9 +586,9 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { prefix: "t3code-plugin-package-startup-interruption-test-", }); const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; - const gateEnabledSymbol = nextTestSymbol("t3code-plugin-startup-gate"); - const startedSymbol = `${gateEnabledSymbol}-started`; - const releaseSymbol = `${gateEnabledSymbol}-release`; + const gateFile = `${baseDir}/startup-gate`; + const startedFile = `${baseDir}/startup-started`; + const releaseFile = `${baseDir}/startup-release`; yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); yield* fileSystem.writeFileString( `${packageDirectory}/t3-plugin.json`, @@ -514,9 +596,8 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { ); yield* fileSystem.writeFileString( `${packageDirectory}/index.mjs`, - gatedCommandPluginSource(commandId, gateEnabledSymbol, startedSymbol, releaseSymbol), + gatedCommandPluginSource(commandId, gateFile, startedFile, releaseFile), ); - Reflect.set(globalThis, Symbol.for(gateEnabledSymbol), false); yield* useEnvironment( baseDir, @@ -526,29 +607,18 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { }), ); - let markStarted!: () => void; - const started = new Promise((resolve) => { - markStarted = resolve; - }); - Reflect.set(globalThis, Symbol.for(gateEnabledSymbol), true); - Reflect.set(globalThis, Symbol.for(startedSymbol), markStarted); + yield* fileSystem.writeFileString(gateFile, "enabled"); const startup = yield* Effect.forkChild( useEnvironment(baseDir, Effect.asVoid(PluginPackageManager.PluginPackageManager)), ); - yield* Effect.promise(() => started); + yield* waitForFile(fileSystem, startedFile, true); const interrupting = yield* Effect.forkChild(Fiber.interrupt(startup)); yield* Effect.yieldNow; - const release = Reflect.get(globalThis, Symbol.for(releaseSymbol)); - expect(release).toBeTypeOf("function"); - if (typeof release === "function") release(); + yield* fileSystem.writeFileString(releaseFile, "release"); yield* Fiber.join(interrupting); const exit = yield* Fiber.await(startup); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true); - - for (const symbol of [gateEnabledSymbol, startedSymbol, releaseSymbol]) { - Reflect.deleteProperty(globalThis, Symbol.for(symbol)); - } }), ); @@ -735,6 +805,15 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { const remainingCommandIds = (yield* catalog.list).commands.map(({ id }) => id); expect(remainingCommandIds).not.toContain(providerCommandId); expect(remainingCommandIds).not.toContain(consumerCommandId); + + yield* manager.enable(providerId); + const restoredCatalog = yield* catalog.list; + expect( + yield* catalog.invoke({ + generation: restoredCatalog.generation, + id: consumerCommandId, + }), + ).toEqual({ message: "issues consumer", tone: "success" }); }), ); }), @@ -993,13 +1072,8 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { prefix: "t3code-plugin-package-interruption-test-", }); const packageDirectory = `${baseDir}/userdata/plugins/${packageId}`; - const startedSymbol = `t3.test.plugin.retirement.started.${baseDir}`; - const releaseSymbol = `t3.test.plugin.retirement.${baseDir}`; - let markRetirementStarted!: () => void; - const retirementStarted = new Promise((resolve) => { - markRetirementStarted = resolve; - }); - Reflect.set(globalThis, Symbol.for(startedSymbol), markRetirementStarted); + const startedFile = `${baseDir}/retirement-started`; + const releaseFile = `${baseDir}/retirement-release`; yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); yield* fileSystem.writeFileString( `${packageDirectory}/t3-plugin.json`, @@ -1007,7 +1081,7 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { ); yield* fileSystem.writeFileString( `${packageDirectory}/index.mjs`, - pluginSourceWithRetirementGate(startedSymbol, releaseSymbol), + pluginSourceWithRetirementGate(startedFile, releaseFile), ); yield* useEnvironment( @@ -1017,25 +1091,13 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; yield* manager.enable(packageId); const disabling = yield* Effect.forkChild(manager.disable(packageId)); - yield* Effect.promise(() => retirementStarted); + yield* waitForFile(fileSystem, startedFile, true); const interrupting = yield* Effect.forkChild(Fiber.interrupt(disabling)); yield* Effect.yieldNow; - const release = Reflect.get(globalThis, Symbol.for(releaseSymbol)); - expect(release).toBeTypeOf("function"); - if (typeof release === "function") release(); + yield* fileSystem.writeFileString(releaseFile, "release"); yield* Fiber.join(interrupting); - expect( - yield* fileSystem.exists(`${baseDir}/userdata/plugin-cache/${packageId}/0`).pipe( - Effect.repeat({ - schedule: Schedule.spaced("1 millis"), - until: (exists) => !exists, - }), - Effect.timeout("2 seconds"), - ), - ).toBe(false); - Reflect.deleteProperty(globalThis, Symbol.for(startedSymbol)); - Reflect.deleteProperty(globalThis, Symbol.for(releaseSymbol)); + yield* waitForFile(fileSystem, `${baseDir}/userdata/plugin-cache/${packageId}/0`, false); expect(yield* manager.status).toMatchObject({ packages: [{ id: packageId, enabled: false, state: "disabled" }], diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index edf8665a7f29..d97beec8b698 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -1,5 +1,3 @@ -import * as NodeURL from "node:url"; - import { PluginCommandInvocationResult, PluginPackageNotFoundError, @@ -27,6 +25,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PluginCommandCatalog from "./PluginCommandCatalog.ts"; import * as PluginHostCapabilityBroker from "./PluginHostCapabilityBroker.ts"; +import * as PluginWorkerSupervisor from "./PluginWorkerSupervisor.ts"; const MANIFEST_FILE_NAME = "t3-plugin.json"; const COMMAND_CAPABILITY = "t3.commands@1"; @@ -45,37 +44,15 @@ interface LoadedDefinition { readonly cacheDirectory: string; readonly definition: PluginDefinition; readonly retired: Promise; + readonly worker: PluginWorkerSupervisor.SupervisedPluginWorker; } -export interface PluginPackageApi { - readonly host: PluginHostCapabilityBroker.PluginHostApi; - readonly effect: { - readonly succeed:
(value: A) => Effect.Effect; - readonly map: (effect: Effect.Effect, f: (value: A) => B) => Effect.Effect; - readonly flatMap: ( - effect: Effect.Effect, - f: (value: A) => Effect.Effect, - ) => Effect.Effect; - }; - readonly onDispose: (cleanup: () => void | Promise) => void; - readonly registerCommand: ( - command: { - readonly id: string; - readonly label: string; - readonly description?: string; - readonly surfaces: ReadonlyArray<"web" | "desktop" | "mobile">; - }, - handler: () => unknown | Promise | Effect.Effect, - ) => void; -} - -type PluginPackageActivator = (api: PluginPackageApi) => void | Promise; - const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); const decodeInvocationResult = Schema.decodeUnknownEffect(PluginCommandInvocationResult); const isPluginPackageOperationError = Schema.is(PluginPackageOperationError); const detailFromUnknown = (error: unknown): string => { + if (PluginWorkerSupervisor.isPluginWorkerError(error)) return error.detail; if (isPluginPackageOperationError(error)) { if (error.detail !== undefined) return error.detail; if (error.cause !== undefined) return detailFromUnknown(error.cause); @@ -107,12 +84,21 @@ const operationError = ( const makeDefinition = ( discovered: DiscoveredPackage, - activatePackage: PluginPackageActivator, - host: PluginHostCapabilityBroker.PluginHostApi, + worker: PluginWorkerSupervisor.SupervisedPluginWorker, onRetired: () => void, - onCleanupError: (error: unknown) => void, ): PluginDefinition => { const declaredCommands = new Set(discovered.manifest.contributes?.commands ?? []); + if ( + worker.commands.length > 0 && + !discovered.manifest.capabilities.includes(COMMAND_CAPABILITY) + ) { + throw new Error(`Manifest does not declare capability ${COMMAND_CAPABILITY}`); + } + for (const command of worker.commands) { + if (!declaredCommands.has(command.id)) { + throw new Error(`Command ${command.id} is not declared in the manifest`); + } + } const providedCapabilities = Object.fromEntries( (discovered.manifest.provides ?? []).map((capability) => [ capability, @@ -128,80 +114,31 @@ const makeDefinition = ( provides: providedCapabilities, activate(context: PluginActivationContext) { context.onDispose(onRetired); - const api: PluginPackageApi = { - host, - effect: { - succeed: Effect.succeed, - map: (effect, f) => Effect.map(effect, f), - flatMap: (effect, f) => Effect.flatMap(effect, f), - }, - onDispose(cleanup) { - context.onDispose(async () => { - try { - await cleanup(); - } catch (error) { - onCleanupError(error); - throw error; - } - }); - }, - registerCommand(command, handler) { - if (!discovered.manifest.capabilities.includes(COMMAND_CAPABILITY)) { - throw new Error(`Manifest does not declare capability ${COMMAND_CAPABILITY}`); - } - if (!declaredCommands.has(command.id)) { - throw new Error(`Command ${command.id} is not declared in the manifest`); - } - const invokeHandler: Effect.Effect< - unknown, - PluginCommandCatalog.PluginCommandExecutionError - > = Effect.try({ - try: handler, - catch: (cause) => - new PluginCommandCatalog.PluginCommandExecutionError({ cause, id: command.id }), - }).pipe( - Effect.flatMap((result) => { - if (Effect.isEffect(result)) { - const pluginEffect = result as Effect.Effect; - return pluginEffect.pipe( - Effect.mapError( - (cause) => - new PluginCommandCatalog.PluginCommandExecutionError({ - cause, - id: command.id, - }), - ), - ); - } - return Effect.tryPromise({ - try: () => Promise.resolve(result), - catch: (cause) => - new PluginCommandCatalog.PluginCommandExecutionError({ - cause, - id: command.id, - }), - }); - }), - ); - PluginCommandCatalog.registerPluginCommand(context, { - command, - handler: invokeHandler.pipe( - Effect.flatMap((result) => - decodeInvocationResult(result).pipe( - Effect.mapError( - (cause) => - new PluginCommandCatalog.PluginCommandExecutionError({ - cause, - id: command.id, - }), - ), + for (const command of worker.commands) { + PluginCommandCatalog.registerPluginCommand(context, { + command, + handler: worker.invoke(command.id).pipe( + Effect.flatMap((result) => + decodeInvocationResult(result).pipe( + Effect.mapError( + (cause) => + new PluginCommandCatalog.PluginCommandExecutionError({ + cause, + id: command.id, + }), ), ), ), - }); - }, - }; - return activatePackage(api); + Effect.mapError( + (cause) => + new PluginCommandCatalog.PluginCommandExecutionError({ + cause, + id: command.id, + }), + ), + ), + }); + } }, }; }; @@ -238,12 +175,14 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { const settings = yield* ServerSettings.ServerSettingsService; const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; const hostCapabilities = yield* PluginHostCapabilityBroker.PluginHostCapabilityBroker; + const workerSupervisor = yield* PluginWorkerSupervisor.PluginWorkerSupervisor; const semaphore = yield* Semaphore.make(1); const pluginsDirectory = path.join(config.stateDir, "plugins"); const pluginCacheDirectory = path.join(config.stateDir, "plugin-cache"); const activeDefinitions = new Map(); const activeCacheDirectories = new Map(); const activeManifests = new Map(); + const activeWorkers = new Map(); const activeRetirements = new Map>(); const packageErrors = new Map(); let loadSequence = 0; @@ -257,6 +196,15 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { ), ); + const stopWorker = (id: string, worker: PluginWorkerSupervisor.SupervisedPluginWorker) => + Effect.promise(worker.dispose).pipe( + Effect.catchCause((cause) => { + const detail = detailFromCause(cause); + packageErrors.set(id, detail); + return Effect.logWarning("Failed to stop local plugin worker", { id, detail }); + }), + ); + const validatePackageTree = Effect.fn("PluginPackageManager.validatePackageTree")(function* ( discovered: DiscoveredPackage, operation: PluginPackageOperation, @@ -388,38 +336,41 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { } const entrypointPath = path.resolve(cacheDirectory, serverEntrypoint); - const loaded = yield* Effect.exit( - Effect.gen(function* () { - const moduleUrl = NodeURL.pathToFileURL(entrypointPath); - const module = yield* Effect.tryPromise({ - try: () => import(/* @vite-ignore */ moduleUrl.href) as Promise>, - catch: (cause) => operationError(operation, cause, discovered.manifest.id), - }); - if (typeof module.default !== "function") { - return yield* operationError( - operation, - "server entrypoint must export a default activation function", - discovered.manifest.id, - ); - } - return module.default as PluginPackageActivator; - }), + const workerExit = yield* Effect.exit( + workerSupervisor + .start({ + pluginId: discovered.manifest.id, + entrypointPath, + host, + }) + .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id))), ); - if (loaded._tag === "Failure") { + if (workerExit._tag === "Failure") { yield* removeCacheDirectory(cacheDirectory); - return yield* Effect.failCause(loaded.cause); + return yield* Effect.failCause(workerExit.cause); } + const worker = workerExit.value; let markRetired: () => void = () => {}; const retired = new Promise((resolve) => { markRetired = resolve; }); + const definitionExit = yield* Effect.exit( + Effect.try({ + try: () => makeDefinition(discovered, worker, markRetired), + catch: (error) => operationError(operation, error, discovered.manifest.id), + }), + ); + if (definitionExit._tag === "Failure") { + yield* stopWorker(discovered.manifest.id, worker); + yield* removeCacheDirectory(cacheDirectory); + return yield* Effect.failCause(definitionExit.cause); + } return { cacheDirectory, - definition: makeDefinition(discovered, loaded.value, host, markRetired, (error) => { - packageErrors.set(discovered.manifest.id, detailFromUnknown(error)); - }), + definition: definitionExit.value, retired, + worker, } satisfies LoadedDefinition; }); @@ -478,27 +429,42 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { const enabled = enabledIds.has(id); const active = composition.active.includes(id); const blocked = composition.blocked[id]; + const workerHealth = activeWorkers.get(id)?.health() ?? { + state: "stopped" as const, + restartCount: 0, + }; + const runtimeError = + workerHealth.state === "restarting" || workerHealth.state === "crashed" + ? workerHealth.detail + : undefined; const packageError = packageErrors.get(id); const error = packageError ?? + runtimeError ?? blocked ?? (enabled && !active ? "enabled package is not active" : undefined); const state = packageError !== undefined ? "error" - : blocked !== undefined - ? "blocked" - : error !== undefined - ? "error" - : active - ? "active" - : "disabled"; + : workerHealth.state === "crashed" + ? "crashed" + : workerHealth.state === "restarting" + ? "restarting" + : blocked !== undefined + ? "blocked" + : error !== undefined + ? "error" + : active + ? "active" + : "disabled"; packages.push({ id: packageManifest.id, version: packageManifest.version, apiVersion: packageManifest.apiVersion, enabled, state, + runtimeState: workerHealth.state, + restartCount: workerHealth.restartCount, capabilities: [...packageManifest.capabilities], permissions: requestedPermissions, grantedPermissions: requestedPermissions.filter((permission) => @@ -565,6 +531,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { ); const previousCacheDirectory = activeCacheDirectories.get(id); const previousRetirement = activeRetirements.get(id); + const previousWorker = activeWorkers.get(id); const loadedExit = yield* Effect.exit(restore(loadDefinition(pluginPackage, operation))); if (loadedExit._tag === "Failure") { packageErrors.set(id, detailFromCause(loadedExit.cause)); @@ -578,6 +545,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { if (persisted._tag === "Failure") { packageErrors.set(id, detailFromCause(persisted.cause)); yield* restorePreviousGrants; + yield* stopWorker(id, loaded.worker); yield* removeCacheDirectory(loaded.cacheDirectory); return yield* Effect.failCause(persisted.cause); } @@ -600,6 +568,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { yield* restorePreviousGrants; if (rolledBack._tag === "Failure") { packageErrors.set(id, detailFromCause(reconciled.cause)); + yield* stopWorker(id, loaded.worker); yield* removeCacheDirectory(loaded.cacheDirectory); yield* Effect.logWarning("Failed to restore enabled package settings", { id, @@ -609,6 +578,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { } } packageErrors.set(id, detailFromCause(reconciled.cause)); + yield* stopWorker(id, loaded.worker); yield* removeCacheDirectory(loaded.cacheDirectory); return yield* Effect.failCause(reconciled.cause); } @@ -617,11 +587,13 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { activeDefinitions.set(id, loaded.definition); activeCacheDirectories.set(id, loaded.cacheDirectory); activeManifests.set(id, pluginPackage.manifest); + activeWorkers.set(id, loaded.worker); activeRetirements.set(id, loaded.retired); if (previousCacheDirectory !== undefined) { if (reconciled._tag === "Failure" && previousRetirement !== undefined) { yield* Effect.promise(() => previousRetirement); } + if (previousWorker !== undefined) yield* stopWorker(id, previousWorker); yield* removeCacheDirectory(previousCacheDirectory); } if (reconciled._tag === "Failure") return yield* Effect.failCause(reconciled.cause); @@ -671,12 +643,15 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { } } + const worker = activeWorkers.get(id); activeDefinitions.delete(id); activeManifests.delete(id); + activeWorkers.delete(id); const cacheDirectory = activeCacheDirectories.get(id); const retirement = activeRetirements.get(id); activeCacheDirectories.delete(id); activeRetirements.delete(id); + if (worker !== undefined) yield* stopWorker(id, worker); if (cacheDirectory !== undefined) { if (reconciled._tag === "Failure" && retirement !== undefined) { yield* Effect.promise(() => retirement); @@ -721,12 +696,14 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { .pipe(Effect.mapError((error) => operationError("status", error, id))), ); if (reconciled._tag === "Failure") { + yield* stopWorker(id, loaded.worker); yield* removeCacheDirectory(loaded.cacheDirectory); return yield* Effect.failCause(reconciled.cause); } activeDefinitions.set(id, loaded.definition); activeCacheDirectories.set(id, loaded.cacheDirectory); activeManifests.set(id, pluginPackage.manifest); + activeWorkers.set(id, loaded.worker); activeRetirements.set(id, loaded.retired); }).pipe( Effect.retry({ times: 1 }), @@ -748,6 +725,11 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { error: detailFromCause(shutdown.cause), }); } + yield* Effect.forEach(activeWorkers, ([id, worker]) => stopWorker(id, worker), { + concurrency: "unbounded", + discard: true, + }); + activeWorkers.clear(); for (const [id, error] of packageErrors) { yield* Effect.logWarning("Local plugin package reported a shutdown error", { id, error }); } diff --git a/apps/server/src/plugins/PluginWorkerProtocol.test.ts b/apps/server/src/plugins/PluginWorkerProtocol.test.ts new file mode 100644 index 000000000000..8b6de0f32377 --- /dev/null +++ b/apps/server/src/plugins/PluginWorkerProtocol.test.ts @@ -0,0 +1,58 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { PluginWorkerMessage } from "./PluginWorkerProtocol.ts"; + +const decode = Schema.decodeUnknownSync(PluginWorkerMessage); + +describe("PluginWorkerProtocol", () => { + it("decodes activation and host capability messages", () => { + expect( + decode({ + type: "activated", + commands: [ + { + id: "acme.issue.create", + label: "Create issue", + surfaces: ["web"], + }, + ], + }), + ).toMatchObject({ type: "activated", commands: [{ id: "acme.issue.create" }] }); + + expect( + decode({ + type: "hostCall", + callId: "call-1", + operation: "state.set", + key: "cursor", + value: { page: 2 }, + }), + ).toEqual({ + type: "hostCall", + callId: "call-1", + operation: "state.set", + key: "cursor", + value: { page: 2 }, + }); + }); + + it("rejects malformed messages, executable metadata, and excess properties", () => { + for (const message of [ + { type: "activated", commands: [{ id: "", label: "bad", surfaces: ["web"] }] }, + { type: "hostCall", callId: "call-1", operation: "state.set", key: "cursor" }, + { + type: "hostCall", + callId: "call-1", + operation: "process.run", + command: "sh", + args: [], + extra: true, + }, + { type: "invocationResult", requestId: "../bad", value: null }, + { type: "unknown" }, + ]) { + expect(() => decode(message)).toThrow(); + } + }); +}); diff --git a/apps/server/src/plugins/PluginWorkerProtocol.ts b/apps/server/src/plugins/PluginWorkerProtocol.ts new file mode 100644 index 000000000000..c18a92977274 --- /dev/null +++ b/apps/server/src/plugins/PluginWorkerProtocol.ts @@ -0,0 +1,177 @@ +import { PluginCommand } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const strict = (schema: S) => + schema.annotate({ parseOptions: { onExcessProperty: "error" } }); + +export const PluginWorkerRequestId = Schema.String.check( + Schema.isPattern(/^[a-z][a-z0-9-]{0,63}$/), +); +export type PluginWorkerRequestId = typeof PluginWorkerRequestId.Type; + +const DataKey = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]{0,127}$/)); +const RelativeDataPath = Schema.String.check( + Schema.isMaxLength(500), + Schema.isPattern(/^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$)).+$/), +); +const Detail = Schema.String.check(Schema.isMaxLength(2_000)); + +const Activated = strict( + Schema.Struct({ + type: Schema.Literal("activated"), + commands: Schema.Array(PluginCommand), + }), +); +const ActivationFailed = strict( + Schema.Struct({ type: Schema.Literal("activationFailed"), detail: Detail }), +); +const InvocationResult = strict( + Schema.Struct({ + type: Schema.Literal("invocationResult"), + requestId: PluginWorkerRequestId, + value: Schema.Json, + }), +); +const InvocationFailed = strict( + Schema.Struct({ + type: Schema.Literal("invocationFailed"), + requestId: PluginWorkerRequestId, + detail: Detail, + }), +); +const Disposed = strict( + Schema.Struct({ type: Schema.Literal("disposed"), requestId: PluginWorkerRequestId }), +); +const DisposeFailed = strict( + Schema.Struct({ + type: Schema.Literal("disposeFailed"), + requestId: PluginWorkerRequestId, + detail: Detail, + }), +); + +const keyedHostCall = (operation: O) => + strict( + Schema.Struct({ + type: Schema.Literal("hostCall"), + callId: PluginWorkerRequestId, + operation: Schema.Literal(operation), + key: DataKey, + }), + ); +const valuedHostCall = (operation: O) => + strict( + Schema.Struct({ + type: Schema.Literal("hostCall"), + callId: PluginWorkerRequestId, + operation: Schema.Literal(operation), + key: DataKey, + value: Schema.Json, + }), + ); +const clearHostCall = (operation: O) => + strict( + Schema.Struct({ + type: Schema.Literal("hostCall"), + callId: PluginWorkerRequestId, + operation: Schema.Literal(operation), + }), + ); + +const SecretSet = strict( + Schema.Struct({ + type: Schema.Literal("hostCall"), + callId: PluginWorkerRequestId, + operation: Schema.Literal("secrets.set"), + name: DataKey, + value: Schema.String.check(Schema.isMaxLength(1_000_000)), + }), +); +const SecretAccess = (operation: "secrets.get" | "secrets.delete") => + strict( + Schema.Struct({ + type: Schema.Literal("hostCall"), + callId: PluginWorkerRequestId, + operation: Schema.Literal(operation), + name: DataKey, + }), + ); +const FileWrite = strict( + Schema.Struct({ + type: Schema.Literal("hostCall"), + callId: PluginWorkerRequestId, + operation: Schema.Literal("files.writeText"), + path: RelativeDataPath, + contents: Schema.String.check(Schema.isMaxLength(1_000_000)), + }), +); +const FileAccess = (operation: "files.readText" | "files.remove") => + strict( + Schema.Struct({ + type: Schema.Literal("hostCall"), + callId: PluginWorkerRequestId, + operation: Schema.Literal(operation), + path: RelativeDataPath, + }), + ); +const NetworkFetch = strict( + Schema.Struct({ + type: Schema.Literal("hostCall"), + callId: PluginWorkerRequestId, + operation: Schema.Literal("network.fetchText"), + url: Schema.String.check(Schema.isMaxLength(2_000)), + }), +); +const ProcessRun = strict( + Schema.Struct({ + type: Schema.Literal("hostCall"), + callId: PluginWorkerRequestId, + operation: Schema.Literal("process.run"), + command: Schema.String.check(Schema.isMaxLength(128)), + args: Schema.Array(Schema.String.check(Schema.isMaxLength(10_000))).check( + Schema.isMaxLength(256), + ), + }), +); + +export const PluginWorkerHostCall = Schema.Union([ + keyedHostCall("settings.get"), + valuedHostCall("settings.set"), + keyedHostCall("settings.delete"), + clearHostCall("settings.clear"), + keyedHostCall("state.get"), + valuedHostCall("state.set"), + keyedHostCall("state.delete"), + clearHostCall("state.clear"), + keyedHostCall("cache.get"), + valuedHostCall("cache.set"), + keyedHostCall("cache.delete"), + clearHostCall("cache.clear"), + SecretSet, + SecretAccess("secrets.get"), + SecretAccess("secrets.delete"), + FileWrite, + FileAccess("files.readText"), + FileAccess("files.remove"), + NetworkFetch, + ProcessRun, +]); +export type PluginWorkerHostCall = typeof PluginWorkerHostCall.Type; + +export const PluginWorkerMessage = Schema.Union([ + Activated, + ActivationFailed, + InvocationResult, + InvocationFailed, + Disposed, + DisposeFailed, + PluginWorkerHostCall, +]); +export type PluginWorkerMessage = typeof PluginWorkerMessage.Type; + +export type PluginWorkerParentMessage = + | { readonly type: "invoke"; readonly requestId: string; readonly commandId: string } + | { readonly type: "cancel"; readonly requestId: string } + | { readonly type: "dispose"; readonly requestId: string } + | { readonly type: "hostResult"; readonly callId: string; readonly value?: Schema.Json } + | { readonly type: "hostFailed"; readonly callId: string; readonly detail: string }; diff --git a/apps/server/src/plugins/PluginWorkerRuntime.mjs b/apps/server/src/plugins/PluginWorkerRuntime.mjs new file mode 100644 index 000000000000..035f6cad5b6e --- /dev/null +++ b/apps/server/src/plugins/PluginWorkerRuntime.mjs @@ -0,0 +1,254 @@ +import { pathToFileURL } from "node:url"; +import { createInterface } from "node:readline"; +import { inspect } from "node:util"; + +const MAX_PROTOCOL_LINE_BYTES = 1_000_000; +const entrypointPath = process.argv[2]; +const pluginId = process.argv[3]; +const protocolWrite = process.stdout.write.bind(process.stdout); +let sequence = 0; +let disposing = false; + +const detailFrom = (error) => { + const detail = error instanceof Error ? error.message : String(error); + return (detail.trim() || "unknown error").slice(0, 2_000); +}; + +const write = (message) => { + const line = `${JSON.stringify(message)}\n`; + if (Buffer.byteLength(line, "utf8") > MAX_PROTOCOL_LINE_BYTES) { + throw new Error("plugin worker protocol message exceeds limit"); + } + protocolWrite(line); +}; + +const writeDiagnostic = (...values) => { + const detail = values + .map((value) => (typeof value === "string" ? value : inspect(value))) + .join(" "); + process.stderr.write(`${detail.slice(0, 4_000)}\n`); +}; +for (const method of ["log", "info", "warn", "error", "debug"]) { + console[method] = writeDiagnostic; +} + +class RemoteEffect { + constructor(run) { + this.run = run; + } +} + +const pendingHostCalls = new Map(); +const invocations = new Map(); +const commands = new Map(); +const finalizers = []; + +const runValue = async (value, signal) => { + if (value instanceof RemoteEffect) return await value.run(signal); + return await value; +}; + +const hostCall = (operation, fields) => + new RemoteEffect( + (signal) => + new Promise((resolve, reject) => { + const callId = `call-${++sequence}`; + const onAbort = () => { + pendingHostCalls.delete(callId); + reject(new Error("plugin invocation cancelled")); + }; + if (signal.aborted) return onAbort(); + signal.addEventListener("abort", onAbort, { once: true }); + pendingHostCalls.set(callId, { + resolve: (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + reject: (error) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + }); + write({ type: "hostCall", callId, operation, ...fields }); + }), + ); + +const store = (name) => ({ + get: (key) => hostCall(`${name}.get`, { key }), + set: (key, value) => hostCall(`${name}.set`, { key, value }), + delete: (key) => hostCall(`${name}.delete`, { key }), + clear: hostCall(`${name}.clear`, {}), +}); + +const api = { + host: { + settings: store("settings"), + state: store("state"), + cache: store("cache"), + secrets: { + get: (name) => hostCall("secrets.get", { name }), + set: (name, value) => hostCall("secrets.set", { name, value }), + delete: (name) => hostCall("secrets.delete", { name }), + }, + files: { + readText: (path) => hostCall("files.readText", { path }), + writeText: (path, contents) => hostCall("files.writeText", { path, contents }), + remove: (path) => hostCall("files.remove", { path }), + }, + network: { + fetchText: (url) => hostCall("network.fetchText", { url }), + }, + process: { + run: (command, args = []) => hostCall("process.run", { command, args }), + }, + }, + effect: { + succeed: (value) => new RemoteEffect(async () => value), + map: (effect, f) => new RemoteEffect(async (signal) => f(await runValue(effect, signal))), + flatMap: (effect, f) => + new RemoteEffect(async (signal) => runValue(f(await runValue(effect, signal)), signal)), + }, + onDispose: (cleanup) => { + if (typeof cleanup !== "function") throw new Error("dispose callback must be a function"); + finalizers.push(cleanup); + }, + registerCommand: (command, handler) => { + if (typeof command?.id !== "string" || typeof handler !== "function") { + throw new Error("invalid command registration"); + } + if (commands.has(command.id)) throw new Error(`duplicate command ${command.id}`); + commands.set(command.id, { command, handler }); + }, +}; + +const dispose = async (requestId = "dispose-signal") => { + if (disposing) return; + disposing = true; + for (const controller of invocations.values()) controller.abort(); + invocations.clear(); + const failures = []; + for (const cleanup of [...finalizers].reverse()) { + try { + await runValue(cleanup(), new AbortController().signal); + } catch (error) { + failures.push(detailFrom(error)); + } + } + if (failures.length > 0) { + writeDiagnostic(`plugin cleanup failed: ${failures.join("; ")}`); + write({ type: "disposeFailed", requestId, detail: failures.join("; ").slice(0, 2_000) }); + } else { + write({ type: "disposed", requestId }); + } + setImmediate(() => process.exit(0)); +}; + +const handleMessage = async (message) => { + if (message === null || typeof message !== "object" || typeof message.type !== "string") { + throw new Error("invalid parent message"); + } + switch (message.type) { + case "hostResult": { + const pending = pendingHostCalls.get(message.callId); + if (pending === undefined) return; + pendingHostCalls.delete(message.callId); + pending.resolve(message.value); + return; + } + case "hostFailed": { + const pending = pendingHostCalls.get(message.callId); + if (pending === undefined) return; + pendingHostCalls.delete(message.callId); + pending.reject(new Error(String(message.detail ?? "host capability failed"))); + return; + } + case "invoke": { + if ( + disposing || + typeof message.requestId !== "string" || + typeof message.commandId !== "string" + ) { + return; + } + const registration = commands.get(message.commandId); + if (registration === undefined) { + write({ + type: "invocationFailed", + requestId: message.requestId, + detail: `command not found: ${message.commandId}`, + }); + return; + } + const controller = new AbortController(); + invocations.set(message.requestId, controller); + void Promise.resolve() + .then(() => registration.handler()) + .then((result) => runValue(result, controller.signal)) + .then( + (value) => write({ type: "invocationResult", requestId: message.requestId, value }), + (error) => + write({ + type: "invocationFailed", + requestId: message.requestId, + detail: detailFrom(error), + }), + ) + .finally(() => invocations.delete(message.requestId)); + return; + } + case "cancel": { + invocations.get(message.requestId)?.abort(); + return; + } + case "dispose": { + await dispose(message.requestId); + return; + } + default: + throw new Error(`unsupported parent message: ${message.type}`); + } +}; + +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); +input.on("line", (line) => { + if (Buffer.byteLength(line, "utf8") > MAX_PROTOCOL_LINE_BYTES) { + writeDiagnostic("parent protocol line exceeds limit"); + process.exitCode = 1; + input.close(); + return; + } + try { + const message = JSON.parse(line); + void handleMessage(message).catch((error) => { + writeDiagnostic(detailFrom(error)); + process.exitCode = 1; + input.close(); + }); + } catch (error) { + writeDiagnostic(detailFrom(error)); + process.exitCode = 1; + input.close(); + } +}); + +process.once("SIGTERM", () => void dispose()); +process.once("SIGINT", () => void dispose()); + +try { + if (typeof entrypointPath !== "string" || typeof pluginId !== "string") { + throw new Error("plugin worker requires an entrypoint and plugin id"); + } + const module = await import(pathToFileURL(entrypointPath).href); + if (typeof module.default !== "function") { + throw new Error("server entrypoint must export a default activation function"); + } + await runValue(module.default(api), new AbortController().signal); + write({ + type: "activated", + commands: [...commands.values()].map(({ command }) => command), + }); +} catch (error) { + write({ type: "activationFailed", detail: detailFrom(error) }); + process.exitCode = 1; + input.close(); +} diff --git a/apps/server/src/plugins/PluginWorkerSupervisor.test.ts b/apps/server/src/plugins/PluginWorkerSupervisor.test.ts new file mode 100644 index 000000000000..9be9e74405b3 --- /dev/null +++ b/apps/server/src/plugins/PluginWorkerSupervisor.test.ts @@ -0,0 +1,338 @@ +import { it } from "@effect/vitest"; +import { expect, vi } from "vite-plus/test"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; + +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; +import { NodeServices } from "@effect/platform-node"; + +import type { PluginHostApi, PluginHostKeyValueStore } from "./PluginHostCapabilityBroker.ts"; +import * as PluginWorkerSupervisor from "./PluginWorkerSupervisor.ts"; + +const makeStore = (): PluginHostKeyValueStore => { + const values = new Map(); + return { + get: (key) => Effect.succeed(values.get(key)), + set: (key, value) => Effect.sync(() => void values.set(key, structuredClone(value))), + delete: (key) => Effect.sync(() => void values.delete(key)), + clear: Effect.sync(() => values.clear()), + }; +}; + +const makeHost = (): PluginHostApi => ({ + settings: makeStore(), + state: makeStore(), + cache: makeStore(), + secrets: { + get: () => Effect.succeed(undefined), + set: () => Effect.succeed(undefined), + delete: () => Effect.succeed(undefined), + }, + files: { + readText: () => Effect.fail(new Error("unused") as never), + writeText: () => Effect.succeed(undefined), + remove: () => Effect.succeed(undefined), + }, + network: { + fetchText: () => Effect.fail(new Error("unused") as never), + }, + process: { + run: () => Effect.fail(new Error("unused") as never), + }, +}); + +const waitForHealth = ( + worker: PluginWorkerSupervisor.SupervisedPluginWorker, + expected: PluginWorkerSupervisor.PluginWorkerHealth, +) => + Effect.promise(() => + vi.waitFor(() => expect(worker.health().state).toBe(expected), { + interval: 5, + timeout: 1_000, + }), + ); + +it.layer(NodeServices.layer)("plugin worker supervisor", (it) => { + it.effect("activates and invokes a plugin through typed worker transport", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-worker-invoke-test-", + }); + const entrypointPath = path.join(directory, "index.mjs"); + yield* fileSystem.writeFileString( + entrypointPath, + `export default function activate(api) { + if (!process.execArgv.includes("--max-old-space-size=128") || !process.execArgv.includes("--no-addons")) { + throw new Error("worker resource flags missing"); + } + api.registerCommand( + { id: "acme.counter", label: "Counter", surfaces: ["web"] }, + () => api.effect.flatMap(api.host.state.get("count"), (stored) => { + const next = typeof stored === "number" ? stored + 1 : 1; + return api.effect.flatMap( + api.host.state.set("count", next), + () => api.effect.succeed({ message: String(next), tone: "success" }) + ); + }) + ); + }`, + ); + const supervisor = yield* PluginWorkerSupervisor.PluginWorkerSupervisor; + const worker = yield* supervisor.start({ + pluginId: "com.acme.counter", + entrypointPath, + host: makeHost(), + }); + + expect(worker.commands.map(({ id }) => id)).toEqual(["acme.counter"]); + expect(yield* worker.invoke("acme.counter")).toEqual({ message: "1", tone: "success" }); + expect(yield* worker.invoke("acme.counter")).toEqual({ message: "2", tone: "success" }); + expect(worker.health().state).toBe("running"); + yield* Effect.promise(worker.dispose); + }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), + ), + ); + + it.effect("kills a worker that exceeds the protocol line limit", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-worker-protocol-limit-test-", + }); + const entrypointPath = path.join(directory, "index.mjs"); + yield* fileSystem.writeFileString( + entrypointPath, + `export default async function activate() { + process.stdout.write("x".repeat(1_000_001)); + await new Promise(() => {}); + }`, + ); + const supervisor = yield* PluginWorkerSupervisor.PluginWorkerSupervisor; + const started = yield* Effect.exit( + supervisor.start({ + pluginId: "com.acme.protocol-limit", + entrypointPath, + host: makeHost(), + }), + ); + expect(started._tag).toBe("Failure"); + if (started._tag === "Failure") { + const cause = Cause.squash(started.cause); + expect(cause instanceof Error ? cause.message : String(cause)).toContain( + "protocol line exceeds limit", + ); + } + }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), + ), + ); + + it.effect("restarts after a plugin crash without crashing the server", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-worker-crash-test-", + }); + const entrypointPath = path.join(directory, "index.mjs"); + yield* fileSystem.writeFileString( + entrypointPath, + `export default function activate(api) { + api.registerCommand( + { id: "acme.crash-once", label: "Crash once", surfaces: ["web"] }, + () => api.effect.flatMap(api.host.state.get("crashed"), (crashed) => { + if (crashed === true) { + return api.effect.succeed({ message: "recovered", tone: "success" }); + } + return api.effect.flatMap(api.host.state.set("crashed", true), () => { + process.exit(23); + }); + }) + ); + }`, + ); + const supervisor = yield* PluginWorkerSupervisor.PluginWorkerSupervisor; + const worker = yield* supervisor.start({ + pluginId: "com.acme.crash-once", + entrypointPath, + host: makeHost(), + }); + + expect((yield* Effect.exit(worker.invoke("acme.crash-once")))._tag).toBe("Failure"); + yield* waitForHealth(worker, "restarting"); + yield* TestClock.adjust("1 second"); + yield* waitForHealth(worker, "running"); + expect(yield* worker.invoke("acme.crash-once")).toEqual({ + message: "recovered", + tone: "success", + }); + yield* Effect.promise(worker.dispose); + }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), + ), + ); + + it.effect("stops restarting after the crash budget is exhausted", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-worker-quarantine-test-", + }); + const entrypointPath = path.join(directory, "index.mjs"); + yield* fileSystem.writeFileString( + entrypointPath, + `export default function activate(api) { + api.registerCommand( + { id: "acme.always-crash", label: "Always crash", surfaces: ["web"] }, + () => process.exit(24) + ); + }`, + ); + const supervisor = yield* PluginWorkerSupervisor.PluginWorkerSupervisor; + const worker = yield* supervisor.start( + { + pluginId: "com.acme.always-crash", + entrypointPath, + host: makeHost(), + }, + { maxRestarts: 1 }, + ); + + expect((yield* Effect.exit(worker.invoke("acme.always-crash")))._tag).toBe("Failure"); + yield* waitForHealth(worker, "restarting"); + yield* TestClock.adjust("1 second"); + yield* waitForHealth(worker, "running"); + expect((yield* Effect.exit(worker.invoke("acme.always-crash")))._tag).toBe("Failure"); + yield* waitForHealth(worker, "crashed"); + expect(worker.health().restartCount).toBe(1); + yield* Effect.promise(worker.dispose); + }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), + ), + ); + + it.effect("kills and restarts a worker after an invocation timeout", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-worker-timeout-test-", + }); + const entrypointPath = path.join(directory, "index.mjs"); + yield* fileSystem.writeFileString( + entrypointPath, + `export default function activate(api) { + api.registerCommand( + { id: "acme.hang", label: "Hang", surfaces: ["web"] }, + () => new Promise(() => {}) + ); + }`, + ); + const supervisor = yield* PluginWorkerSupervisor.PluginWorkerSupervisor; + const worker = yield* supervisor.start( + { + pluginId: "com.acme.hang", + entrypointPath, + host: makeHost(), + }, + { invocationTimeout: "100 millis" }, + ); + + const invocationFiber = yield* Effect.forkChild(Effect.exit(worker.invoke("acme.hang"))); + yield* Effect.yieldNow; + yield* TestClock.adjust("101 millis"); + const invocation = yield* Fiber.join(invocationFiber); + expect(invocation._tag).toBe("Failure"); + if (invocation._tag === "Failure") { + const cause = Cause.squash(invocation.cause); + expect(cause instanceof Error ? cause.message : String(cause)).toContain("timed out"); + } + yield* waitForHealth(worker, "restarting"); + yield* TestClock.adjust("1 second"); + yield* waitForHealth(worker, "running"); + yield* Effect.promise(worker.dispose); + }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), + ), + ); + + it.effect("restarts a worker when a non-cooperative invocation is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-worker-cancel-test-", + }); + const entrypointPath = path.join(directory, "index.mjs"); + yield* fileSystem.writeFileString( + entrypointPath, + `export default function activate(api) { + api.registerCommand( + { id: "acme.cancel", label: "Cancel", surfaces: ["web"] }, + () => new Promise(() => {}) + ); + }`, + ); + const supervisor = yield* PluginWorkerSupervisor.PluginWorkerSupervisor; + const worker = yield* supervisor.start({ + pluginId: "com.acme.cancel", + entrypointPath, + host: makeHost(), + }); + + const invocation = yield* Effect.forkChild(worker.invoke("acme.cancel")); + yield* Effect.yieldNow; + yield* Fiber.interrupt(invocation); + yield* waitForHealth(worker, "restarting"); + yield* TestClock.adjust("1 second"); + yield* waitForHealth(worker, "running"); + yield* Effect.promise(worker.dispose); + }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), + ), + ); + + it.effect("runs worker cleanup during disposal", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-worker-dispose-test-", + }); + const entrypointPath = path.join(directory, "index.mjs"); + const markerPath = path.join(directory, "disposed.txt"); + // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in a test plugin source. + const markerPathJson = JSON.stringify(markerPath); + yield* fileSystem.writeFileString( + entrypointPath, + `import { writeFile } from "node:fs/promises"; + export default function activate(api) { + api.onDispose(() => writeFile(${markerPathJson}, "disposed")); + api.registerCommand( + { id: "acme.dispose", label: "Dispose", surfaces: ["web"] }, + () => ({ message: "ok", tone: "success" }) + ); + }`, + ); + const supervisor = yield* PluginWorkerSupervisor.PluginWorkerSupervisor; + const worker = yield* supervisor.start({ + pluginId: "com.acme.dispose", + entrypointPath, + host: makeHost(), + }); + + yield* Effect.promise(worker.dispose); + expect(yield* fileSystem.readFileString(markerPath)).toBe("disposed"); + }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), + ), + ); +}); diff --git a/apps/server/src/plugins/PluginWorkerSupervisor.ts b/apps/server/src/plugins/PluginWorkerSupervisor.ts new file mode 100644 index 000000000000..38e9399d035e --- /dev/null +++ b/apps/server/src/plugins/PluginWorkerSupervisor.ts @@ -0,0 +1,664 @@ +import type { PluginCommand } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import type { PluginHostApi, PluginHostCapabilityError } from "./PluginHostCapabilityBroker.ts"; +import { + type PluginWorkerHostCall, + type PluginWorkerParentMessage, + PluginWorkerMessage, +} from "./PluginWorkerProtocol.ts"; + +const MAX_PROTOCOL_LINE_BYTES = 1_000_000; +const MAX_STDERR_BYTES = 64_000; +const MAX_PENDING_HOST_CALLS = 64; +const HOST_CALL_CONCURRENCY = 8; +const DEFAULT_MEMORY_LIMIT_MB = 128; +const DEFAULT_RESTARTS = 2; + +const sameCommands = ( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean => + left.length === right.length && + left.every((command, index) => { + const other = right[index]; + return ( + other !== undefined && + command.id === other.id && + command.label === other.label && + command.description === other.description && + command.surfaces.length === other.surfaces.length && + command.surfaces.every((surface, surfaceIndex) => surface === other.surfaces[surfaceIndex]) + ); + }); + +const decodeWorkerMessage = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginWorkerMessage)); + +export type PluginWorkerHealth = "starting" | "running" | "restarting" | "crashed" | "stopped"; + +export interface PluginWorkerHealthSnapshot { + readonly state: PluginWorkerHealth; + readonly detail?: string; + readonly restartCount: number; +} + +export class PluginWorkerError extends Schema.TaggedErrorClass()( + "PluginWorkerError", + { + pluginId: Schema.String, + phase: Schema.Literals(["activation", "invocation", "host", "protocol", "restart", "dispose"]), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `${this.phase} failed for plugin ${this.pluginId}: ${this.detail}`; + } +} + +export const isPluginWorkerError = Schema.is(PluginWorkerError); + +export interface SupervisedPluginWorker { + readonly commands: ReadonlyArray; + readonly invoke: (commandId: string) => Effect.Effect; + readonly dispose: () => Promise; + readonly health: () => PluginWorkerHealthSnapshot; +} + +export interface PluginWorkerStartInput { + readonly pluginId: string; + readonly entrypointPath: string; + readonly host: PluginHostApi; +} + +export interface PluginWorkerOptions { + readonly activationTimeout?: Duration.Input; + readonly invocationTimeout?: Duration.Input; + readonly disposeTimeout?: Duration.Input; + readonly restartDelay?: Duration.Input; + readonly maxRestarts?: number; + readonly memoryLimitMb?: number; +} + +export class PluginWorkerSupervisor extends Context.Service< + PluginWorkerSupervisor, + { + readonly start: ( + input: PluginWorkerStartInput, + options?: PluginWorkerOptions, + ) => Effect.Effect; + } +>()("t3/plugins/PluginWorkerSupervisor") {} + +interface Session { + readonly id: number; + readonly commands: ReadonlyArray; + readonly invoke: (commandId: string) => Effect.Effect; + readonly requestDispose: Effect.Effect; + readonly terminate: (detail: string) => Effect.Effect; + readonly close: Effect.Effect; +} + +interface CrashEvent { + readonly sessionId: number; + readonly detail: string; +} + +export const make = Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const parentScope = yield* Scope.Scope; + const path = yield* Path.Path; + const runtimePath = yield* path.fromFileUrl( + new URL("./PluginWorkerRuntime.mjs", import.meta.url), + ); + let workerSequence = 0; + + const start: PluginWorkerSupervisor["Service"]["start"] = (input, suppliedOptions = {}) => { + let allocatedScope: Scope.Closeable | undefined; + return Effect.gen(function* () { + const options = { + activationTimeout: suppliedOptions.activationTimeout ?? "5 seconds", + invocationTimeout: suppliedOptions.invocationTimeout ?? "30 seconds", + disposeTimeout: suppliedOptions.disposeTimeout ?? "2 seconds", + restartDelay: suppliedOptions.restartDelay ?? "100 millis", + maxRestarts: suppliedOptions.maxRestarts ?? DEFAULT_RESTARTS, + memoryLimitMb: suppliedOptions.memoryLimitMb ?? DEFAULT_MEMORY_LIMIT_MB, + } as const; + const workerScope = yield* Scope.fork(parentScope, "sequential"); + allocatedScope = workerScope; + const transition = yield* Semaphore.make(1); + const crashes = yield* Queue.unbounded(); + const disposeRequest = yield* Deferred.make(); + + let disposed = false; + let restartCount = 0; + let health: PluginWorkerHealthSnapshot = { state: "starting", restartCount: 0 }; + let current: Session | undefined; + let expectedCommands: ReadonlyArray | undefined; + let resolveDisposed!: () => void; + let rejectDisposed!: (error: unknown) => void; + const disposedPromise = new Promise((resolve, reject) => { + resolveDisposed = resolve; + rejectDisposed = reject; + }); + + const detailFrom = (error: unknown): string => { + const detail = error instanceof Error ? error.message : String(error); + return (detail.trim() || "unknown error").slice(0, 2_000); + }; + + const detailFromCause = (cause: Cause.Cause): string => { + const failure = Cause.squash(cause); + return isPluginWorkerError(failure) ? failure.detail : detailFrom(failure); + }; + + const workerError = ( + phase: PluginWorkerError["phase"], + detail: string, + cause?: unknown, + ): PluginWorkerError => + new PluginWorkerError({ + pluginId: input.pluginId, + phase, + detail: detail.slice(0, 2_000), + ...(cause === undefined ? {} : { cause }), + }); + + const dispatchHostCall = ( + call: PluginWorkerHostCall, + ): Effect.Effect => { + switch (call.operation) { + case "settings.get": + return input.host.settings.get(call.key); + case "settings.set": + return input.host.settings.set(call.key, call.value); + case "settings.delete": + return input.host.settings.delete(call.key); + case "settings.clear": + return input.host.settings.clear; + case "state.get": + return input.host.state.get(call.key); + case "state.set": + return input.host.state.set(call.key, call.value); + case "state.delete": + return input.host.state.delete(call.key); + case "state.clear": + return input.host.state.clear; + case "cache.get": + return input.host.cache.get(call.key); + case "cache.set": + return input.host.cache.set(call.key, call.value); + case "cache.delete": + return input.host.cache.delete(call.key); + case "cache.clear": + return input.host.cache.clear; + case "secrets.get": + return input.host.secrets.get(call.name); + case "secrets.set": + return input.host.secrets.set(call.name, call.value); + case "secrets.delete": + return input.host.secrets.delete(call.name); + case "files.readText": + return input.host.files.readText(call.path); + case "files.writeText": + return input.host.files.writeText(call.path, call.contents); + case "files.remove": + return input.host.files.remove(call.path); + case "network.fetchText": + return input.host.network.fetchText(call.url); + case "process.run": + return input.host.process.run(call.command, call.args); + } + return Effect.fail(workerError("host", "unsupported host operation")); + }; + + const spawnSession = Effect.fn("PluginWorkerSupervisor.spawnSession")(function* () { + const sessionId = ++workerSequence; + const sessionScope = yield* Scope.fork(workerScope, "sequential"); + const inputQueue = yield* Queue.unbounded(); + const activation = yield* Deferred.make, PluginWorkerError>(); + const hostCallSemaphore = yield* Semaphore.make(HOST_CALL_CONCURRENCY); + const encoder = new TextEncoder(); + const protocolDecoder = new TextDecoder(); + const stderrDecoder = new TextDecoder(); + let protocolBuffer = ""; + let pendingHostCalls = 0; + const pending = new Map< + string, + (effect: Effect.Effect) => void + >(); + let requestSequence = 0; + let closing = false; + let crashReported = false; + let stderr = ""; + + const send = (message: PluginWorkerParentMessage): boolean => { + let line: string; + try { + line = `${JSON.stringify(message)}\n`; + } catch { + return false; + } + if (Buffer.byteLength(line, "utf8") > MAX_PROTOCOL_LINE_BYTES) return false; + return Queue.offerUnsafe(inputQueue, encoder.encode(line)); + }; + + const failPending = (error: PluginWorkerError) => { + for (const resume of pending.values()) resume(Effect.fail(error)); + pending.clear(); + }; + + const reportCrash = (detail: string) => { + if (closing || crashReported || disposed) return; + crashReported = true; + health = { state: "restarting", detail, restartCount }; + failPending(workerError("invocation", detail)); + Deferred.doneUnsafe(activation, Effect.fail(workerError("activation", detail))); + Queue.offerUnsafe(crashes, { sessionId, detail }); + }; + + const command = ChildProcess.make( + process.execPath, + [ + `--max-old-space-size=${String(options.memoryLimitMb)}`, + "--no-addons", + "--unhandled-rejections=strict", + runtimePath, + input.entrypointPath, + input.pluginId, + ], + { + cwd: path.dirname(input.entrypointPath), + env: { PATH: process.env.PATH, NODE_NO_WARNINGS: "1" }, + stdin: Stream.fromQueue(inputQueue), + stdout: "pipe", + stderr: "pipe", + shell: false, + killSignal: "SIGTERM", + forceKillAfter: "1 second", + }, + ); + const handle = yield* childProcessSpawner.spawn(command).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError((cause) => workerError("activation", "worker failed to start", cause)), + ); + + const handleMessage = Effect.fn("PluginWorkerSupervisor.handleMessage")(function* ( + line: string, + ) { + if (Buffer.byteLength(line, "utf8") > MAX_PROTOCOL_LINE_BYTES) { + return yield* workerError("protocol", "worker protocol line exceeds limit"); + } + const message = yield* decodeWorkerMessage(line).pipe( + Effect.mapError((cause) => + workerError("protocol", "worker sent an invalid message", cause), + ), + ); + switch (message.type) { + case "activated": + Deferred.doneUnsafe(activation, Effect.succeed(message.commands)); + return; + case "activationFailed": + Deferred.doneUnsafe( + activation, + Effect.fail(workerError("activation", message.detail)), + ); + return; + case "invocationResult": { + const resume = pending.get(message.requestId); + if (resume !== undefined) { + pending.delete(message.requestId); + resume(Effect.succeed(message.value)); + } + return; + } + case "invocationFailed": { + const resume = pending.get(message.requestId); + if (resume !== undefined) { + pending.delete(message.requestId); + resume(Effect.fail(workerError("invocation", message.detail))); + } + return; + } + case "disposed": { + const resume = pending.get(message.requestId); + if (resume !== undefined) { + pending.delete(message.requestId); + resume(Effect.succeed(undefined)); + } + return; + } + case "disposeFailed": { + const resume = pending.get(message.requestId); + if (resume !== undefined) { + pending.delete(message.requestId); + resume(Effect.fail(workerError("dispose", message.detail))); + } + return; + } + case "hostCall": { + if (pendingHostCalls >= MAX_PENDING_HOST_CALLS) { + send({ + type: "hostFailed", + callId: message.callId, + detail: "too many pending host capability calls", + }); + return; + } + pendingHostCalls += 1; + const hostCall = hostCallSemaphore + .withPermits(1)(dispatchHostCall(message)) + .pipe( + Effect.match({ + onFailure: (cause) => { + send({ + type: "hostFailed", + callId: message.callId, + detail: detailFrom(cause), + }); + }, + onSuccess: (value) => { + const sent = send({ + type: "hostResult", + callId: message.callId, + ...(value === undefined ? {} : { value: value as Schema.Json }), + }); + if (!sent) { + send({ + type: "hostFailed", + callId: message.callId, + detail: "host capability result exceeds protocol limit", + }); + } + }, + }), + Effect.ensuring( + Effect.sync(() => { + pendingHostCalls -= 1; + }), + ), + ); + yield* Effect.forkIn(hostCall, sessionScope); + return; + } + } + }); + + const readProtocolChunk = Effect.fn("PluginWorkerSupervisor.readProtocolChunk")(function* ( + chunk: Uint8Array, + ) { + protocolBuffer += protocolDecoder.decode(chunk, { stream: true }); + let newline = protocolBuffer.indexOf("\n"); + while (newline >= 0) { + const line = protocolBuffer.slice(0, newline); + protocolBuffer = protocolBuffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_PROTOCOL_LINE_BYTES) { + return yield* workerError("protocol", "worker protocol line exceeds limit"); + } + if (line.length > 0) yield* handleMessage(line); + newline = protocolBuffer.indexOf("\n"); + } + if (Buffer.byteLength(protocolBuffer, "utf8") > MAX_PROTOCOL_LINE_BYTES) { + return yield* workerError("protocol", "worker protocol line exceeds limit"); + } + }); + + const reader = handle.stdout.pipe( + Stream.runForEach(readProtocolChunk), + Effect.catchCause((cause) => + Effect.sync(() => + reportCrash(`worker protocol failed: ${detailFromCause(cause)}`), + ).pipe(Effect.tap(() => handle.kill().pipe(Effect.ignore))), + ), + ); + yield* Effect.forkIn(reader, sessionScope); + + const stderrReader = handle.stderr.pipe( + Stream.runForEach((chunk) => + Effect.sync(() => { + if (stderr.length >= MAX_STDERR_BYTES) return; + stderr += stderrDecoder + .decode(chunk, { stream: true }) + .slice(0, MAX_STDERR_BYTES - stderr.length); + }), + ), + Effect.ignore, + ); + yield* Effect.forkIn(stderrReader, sessionScope); + + const exitWatcher = handle.exitCode.pipe( + Effect.tap((exitCode) => + Effect.sync(() => { + if (!closing) { + const diagnostic = stderr.trim().slice(0, 500); + reportCrash( + `worker exited with code ${String(exitCode)}${diagnostic.length === 0 ? "" : `: ${diagnostic}`}`, + ); + } + }), + ), + Effect.ignore, + ); + yield* Effect.forkIn(exitWatcher, sessionScope); + + const activated = yield* Deferred.await(activation).pipe( + Effect.timeout(options.activationTimeout), + Effect.mapError((cause) => + isPluginWorkerError(cause) + ? cause + : workerError("activation", "worker activation timed out", cause), + ), + Effect.onError(() => Scope.close(sessionScope, Exit.void)), + ); + + const request = ( + message: + | { readonly type: "invoke"; readonly commandId: string } + | { readonly type: "dispose" }, + ): Effect.Effect => + Effect.callback((resume, signal) => { + const requestId = `request-${++requestSequence}`; + const onAbort = () => { + pending.delete(requestId); + send({ type: "cancel", requestId }); + }; + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + pending.set(requestId, (effect) => { + signal.removeEventListener("abort", onAbort); + resume(effect); + }); + if (!send({ ...message, requestId } as PluginWorkerParentMessage)) { + pending.delete(requestId); + resume(Effect.fail(workerError("protocol", "worker input is unavailable"))); + } + }); + + const close = Effect.uninterruptible( + Effect.sync(() => { + closing = true; + failPending(workerError("dispose", "worker stopped")); + Queue.endUnsafe(inputQueue); + }).pipe(Effect.flatMap(() => Scope.close(sessionScope, Exit.void))), + ); + + return { + id: sessionId, + commands: activated, + invoke: (commandId) => + request({ type: "invoke", commandId }).pipe( + Effect.mapError((error) => + isPluginWorkerError(error) + ? error + : workerError("invocation", "worker invocation failed", error), + ), + ), + requestDispose: request({ type: "dispose" }).pipe(Effect.asVoid), + terminate: (detail) => + Effect.sync(() => reportCrash(detail)).pipe(Effect.flatMap(() => close)), + close, + } satisfies Session; + }); + + const firstSessionExit = yield* Effect.exit(spawnSession()); + if (firstSessionExit._tag === "Failure") { + yield* Scope.close(workerScope, Exit.void); + return yield* Effect.failCause(firstSessionExit.cause); + } + current = firstSessionExit.value; + expectedCommands = current.commands; + health = { state: "running", restartCount: 0 }; + + const restartLoop = Effect.forever( + Queue.take(crashes).pipe( + Effect.flatMap((event) => + transition.withPermits(1)( + Effect.gen(function* () { + if (disposed || current?.id !== event.sessionId) return; + yield* current.close; + while (restartCount < options.maxRestarts) { + if (disposed) return; + restartCount += 1; + health = { state: "restarting", detail: event.detail, restartCount }; + yield* Effect.sleep(options.restartDelay); + const restarted = yield* Effect.exit(spawnSession()); + if (restarted._tag === "Failure") { + health = { + state: "restarting", + detail: detailFromCause(restarted.cause), + restartCount, + }; + continue; + } + if (!sameCommands(restarted.value.commands, expectedCommands)) { + yield* restarted.value.close; + health = { + state: "crashed", + detail: "restarted worker changed its command catalog", + restartCount, + }; + return; + } + current = restarted.value; + health = { state: "running", restartCount }; + return; + } + health = { state: "crashed", detail: event.detail, restartCount }; + }), + ), + ), + ), + ); + yield* Effect.forkIn(restartLoop, workerScope); + + const disposeFiber = Deferred.await(disposeRequest).pipe( + Effect.flatMap(() => + Effect.exit( + transition.withPermits(1)( + Effect.gen(function* () { + if (disposed) return; + disposed = true; + const wasCrashed = health.state === "crashed"; + health = { state: "stopped", restartCount }; + const disposeExit = + current === undefined || wasCrashed + ? undefined + : yield* Effect.exit( + current.requestDispose.pipe(Effect.timeout(options.disposeTimeout)), + ); + yield* Scope.close(workerScope, Exit.void); + if (disposeExit?._tag === "Failure") { + const failure = Cause.squash(disposeExit.cause); + return yield* workerError( + "dispose", + isPluginWorkerError(failure) ? failure.detail : detailFrom(failure), + ); + } + }), + ), + ), + ), + Effect.tap((exit) => + Effect.sync(() => { + if (exit._tag === "Failure") rejectDisposed(Cause.squash(exit.cause)); + else resolveDisposed(); + }), + ), + ); + yield* Effect.forkIn(disposeFiber, parentScope); + + const invoke = (commandId: string) => + transition.withPermits(1)( + Effect.gen(function* () { + if (disposed) return yield* workerError("invocation", "worker is stopped"); + if (health.state === "crashed" || current === undefined) { + return yield* workerError("invocation", health.detail ?? "worker is unavailable"); + } + const session = current; + const result = yield* session.invoke(commandId).pipe( + Effect.timeout(options.invocationTimeout), + Effect.catchTag("TimeoutError", (cause) => + session + .terminate("worker invocation timed out") + .pipe( + Effect.flatMap(() => + Effect.fail(workerError("invocation", "worker invocation timed out", cause)), + ), + ), + ), + Effect.onInterrupt(() => session.terminate("worker invocation interrupted")), + ); + restartCount = 0; + health = { state: "running", restartCount: 0 }; + return result; + }), + ); + + return { + commands: expectedCommands, + invoke, + dispose: () => { + Deferred.doneUnsafe(disposeRequest, Effect.void); + return disposedPromise; + }, + health: () => health, + } satisfies SupervisedPluginWorker; + }).pipe( + Effect.onInterrupt(() => + allocatedScope === undefined + ? Effect.succeed(undefined) + : Scope.close(allocatedScope, Exit.void), + ), + Effect.mapError((cause) => + isPluginWorkerError(cause) + ? cause + : new PluginWorkerError({ + pluginId: input.pluginId, + phase: "activation", + detail: "worker supervisor failed", + cause, + }), + ), + ); + }; + + return PluginWorkerSupervisor.of({ start }); +}); + +export const layer = Layer.effect(PluginWorkerSupervisor, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d2ef743d433c..dd01f2a08f8f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -71,6 +71,7 @@ import * as Keybindings from "./keybindings.ts"; import * as PluginCommandCatalog from "./plugins/PluginCommandCatalog.ts"; import * as PluginHostCapabilityBroker from "./plugins/PluginHostCapabilityBroker.ts"; import * as PluginPackageManager from "./plugins/PluginPackageManager.ts"; +import * as PluginWorkerSupervisor from "./plugins/PluginWorkerSupervisor.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { projectActivityEvent, @@ -2513,6 +2514,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( PluginPackageManager.layer.pipe( Layer.provideMerge(PluginCommandCatalog.layer), Layer.provideMerge(PluginHostCapabilityBroker.layer), + Layer.provideMerge(PluginWorkerSupervisor.layer), ), ), ); diff --git a/apps/web/src/components/settings/PluginsSettings.test.tsx b/apps/web/src/components/settings/PluginsSettings.test.tsx index e73bc3180818..c8882a493feb 100644 --- a/apps/web/src/components/settings/PluginsSettings.test.tsx +++ b/apps/web/src/components/settings/PluginsSettings.test.tsx @@ -101,6 +101,8 @@ const snapshot: PluginPackageStatusSnapshot = { apiVersion: 1, enabled: true, state: "active", + runtimeState: "running", + restartCount: 0, capabilities: ["t3.commands@1"], permissions: ["state:read-write", "network:https://api.acme.test"], grantedPermissions: ["state:read-write"], @@ -112,6 +114,8 @@ const snapshot: PluginPackageStatusSnapshot = { apiVersion: 1, enabled: false, state: "disabled", + runtimeState: "stopped", + restartCount: 0, capabilities: ["t3.commands@1"], permissions: ["filesystem:data"], grantedPermissions: [], @@ -209,6 +213,36 @@ describe("PluginsSettingsPanel", () => { ).not.toBeNull(); }); + it("presents crashed workers with restart diagnostics", () => { + query.data = { + errors: [], + packages: [ + { + ...snapshot.packages[0]!, + state: "crashed", + runtimeState: "crashed", + restartCount: 2, + error: "worker exited with code 23", + }, + ], + }; + const row = renderPackageRow(renderPanel(), "com.acme.active"); + expect( + visitElements( + row, + (element) => element.props.variant === "error" && element.props.children === "Crashed", + ), + ).not.toBeNull(); + expect( + visitElements( + row, + (element) => + element.props["data-plugin-worker-health"] === "crashed" && + element.props["data-restart-count"] === 2, + ), + ).not.toBeNull(); + }); + it("shows granted and pending host permissions", () => { const panel = renderPanel(); const activeRow = renderPackageRow(panel, "com.acme.active"); diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index 333d1fae299e..b259b0b1890e 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -34,6 +34,8 @@ const statePresentation = { active: { label: "Active", variant: "success" }, disabled: { label: "Disabled", variant: "secondary" }, blocked: { label: "Blocked", variant: "warning" }, + restarting: { label: "Restarting", variant: "warning" }, + crashed: { label: "Crashed", variant: "error" }, error: { label: "Error", variant: "error" }, } as const; @@ -65,6 +67,13 @@ function PluginPackageRow({
{state.label} v{pluginPackage.version} + + worker: {pluginPackage.runtimeState}, restarts: {pluginPackage.restartCount} + {pluginPackage.capabilities.map((capability) => ( {capability} @@ -236,9 +245,9 @@ export function PluginsSettingsPanel() { Trusted local code - Host APIs enforce declared grants and keep plugin data namespaced. Plugins still run - inside this environment's server process and can bypass those APIs with Node until - execution isolation lands. Only install code you trust. + Plugins run in supervised subprocesses, while host APIs enforce declared grants and keep + plugin data namespaced. Workers still run as your OS user and are not a hostile code + sandbox. Only install code you trust. diff --git a/examples/plugins/runtime-status/README.md b/examples/plugins/runtime-status/README.md index 42633d9d6e4f..d295de9e15aa 100644 --- a/examples/plugins/runtime-status/README.md +++ b/examples/plugins/runtime-status/README.md @@ -26,6 +26,8 @@ plugin data lives under the active environment's `plugin-data//` dire host operations return Effect values. command handlers may return those values directly and compose them with `api.effect.succeed`, `api.effect.map`, and `api.effect.flatMap`. synchronous and Promise handlers remain supported. -these APIs define the boundary that isolated workers will use later. packages still run in the server process today, so trusted code can bypass the broker by importing Node APIs. +plugins run in supervised subprocesses with typed host transport, bounded protocol output, invocation deadlines, a V8 heap limit, crash detection, and automatic restart. failed replacement activation keeps the previous worker and command generation live. -local packages run in the server process and are fully trusted. marketplace distribution, signing, sandboxing, and renderer code are not part of this mvp. +workers still run as the same OS user as the environment server. process isolation prevents a plugin crash or `process.exit()` from stopping t3 code, but it is not a hostile-code filesystem sandbox. only install code you trust. + +local packages are fully trusted. marketplace distribution, signing, OS-level sandboxing, and renderer code are not part of this mvp. diff --git a/packages/contracts/src/pluginPackages.test.ts b/packages/contracts/src/pluginPackages.test.ts index 157bfab46d96..b6fa06bad143 100644 --- a/packages/contracts/src/pluginPackages.test.ts +++ b/packages/contracts/src/pluginPackages.test.ts @@ -23,6 +23,8 @@ describe("plugin package contracts", () => { apiVersion: 1, enabled: true, state: "active", + runtimeState: "running", + restartCount: 0, capabilities: ["t3.commands@1"], permissions: ["state:read-write", "network:https://api.acme.test"], grantedPermissions: ["state:read-write"], @@ -39,6 +41,8 @@ describe("plugin package contracts", () => { apiVersion: 1, enabled: true, state: "active", + runtimeState: "running", + restartCount: 0, capabilities: ["t3.commands@1"], permissions: ["state:read-write", "network:https://api.acme.test"], grantedPermissions: ["state:read-write"], @@ -71,6 +75,8 @@ describe("plugin package contracts", () => { apiVersion: 1, enabled: true, state: "blocked", + runtimeState: "stopped", + restartCount: 0, capabilities: ["t3.commands@1"], permissions: [], grantedPermissions: [], @@ -105,6 +111,8 @@ describe("plugin package contracts", () => { apiVersion: 1, enabled: false, state: "disabled", + runtimeState: "stopped", + restartCount: 0, capabilities: [], permissions: [permission], grantedPermissions: [], @@ -127,6 +135,8 @@ describe("plugin package contracts", () => { apiVersion: 1, enabled: false, state: "disabled", + runtimeState: "stopped", + restartCount: 0, capabilities: ["t3.commands@1"], permissions: [], grantedPermissions: [], diff --git a/packages/contracts/src/pluginPackages.ts b/packages/contracts/src/pluginPackages.ts index 39079f30a267..b99e175df39a 100644 --- a/packages/contracts/src/pluginPackages.ts +++ b/packages/contracts/src/pluginPackages.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { PluginCommandId } from "./pluginCommands.ts"; export const PluginPackageId = Schema.String.check( @@ -38,9 +38,25 @@ export const PluginHostPermission = Schema.Union([ ]); export type PluginHostPermission = typeof PluginHostPermission.Type; -export const PluginPackageState = Schema.Literals(["disabled", "active", "blocked", "error"]); +export const PluginPackageState = Schema.Literals([ + "disabled", + "active", + "blocked", + "restarting", + "crashed", + "error", +]); export type PluginPackageState = typeof PluginPackageState.Type; +export const PluginPackageRuntimeState = Schema.Literals([ + "stopped", + "starting", + "running", + "restarting", + "crashed", +]); +export type PluginPackageRuntimeState = typeof PluginPackageRuntimeState.Type; + export const PluginPackageContributions = Schema.Struct({ commands: Schema.Array(PluginCommandId), }); @@ -52,6 +68,8 @@ export const PluginPackageStatus = Schema.Struct({ apiVersion: Schema.Literal(1), enabled: Schema.Boolean, state: PluginPackageState, + runtimeState: PluginPackageRuntimeState, + restartCount: NonNegativeInt, capabilities: Schema.Array(PluginPackageCapability), permissions: Schema.Array(PluginHostPermission), grantedPermissions: Schema.Array(PluginHostPermission), From 4aa61b8a509d7fe7296d18a7c3f7aecb6e1d5799 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:51:52 +0000 Subject: [PATCH 32/45] fix: address plugin worker review Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/plugins/PluginPackageManager.ts | 2 +- .../plugins/PluginWorkerSupervisor.test.ts | 76 +++++++++++++++++-- .../src/plugins/PluginWorkerSupervisor.ts | 64 ++++++++-------- .../settings/PluginsSettings.test.tsx | 19 +++-- .../components/settings/PluginsSettings.tsx | 52 ++++++++----- 5 files changed, 153 insertions(+), 60 deletions(-) diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index d97beec8b698..b4c3a05c30e2 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -197,7 +197,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { ); const stopWorker = (id: string, worker: PluginWorkerSupervisor.SupervisedPluginWorker) => - Effect.promise(worker.dispose).pipe( + worker.dispose.pipe( Effect.catchCause((cause) => { const detail = detailFromCause(cause); packageErrors.set(id, detail); diff --git a/apps/server/src/plugins/PluginWorkerSupervisor.test.ts b/apps/server/src/plugins/PluginWorkerSupervisor.test.ts index 9be9e74405b3..d1bfa1247605 100644 --- a/apps/server/src/plugins/PluginWorkerSupervisor.test.ts +++ b/apps/server/src/plugins/PluginWorkerSupervisor.test.ts @@ -1,6 +1,7 @@ import { it } from "@effect/vitest"; import { expect, vi } from "vite-plus/test"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; @@ -94,7 +95,70 @@ it.layer(NodeServices.layer)("plugin worker supervisor", (it) => { expect(yield* worker.invoke("acme.counter")).toEqual({ message: "1", tone: "success" }); expect(yield* worker.invoke("acme.counter")).toEqual({ message: "2", tone: "success" }); expect(worker.health().state).toBe("running"); - yield* Effect.promise(worker.dispose); + yield* worker.dispose; + }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), + ), + ); + + it.effect("allows independent invocations to run concurrently", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-worker-concurrency-test-", + }); + const entrypointPath = path.join(directory, "index.mjs"); + yield* fileSystem.writeFileString( + entrypointPath, + `export default function activate(api) { + api.registerCommand( + { id: "acme.slow", label: "Slow", surfaces: ["web"] }, + () => api.effect.flatMap( + api.host.state.set("slow-started", true), + () => new Promise((resolve) => setTimeout(() => resolve({ message: "slow", tone: "success" }), 200)) + ) + ); + api.registerCommand( + { id: "acme.fast", label: "Fast", surfaces: ["web"] }, + () => ({ message: "fast", tone: "success" }) + ); + }`, + ); + const started = yield* Deferred.make(); + const baseHost = makeHost(); + const host: PluginHostApi = { + ...baseHost, + state: { + ...baseHost.state, + set: (key, value) => + baseHost.state + .set(key, value) + .pipe( + Effect.tap(() => + key === "slow-started" ? Deferred.succeed(started, undefined) : Effect.void, + ), + ), + }, + }; + const supervisor = yield* PluginWorkerSupervisor.PluginWorkerSupervisor; + const worker = yield* supervisor.start({ + pluginId: "com.acme.concurrent", + entrypointPath, + host, + }); + + const slow = yield* Effect.forkChild(worker.invoke("acme.slow")); + yield* Deferred.await(started); + const fast = yield* Effect.forkChild(worker.invoke("acme.fast")); + const winner = yield* Effect.race( + Fiber.join(fast).pipe(Effect.as("fast" as const)), + Fiber.join(slow).pipe(Effect.as("slow" as const)), + ); + expect(winner).toBe("fast"); + expect(yield* Fiber.join(fast)).toEqual({ message: "fast", tone: "success" }); + expect(yield* Fiber.join(slow)).toEqual({ message: "slow", tone: "success" }); + yield* worker.dispose; }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), ), ); @@ -174,7 +238,7 @@ it.layer(NodeServices.layer)("plugin worker supervisor", (it) => { message: "recovered", tone: "success", }); - yield* Effect.promise(worker.dispose); + yield* worker.dispose; }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), ), ); @@ -214,7 +278,7 @@ it.layer(NodeServices.layer)("plugin worker supervisor", (it) => { expect((yield* Effect.exit(worker.invoke("acme.always-crash")))._tag).toBe("Failure"); yield* waitForHealth(worker, "crashed"); expect(worker.health().restartCount).toBe(1); - yield* Effect.promise(worker.dispose); + yield* worker.dispose; }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), ), ); @@ -259,7 +323,7 @@ it.layer(NodeServices.layer)("plugin worker supervisor", (it) => { yield* waitForHealth(worker, "restarting"); yield* TestClock.adjust("1 second"); yield* waitForHealth(worker, "running"); - yield* Effect.promise(worker.dispose); + yield* worker.dispose; }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), ), ); @@ -295,7 +359,7 @@ it.layer(NodeServices.layer)("plugin worker supervisor", (it) => { yield* waitForHealth(worker, "restarting"); yield* TestClock.adjust("1 second"); yield* waitForHealth(worker, "running"); - yield* Effect.promise(worker.dispose); + yield* worker.dispose; }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), ), ); @@ -330,7 +394,7 @@ it.layer(NodeServices.layer)("plugin worker supervisor", (it) => { host: makeHost(), }); - yield* Effect.promise(worker.dispose); + yield* worker.dispose; expect(yield* fileSystem.readFileString(markerPath)).toBe("disposed"); }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), ), diff --git a/apps/server/src/plugins/PluginWorkerSupervisor.ts b/apps/server/src/plugins/PluginWorkerSupervisor.ts index 38e9399d035e..d237e05e3693 100644 --- a/apps/server/src/plugins/PluginWorkerSupervisor.ts +++ b/apps/server/src/plugins/PluginWorkerSupervisor.ts @@ -74,7 +74,7 @@ export const isPluginWorkerError = Schema.is(PluginWorkerError); export interface SupervisedPluginWorker { readonly commands: ReadonlyArray; readonly invoke: (commandId: string) => Effect.Effect; - readonly dispose: () => Promise; + readonly dispose: Effect.Effect; readonly health: () => PluginWorkerHealthSnapshot; } @@ -142,18 +142,13 @@ export const make = Effect.gen(function* () { const transition = yield* Semaphore.make(1); const crashes = yield* Queue.unbounded(); const disposeRequest = yield* Deferred.make(); + const disposeResult = yield* Deferred.make(); let disposed = false; let restartCount = 0; let health: PluginWorkerHealthSnapshot = { state: "starting", restartCount: 0 }; let current: Session | undefined; let expectedCommands: ReadonlyArray | undefined; - let resolveDisposed!: () => void; - let rejectDisposed!: (error: unknown) => void; - const disposedPromise = new Promise((resolve, reject) => { - resolveDisposed = resolve; - rejectDisposed = reject; - }); const detailFrom = (error: unknown): string => { const detail = error instanceof Error ? error.message : String(error); @@ -596,24 +591,27 @@ export const make = Effect.gen(function* () { ), Effect.tap((exit) => Effect.sync(() => { - if (exit._tag === "Failure") rejectDisposed(Cause.squash(exit.cause)); - else resolveDisposed(); + Deferred.doneUnsafe(disposeResult, exit); }), ), ); yield* Effect.forkIn(disposeFiber, parentScope); const invoke = (commandId: string) => - transition.withPermits(1)( - Effect.gen(function* () { - if (disposed) return yield* workerError("invocation", "worker is stopped"); - if (health.state === "crashed" || current === undefined) { - return yield* workerError("invocation", health.detail ?? "worker is unavailable"); - } - const session = current; - const result = yield* session.invoke(commandId).pipe( - Effect.timeout(options.invocationTimeout), - Effect.catchTag("TimeoutError", (cause) => + Effect.gen(function* () { + const session = yield* transition.withPermits(1)( + Effect.gen(function* () { + if (disposed) return yield* workerError("invocation", "worker is stopped"); + if (health.state !== "running" || current === undefined) { + return yield* workerError("invocation", health.detail ?? "worker is unavailable"); + } + return current; + }), + ); + const result = yield* session.invoke(commandId).pipe( + Effect.timeout(options.invocationTimeout), + Effect.catchTags({ + TimeoutError: (cause) => session .terminate("worker invocation timed out") .pipe( @@ -621,22 +619,26 @@ export const make = Effect.gen(function* () { Effect.fail(workerError("invocation", "worker invocation timed out", cause)), ), ), - ), - Effect.onInterrupt(() => session.terminate("worker invocation interrupted")), - ); - restartCount = 0; - health = { state: "running", restartCount: 0 }; - return result; - }), - ); + }), + Effect.onInterrupt(() => session.terminate("worker invocation interrupted")), + ); + yield* transition.withPermits(1)( + Effect.sync(() => { + if (!disposed && current?.id === session.id && health.state === "running") { + restartCount = 0; + health = { state: "running", restartCount: 0 }; + } + }), + ); + return result; + }); return { commands: expectedCommands, invoke, - dispose: () => { - Deferred.doneUnsafe(disposeRequest, Effect.void); - return disposedPromise; - }, + dispose: Effect.sync(() => Deferred.doneUnsafe(disposeRequest, Effect.void)).pipe( + Effect.flatMap(() => Deferred.await(disposeResult)), + ), health: () => health, } satisfies SupervisedPluginWorker; }).pipe( diff --git a/apps/web/src/components/settings/PluginsSettings.test.tsx b/apps/web/src/components/settings/PluginsSettings.test.tsx index c8882a493feb..430619fb50a3 100644 --- a/apps/web/src/components/settings/PluginsSettings.test.tsx +++ b/apps/web/src/components/settings/PluginsSettings.test.tsx @@ -89,6 +89,7 @@ vi.mock("../ui/toast", () => ({ })); import { toastManager } from "../ui/toast"; +import { TooltipPopup } from "../ui/tooltip"; import { PluginsSettingsPanel } from "./PluginsSettings"; @@ -250,11 +251,12 @@ describe("PluginsSettingsPanel", () => { expect( visitElements( activeRow, - (element) => - element.props.title === "t3.commands@1" && - element.props.className === "min-w-0 max-w-full", + (element) => element.type === TooltipPopup && element.props.children === "t3.commands@1", ), ).not.toBeNull(); + expect( + visitElements(activeRow, (element) => typeof element.props.title === "string"), + ).toBeNull(); expect( visitElements( activeRow, @@ -269,8 +271,15 @@ describe("PluginsSettingsPanel", () => { (element) => element.props["data-plugin-permission"] === "network:https://api.acme.test" && element.props["data-granted"] === false && - element.props.className === "min-w-0 max-w-full" && - element.props.title === "network:https://api.acme.test approval required", + element.props.className === "min-w-0 max-w-full", + ), + ).not.toBeNull(); + expect( + visitElements( + activeRow, + (element) => + element.type === TooltipPopup && + element.props.children === "network:https://api.acme.test approval required", ), ).not.toBeNull(); }); diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index b259b0b1890e..a48d8f290aaa 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -10,7 +10,7 @@ import { RotateCwIcon, ShieldAlertIcon, } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { Fragment, useCallback, useMemo, useState } from "react"; import { isElectron } from "../../env"; import { usePrimarySessionState } from "../../environments/primary"; @@ -75,26 +75,44 @@ function PluginPackageRow({ worker: {pluginPackage.runtimeState}, restarts: {pluginPackage.restartCount} {pluginPackage.capabilities.map((capability) => ( - - {capability} - + + + + {capability} + + } + /> + + {capability} + + + ))} {pluginPackage.permissions.map((permission) => { const granted = grantedPermissions.has(permission); + const label = `${permission}${granted ? " granted" : " approval required"}`; return ( - - - {permission} - {granted ? " granted" : " approval required"} - - + + + + {label} + + } + /> + + {label} + + + ); })}
From a8e98bdb65dadb3a01064b96a153227fb3d5c828 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:00:21 +0000 Subject: [PATCH 33/45] fix: reject invalid worker results Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/plugins/PluginWorkerRuntime.mjs | 33 +++++++++++++- .../plugins/PluginWorkerSupervisor.test.ts | 45 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/apps/server/src/plugins/PluginWorkerRuntime.mjs b/apps/server/src/plugins/PluginWorkerRuntime.mjs index 035f6cad5b6e..98cd5e302ecd 100644 --- a/apps/server/src/plugins/PluginWorkerRuntime.mjs +++ b/apps/server/src/plugins/PluginWorkerRuntime.mjs @@ -14,6 +14,29 @@ const detailFrom = (error) => { return (detail.trim() || "unknown error").slice(0, 2_000); }; +const detachJson = (value, ancestors = new Set()) => { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("command result contains a non-finite number"); + return value; + } + if (typeof value !== "object") throw new Error("command result is not JSON-compatible"); + if (ancestors.has(value)) throw new Error("command result contains a cycle"); + ancestors.add(value); + try { + if (Array.isArray(value)) return value.map((item) => detachJson(item, ancestors)); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error("command result contains a non-plain object"); + } + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, detachJson(item, ancestors)]), + ); + } finally { + ancestors.delete(value); + } +}; + const write = (message) => { const line = `${JSON.stringify(message)}\n`; if (Buffer.byteLength(line, "utf8") > MAX_PROTOCOL_LINE_BYTES) { @@ -22,6 +45,14 @@ const write = (message) => { protocolWrite(line); }; +const writeInvocationResult = (requestId, value) => { + try { + write({ type: "invocationResult", requestId, value: detachJson(value) }); + } catch (error) { + write({ type: "invocationFailed", requestId, detail: detailFrom(error) }); + } +}; + const writeDiagnostic = (...values) => { const detail = values .map((value) => (typeof value === "string" ? value : inspect(value))) @@ -185,7 +216,7 @@ const handleMessage = async (message) => { .then(() => registration.handler()) .then((result) => runValue(result, controller.signal)) .then( - (value) => write({ type: "invocationResult", requestId: message.requestId, value }), + (value) => writeInvocationResult(message.requestId, value), (error) => write({ type: "invocationFailed", diff --git a/apps/server/src/plugins/PluginWorkerSupervisor.test.ts b/apps/server/src/plugins/PluginWorkerSupervisor.test.ts index d1bfa1247605..26079050904a 100644 --- a/apps/server/src/plugins/PluginWorkerSupervisor.test.ts +++ b/apps/server/src/plugins/PluginWorkerSupervisor.test.ts @@ -163,6 +163,51 @@ it.layer(NodeServices.layer)("plugin worker supervisor", (it) => { ), ); + it.effect("rejects non-JSON command results without crashing the worker", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-worker-result-test-", + }); + const entrypointPath = path.join(directory, "index.mjs"); + yield* fileSystem.writeFileString( + entrypointPath, + `export default function activate(api) { + api.registerCommand( + { id: "acme.undefined", label: "Undefined", surfaces: ["web"] }, + () => undefined + ); + api.registerCommand( + { id: "acme.cyclic", label: "Cyclic", surfaces: ["web"] }, + () => { const value = {}; value.self = value; return value; } + ); + api.registerCommand( + { id: "acme.valid", label: "Valid", surfaces: ["web"] }, + () => ({ message: "still running", tone: "success" }) + ); + }`, + ); + const supervisor = yield* PluginWorkerSupervisor.PluginWorkerSupervisor; + const worker = yield* supervisor.start({ + pluginId: "com.acme.invalid-results", + entrypointPath, + host: makeHost(), + }); + + expect((yield* Effect.exit(worker.invoke("acme.undefined")))._tag).toBe("Failure"); + expect((yield* Effect.exit(worker.invoke("acme.cyclic")))._tag).toBe("Failure"); + expect(worker.health().state).toBe("running"); + expect(yield* worker.invoke("acme.valid")).toEqual({ + message: "still running", + tone: "success", + }); + yield* worker.dispose; + }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), + ), + ); + it.effect("kills a worker that exceeds the protocol line limit", () => Effect.scoped( Effect.gen(function* () { From 410bf398ebd6d1e58aa327086eb967cb4b8688e2 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:12:35 +0000 Subject: [PATCH 34/45] fix: preserve plugin error structure Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/plugins/PluginCommandCatalog.ts | 47 ++++++++++++------- .../src/plugins/PluginPackageManager.ts | 40 +++++++++++----- 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/apps/server/src/plugins/PluginCommandCatalog.ts b/apps/server/src/plugins/PluginCommandCatalog.ts index f7c382d4de98..2267ce06931f 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.ts @@ -28,8 +28,6 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; const COMMAND_SLOT = "commands"; const decodePluginCommand = Schema.decodeUnknownSync(PluginCommandSchema); const decodePluginCommandEffect = Schema.decodeUnknownEffect(PluginCommandSchema); -const isContributionGenerationError = Schema.is(PluginRuntime.PluginContributionGenerationError); -const isContributionNotFoundError = Schema.is(PluginRuntime.PluginContributionNotFoundError); const commandInputFromContribution = (entry: Contribution) => { const data = typeof entry.data === "object" && entry.data !== null ? entry.data : {}; @@ -196,20 +194,37 @@ export const make = Effect.gen(function* () { never >(COMMAND_SLOT, input.id, input.generation, (handler) => handler) .pipe( - Effect.mapError((error) => { - if (isContributionGenerationError(error)) { - return new PluginCommandCatalogChangedError({ - actualGeneration: error.actual, - expectedGeneration: error.expected, - }); - } - if (isContributionNotFoundError(error)) { - return new PluginCommandNotFoundError({ id: input.id }); - } - return new PluginCommandInvocationError({ - cause: error, - id: input.id, - }); + Effect.catchTags({ + PluginContributionGenerationError: (error) => + Effect.fail( + new PluginCommandCatalogChangedError({ + actualGeneration: error.actual, + expectedGeneration: error.expected, + }), + ), + PluginContributionNotFoundError: () => + Effect.fail(new PluginCommandNotFoundError({ id: input.id })), + PluginCommandExecutionError: (error) => + Effect.fail( + new PluginCommandInvocationError({ + cause: error, + id: input.id, + }), + ), + PluginRuntimeDisposedError: (error) => + Effect.fail( + new PluginCommandInvocationError({ + cause: error, + id: input.id, + }), + ), + PluginRuntimeReentrancyError: (error) => + Effect.fail( + new PluginCommandInvocationError({ + cause: error, + id: input.id, + }), + ), }), ), ); diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index b4c3a05c30e2..ca23a5267cd2 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -40,6 +40,22 @@ interface DiscoveryResult { readonly packages: ReadonlyMap; } +class PluginPackageContributionMismatchError extends Schema.TaggedErrorClass()( + "PluginPackageContributionMismatchError", + { + id: Schema.String, + reason: Schema.Literals(["missing-capability", "undeclared-command"]), + capability: Schema.optional(Schema.String), + commandId: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return this.reason === "missing-capability" + ? `Manifest does not declare capability ${this.capability ?? "unknown"}` + : `Command ${this.commandId ?? "unknown"} is not declared in the manifest`; + } +} + interface LoadedDefinition { readonly cacheDirectory: string; readonly definition: PluginDefinition; @@ -92,11 +108,19 @@ const makeDefinition = ( worker.commands.length > 0 && !discovered.manifest.capabilities.includes(COMMAND_CAPABILITY) ) { - throw new Error(`Manifest does not declare capability ${COMMAND_CAPABILITY}`); + throw new PluginPackageContributionMismatchError({ + id: discovered.manifest.id, + reason: "missing-capability", + capability: COMMAND_CAPABILITY, + }); } for (const command of worker.commands) { if (!declaredCommands.has(command.id)) { - throw new Error(`Command ${command.id} is not declared in the manifest`); + throw new PluginPackageContributionMismatchError({ + id: discovered.manifest.id, + reason: "undeclared-command", + commandId: command.id, + }); } } const providedCapabilities = Object.fromEntries( @@ -118,17 +142,7 @@ const makeDefinition = ( PluginCommandCatalog.registerPluginCommand(context, { command, handler: worker.invoke(command.id).pipe( - Effect.flatMap((result) => - decodeInvocationResult(result).pipe( - Effect.mapError( - (cause) => - new PluginCommandCatalog.PluginCommandExecutionError({ - cause, - id: command.id, - }), - ), - ), - ), + Effect.flatMap(decodeInvocationResult), Effect.mapError( (cause) => new PluginCommandCatalog.PluginCommandExecutionError({ From 5c53f8cb328dcbda794753a05c95c643dcfe1de0 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:18:17 +0000 Subject: [PATCH 35/45] fix: split plugin mismatch errors Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/plugins/PluginPackageManager.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index ca23a5267cd2..71462fd0da12 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -40,19 +40,21 @@ interface DiscoveryResult { readonly packages: ReadonlyMap; } -class PluginPackageContributionMismatchError extends Schema.TaggedErrorClass()( - "PluginPackageContributionMismatchError", - { - id: Schema.String, - reason: Schema.Literals(["missing-capability", "undeclared-command"]), - capability: Schema.optional(Schema.String), - commandId: Schema.optional(Schema.String), - }, +class PluginPackageMissingCapabilityError extends Schema.TaggedErrorClass()( + "PluginPackageMissingCapabilityError", + { capability: Schema.String, id: Schema.String }, +) { + override get message(): string { + return `Manifest does not declare capability ${this.capability}`; + } +} + +class PluginPackageUndeclaredCommandError extends Schema.TaggedErrorClass()( + "PluginPackageUndeclaredCommandError", + { commandId: Schema.String, id: Schema.String }, ) { override get message(): string { - return this.reason === "missing-capability" - ? `Manifest does not declare capability ${this.capability ?? "unknown"}` - : `Command ${this.commandId ?? "unknown"} is not declared in the manifest`; + return `Command ${this.commandId} is not declared in the manifest`; } } @@ -108,17 +110,15 @@ const makeDefinition = ( worker.commands.length > 0 && !discovered.manifest.capabilities.includes(COMMAND_CAPABILITY) ) { - throw new PluginPackageContributionMismatchError({ + throw new PluginPackageMissingCapabilityError({ id: discovered.manifest.id, - reason: "missing-capability", capability: COMMAND_CAPABILITY, }); } for (const command of worker.commands) { if (!declaredCommands.has(command.id)) { - throw new PluginPackageContributionMismatchError({ + throw new PluginPackageUndeclaredCommandError({ id: discovered.manifest.id, - reason: "undeclared-command", commandId: command.id, }); } From 050f279f6024a299debd9147796119117b5b0583 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:23:03 +0000 Subject: [PATCH 36/45] fix: retain worker disposal errors Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- apps/server/src/plugins/PluginWorkerSupervisor.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/server/src/plugins/PluginWorkerSupervisor.ts b/apps/server/src/plugins/PluginWorkerSupervisor.ts index d237e05e3693..327433415b31 100644 --- a/apps/server/src/plugins/PluginWorkerSupervisor.ts +++ b/apps/server/src/plugins/PluginWorkerSupervisor.ts @@ -580,10 +580,9 @@ export const make = Effect.gen(function* () { yield* Scope.close(workerScope, Exit.void); if (disposeExit?._tag === "Failure") { const failure = Cause.squash(disposeExit.cause); - return yield* workerError( - "dispose", - isPluginWorkerError(failure) ? failure.detail : detailFrom(failure), - ); + return yield* isPluginWorkerError(failure) + ? failure + : workerError("dispose", "worker dispose failed", failure); } }), ), From ba752ad423dba62a66c07b8a102a5248b84b95bb Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:31:55 +0000 Subject: [PATCH 37/45] fix: sanitize worker crash diagnostics Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../plugins/PluginWorkerSupervisor.test.ts | 85 +++++++++++++++++++ .../src/plugins/PluginWorkerSupervisor.ts | 29 ++++--- 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/apps/server/src/plugins/PluginWorkerSupervisor.test.ts b/apps/server/src/plugins/PluginWorkerSupervisor.test.ts index 26079050904a..53440835e40a 100644 --- a/apps/server/src/plugins/PluginWorkerSupervisor.test.ts +++ b/apps/server/src/plugins/PluginWorkerSupervisor.test.ts @@ -409,6 +409,91 @@ it.layer(NodeServices.layer)("plugin worker supervisor", (it) => { ), ); + it.effect("does not expose worker stderr through crash failures", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-worker-stderr-test-", + }); + const entrypointPath = path.join(directory, "index.mjs"); + yield* fileSystem.writeFileString( + entrypointPath, + `export default function activate(api) { + api.registerCommand( + { id: "acme.stderr", label: "Stderr", surfaces: ["web"] }, + () => new Promise(() => { + process.stderr.write("sensitive-worker-value", () => process.exit(29)); + }) + ); + }`, + ); + const supervisor = yield* PluginWorkerSupervisor.PluginWorkerSupervisor; + const worker = yield* supervisor.start({ + pluginId: "com.acme.stderr", + entrypointPath, + host: makeHost(), + }); + + const invocation = yield* Effect.exit(worker.invoke("acme.stderr")); + expect(invocation._tag).toBe("Failure"); + if (invocation._tag === "Failure") { + const failure = Cause.squash(invocation.cause); + expect(failure instanceof Error ? failure.message : String(failure)).not.toContain( + "sensitive-worker-value", + ); + } + yield* waitForHealth(worker, "restarting"); + yield* TestClock.adjust("1 second"); + yield* waitForHealth(worker, "running"); + yield* worker.dispose; + }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), + ), + ); + + it.effect("reports an explicit worker disposal timeout", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-worker-dispose-timeout-test-", + }); + const entrypointPath = path.join(directory, "index.mjs"); + yield* fileSystem.writeFileString( + entrypointPath, + `export default function activate(api) { + api.onDispose(() => new Promise(() => {})); + }`, + ); + const supervisor = yield* PluginWorkerSupervisor.PluginWorkerSupervisor; + const worker = yield* supervisor.start( + { + pluginId: "com.acme.dispose-timeout", + entrypointPath, + host: makeHost(), + }, + { disposeTimeout: "10 millis" }, + ); + + const disposal = yield* Effect.forkChild(Effect.exit(worker.dispose)); + yield* Effect.yieldNow; + yield* TestClock.adjust("20 millis"); + yield* Effect.yieldNow; + yield* TestClock.adjust("2 seconds"); + const disposed = yield* Fiber.join(disposal); + expect(disposed._tag).toBe("Failure"); + if (disposed._tag === "Failure") { + const failure = Cause.squash(disposed.cause); + expect(failure instanceof Error ? failure.message : String(failure)).toContain( + "worker disposal timed out", + ); + } + }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), + ), + ); + it.effect("runs worker cleanup during disposal", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/plugins/PluginWorkerSupervisor.ts b/apps/server/src/plugins/PluginWorkerSupervisor.ts index 327433415b31..13f7c900021c 100644 --- a/apps/server/src/plugins/PluginWorkerSupervisor.ts +++ b/apps/server/src/plugins/PluginWorkerSupervisor.ts @@ -228,7 +228,6 @@ export const make = Effect.gen(function* () { const hostCallSemaphore = yield* Semaphore.make(HOST_CALL_CONCURRENCY); const encoder = new TextEncoder(); const protocolDecoder = new TextDecoder(); - const stderrDecoder = new TextDecoder(); let protocolBuffer = ""; let pendingHostCalls = 0; const pending = new Map< @@ -238,7 +237,7 @@ export const make = Effect.gen(function* () { let requestSequence = 0; let closing = false; let crashReported = false; - let stderr = ""; + let stderrBytes = 0; const send = (message: PluginWorkerParentMessage): boolean => { let line: string; @@ -256,12 +255,12 @@ export const make = Effect.gen(function* () { pending.clear(); }; - const reportCrash = (detail: string) => { + const reportCrash = (detail: string, cause?: unknown) => { if (closing || crashReported || disposed) return; crashReported = true; health = { state: "restarting", detail, restartCount }; - failPending(workerError("invocation", detail)); - Deferred.doneUnsafe(activation, Effect.fail(workerError("activation", detail))); + failPending(workerError("invocation", detail, cause)); + Deferred.doneUnsafe(activation, Effect.fail(workerError("activation", detail, cause))); Queue.offerUnsafe(crashes, { sessionId, detail }); }; @@ -415,7 +414,7 @@ export const make = Effect.gen(function* () { Stream.runForEach(readProtocolChunk), Effect.catchCause((cause) => Effect.sync(() => - reportCrash(`worker protocol failed: ${detailFromCause(cause)}`), + reportCrash(`worker protocol failed: ${detailFromCause(cause)}`, Cause.squash(cause)), ).pipe(Effect.tap(() => handle.kill().pipe(Effect.ignore))), ), ); @@ -424,10 +423,7 @@ export const make = Effect.gen(function* () { const stderrReader = handle.stderr.pipe( Stream.runForEach((chunk) => Effect.sync(() => { - if (stderr.length >= MAX_STDERR_BYTES) return; - stderr += stderrDecoder - .decode(chunk, { stream: true }) - .slice(0, MAX_STDERR_BYTES - stderr.length); + stderrBytes = Math.min(MAX_STDERR_BYTES, stderrBytes + chunk.byteLength); }), ), Effect.ignore, @@ -438,9 +434,8 @@ export const make = Effect.gen(function* () { Effect.tap((exitCode) => Effect.sync(() => { if (!closing) { - const diagnostic = stderr.trim().slice(0, 500); reportCrash( - `worker exited with code ${String(exitCode)}${diagnostic.length === 0 ? "" : `: ${diagnostic}`}`, + `worker exited with code ${String(exitCode)}${stderrBytes === 0 ? "" : ` after ${String(stderrBytes)} stderr bytes`}`, ); } }), @@ -575,7 +570,15 @@ export const make = Effect.gen(function* () { current === undefined || wasCrashed ? undefined : yield* Effect.exit( - current.requestDispose.pipe(Effect.timeout(options.disposeTimeout)), + current.requestDispose.pipe( + Effect.timeout(options.disposeTimeout), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + workerError("dispose", "worker disposal timed out", cause), + ), + }), + ), ); yield* Scope.close(workerScope, Exit.void); if (disposeExit?._tag === "Failure") { From cd7c49655fe68ff2941f093007a68713414e9f63 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:31:50 +0000 Subject: [PATCH 38/45] feat: add declarative plugin ui kit Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- apps/mobile/src/Stack.tsx | 2 + .../src/features/home/HomeRouteScreen.tsx | 3 + .../features/plugins/PluginUiMobileCards.tsx | 137 +++++ apps/server/src/auth/RpcAuthorization.test.ts | 13 + apps/server/src/auth/RpcAuthorization.ts | 5 + .../src/plugins/PluginCommandCatalog.test.ts | 79 +++ .../src/plugins/PluginCommandCatalog.ts | 151 +++++- .../src/plugins/PluginHostCapabilityBroker.ts | 15 +- .../src/plugins/PluginPackageManager.test.ts | 130 +++++ .../src/plugins/PluginPackageManager.ts | 280 +++++++++- .../src/plugins/PluginWorkerProtocol.test.ts | 25 +- .../src/plugins/PluginWorkerProtocol.ts | 24 +- .../src/plugins/PluginWorkerRuntime.mjs | 55 +- .../plugins/PluginWorkerSupervisor.test.ts | 43 +- .../src/plugins/PluginWorkerSupervisor.ts | 66 ++- apps/server/src/server.test.ts | 12 +- apps/server/src/ws.ts | 28 + apps/web/src/components/ChatView.tsx | 12 + .../src/components/plugins/PluginUi.test.tsx | 74 +++ apps/web/src/components/plugins/PluginUi.tsx | 486 ++++++++++++++++++ .../settings/PluginsSettings.test.tsx | 26 +- .../components/settings/PluginsSettings.tsx | 16 +- .../src/components/sidebar/SidebarChrome.tsx | 10 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/__root.tsx | 2 + .../src/routes/plugins.$pluginId.$viewId.tsx | 44 ++ examples/plugins/runtime-status/README.md | 9 +- examples/plugins/runtime-status/index.mjs | 103 +++- .../plugins/runtime-status/t3-plugin.json | 14 +- packages/client-runtime/src/rpc/client.ts | 2 + packages/client-runtime/src/state/server.ts | 23 + packages/contracts/src/index.ts | 1 + packages/contracts/src/pluginCommands.ts | 10 + packages/contracts/src/pluginPackages.test.ts | 44 +- packages/contracts/src/pluginPackages.ts | 14 + packages/contracts/src/pluginUi.test.ts | 184 +++++++ packages/contracts/src/pluginUi.ts | 308 +++++++++++ packages/contracts/src/rpc.ts | 55 ++ packages/plugin-runtime/src/manifest.ts | 6 + packages/plugin-runtime/test/manifest.test.ts | 9 +- 40 files changed, 2456 insertions(+), 85 deletions(-) create mode 100644 apps/mobile/src/features/plugins/PluginUiMobileCards.tsx create mode 100644 apps/web/src/components/plugins/PluginUi.test.tsx create mode 100644 apps/web/src/components/plugins/PluginUi.tsx create mode 100644 apps/web/src/routes/plugins.$pluginId.$viewId.tsx create mode 100644 packages/contracts/src/pluginUi.test.ts create mode 100644 packages/contracts/src/pluginUi.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7cffbf62b0d7..427c4df8672c 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -34,6 +34,7 @@ import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; +import { PluginUiMobileNotificationHost } from "./features/plugins/PluginUiMobileCards"; import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute"; import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; @@ -394,6 +395,7 @@ function RootStackLayout(props: { return ( + diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 8061b1d1e85b..57e300e525ca 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -20,6 +20,7 @@ import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle"; +import { PluginUiMobileCards } from "../plugins/PluginUiMobileCards"; /* ─── Route screen ───────────────────────────────────────────────────── */ @@ -170,6 +171,8 @@ export function HomeRouteScreen() { onThreadSortOrderChange={setThreadSortOrder} /> + + ( + + )); +} + +function EnvironmentPluginUiMobileNotificationHost({ + environmentId, +}: { + readonly environmentId: EnvironmentId; +}) { + const result = useAtomValue( + serverEnvironment.pluginUiNotifications({ environmentId, input: {} }), + ); + const notification = Option.getOrNull(AsyncResult.value(result)); + const shown = useRef(null); + + useEffect(() => { + if (notification === null) return; + if (shown.current === notification) return; + shown.current = notification; + Alert.alert(notification.title, notification.message); + }, [notification]); + + return null; +} + +export function PluginUiMobileCards({ + environmentId, +}: { + readonly environmentId: EnvironmentId | null; +}) { + if (environmentId === null) return null; + return ; +} + +function EnvironmentPluginUiMobileCards({ + environmentId, +}: { + readonly environmentId: EnvironmentId; +}) { + const result = useAtomValue(serverEnvironment.pluginUi({ environmentId, input: {} })); + const catalog = Option.getOrNull(AsyncResult.value(result)) as PluginUiCatalog | null; + const invoke = useAtomCommand(serverEnvironment.invokePluginCommand, { reportFailure: false }); + if (catalog === null) return null; + + const packages = catalog.packages + .map((pluginPackage) => ({ + pluginPackage, + cards: pluginPackage.cards.filter((card) => card.surfaces.includes("mobile")), + statuses: pluginPackage.statusItems.filter((item) => item.surfaces.includes("mobile")), + })) + .filter(({ cards, statuses }) => cards.length > 0 || statuses.length > 0); + if (packages.length === 0) return null; + + return ( + + {packages.flatMap(({ pluginPackage, cards, statuses }) => [ + ...cards.map((card) => { + const action = card.actionId + ? [...pluginPackage.composerActions, ...pluginPackage.contextualActions].find( + (candidate) => candidate.id === card.actionId, + ) + : undefined; + return ( + { + void invoke({ + environmentId, + input: { + generation: catalog.generation, + id: action.commandId, + context: { cardId: card.id }, + }, + }).then((outcome) => { + if (outcome._tag === "Success") { + Alert.alert(card.title, outcome.value.message); + } else { + Alert.alert("Plugin action failed"); + } + }); + } + } + > + {card.title} + {card.value ? ( + {card.value} + ) : null} + {card.description ? ( + {card.description} + ) : null} + + ); + }), + ...statuses.map((status) => ( + + {status.label} + {status.value} + + )), + ])} + + ); +} diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 540c6c6acf26..329947568434 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -47,6 +47,19 @@ describe("RPC authorization scopes", () => { expect(requiredScopeForRpcMethod(WS_METHODS.pluginCommandsInvoke)).toBe( AuthOrchestrationOperateScope, ); + expect(requiredScopeForRpcMethod(WS_METHODS.pluginUiList)).toBe(AuthOrchestrationReadScope); + expect(requiredScopeForRpcMethod(WS_METHODS.subscribePluginUi)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.subscribePluginUiNotifications)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.pluginUiSettingGet)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.pluginUiSettingSet)).toBe( + AuthOrchestrationOperateScope, + ); }); it("requires permission to operate on a thread before uploading feedback", () => { diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a5b84a13c1d5..558fe2ce6b4f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -52,11 +52,16 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.pluginCommandsList]: AuthOrchestrationReadScope, [WS_METHODS.pluginCommandsInvoke]: AuthOrchestrationOperateScope, + [WS_METHODS.pluginUiList]: AuthOrchestrationReadScope, + [WS_METHODS.pluginUiSettingGet]: AuthOrchestrationReadScope, + [WS_METHODS.pluginUiSettingSet]: AuthOrchestrationOperateScope, [WS_METHODS.pluginPackagesStatus]: AuthOrchestrationReadScope, [WS_METHODS.pluginPackagesEnable]: AuthOrchestrationOperateScope, [WS_METHODS.pluginPackagesDisable]: AuthOrchestrationOperateScope, [WS_METHODS.pluginPackagesReload]: AuthOrchestrationOperateScope, [WS_METHODS.subscribePluginCommands]: AuthOrchestrationReadScope, + [WS_METHODS.subscribePluginUi]: AuthOrchestrationReadScope, + [WS_METHODS.subscribePluginUiNotifications]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, diff --git a/apps/server/src/plugins/PluginCommandCatalog.test.ts b/apps/server/src/plugins/PluginCommandCatalog.test.ts index 170708b05bb9..6bfec692ab26 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.test.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.test.ts @@ -64,6 +64,85 @@ describe("plugin command catalog", () => { }).pipe(Effect.provide(PluginCommandCatalog.layer)), ); + it.effect( + "publishes declarative ui and host-rendered notifications with the runtime generation", + () => + Effect.gen(function* () { + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + const definition: PluginDefinition = { + id: "com.acme.ui-plugin", + version: "1.0.0", + activate(context) { + PluginCommandCatalog.registerPluginUi(context, "com.acme.ui-plugin", { + settings: [], + navigation: [ + { + id: "com.acme.ui.navigation", + label: "Acme", + viewId: "com.acme.ui.view", + surfaces: ["web"], + }, + ], + views: [ + { + id: "com.acme.ui.view", + label: "Acme", + surfaces: ["web"], + blocks: [{ kind: "text", text: "Hello from Acme" }], + }, + ], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }); + }, + }; + + const published = yield* catalog.reconcile([definition]); + const ui = yield* catalog.ui; + expect(ui.generation).toBe(published.generation); + expect(ui.packages[0]?.navigation[0]?.id).toBe("com.acme.ui.navigation"); + expect(Object.isFrozen(ui)).toBe(true); + + const failed = yield* Effect.exit( + catalog.reconcile([ + { + id: "com.acme.ui-plugin", + version: "2.0.0", + activate() { + throw new Error("replacement failed"); + }, + }, + ]), + ); + expect(Exit.isFailure(failed)).toBe(true); + expect(yield* catalog.ui).toBe(ui); + + const notificationFiber = yield* Effect.forkChild(Stream.runHead(catalog.notifications)); + yield* Effect.yieldNow; + yield* catalog.notify("com.acme.ui-plugin", { + id: "notification-1", + title: "Done", + message: "The plugin finished.", + tone: "success", + }); + expect(Option.getOrNull(yield* Fiber.join(notificationFiber))).toMatchObject({ + pluginId: "com.acme.ui-plugin", + title: "Done", + }); + const rateLimited = yield* Effect.flip( + catalog.notify("com.acme.ui-plugin", { + id: "notification-2", + title: "Again", + message: "Too soon.", + tone: "info", + }), + ); + expect(rateLimited.detail).toBe("notification rate limit exceeded"); + }).pipe(Effect.provide(PluginCommandCatalog.layer)), + ); + it.effect("keeps the committed command and handler when replacement activation fails", () => Effect.gen(function* () { const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; diff --git a/apps/server/src/plugins/PluginCommandCatalog.ts b/apps/server/src/plugins/PluginCommandCatalog.ts index 2267ce06931f..72822a4219f0 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.ts @@ -1,6 +1,7 @@ import { PluginCommand as PluginCommandSchema, type PluginCommand, + type PluginCommandInvocationContext, type PluginCommandCatalog as PluginCommandCatalogSnapshot, PluginCommandCatalogChangedError, PluginCommandId, @@ -8,6 +9,14 @@ import { PluginCommandInvocationError, type PluginCommandInvokeInput, PluginCommandNotFoundError, + PluginUiCatalog as PluginUiCatalogSchema, + type PluginUiCatalog as PluginUiCatalogSnapshot, + PluginUiContribution, + type PluginUiContribution as PluginUiContributionType, + PluginUiNotification, + type PluginUiNotification as PluginUiNotificationType, + type PluginUiNotificationInput, + PluginUiPackageContribution, } from "@t3tools/contracts"; import type { Contribution, @@ -16,18 +25,27 @@ import type { PluginRuntimeSnapshot, } from "@t3tools/plugin-runtime"; import { PluginRuntime } from "@t3tools/plugin-runtime"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import type * as Stream from "effect/Stream"; +import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; const COMMAND_SLOT = "commands"; +const UI_SLOT = "ui"; const decodePluginCommand = Schema.decodeUnknownSync(PluginCommandSchema); const decodePluginCommandEffect = Schema.decodeUnknownEffect(PluginCommandSchema); +const decodePluginUi = Schema.decodeUnknownSync(PluginUiContribution); +const decodePluginUiPackageEffect = Schema.decodeUnknownEffect(PluginUiPackageContribution); +const decodePluginUiPackage = Schema.decodeUnknownSync(PluginUiPackageContribution); +const decodePluginUiNotification = Schema.decodeUnknownEffect(PluginUiNotification); +const decodePluginUiCatalog = Schema.decodeUnknownEffect(PluginUiCatalogSchema); +const decodeContributionData = Schema.decodeUnknownSync(Schema.Json); const commandInputFromContribution = (entry: Contribution) => { const data = typeof entry.data === "object" && entry.data !== null ? entry.data : {}; @@ -37,10 +55,18 @@ const commandInputFromContribution = (entry: Contribution) => { return { ...data, id: entry.id, label: entry.label }; }; -const validateCommandSnapshot = (snapshot: PluginRuntimeSnapshot): void => { +const uiInputFromContribution = (entry: Contribution) => { + const data = typeof entry.data === "object" && entry.data !== null ? entry.data : {}; + return { ...data, pluginId: entry.id }; +}; + +const validateSnapshot = (snapshot: PluginRuntimeSnapshot): void => { for (const entry of snapshot.contributions[COMMAND_SLOT] ?? []) { decodePluginCommand(commandInputFromContribution(entry)); } + for (const entry of snapshot.contributions[UI_SLOT] ?? []) { + decodePluginUiPackage(uiInputFromContribution(entry)); + } }; export class PluginCommandExecutionError extends Schema.TaggedErrorClass()( @@ -52,10 +78,9 @@ export class PluginCommandExecutionError extends Schema.TaggedErrorClass; +type PluginCommandHandler = ( + context?: PluginCommandInvocationContext, +) => Effect.Effect; export class PluginCommandDefinitionError extends Schema.TaggedErrorClass()( "PluginCommandDefinitionError", @@ -68,7 +93,9 @@ export class PluginCommandDefinitionError extends Schema.TaggedErrorClass; } export const registerPluginCommand = ( @@ -87,10 +114,37 @@ export const registerPluginCommand = ( surfaces, }, }, - registration.handler, + Effect.isEffect(registration.handler) ? () => registration.handler : registration.handler, ); }; +export const registerPluginUi = ( + context: PluginActivationContext, + pluginId: string, + contribution: PluginUiContributionType, +): void => { + const ui = decodePluginUi(contribution); + context.register(UI_SLOT, { id: pluginId, label: pluginId, data: decodeContributionData(ui) }); +}; + +export class PluginUiDefinitionError extends Schema.TaggedErrorClass()( + "PluginUiDefinitionError", + { cause: Schema.Defect(), id: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.id} has invalid declarative UI metadata.`; + } +} + +export class PluginUiNotificationError extends Schema.TaggedErrorClass()( + "PluginUiNotificationError", + { cause: Schema.optional(Schema.Defect()), pluginId: Schema.String, detail: Schema.String }, +) { + override get message(): string { + return `Plugin notification failed for ${this.pluginId}: ${this.detail}`; + } +} + const builtInPlugin: PluginDefinition = { id: "t3.plugin-runtime.commands", version: "1.0.0", @@ -128,12 +182,41 @@ const catalogFromRuntime = Effect.fn("PluginCommandCatalog.catalogFromRuntime")( }) satisfies PluginCommandCatalogSnapshot; }); +const deepFreeze =
(value: A): A => { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; + for (const nested of Object.values(value)) deepFreeze(nested); + return Object.freeze(value); +}; + +const uiFromRuntime = Effect.fn("PluginCommandCatalog.uiFromRuntime")(function* ( + runtime: PluginRuntime.PluginRuntime["Service"], +) { + const snapshot = yield* runtime.contributions(UI_SLOT); + const packages = yield* Effect.forEach(snapshot.entries, (entry) => + decodePluginUiPackageEffect(uiInputFromContribution(entry)).pipe( + Effect.mapError((cause) => new PluginUiDefinitionError({ cause, id: entry.id })), + ), + ); + const catalog = yield* decodePluginUiCatalog({ + generation: snapshot.generation, + packages, + }).pipe(Effect.mapError((cause) => new PluginUiDefinitionError({ cause, id: "catalog" }))); + return deepFreeze(catalog); +}); + export class PluginCommandCatalog extends Context.Service< PluginCommandCatalog, { readonly list: Effect.Effect; + readonly ui: Effect.Effect; readonly composition: Effect.Effect; readonly changes: Stream.Stream; + readonly uiChanges: Stream.Stream; + readonly notifications: Stream.Stream; + readonly notify: ( + pluginId: string, + notification: PluginUiNotificationInput, + ) => Effect.Effect; readonly invoke: ( input: PluginCommandInvokeInput, ) => Effect.Effect< @@ -144,7 +227,9 @@ export class PluginCommandCatalog extends Context.Service< definitions: ReadonlyArray, ) => Effect.Effect< PluginCommandCatalogSnapshot, - PluginCommandDefinitionError | PluginRuntime.PluginRuntimeReconcileError + | PluginCommandDefinitionError + | PluginUiDefinitionError + | PluginRuntime.PluginRuntimeReconcileError >; } >()("t3/plugins/PluginCommandCatalog") {} @@ -155,6 +240,12 @@ export const make = Effect.gen(function* () { commands: [], generation: 0, }); + const uiState = yield* SubscriptionRef.make({ + generation: 0, + packages: [], + }); + const notificationPubSub = yield* PubSub.sliding(64); + const lastNotificationAt = new Map(); const reconcileSemaphore = yield* Semaphore.make(1); const reconcile = Effect.fn("PluginCommandCatalog.reconcile")( @@ -166,11 +257,16 @@ export const make = Effect.gen(function* () { restore(runtime.reconcile([builtInPlugin, ...definitions])), ); const catalog = yield* catalogFromRuntime(runtime); + const ui = yield* uiFromRuntime(runtime); const previous = yield* SubscriptionRef.get(state); + const previousUi = yield* SubscriptionRef.get(uiState); const published = previous.generation === catalog.generation ? previous : yield* SubscriptionRef.set(state, catalog).pipe(Effect.as(catalog)); + if (previousUi.generation !== ui.generation) { + yield* SubscriptionRef.set(uiState, ui); + } if (Exit.isFailure(transitionExit)) { return yield* Effect.failCause(transitionExit.cause); } @@ -182,6 +278,35 @@ export const make = Effect.gen(function* () { yield* reconcile([]); + const notify = Effect.fn("PluginCommandCatalog.notify")(function* ( + pluginId: string, + notification: PluginUiNotificationInput, + ) { + const snapshot = yield* runtime.snapshot; + if (!snapshot.active.includes(pluginId)) { + return yield* new PluginUiNotificationError({ + pluginId, + detail: "plugin is not active", + }); + } + const now = yield* Clock.currentTimeMillis; + const previous = lastNotificationAt.get(pluginId); + if (previous !== undefined && now - previous < 250) { + return yield* new PluginUiNotificationError({ + pluginId, + detail: "notification rate limit exceeded", + }); + } + const decoded = yield* decodePluginUiNotification({ ...notification, pluginId }).pipe( + Effect.mapError( + (cause) => + new PluginUiNotificationError({ pluginId, detail: "invalid notification", cause }), + ), + ); + lastNotificationAt.set(pluginId, now); + yield* PubSub.publish(notificationPubSub, deepFreeze(decoded)); + }); + const invoke = Effect.fn("PluginCommandCatalog.invoke")(function* ( input: PluginCommandInvokeInput, ) { @@ -192,7 +317,7 @@ export const make = Effect.gen(function* () { PluginCommandInvocationResult, PluginCommandExecutionError, never - >(COMMAND_SLOT, input.id, input.generation, (handler) => handler) + >(COMMAND_SLOT, input.id, input.generation, (handler) => handler(input.context)) .pipe( Effect.catchTags({ PluginContributionGenerationError: (error) => @@ -235,10 +360,14 @@ export const make = Effect.gen(function* () { composition: runtime.snapshot, invoke, list: SubscriptionRef.get(state), + notifications: Stream.fromPubSub(notificationPubSub), + notify, reconcile, + ui: SubscriptionRef.get(uiState), + uiChanges: SubscriptionRef.changes(uiState), }); }); export const layer = Layer.effect(PluginCommandCatalog, make).pipe( - Layer.provide(PluginRuntime.layer({ validateSnapshot: validateCommandSnapshot })), + Layer.provide(PluginRuntime.layer({ validateSnapshot })), ); diff --git a/apps/server/src/plugins/PluginHostCapabilityBroker.ts b/apps/server/src/plugins/PluginHostCapabilityBroker.ts index 158f9840c70a..ec29f0a76b40 100644 --- a/apps/server/src/plugins/PluginHostCapabilityBroker.ts +++ b/apps/server/src/plugins/PluginHostCapabilityBroker.ts @@ -1,5 +1,5 @@ import { NodeHttpClient } from "@effect/platform-node"; -import { PluginHostPermission } from "@t3tools/contracts"; +import { PluginHostPermission, type PluginUiNotificationInput } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -98,6 +98,11 @@ export interface PluginHostApi { PluginHostCapabilityError >; }; + readonly ui: { + readonly notify: ( + notification: PluginUiNotificationInput, + ) => Effect.Effect; + }; } export class PluginHostCapabilityBroker extends Context.Service< @@ -732,6 +737,14 @@ export const make = Effect.gen(function* () { return result; }), }, + ui: { + notify: (_notification) => + requirePermission(pluginId, requested, "notifications:send", "notification send").pipe( + Effect.flatMap(() => + Effect.fail(fail(pluginId, "notification send", "notification sink unavailable")), + ), + ), + }, } satisfies PluginHostApi; }); diff --git a/apps/server/src/plugins/PluginPackageManager.test.ts b/apps/server/src/plugins/PluginPackageManager.test.ts index 287b94a95410..e3d77e56a67a 100644 --- a/apps/server/src/plugins/PluginPackageManager.test.ts +++ b/apps/server/src/plugins/PluginPackageManager.test.ts @@ -8,8 +8,10 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { PluginManifest } from "@t3tools/plugin-runtime/manifest"; @@ -622,6 +624,134 @@ it.layer(NodeServices.layer)("plugin package lifecycle", (it) => { }), ); + it.effect("publishes declared ui, stores plugin settings, and brokers notifications", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-plugin-package-ui-test-", + }); + const uiPackageId = "com.acme.fun"; + const uiCommandId = "com.acme.fun.celebrate"; + const packageDirectory = `${baseDir}/userdata/plugins/${uiPackageId}`; + const uiManifest = { + ...manifest, + id: uiPackageId, + capabilities: ["t3.commands@1", "t3.ui@1"], + permissions: ["settings:read-write", "notifications:send"], + contributes: { + commands: [uiCommandId], + settings: ["com.acme.fun.enabled"], + navigation: ["com.acme.fun.navigation"], + views: ["com.acme.fun.view"], + cards: ["com.acme.fun.card"], + statusItems: ["com.acme.fun.status"], + composerActions: ["com.acme.fun.composer"], + contextualActions: ["com.acme.fun.context"], + }, + } as const; + yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest(uiManifest), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + `export default function activate(api) { + api.registerUi({ + settings: [{ + id: "com.acme.fun.enabled", + kind: "boolean", + label: "Enable fun", + defaultValue: true, + surfaces: ["web", "desktop", "mobile"] + }], + navigation: [{ + id: "com.acme.fun.navigation", + label: "Fun", + viewId: "com.acme.fun.view", + surfaces: ["web", "desktop"] + }], + views: [{ + id: "com.acme.fun.view", + label: "Fun", + surfaces: ["web", "desktop"], + blocks: [{ kind: "text", text: "Fun dashboard" }] + }], + cards: [{ + id: "com.acme.fun.card", + title: "Fun score", + value: "10", + surfaces: ["web", "desktop", "mobile"] + }], + statusItems: [{ + id: "com.acme.fun.status", + label: "Fun", + value: "Ready", + surfaces: ["web", "desktop", "mobile"] + }], + composerActions: [{ + id: "com.acme.fun.composer", + label: "Celebrate", + commandId: "${uiCommandId}", + surfaces: ["web", "desktop", "mobile"] + }], + contextualActions: [{ + id: "com.acme.fun.context", + label: "Celebrate thread", + commandId: "${uiCommandId}", + contexts: ["thread"], + surfaces: ["web", "desktop", "mobile"] + }] + }); + api.registerCommand( + { id: "${uiCommandId}", label: "Celebrate", surfaces: ["web", "desktop", "mobile"] }, + (context) => api.effect.flatMap( + api.host.ui.notify({ + id: "celebrated", + title: "Celebrated", + message: context?.threadId ?? "No thread", + tone: "success" + }), + () => api.effect.succeed({ message: context?.threadId ?? "none", tone: "success" }) + ) + ); + }`, + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + const catalog = yield* PluginCommandCatalog.PluginCommandCatalog; + yield* manager.enable(uiPackageId); + + const ui = yield* catalog.ui; + expect(ui.packages[0]).toMatchObject({ + pluginId: uiPackageId, + navigation: [{ id: "com.acme.fun.navigation" }], + cards: [{ id: "com.acme.fun.card" }], + }); + expect(yield* manager.settingRead(uiPackageId, "com.acme.fun.enabled")).toBeUndefined(); + yield* manager.settingWrite(uiPackageId, "com.acme.fun.enabled", false); + expect(yield* manager.settingRead(uiPackageId, "com.acme.fun.enabled")).toBe(false); + + const notification = yield* Effect.forkChild(Stream.runHead(catalog.notifications)); + yield* Effect.yieldNow; + const result = yield* catalog.invoke({ + generation: ui.generation, + id: uiCommandId, + context: { threadId: "thread-1" }, + }); + expect(result.message).toBe("thread-1"); + expect(Option.getOrNull(yield* Fiber.join(notification))).toMatchObject({ + pluginId: uiPackageId, + message: "thread-1", + }); + }), + ); + }), + ); + it.effect("loads the committed external runtime-status example without rebuilding", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index 71462fd0da12..af55a58fd20a 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -6,6 +6,8 @@ import { type PluginPackageOperation, type PluginPackageStatus, type PluginPackageStatusSnapshot, + type PluginUiSetting, + PluginUiSettingError, } from "@t3tools/contracts"; import type { PluginActivationContext, PluginDefinition } from "@t3tools/plugin-runtime"; import { @@ -29,6 +31,8 @@ import * as PluginWorkerSupervisor from "./PluginWorkerSupervisor.ts"; const MANIFEST_FILE_NAME = "t3-plugin.json"; const COMMAND_CAPABILITY = "t3.commands@1"; +const UI_CAPABILITY = "t3.ui@1"; +const NOTIFICATION_PERMISSION = "notifications:send"; interface DiscoveredPackage { readonly directory: string; @@ -58,11 +62,21 @@ class PluginPackageUndeclaredCommandError extends Schema.TaggedErrorClass()( + "PluginPackageUiReferenceError", + { id: Schema.String, reference: Schema.String, detail: Schema.String }, +) { + override get message(): string { + return `Plugin UI reference ${this.reference} is invalid: ${this.detail}`; + } +} + interface LoadedDefinition { readonly cacheDirectory: string; readonly definition: PluginDefinition; readonly retired: Promise; readonly worker: PluginWorkerSupervisor.SupervisedPluginWorker; + readonly host: PluginHostCapabilityBroker.PluginHostApi; } const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); @@ -106,6 +120,103 @@ const makeDefinition = ( onRetired: () => void, ): PluginDefinition => { const declaredCommands = new Set(discovered.manifest.contributes?.commands ?? []); + const ui = worker.ui; + const uiEntries = [ + ...ui.settings, + ...ui.navigation, + ...ui.views, + ...ui.cards, + ...ui.statusItems, + ...ui.composerActions, + ...ui.contextualActions, + ]; + if (uiEntries.length > 0 && !discovered.manifest.capabilities.includes(UI_CAPABILITY)) { + throw new PluginPackageMissingCapabilityError({ + id: discovered.manifest.id, + capability: UI_CAPABILITY, + }); + } + const declaredUi = { + settings: new Set(discovered.manifest.contributes?.settings ?? []), + navigation: new Set(discovered.manifest.contributes?.navigation ?? []), + views: new Set(discovered.manifest.contributes?.views ?? []), + cards: new Set([ + ...(discovered.manifest.contributes?.cards ?? []), + ...(discovered.manifest.contributes?.mobileCards ?? []), + ]), + statusItems: new Set(discovered.manifest.contributes?.statusItems ?? []), + composerActions: new Set(discovered.manifest.contributes?.composerActions ?? []), + contextualActions: new Set(discovered.manifest.contributes?.contextualActions ?? []), + }; + for (const [slot, entries] of [ + ["settings", ui.settings], + ["navigation", ui.navigation], + ["views", ui.views], + ["cards", ui.cards], + ["statusItems", ui.statusItems], + ["composerActions", ui.composerActions], + ["contextualActions", ui.contextualActions], + ] as const) { + for (const entry of entries) { + if (!entry.id.startsWith(`${discovered.manifest.id}.`)) { + throw new PluginPackageUiReferenceError({ + id: discovered.manifest.id, + reference: entry.id, + detail: "contribution is outside plugin namespace", + }); + } + if (!declaredUi[slot].has(entry.id)) { + throw new PluginPackageUiReferenceError({ + id: discovered.manifest.id, + reference: entry.id, + detail: "contribution is not declared in the manifest", + }); + } + } + } + if ( + ui.settings.length > 0 && + !(discovered.manifest.permissions ?? []).includes("settings:read-write") + ) { + throw new PluginPackageUiReferenceError({ + id: discovered.manifest.id, + reference: "settings:read-write", + detail: "plugin settings require the settings host permission", + }); + } + const registeredCommandIds = new Set(worker.commands.map((command) => command.id)); + const actionIds = new Set( + [...ui.composerActions, ...ui.contextualActions].map((action) => action.id), + ); + const commandReferences = [ + ...ui.composerActions.map((action) => [action.id, action.commandId] as const), + ...ui.contextualActions.map((action) => [action.id, action.commandId] as const), + ...ui.views.flatMap((view) => + view.blocks.flatMap((block) => + "commandId" in block && block.commandId !== undefined + ? ([[block.id, block.commandId]] as const) + : [], + ), + ), + ]; + for (const [reference, commandId] of commandReferences) { + if (!registeredCommandIds.has(commandId)) { + throw new PluginPackageUiReferenceError({ + id: discovered.manifest.id, + reference, + detail: `command ${commandId} is not registered`, + }); + } + } + for (const card of ui.cards) { + if (card.actionId !== undefined && !actionIds.has(card.actionId)) { + throw new PluginPackageUiReferenceError({ + id: discovered.manifest.id, + reference: card.id, + detail: `action ${card.actionId} is not contributed`, + }); + } + } if ( worker.commands.length > 0 && !discovered.manifest.capabilities.includes(COMMAND_CAPABILITY) @@ -138,19 +249,23 @@ const makeDefinition = ( provides: providedCapabilities, activate(context: PluginActivationContext) { context.onDispose(onRetired); + if (uiEntries.length > 0) { + PluginCommandCatalog.registerPluginUi(context, discovered.manifest.id, ui); + } for (const command of worker.commands) { PluginCommandCatalog.registerPluginCommand(context, { command, - handler: worker.invoke(command.id).pipe( - Effect.flatMap(decodeInvocationResult), - Effect.mapError( - (cause) => - new PluginCommandCatalog.PluginCommandExecutionError({ - cause, - id: command.id, - }), + handler: (invocationContext) => + worker.invoke(command.id, invocationContext).pipe( + Effect.flatMap(decodeInvocationResult), + Effect.mapError( + (cause) => + new PluginCommandCatalog.PluginCommandExecutionError({ + cause, + id: command.id, + }), + ), ), - ), }); } }, @@ -179,6 +294,15 @@ export class PluginPackageManager extends Context.Service< PluginPackageStatusSnapshot, PluginPackageNotFoundError | PluginPackageOperationError >; + readonly settingRead: ( + pluginId: string, + settingId: string, + ) => Effect.Effect; + readonly settingWrite: ( + pluginId: string, + settingId: string, + value: Schema.Json, + ) => Effect.Effect; } >()("t3/plugins/PluginPackageManager") {} @@ -197,6 +321,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { const activeCacheDirectories = new Map(); const activeManifests = new Map(); const activeWorkers = new Map(); + const activeHosts = new Map(); const activeRetirements = new Map>(); const packageErrors = new Map(); let loadSequence = 0; @@ -305,9 +430,36 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { discovered: DiscoveredPackage, operation: PluginPackageOperation, ) { - const host = yield* hostCapabilities + const baseHost = yield* hostCapabilities .open(discovered.manifest.id, discovered.manifest.permissions ?? []) .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id))); + const host: PluginHostCapabilityBroker.PluginHostApi = { + ...baseHost, + ui: { + notify: (notification) => { + if (!(discovered.manifest.permissions ?? []).includes(NOTIFICATION_PERMISSION)) { + return Effect.fail( + new PluginHostCapabilityBroker.PluginHostCapabilityError({ + pluginId: discovered.manifest.id, + operation: "notification send", + detail: `permission not declared: ${NOTIFICATION_PERMISSION}`, + }), + ); + } + return catalog.notify(discovered.manifest.id, notification).pipe( + Effect.mapError( + (cause) => + new PluginHostCapabilityBroker.PluginHostCapabilityError({ + pluginId: discovered.manifest.id, + operation: "notification send", + detail: cause.detail, + cause, + }), + ), + ); + }, + }, + }; const serverEntrypoint = discovered.manifest.entrypoints.server; if (serverEntrypoint === undefined) { return yield* operationError( @@ -385,6 +537,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { definition: definitionExit.value, retired, worker, + host, } satisfies LoadedDefinition; }); @@ -484,7 +637,19 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { grantedPermissions: requestedPermissions.filter((permission) => grantedPermissionSet.has(permission), ), - contributions: { commands: [...(packageManifest.contributes?.commands ?? [])] }, + contributions: { + commands: [...(packageManifest.contributes?.commands ?? [])], + settings: [...(packageManifest.contributes?.settings ?? [])], + navigation: [...(packageManifest.contributes?.navigation ?? [])], + views: [...(packageManifest.contributes?.views ?? [])], + cards: [ + ...(packageManifest.contributes?.cards ?? []), + ...(packageManifest.contributes?.mobileCards ?? []), + ], + statusItems: [...(packageManifest.contributes?.statusItems ?? [])], + composerActions: [...(packageManifest.contributes?.composerActions ?? [])], + contextualActions: [...(packageManifest.contributes?.contextualActions ?? [])], + }, ...(error === undefined ? {} : { error }), }); } @@ -602,6 +767,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { activeCacheDirectories.set(id, loaded.cacheDirectory); activeManifests.set(id, pluginPackage.manifest); activeWorkers.set(id, loaded.worker); + activeHosts.set(id, loaded.host); activeRetirements.set(id, loaded.retired); if (previousCacheDirectory !== undefined) { if (reconciled._tag === "Failure" && previousRetirement !== undefined) { @@ -661,6 +827,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { activeDefinitions.delete(id); activeManifests.delete(id); activeWorkers.delete(id); + activeHosts.delete(id); const cacheDirectory = activeCacheDirectories.get(id); const retirement = activeRetirements.get(id); activeCacheDirectories.delete(id); @@ -718,6 +885,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { activeCacheDirectories.set(id, loaded.cacheDirectory); activeManifests.set(id, pluginPackage.manifest); activeWorkers.set(id, loaded.worker); + activeHosts.set(id, loaded.host); activeRetirements.set(id, loaded.retired); }).pipe( Effect.retry({ times: 1 }), @@ -730,6 +898,75 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { ); } + const settingError = (pluginId: string, settingId: string, detail: string, cause?: unknown) => + new PluginUiSettingError({ + pluginId, + settingId, + detail, + ...(cause === undefined ? {} : { cause }), + }); + + const resolveSetting = Effect.fn("PluginPackageManager.resolveSetting")(function* ( + pluginId: string, + settingId: string, + ) { + const host = activeHosts.get(pluginId); + if (host === undefined) return yield* settingError(pluginId, settingId, "plugin is not active"); + const ui = yield* catalog.ui; + const setting = ui.packages + .find((pluginPackage) => pluginPackage.pluginId === pluginId) + ?.settings.find((candidate) => candidate.id === settingId); + if (setting === undefined) { + return yield* settingError(pluginId, settingId, "setting is not declared"); + } + return { host, setting }; + }); + + const valueMatchesSetting = (setting: PluginUiSetting, value: Schema.Json): boolean => { + switch (setting.kind) { + case "boolean": + return typeof value === "boolean"; + case "text": + return typeof value === "string" && value.length <= 2_000; + case "select": + return ( + typeof value === "string" && setting.options.some((option) => option.value === value) + ); + } + }; + + const settingRead = (pluginId: string, settingId: string) => + semaphore.withPermits(1)( + Effect.gen(function* () { + const { host } = yield* resolveSetting(pluginId, settingId); + const value = yield* host.settings + .get(settingId) + .pipe( + Effect.mapError((cause) => + settingError(pluginId, settingId, detailFromUnknown(cause), cause), + ), + ); + return value as Schema.Json | undefined; + }), + ); + + const settingWrite = (pluginId: string, settingId: string, value: Schema.Json) => + semaphore.withPermits(1)( + Effect.gen(function* () { + const { host, setting } = yield* resolveSetting(pluginId, settingId); + if (!valueMatchesSetting(setting, value)) { + return yield* settingError(pluginId, settingId, "value does not match setting schema"); + } + yield* host.settings + .set(settingId, value) + .pipe( + Effect.mapError((cause) => + settingError(pluginId, settingId, detailFromUnknown(cause), cause), + ), + ); + }), + ); + yield* Effect.addFinalizer(() => semaphore.withPermits(1)( Effect.gen(function* () { @@ -744,6 +981,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { discard: true, }); activeWorkers.clear(); + activeHosts.clear(); for (const [id, error] of packageErrors) { yield* Effect.logWarning("Local plugin package reported a shutdown error", { id, error }); } @@ -757,6 +995,8 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { enable: (id: string) => semaphore.withPermits(1)(transition("enable", id)), disable: (id: string) => semaphore.withPermits(1)(disableUnlocked(id)), reload: (id: string) => semaphore.withPermits(1)(transition("reload", id)), + settingRead, + settingWrite, } as const; }); @@ -766,6 +1006,24 @@ const unavailableService = (error: PluginPackageOperationError) => enable: () => Effect.fail(error), disable: () => Effect.fail(error), reload: () => Effect.fail(error), + settingRead: (pluginId, settingId) => + Effect.fail( + new PluginUiSettingError({ + pluginId, + settingId, + detail: error.message, + cause: error, + }), + ), + settingWrite: (pluginId, settingId) => + Effect.fail( + new PluginUiSettingError({ + pluginId, + settingId, + detail: error.message, + cause: error, + }), + ), }); export const layer = Layer.effect( diff --git a/apps/server/src/plugins/PluginWorkerProtocol.test.ts b/apps/server/src/plugins/PluginWorkerProtocol.test.ts index 8b6de0f32377..729e2127d5f1 100644 --- a/apps/server/src/plugins/PluginWorkerProtocol.test.ts +++ b/apps/server/src/plugins/PluginWorkerProtocol.test.ts @@ -17,6 +17,15 @@ describe("PluginWorkerProtocol", () => { surfaces: ["web"], }, ], + ui: { + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, }), ).toMatchObject({ type: "activated", commands: [{ id: "acme.issue.create" }] }); @@ -35,11 +44,25 @@ describe("PluginWorkerProtocol", () => { key: "cursor", value: { page: 2 }, }); + + expect( + decode({ + type: "hostCall", + callId: "call-2", + operation: "ui.notify", + notification: { + id: "challenge-complete", + title: "Challenge complete", + message: "Nice work.", + tone: "success", + }, + }), + ).toMatchObject({ operation: "ui.notify", notification: { tone: "success" } }); }); it("rejects malformed messages, executable metadata, and excess properties", () => { for (const message of [ - { type: "activated", commands: [{ id: "", label: "bad", surfaces: ["web"] }] }, + { type: "activated", commands: [{ id: "", label: "bad", surfaces: ["web"] }], ui: {} }, { type: "hostCall", callId: "call-1", operation: "state.set", key: "cursor" }, { type: "hostCall", diff --git a/apps/server/src/plugins/PluginWorkerProtocol.ts b/apps/server/src/plugins/PluginWorkerProtocol.ts index c18a92977274..c7f496980604 100644 --- a/apps/server/src/plugins/PluginWorkerProtocol.ts +++ b/apps/server/src/plugins/PluginWorkerProtocol.ts @@ -1,4 +1,9 @@ -import { PluginCommand } from "@t3tools/contracts"; +import { + PluginCommand, + type PluginCommandInvocationContext, + PluginUiContribution, + PluginUiNotificationInput, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; const strict = (schema: S) => @@ -20,6 +25,7 @@ const Activated = strict( Schema.Struct({ type: Schema.Literal("activated"), commands: Schema.Array(PluginCommand), + ui: PluginUiContribution, }), ); const ActivationFailed = strict( @@ -133,6 +139,14 @@ const ProcessRun = strict( ), }), ); +const UiNotify = strict( + Schema.Struct({ + type: Schema.Literal("hostCall"), + callId: PluginWorkerRequestId, + operation: Schema.Literal("ui.notify"), + notification: PluginUiNotificationInput, + }), +); export const PluginWorkerHostCall = Schema.Union([ keyedHostCall("settings.get"), @@ -155,6 +169,7 @@ export const PluginWorkerHostCall = Schema.Union([ FileAccess("files.remove"), NetworkFetch, ProcessRun, + UiNotify, ]); export type PluginWorkerHostCall = typeof PluginWorkerHostCall.Type; @@ -170,7 +185,12 @@ export const PluginWorkerMessage = Schema.Union([ export type PluginWorkerMessage = typeof PluginWorkerMessage.Type; export type PluginWorkerParentMessage = - | { readonly type: "invoke"; readonly requestId: string; readonly commandId: string } + | { + readonly type: "invoke"; + readonly requestId: string; + readonly commandId: string; + readonly context?: PluginCommandInvocationContext; + } | { readonly type: "cancel"; readonly requestId: string } | { readonly type: "dispose"; readonly requestId: string } | { readonly type: "hostResult"; readonly callId: string; readonly value?: Schema.Json } diff --git a/apps/server/src/plugins/PluginWorkerRuntime.mjs b/apps/server/src/plugins/PluginWorkerRuntime.mjs index 98cd5e302ecd..07e961a425d8 100644 --- a/apps/server/src/plugins/PluginWorkerRuntime.mjs +++ b/apps/server/src/plugins/PluginWorkerRuntime.mjs @@ -1,6 +1,6 @@ -import { pathToFileURL } from "node:url"; -import { createInterface } from "node:readline"; -import { inspect } from "node:util"; +import * as NodeReadline from "node:readline"; +import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; const MAX_PROTOCOL_LINE_BYTES = 1_000_000; const entrypointPath = process.argv[2]; @@ -55,7 +55,7 @@ const writeInvocationResult = (requestId, value) => { const writeDiagnostic = (...values) => { const detail = values - .map((value) => (typeof value === "string" ? value : inspect(value))) + .map((value) => (typeof value === "string" ? value : NodeUtil.inspect(value))) .join(" "); process.stderr.write(`${detail.slice(0, 4_000)}\n`); }; @@ -63,24 +63,34 @@ for (const method of ["log", "info", "warn", "error", "debug"]) { console[method] = writeDiagnostic; } -class RemoteEffect { - constructor(run) { - this.run = run; - } -} +const REMOTE_EFFECT = Symbol("plugin-remote-effect"); +const remoteEffect = (run) => ({ [REMOTE_EFFECT]: true, run }); +const isRemoteEffect = (value) => + typeof value === "object" && value !== null && value[REMOTE_EFFECT] === true; const pendingHostCalls = new Map(); const invocations = new Map(); const commands = new Map(); const finalizers = []; +const emptyUi = () => ({ + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], +}); +let uiContribution = emptyUi(); +let uiRegistered = false; const runValue = async (value, signal) => { - if (value instanceof RemoteEffect) return await value.run(signal); + if (isRemoteEffect(value)) return await value.run(signal); return await value; }; const hostCall = (operation, fields) => - new RemoteEffect( + remoteEffect( (signal) => new Promise((resolve, reject) => { const callId = `call-${++sequence}`; @@ -132,12 +142,15 @@ const api = { process: { run: (command, args = []) => hostCall("process.run", { command, args }), }, + ui: { + notify: (notification) => hostCall("ui.notify", { notification }), + }, }, effect: { - succeed: (value) => new RemoteEffect(async () => value), - map: (effect, f) => new RemoteEffect(async (signal) => f(await runValue(effect, signal))), + succeed: (value) => remoteEffect(async () => value), + map: (effect, f) => remoteEffect(async (signal) => f(await runValue(effect, signal))), flatMap: (effect, f) => - new RemoteEffect(async (signal) => runValue(f(await runValue(effect, signal)), signal)), + remoteEffect(async (signal) => runValue(f(await runValue(effect, signal)), signal)), }, onDispose: (cleanup) => { if (typeof cleanup !== "function") throw new Error("dispose callback must be a function"); @@ -150,6 +163,11 @@ const api = { if (commands.has(command.id)) throw new Error(`duplicate command ${command.id}`); commands.set(command.id, { command, handler }); }, + registerUi: (contribution) => { + if (uiRegistered) throw new Error("plugin ui contribution already registered"); + uiContribution = detachJson(contribution); + uiRegistered = true; + }, }; const dispose = async (requestId = "dispose-signal") => { @@ -158,7 +176,7 @@ const dispose = async (requestId = "dispose-signal") => { for (const controller of invocations.values()) controller.abort(); invocations.clear(); const failures = []; - for (const cleanup of [...finalizers].reverse()) { + for (const cleanup of finalizers.toReversed()) { try { await runValue(cleanup(), new AbortController().signal); } catch (error) { @@ -213,7 +231,7 @@ const handleMessage = async (message) => { const controller = new AbortController(); invocations.set(message.requestId, controller); void Promise.resolve() - .then(() => registration.handler()) + .then(() => registration.handler(message.context)) .then((result) => runValue(result, controller.signal)) .then( (value) => writeInvocationResult(message.requestId, value), @@ -240,7 +258,7 @@ const handleMessage = async (message) => { } }; -const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); +const input = NodeReadline.createInterface({ input: process.stdin, crlfDelay: Infinity }); input.on("line", (line) => { if (Buffer.byteLength(line, "utf8") > MAX_PROTOCOL_LINE_BYTES) { writeDiagnostic("parent protocol line exceeds limit"); @@ -269,7 +287,7 @@ try { if (typeof entrypointPath !== "string" || typeof pluginId !== "string") { throw new Error("plugin worker requires an entrypoint and plugin id"); } - const module = await import(pathToFileURL(entrypointPath).href); + const module = await import(NodeURL.pathToFileURL(entrypointPath).href); if (typeof module.default !== "function") { throw new Error("server entrypoint must export a default activation function"); } @@ -277,6 +295,7 @@ try { write({ type: "activated", commands: [...commands.values()].map(({ command }) => command), + ui: uiContribution, }); } catch (error) { write({ type: "activationFailed", detail: detailFrom(error) }); diff --git a/apps/server/src/plugins/PluginWorkerSupervisor.test.ts b/apps/server/src/plugins/PluginWorkerSupervisor.test.ts index 53440835e40a..f7f430d65875 100644 --- a/apps/server/src/plugins/PluginWorkerSupervisor.test.ts +++ b/apps/server/src/plugins/PluginWorkerSupervisor.test.ts @@ -9,6 +9,7 @@ import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; import { NodeServices } from "@effect/platform-node"; +import type { PluginUiNotificationInput } from "@t3tools/contracts"; import type { PluginHostApi, PluginHostKeyValueStore } from "./PluginHostCapabilityBroker.ts"; import * as PluginWorkerSupervisor from "./PluginWorkerSupervisor.ts"; @@ -23,7 +24,7 @@ const makeStore = (): PluginHostKeyValueStore => { }; }; -const makeHost = (): PluginHostApi => ({ +const makeHost = (notifications: Array = []): PluginHostApi => ({ settings: makeStore(), state: makeStore(), cache: makeStore(), @@ -43,6 +44,12 @@ const makeHost = (): PluginHostApi => ({ process: { run: () => Effect.fail(new Error("unused") as never), }, + ui: { + notify: (notification) => + Effect.sync(() => { + notifications.push(structuredClone(notification)); + }), + }, }); const waitForHealth = ( @@ -66,19 +73,47 @@ it.layer(NodeServices.layer)("plugin worker supervisor", (it) => { prefix: "t3code-plugin-worker-invoke-test-", }); const entrypointPath = path.join(directory, "index.mjs"); + const notifications: Array = []; yield* fileSystem.writeFileString( entrypointPath, `export default function activate(api) { if (!process.execArgv.includes("--max-old-space-size=128") || !process.execArgv.includes("--no-addons")) { throw new Error("worker resource flags missing"); } + api.registerUi({ + settings: [], + navigation: [{ + id: "com.acme.counter.navigation", + label: "Counter", + viewId: "com.acme.counter.view", + surfaces: ["web"] + }], + views: [{ + id: "com.acme.counter.view", + label: "Counter", + surfaces: ["web"], + blocks: [{ kind: "text", text: "Counter dashboard" }] + }], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [] + }); api.registerCommand( { id: "acme.counter", label: "Counter", surfaces: ["web"] }, () => api.effect.flatMap(api.host.state.get("count"), (stored) => { const next = typeof stored === "number" ? stored + 1 : 1; return api.effect.flatMap( api.host.state.set("count", next), - () => api.effect.succeed({ message: String(next), tone: "success" }) + () => api.effect.flatMap( + api.host.ui.notify({ + id: "counter-updated", + title: "Counter updated", + message: String(next), + tone: "success" + }), + () => api.effect.succeed({ message: String(next), tone: "success" }) + ) ); }) ); @@ -88,12 +123,14 @@ it.layer(NodeServices.layer)("plugin worker supervisor", (it) => { const worker = yield* supervisor.start({ pluginId: "com.acme.counter", entrypointPath, - host: makeHost(), + host: makeHost(notifications), }); expect(worker.commands.map(({ id }) => id)).toEqual(["acme.counter"]); + expect(worker.ui.navigation.map(({ id }) => id)).toEqual(["com.acme.counter.navigation"]); expect(yield* worker.invoke("acme.counter")).toEqual({ message: "1", tone: "success" }); expect(yield* worker.invoke("acme.counter")).toEqual({ message: "2", tone: "success" }); + expect(notifications).toHaveLength(2); expect(worker.health().state).toBe("running"); yield* worker.dispose; }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), diff --git a/apps/server/src/plugins/PluginWorkerSupervisor.ts b/apps/server/src/plugins/PluginWorkerSupervisor.ts index 13f7c900021c..9b79ef2be048 100644 --- a/apps/server/src/plugins/PluginWorkerSupervisor.ts +++ b/apps/server/src/plugins/PluginWorkerSupervisor.ts @@ -1,4 +1,9 @@ -import type { PluginCommand } from "@t3tools/contracts"; +import { + type PluginCommand, + type PluginCommandInvocationContext, + PluginUiContribution, + type PluginUiContribution as PluginUiContributionType, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; @@ -46,6 +51,7 @@ const sameCommands = ( }); const decodeWorkerMessage = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginWorkerMessage)); +const encodeUi = Schema.encodeSync(Schema.fromJsonString(PluginUiContribution)); export type PluginWorkerHealth = "starting" | "running" | "restarting" | "crashed" | "stopped"; @@ -73,7 +79,11 @@ export const isPluginWorkerError = Schema.is(PluginWorkerError); export interface SupervisedPluginWorker { readonly commands: ReadonlyArray; - readonly invoke: (commandId: string) => Effect.Effect; + readonly ui: PluginUiContributionType; + readonly invoke: ( + commandId: string, + context?: PluginCommandInvocationContext, + ) => Effect.Effect; readonly dispose: Effect.Effect; readonly health: () => PluginWorkerHealthSnapshot; } @@ -106,7 +116,11 @@ export class PluginWorkerSupervisor extends Context.Service< interface Session { readonly id: number; readonly commands: ReadonlyArray; - readonly invoke: (commandId: string) => Effect.Effect; + readonly ui: PluginUiContributionType; + readonly invoke: ( + commandId: string, + context?: PluginCommandInvocationContext, + ) => Effect.Effect; readonly requestDispose: Effect.Effect; readonly terminate: (detail: string) => Effect.Effect; readonly close: Effect.Effect; @@ -149,6 +163,7 @@ export const make = Effect.gen(function* () { let health: PluginWorkerHealthSnapshot = { state: "starting", restartCount: 0 }; let current: Session | undefined; let expectedCommands: ReadonlyArray | undefined; + let expectedUi: PluginUiContributionType | undefined; const detailFrom = (error: unknown): string => { const detail = error instanceof Error ? error.message : String(error); @@ -216,6 +231,8 @@ export const make = Effect.gen(function* () { return input.host.network.fetchText(call.url); case "process.run": return input.host.process.run(call.command, call.args); + case "ui.notify": + return input.host.ui.notify(call.notification); } return Effect.fail(workerError("host", "unsupported host operation")); }; @@ -224,7 +241,13 @@ export const make = Effect.gen(function* () { const sessionId = ++workerSequence; const sessionScope = yield* Scope.fork(workerScope, "sequential"); const inputQueue = yield* Queue.unbounded(); - const activation = yield* Deferred.make, PluginWorkerError>(); + const activation = yield* Deferred.make< + { + readonly commands: ReadonlyArray; + readonly ui: PluginUiContributionType; + }, + PluginWorkerError + >(); const hostCallSemaphore = yield* Semaphore.make(HOST_CALL_CONCURRENCY); const encoder = new TextEncoder(); const protocolDecoder = new TextDecoder(); @@ -303,7 +326,10 @@ export const make = Effect.gen(function* () { ); switch (message.type) { case "activated": - Deferred.doneUnsafe(activation, Effect.succeed(message.commands)); + Deferred.doneUnsafe( + activation, + Effect.succeed({ commands: message.commands, ui: message.ui }), + ); return; case "activationFailed": Deferred.doneUnsafe( @@ -456,7 +482,11 @@ export const make = Effect.gen(function* () { const request = ( message: - | { readonly type: "invoke"; readonly commandId: string } + | { + readonly type: "invoke"; + readonly commandId: string; + readonly context?: PluginCommandInvocationContext; + } | { readonly type: "dispose" }, ): Effect.Effect => Effect.callback((resume, signal) => { @@ -490,9 +520,14 @@ export const make = Effect.gen(function* () { return { id: sessionId, - commands: activated, - invoke: (commandId) => - request({ type: "invoke", commandId }).pipe( + commands: activated.commands, + ui: activated.ui, + invoke: (commandId, context) => + request({ + type: "invoke", + commandId, + ...(context === undefined ? {} : { context }), + }).pipe( Effect.mapError((error) => isPluginWorkerError(error) ? error @@ -513,6 +548,7 @@ export const make = Effect.gen(function* () { } current = firstSessionExit.value; expectedCommands = current.commands; + expectedUi = current.ui; health = { state: "running", restartCount: 0 }; const restartLoop = Effect.forever( @@ -536,11 +572,14 @@ export const make = Effect.gen(function* () { }; continue; } - if (!sameCommands(restarted.value.commands, expectedCommands)) { + if ( + !sameCommands(restarted.value.commands, expectedCommands) || + encodeUi(restarted.value.ui) !== encodeUi(expectedUi) + ) { yield* restarted.value.close; health = { state: "crashed", - detail: "restarted worker changed its command catalog", + detail: "restarted worker changed its contribution catalog", restartCount, }; return; @@ -599,7 +638,7 @@ export const make = Effect.gen(function* () { ); yield* Effect.forkIn(disposeFiber, parentScope); - const invoke = (commandId: string) => + const invoke = (commandId: string, context?: PluginCommandInvocationContext) => Effect.gen(function* () { const session = yield* transition.withPermits(1)( Effect.gen(function* () { @@ -610,7 +649,7 @@ export const make = Effect.gen(function* () { return current; }), ); - const result = yield* session.invoke(commandId).pipe( + const result = yield* session.invoke(commandId, context).pipe( Effect.timeout(options.invocationTimeout), Effect.catchTags({ TimeoutError: (cause) => @@ -637,6 +676,7 @@ export const make = Effect.gen(function* () { return { commands: expectedCommands, + ui: expectedUi, invoke, dispose: Effect.sync(() => Deferred.doneUnsafe(disposeRequest, Effect.void)).pipe( Effect.flatMap(() => Deferred.await(disposeResult)), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 72249717bdee..a345b402e00b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4683,7 +4683,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("lists, subscribes to, and invokes plugin commands over websocket rpc", () => + it.effect("publishes plugin commands and declarative ui over websocket rpc", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -4696,16 +4696,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Stream.runHead, Effect.map(Option.getOrThrow), ); + const ui = yield* client[WS_METHODS.pluginUiList]({}); + const streamedUi = yield* client[WS_METHODS.subscribePluginUi]({}).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); const invoked = yield* client[WS_METHODS.pluginCommandsInvoke]({ generation: listed.generation, id: "t3.plugin-runtime.status", }); - return { invoked, listed, streamed }; + return { invoked, listed, streamed, streamedUi, ui }; }), ), ); assert.deepEqual(result.streamed, result.listed); + assert.deepEqual(result.streamedUi, result.ui); + assert.deepEqual(result.ui.packages, []); + assert.equal(result.ui.generation, result.listed.generation); assert.deepEqual(result.invoked, { message: "Plugin runtime is active.", tone: "success", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index dd01f2a08f8f..6c1206eaaa88 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1542,6 +1542,24 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pluginCommandsInvoke, pluginCommands.invoke(input), { "rpc.aggregate": "pluginCommands", }), + [WS_METHODS.pluginUiList]: (_input) => + observeRpcEffect(WS_METHODS.pluginUiList, pluginCommands.ui, { + "rpc.aggregate": "pluginUi", + }), + [WS_METHODS.pluginUiSettingGet]: (input) => + observeRpcEffect( + WS_METHODS.pluginUiSettingGet, + pluginPackages + .settingRead(input.pluginId, input.settingId) + .pipe(Effect.map((value) => (value === undefined ? {} : { value }))), + { "rpc.aggregate": "pluginUi" }, + ), + [WS_METHODS.pluginUiSettingSet]: (input) => + observeRpcEffect( + WS_METHODS.pluginUiSettingSet, + pluginPackages.settingWrite(input.pluginId, input.settingId, input.value), + { "rpc.aggregate": "pluginUi" }, + ), [WS_METHODS.pluginPackagesStatus]: (_input) => observeRpcEffect(WS_METHODS.pluginPackagesStatus, pluginPackages.status, { "rpc.aggregate": "pluginPackages", @@ -2425,6 +2443,16 @@ const makeWsRpcLayer = ( observeRpcStream(WS_METHODS.subscribePluginCommands, pluginCommands.changes, { "rpc.aggregate": "pluginCommands", }), + [WS_METHODS.subscribePluginUi]: (_input) => + observeRpcStream(WS_METHODS.subscribePluginUi, pluginCommands.uiChanges, { + "rpc.aggregate": "pluginUi", + }), + [WS_METHODS.subscribePluginUiNotifications]: (_input) => + observeRpcStream( + WS_METHODS.subscribePluginUiNotifications, + pluginCommands.notifications, + { "rpc.aggregate": "pluginUi" }, + ), }); }), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 46ed051154a6..a2694718f2ff 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -270,6 +270,7 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { PluginComposerContributions } from "./plugins/PluginUi"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -6770,6 +6771,17 @@ function ChatViewContent(props: ChatViewProps) { >
+ { + it("renders cards, statuses, text, and actions with host-owned components", () => { + const onAction = vi.fn(); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Fun score"); + expect(markup).toContain("42"); + expect(markup).toContain("Arcade"); + expect(markup).toContain("Ready"); + expect(markup).toContain("Welcome to the arcade"); + expect(markup).toContain("Celebrate"); + expect(markup).toContain("Play"); + expect(markup).not.toContain("script"); + expect(markup).not.toContain("iframe"); + }); +}); diff --git a/apps/web/src/components/plugins/PluginUi.tsx b/apps/web/src/components/plugins/PluginUi.tsx new file mode 100644 index 000000000000..651123c3eaa9 --- /dev/null +++ b/apps/web/src/components/plugins/PluginUi.tsx @@ -0,0 +1,486 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + EnvironmentId, + PluginCommandInvocationContext, + PluginUiAction, + PluginUiBlock, + PluginUiCatalog, + PluginUiNotification, + PluginUiPackageContribution, + PluginUiSetting, + PluginUiView, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { PuzzleIcon, SparklesIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; + +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Switch } from "../ui/switch"; +import { toastManager } from "../ui/toast"; +import { SidebarMenuItem, SidebarMenuButton } from "../ui/sidebar"; +import { SettingsRow, SettingsSection } from "../settings/settingsLayout"; + +const EMPTY_PLUGIN_UI_CATALOG: PluginUiCatalog = Object.freeze({ + generation: 0, + packages: Object.freeze([]), +}); +const EMPTY_PLUGIN_UI_ATOM = Atom.make(AsyncResult.success(EMPTY_PLUGIN_UI_CATALOG)); +const EMPTY_PLUGIN_NOTIFICATION: PluginUiNotification = { + id: "empty", + pluginId: "t3.empty", + title: "Empty", + message: "Empty", + tone: "info", +}; +const EMPTY_PLUGIN_NOTIFICATION_ATOM = Atom.make(AsyncResult.success(EMPTY_PLUGIN_NOTIFICATION)); + +const surface = (): "web" | "desktop" => + typeof window !== "undefined" && window.desktopBridge !== undefined ? "desktop" : "web"; + +const toneClass = { + neutral: "border-border bg-card text-card-foreground", + muted: "border-border/60 bg-muted/35 text-muted-foreground", + info: "border-info/30 bg-info/10 text-info", + success: "border-success/30 bg-success/10 text-success", + warning: "border-warning/30 bg-warning/10 text-warning", + danger: "border-destructive/30 bg-destructive/10 text-destructive", +} as const; + +export function usePluginUiCatalog(environmentId: EnvironmentId | null): PluginUiCatalog { + const result = useAtomValue( + environmentId === null + ? EMPTY_PLUGIN_UI_ATOM + : serverEnvironment.pluginUi({ environmentId, input: {} }), + ); + return Option.getOrElse(AsyncResult.value(result), () => EMPTY_PLUGIN_UI_CATALOG); +} + +function usePluginAction(environmentId: EnvironmentId | null, catalog: PluginUiCatalog) { + const invoke = useAtomCommand(serverEnvironment.invokePluginCommand, { reportFailure: false }); + return useCallback( + async (commandId: string, context?: PluginCommandInvocationContext) => { + if (environmentId === null) return; + const result = await invoke({ + environmentId, + input: { + generation: catalog.generation, + id: commandId, + ...(context === undefined ? {} : { context }), + }, + }); + if (result._tag === "Success") { + toastManager.add({ + type: result.value.tone, + title: result.value.message, + }); + return; + } + if (!isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Plugin action failed", + description: failure instanceof Error ? failure.message : String(failure), + }); + } + }, + [catalog.generation, environmentId, invoke], + ); +} + +export function PluginUiNotificationHost() { + const environmentId = usePrimaryEnvironmentId(); + const result = useAtomValue( + environmentId === null + ? EMPTY_PLUGIN_NOTIFICATION_ATOM + : serverEnvironment.pluginUiNotifications({ environmentId, input: {} }), + ); + const notification = environmentId === null ? null : Option.getOrNull(AsyncResult.value(result)); + const shown = useRef(null); + + useEffect(() => { + if (notification === null) return; + if (shown.current === notification) return; + shown.current = notification; + toastManager.add({ + type: notification.tone, + title: notification.title, + description: notification.message, + }); + }, [notification]); + + return null; +} + +export function PluginUiNavigationItems({ closeMobile }: { readonly closeMobile: () => void }) { + const environmentId = usePrimaryEnvironmentId(); + const catalog = usePluginUiCatalog(environmentId); + const navigate = useNavigate(); + const currentSurface = surface(); + const items = catalog.packages.flatMap((pluginPackage) => + pluginPackage.navigation + .filter((item) => item.surfaces.includes(currentSurface)) + .map((item) => ({ item, pluginId: pluginPackage.pluginId })), + ); + + return items.map(({ item, pluginId }) => ( + + { + closeMobile(); + void navigate({ + to: "/plugins/$pluginId/$viewId", + params: { pluginId, viewId: item.viewId }, + }); + }} + > + + {item.label} + + + )); +} + +function ActionButton({ + action, + onAction, +}: { + readonly action: Pick; + readonly onAction: (commandId: string) => void; +}) { + return ( + + ); +} + +function RenderBlock({ + block, + onAction, +}: { + readonly block: PluginUiBlock; + readonly onAction: (commandId: string) => void; +}) { + switch (block.kind) { + case "text": + return ( +

+ {block.text} +

+ ); + case "action": + return ; + case "card": + return ( +
+
{block.title}
+ {block.value ?
{block.value}
: null} + {block.description ? ( +

{block.description}

+ ) : null} + {block.commandId ? ( +
+ +
+ ) : null} +
+ ); + case "status": + return ( +
+ {block.label} + + {block.value} + +
+ ); + } +} + +export function PluginUiViewContent({ + pluginPackage, + view, + onAction, +}: { + readonly pluginPackage: PluginUiPackageContribution; + readonly view: PluginUiView; + readonly onAction: (commandId: string) => void; +}) { + const currentSurface = surface(); + const cards = pluginPackage.cards.filter((card) => card.surfaces.includes(currentSurface)); + const statuses = pluginPackage.statusItems.filter((item) => + item.surfaces.includes(currentSurface), + ); + const actions = new Map( + [...pluginPackage.composerActions, ...pluginPackage.contextualActions].map((action) => [ + action.id, + action, + ]), + ); + + return ( +
+ {view.description ? ( +

{view.description}

+ ) : null} + {statuses.length > 0 ? ( +
+ {statuses.map((item) => ( + + {item.label}: {item.value} + + ))} +
+ ) : null} + {cards.length > 0 ? ( +
+ {cards.map((card) => ( +
+
{card.title}
+ {card.value ?
{card.value}
: null} + {card.description ? ( +

{card.description}

+ ) : null} + {card.actionId && actions.get(card.actionId) ? ( +
+ +
+ ) : null} +
+ ))} +
+ ) : null} +
+ {view.blocks.map((block, index) => ( + + ))} +
+
+ ); +} + +function PluginUiSettingControl({ + environmentId, + pluginId, + setting, +}: { + readonly environmentId: EnvironmentId; + readonly pluginId: string; + readonly setting: PluginUiSetting; +}) { + const read = useAtomCommand(serverEnvironment.readPluginUiSetting, { reportFailure: false }); + const write = useAtomCommand(serverEnvironment.writePluginUiSetting, { reportFailure: false }); + const [value, setValue] = useState(setting.defaultValue); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + void read({ environmentId, input: { pluginId, settingId: setting.id } }).then((result) => { + if (cancelled || result._tag !== "Success" || result.value.value === undefined) return; + if (typeof result.value.value === "boolean" || typeof result.value.value === "string") { + setValue(result.value.value); + } + }); + return () => { + cancelled = true; + }; + }, [environmentId, pluginId, read, setting.id]); + + const update = async (next: boolean | string) => { + setValue(next); + setBusy(true); + const result = await write({ + environmentId, + input: { pluginId, settingId: setting.id, value: next }, + }); + setBusy(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Plugin setting failed", + description: failure instanceof Error ? failure.message : String(failure), + }); + } + }; + + const control = + setting.kind === "boolean" ? ( + void update(checked)} + /> + ) : setting.kind === "select" ? ( + + ) : ( + setValue(event.target.value)} + onBlur={() => void update(String(value))} + /> + ); + + return ; +} + +export function PluginUiSettingsSections() { + const environmentId = usePrimaryEnvironmentId(); + const catalog = usePluginUiCatalog(environmentId); + const currentSurface = surface(); + if (environmentId === null) return null; + + return catalog.packages.map((pluginPackage) => { + const settings = pluginPackage.settings.filter((setting) => + setting.surfaces.includes(currentSurface), + ); + if (settings.length === 0) return null; + return ( + + {settings.map((setting) => ( + + ))} + + ); + }); +} + +export function PluginComposerContributions({ + environmentId, + context, +}: { + readonly environmentId: EnvironmentId | null; + readonly context: PluginCommandInvocationContext; +}) { + const catalog = usePluginUiCatalog(environmentId); + const invoke = usePluginAction(environmentId, catalog); + const currentSurface = surface(); + const composer = catalog.packages.flatMap((pluginPackage) => + pluginPackage.composerActions.filter((action) => action.surfaces.includes(currentSurface)), + ); + const contextual = catalog.packages.flatMap((pluginPackage) => + pluginPackage.contextualActions.filter( + (action) => action.surfaces.includes(currentSurface) && action.contexts.includes("thread"), + ), + ); + const statuses = catalog.packages.flatMap((pluginPackage) => + pluginPackage.statusItems.filter((item) => item.surfaces.includes(currentSurface)), + ); + if (composer.length === 0 && contextual.length === 0 && statuses.length === 0) return null; + + return ( +
+ {statuses.map((item) => ( + + {item.label}: {item.value} + + ))} + {[...composer, ...contextual].map((action) => ( + + ))} +
+ ); +} + +export function PluginUiPage({ + pluginId, + viewId, +}: { + readonly pluginId: string; + readonly viewId: string; +}) { + const environmentId = usePrimaryEnvironmentId(); + const catalog = usePluginUiCatalog(environmentId); + const invoke = usePluginAction(environmentId, catalog); + const pluginPackage = useMemo( + () => catalog.packages.find((candidate) => candidate.pluginId === pluginId), + [catalog.packages, pluginId], + ); + const currentSurface = surface(); + const view = pluginPackage?.views.find( + (candidate) => candidate.id === viewId && candidate.surfaces.includes(currentSurface), + ); + + if (pluginPackage === undefined || view === undefined) { + return
Plugin page is unavailable.
; + } + return ( + void invoke(commandId, { viewId })} + /> + ); +} diff --git a/apps/web/src/components/settings/PluginsSettings.test.tsx b/apps/web/src/components/settings/PluginsSettings.test.tsx index 430619fb50a3..826fe02b217e 100644 --- a/apps/web/src/components/settings/PluginsSettings.test.tsx +++ b/apps/web/src/components/settings/PluginsSettings.test.tsx @@ -88,6 +88,10 @@ vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() }, })); +vi.mock("../plugins/PluginUi", () => ({ + PluginUiSettingsSections: () => null, +})); + import { toastManager } from "../ui/toast"; import { TooltipPopup } from "../ui/tooltip"; @@ -107,7 +111,16 @@ const snapshot: PluginPackageStatusSnapshot = { capabilities: ["t3.commands@1"], permissions: ["state:read-write", "network:https://api.acme.test"], grantedPermissions: ["state:read-write"], - contributions: { commands: ["acme.active.run"] }, + contributions: { + commands: ["acme.active.run"], + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, }, { id: "com.acme.disabled", @@ -120,7 +133,16 @@ const snapshot: PluginPackageStatusSnapshot = { capabilities: ["t3.commands@1"], permissions: ["filesystem:data"], grantedPermissions: [], - contributions: { commands: [] }, + contributions: { + commands: [], + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, }, ], }; diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index a48d8f290aaa..7f164d42116f 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -29,6 +29,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { resolvePrimaryOperateAccess } from "./ProviderSettingsPanel.logic"; import { searchableSetting } from "./settingsSearch"; +import { PluginUiSettingsSections } from "../plugins/PluginUi"; const statePresentation = { active: { label: "Active", variant: "success" }, @@ -61,6 +62,14 @@ function PluginPackageRow({ }) { const state = statePresentation[pluginPackage.state]; const commands = pluginPackage.contributions.commands; + const uiContributionCount = + pluginPackage.contributions.settings.length + + pluginPackage.contributions.navigation.length + + pluginPackage.contributions.views.length + + pluginPackage.contributions.cards.length + + pluginPackage.contributions.statusItems.length + + pluginPackage.contributions.composerActions.length + + pluginPackage.contributions.contextualActions.length; const grantedPermissions = new Set(pluginPackage.grantedPermissions); const busy = pendingAction !== null; const status = ( @@ -122,9 +131,9 @@ function PluginPackageRow({ {pluginPackage.id}} description={ - commands.length === 0 - ? "No command contributions" - : `${commands.length} command${commands.length === 1 ? "" : "s"}: ${commands.join(", ")}` + commands.length === 0 && uiContributionCount === 0 + ? "No contributions" + : `${commands.length} command${commands.length === 1 ? "" : "s"}, ${uiContributionCount} UI contribution${uiContributionCount === 1 ? "" : "s"}` } status={status} className="border border-border/60 bg-card/35" @@ -342,6 +351,7 @@ export function PluginsSettingsPanel() { ))}
+ ); } diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 8fc6b835bf1a..d6b15539a454 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -29,6 +29,7 @@ import { useSidebar, } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { PluginUiNavigationItems } from "../plugins/PluginUi"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; @@ -153,9 +154,11 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { ? "settings" : location.pathname === "/usage" ? "usage" - : location.pathname === "/pull-requests" - ? "pull-requests" - : null, + : location.pathname.startsWith("/plugins/") + ? "plugin" + : location.pathname === "/pull-requests" + ? "pull-requests" + : null, }); const { environments } = useEnvironments(); // The page reads every connected server, so one of them offering pull requests is enough for @@ -209,6 +212,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { label="Settings" onClick={handleSettingsClick} /> + {pullRequestsSupported ? ( } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 6c82317d91a7..07807fe2dcf8 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -28,6 +28,7 @@ import { Route as SettingsAppearanceRouteImport } from './routes/settings.appear import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' +import { Route as PluginsPluginIdViewIdRouteImport } from './routes/plugins.$pluginId.$viewId' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -125,6 +126,11 @@ const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ path: '/pull-requests', getParentRoute: () => ChatRoute, } as any) +const PluginsPluginIdViewIdRoute = PluginsPluginIdViewIdRouteImport.update({ + id: '/plugins/$pluginId/$viewId', + path: '/plugins/$pluginId/$viewId', + getParentRoute: () => rootRouteImport, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -158,6 +164,7 @@ export interface FileRoutesByFullPath { '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/plugins/$pluginId/$viewId': typeof PluginsPluginIdViewIdRoute } export interface FileRoutesByTo { '/connect': typeof ConnectRoute @@ -180,6 +187,7 @@ export interface FileRoutesByTo { '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/plugins/$pluginId/$viewId': typeof PluginsPluginIdViewIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -204,6 +212,7 @@ export interface FileRoutesById { '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute + '/plugins/$pluginId/$viewId': typeof PluginsPluginIdViewIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -228,6 +237,7 @@ export interface FileRouteTypes { | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/plugins/$pluginId/$viewId' fileRoutesByTo: FileRoutesByTo to: | '/connect' @@ -250,6 +260,7 @@ export interface FileRouteTypes { | '/' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/plugins/$pluginId/$viewId' id: | '__root__' | '/_chat' @@ -273,6 +284,7 @@ export interface FileRouteTypes { | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' + | '/plugins/$pluginId/$viewId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -283,6 +295,7 @@ export interface RootRouteChildren { UsageRoute: typeof UsageRoute ConnectCallbackRoute: typeof ConnectCallbackRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute + PluginsPluginIdViewIdRoute: typeof PluginsPluginIdViewIdRoute } declare module '@tanstack/react-router' { @@ -420,6 +433,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatPullRequestsRouteImport parentRoute: typeof ChatRoute } + '/plugins/$pluginId/$viewId': { + id: '/plugins/$pluginId/$viewId' + path: '/plugins/$pluginId/$viewId' + fullPath: '/plugins/$pluginId/$viewId' + preLoaderRoute: typeof PluginsPluginIdViewIdRouteImport + parentRoute: typeof rootRouteImport + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -491,6 +511,7 @@ const rootRouteChildren: RootRouteChildren = { UsageRoute: UsageRoute, ConnectCallbackRoute: ConnectCallbackRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, + PluginsPluginIdViewIdRoute: PluginsPluginIdViewIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 7c715dff9e95..1759e0df81b4 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -19,6 +19,7 @@ import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDi import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; +import { PluginUiNotificationHost } from "../components/plugins/PluginUi"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; import { Button } from "../components/ui/button"; @@ -145,6 +146,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {appShell} {/* Above the router: a theme draft is judged by walking the app, so the editor has to survive navigation away from settings. */} diff --git a/apps/web/src/routes/plugins.$pluginId.$viewId.tsx b/apps/web/src/routes/plugins.$pluginId.$viewId.tsx new file mode 100644 index 000000000000..ee969d764a70 --- /dev/null +++ b/apps/web/src/routes/plugins.$pluginId.$viewId.tsx @@ -0,0 +1,44 @@ +import { createFileRoute, useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { ArrowLeftIcon, PuzzleIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { PluginUiPage } from "../components/plugins/PluginUi"; +import { Button } from "../components/ui/button"; +import { SidebarInset } from "../components/ui/sidebar"; +import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; +import { isElectron } from "../env"; + +function PluginPageRoute() { + const { pluginId, viewId } = Route.useParams(); + const canGoBack = useCanGoBack(); + const navigate = useNavigate(); + const back = useCallback(() => { + if (canGoBack) window.history.back(); + else void navigate({ to: "/" }); + }, [canGoBack, navigate]); + + return ( + +
+ +
+ + + {pluginId} +
+
+
+
+ +
+
+
+
+ ); +} + +export const Route = createFileRoute("/plugins/$pluginId/$viewId")({ + component: PluginPageRoute, +}); diff --git a/examples/plugins/runtime-status/README.md b/examples/plugins/runtime-status/README.md index d295de9e15aa..e951c07867ee 100644 --- a/examples/plugins/runtime-status/README.md +++ b/examples/plugins/runtime-status/README.md @@ -1,6 +1,6 @@ # runtime status example plugin -this is the minimal trusted local plugin package used to prove the package lifecycle. plugins run in the server process with the server's full permissions, so only install code you trust. +this trusted local plugin proves package lifecycle, supervised execution, brokered host access, and host-rendered declarative UI. only install code you trust. copy this directory into the active environment's plugin directory: @@ -10,7 +10,9 @@ copy this directory into the active environment's plugin directory: for a normal local install, `` is usually `~/.t3`. development servers use the worktree's `.t3` directory, and an explicit `T3CODE_HOME` or `--base-dir` uses that configured directory. -t3 code discovers the package without a rebuild. use `pluginPackages.status` to inspect it and `pluginPackages.enable` with the manifest id to enable it for that environment. enabling a plugin grants the host permissions currently declared in its manifest. a reload that adds permissions is rejected until the plugin is disabled and enabled again. `pluginPackages.reload` re-evaluates the entrypoint, and `pluginPackages.disable` removes its contributions. once enabled, `example.runtime-status` appears in the web and desktop command palettes. +t3 code discovers the package without a rebuild. use `pluginPackages.status` to inspect it and `pluginPackages.enable` with the manifest id to enable it for that environment. enabling a plugin grants the host permissions currently declared in its manifest. a reload that adds permissions is rejected until the plugin is disabled and enabled again. `pluginPackages.reload` re-evaluates the entrypoint, and `pluginPackages.disable` removes its contributions. + +once enabled, the example adds a command, plugin settings, a navigation page, a card, a status item, composer and thread actions, and host-rendered notifications. mobile renders its card and status metadata without loading plugin renderer code. ## host capabilities and plugin-owned data @@ -21,6 +23,7 @@ manifest permissions are explicit, bounded grants: - `filesystem:data` exposes text files under the plugin's own data directory and rejects traversal and symlink escapes. - `network:https://host` allows text responses from one HTTPS origin. - `process:` allows one exact executable name with no shell, a minimal environment, timeout, and output limits. +- `notifications:send` allows bounded host-rendered notifications. plugin data lives under the active environment's `plugin-data//` directory. settings and state survive reloads and restarts. cache is separately clearable. secrets use the server secret store and never share names with another plugin. @@ -31,3 +34,5 @@ plugins run in supervised subprocesses with typed host transport, bounded protoc workers still run as the same OS user as the environment server. process isolation prevents a plugin crash or `process.exit()` from stopping t3 code, but it is not a hostile-code filesystem sandbox. only install code you trust. local packages are fully trusted. marketplace distribution, signing, OS-level sandboxing, and renderer code are not part of this mvp. + +declarative UI metadata contains no React, HTML, scripts, or arbitrary styling. T3 Code validates it, binds it to the committed plugin generation, and renders it with its own components. arbitrary interactive web content remains outside this kit. diff --git a/examples/plugins/runtime-status/index.mjs b/examples/plugins/runtime-status/index.mjs index 2a387328b331..9615024255fe 100644 --- a/examples/plugins/runtime-status/index.mjs +++ b/examples/plugins/runtime-status/index.mjs @@ -1,4 +1,83 @@ export default function activate(api) { + api.registerUi({ + settings: [ + { + id: "com.t3code.runtime-status-example.celebrations", + kind: "boolean", + label: "Celebrate successful checks", + description: "Show a host-rendered notification when the runtime check succeeds.", + defaultValue: true, + surfaces: ["web", "desktop", "mobile"], + }, + ], + navigation: [ + { + id: "com.t3code.runtime-status-example.navigation", + label: "Plugin status", + viewId: "com.t3code.runtime-status-example.dashboard", + surfaces: ["web", "desktop"], + }, + ], + views: [ + { + id: "com.t3code.runtime-status-example.dashboard", + label: "Plugin status", + description: "A declarative page rendered entirely by T3 Code.", + surfaces: ["web", "desktop"], + blocks: [ + { + kind: "text", + text: "The example plugin is active in its supervised worker.", + tone: "muted", + }, + { + kind: "action", + id: "com.t3code.runtime-status-example.check", + label: "Check runtime", + commandId: "example.runtime-status", + }, + ], + }, + ], + cards: [ + { + id: "com.t3code.runtime-status-example.card", + title: "Plugin worker", + description: "The external example package is active.", + value: "Ready", + tone: "success", + actionId: "com.t3code.runtime-status-example.composer", + surfaces: ["web", "desktop", "mobile"], + }, + ], + statusItems: [ + { + id: "com.t3code.runtime-status-example.status", + label: "Plugin runtime", + value: "Ready", + tone: "success", + surfaces: ["web", "desktop", "mobile"], + }, + ], + composerActions: [ + { + id: "com.t3code.runtime-status-example.composer", + label: "Check plugin runtime", + commandId: "example.runtime-status", + surfaces: ["web", "desktop", "mobile"], + }, + ], + contextualActions: [ + { + id: "com.t3code.runtime-status-example.context", + label: "Check runtime for this thread", + commandId: "example.runtime-status", + contexts: ["thread"], + surfaces: ["web", "desktop", "mobile"], + }, + ], + }); + api.registerCommand( { id: "example.runtime-status", @@ -6,9 +85,25 @@ export default function activate(api) { description: "report status from an external local plugin package.", surfaces: ["web", "desktop", "mobile"], }, - () => ({ - message: "external plugin runtime is active.", - tone: "success", - }), + (context) => + api.effect.flatMap( + api.host.settings.get("com.t3code.runtime-status-example.celebrations"), + (enabled) => { + const message = context?.threadId + ? `external plugin runtime is active for thread ${context.threadId}.` + : "external plugin runtime is active."; + const result = { message, tone: "success" }; + if (enabled === false) return api.effect.succeed(result); + return api.effect.flatMap( + api.host.ui.notify({ + id: "runtime-ready", + title: "Plugin runtime ready", + message, + tone: "success", + }), + () => api.effect.succeed(result), + ); + }, + ), ); } diff --git a/examples/plugins/runtime-status/t3-plugin.json b/examples/plugins/runtime-status/t3-plugin.json index 1a770791d620..9cddb0a7425c 100644 --- a/examples/plugins/runtime-status/t3-plugin.json +++ b/examples/plugins/runtime-status/t3-plugin.json @@ -1,13 +1,21 @@ { "manifestVersion": 1, "id": "com.t3code.runtime-status-example", - "version": "1.0.0", + "version": "1.1.0", "apiVersion": 1, "entrypoints": { "server": "./index.mjs" }, - "capabilities": ["t3.commands@1"], + "capabilities": ["t3.commands@1", "t3.ui@1"], + "permissions": ["settings:read-write", "notifications:send"], "contributes": { - "commands": ["example.runtime-status"] + "commands": ["example.runtime-status"], + "settings": ["com.t3code.runtime-status-example.celebrations"], + "navigation": ["com.t3code.runtime-status-example.navigation"], + "views": ["com.t3code.runtime-status-example.dashboard"], + "cards": ["com.t3code.runtime-status-example.card"], + "statusItems": ["com.t3code.runtime-status-example.status"], + "composerActions": ["com.t3code.runtime-status-example.composer"], + "contextualActions": ["com.t3code.runtime-status-example.context"] } } diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index f2588aad7901..2a840de00f26 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -51,6 +51,8 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeDiscoveredLocalServers | typeof WS_METHODS.subscribeResourceTelemetry | typeof WS_METHODS.subscribePluginCommands + | typeof WS_METHODS.subscribePluginUi + | typeof WS_METHODS.subscribePluginUiNotifications | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.terminalAttach; diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f2d76b5cf712..1ebce291c707 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -722,6 +722,16 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.subscribePluginCommands, idleTtlMs: 0, }), + pluginUi: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:plugin-ui", + tag: WS_METHODS.subscribePluginUi, + idleTtlMs: 0, + }), + pluginUiNotifications: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:plugin-ui-notifications", + tag: WS_METHODS.subscribePluginUiNotifications, + idleTtlMs: 0, + }), pluginPackages: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:plugin-packages", tag: WS_METHODS.pluginPackagesStatus, @@ -764,6 +774,19 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + readPluginUiSetting: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:read-plugin-ui-setting", + tag: WS_METHODS.pluginUiSettingGet, + concurrency: { mode: "parallel" }, + }), + writePluginUiSetting: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:write-plugin-ui-setting", + tag: WS_METHODS.pluginUiSettingSet, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => `${environmentId}:${input.pluginId}:${input.settingId}`, + }, + }), enablePluginPackage: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:enable-plugin-package", tag: WS_METHODS.pluginPackagesEnable, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index a5e5a24703eb..9ec0d4a6f119 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -21,6 +21,7 @@ export * from "./sourceControl.ts"; export * from "./pullRequest.ts"; export * from "./pluginCommands.ts"; export * from "./pluginPackages.ts"; +export * from "./pluginUi.ts"; export * from "./orchestration.ts"; export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; diff --git a/packages/contracts/src/pluginCommands.ts b/packages/contracts/src/pluginCommands.ts index 5d814be97b32..c4444ffd898f 100644 --- a/packages/contracts/src/pluginCommands.ts +++ b/packages/contracts/src/pluginCommands.ts @@ -22,9 +22,19 @@ export const PluginCommandCatalog = Schema.Struct({ }); export type PluginCommandCatalog = typeof PluginCommandCatalog.Type; +export const PluginCommandInvocationContext = Schema.Struct({ + threadId: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(255))), + projectId: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(255))), + viewId: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(200))), + cardId: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(200))), + filePath: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(1_000))), +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +export type PluginCommandInvocationContext = typeof PluginCommandInvocationContext.Type; + export const PluginCommandInvokeInput = Schema.Struct({ generation: NonNegativeInt, id: PluginCommandId, + context: Schema.optional(PluginCommandInvocationContext), }); export type PluginCommandInvokeInput = typeof PluginCommandInvokeInput.Type; diff --git a/packages/contracts/src/pluginPackages.test.ts b/packages/contracts/src/pluginPackages.test.ts index b6fa06bad143..9430eb15fd7c 100644 --- a/packages/contracts/src/pluginPackages.test.ts +++ b/packages/contracts/src/pluginPackages.test.ts @@ -28,7 +28,16 @@ describe("plugin package contracts", () => { capabilities: ["t3.commands@1"], permissions: ["state:read-write", "network:https://api.acme.test"], grantedPermissions: ["state:read-write"], - contributions: { commands: ["acme.runtime-status"] }, + contributions: { + commands: ["acme.runtime-status"], + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, }, ], }), @@ -46,7 +55,16 @@ describe("plugin package contracts", () => { capabilities: ["t3.commands@1"], permissions: ["state:read-write", "network:https://api.acme.test"], grantedPermissions: ["state:read-write"], - contributions: { commands: ["acme.runtime-status"] }, + contributions: { + commands: ["acme.runtime-status"], + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, }, ], }); @@ -80,7 +98,16 @@ describe("plugin package contracts", () => { capabilities: ["t3.commands@1"], permissions: [], grantedPermissions: [], - contributions: { commands: ["acme.issues.create"] }, + contributions: { + commands: ["acme.issues.create"], + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, error: "Missing dependency: acme.database@1", }, ], @@ -116,7 +143,16 @@ describe("plugin package contracts", () => { capabilities: [], permissions: [permission], grantedPermissions: [], - contributions: { commands: [] }, + contributions: { + commands: [], + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, }, ], }), diff --git a/packages/contracts/src/pluginPackages.ts b/packages/contracts/src/pluginPackages.ts index b99e175df39a..8941cacdf7f7 100644 --- a/packages/contracts/src/pluginPackages.ts +++ b/packages/contracts/src/pluginPackages.ts @@ -9,6 +9,12 @@ export const PluginPackageId = Schema.String.check( ); export type PluginPackageId = typeof PluginPackageId.Type; +export const PluginUiId = Schema.String.check( + Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/), + Schema.isMaxLength(200), +); +export type PluginUiId = typeof PluginUiId.Type; + export const PluginPackageCapability = Schema.String.check( Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@[1-9]\d*$/), ); @@ -20,6 +26,7 @@ export const PluginHostPermission = Schema.Union([ "state:read-write", "cache:read-write", "filesystem:data", + "notifications:send", ]), Schema.String.check( Schema.isPattern(/^secrets:[a-z0-9][a-z0-9._-]{0,127}$/), @@ -59,6 +66,13 @@ export type PluginPackageRuntimeState = typeof PluginPackageRuntimeState.Type; export const PluginPackageContributions = Schema.Struct({ commands: Schema.Array(PluginCommandId), + settings: Schema.Array(PluginUiId), + navigation: Schema.Array(PluginUiId), + views: Schema.Array(PluginUiId), + cards: Schema.Array(PluginUiId), + statusItems: Schema.Array(PluginUiId), + composerActions: Schema.Array(PluginUiId), + contextualActions: Schema.Array(PluginUiId), }); export type PluginPackageContributions = typeof PluginPackageContributions.Type; diff --git a/packages/contracts/src/pluginUi.test.ts b/packages/contracts/src/pluginUi.test.ts new file mode 100644 index 000000000000..d0c40e4f8313 --- /dev/null +++ b/packages/contracts/src/pluginUi.test.ts @@ -0,0 +1,184 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + PluginUiCatalog, + PluginUiContribution, + PluginUiNotification, + PluginUiSettingWriteInput, +} from "./pluginUi.ts"; +import { WS_METHODS, WsRpcGroup } from "./rpc.ts"; + +const decodeContribution = Schema.decodeUnknownSync(PluginUiContribution); +const decodeCatalog = Schema.decodeUnknownSync(PluginUiCatalog); + +const completeContribution = { + settings: [ + { + id: "com.acme.fun.enabled", + kind: "boolean", + label: "Enable fun mode", + defaultValue: true, + surfaces: ["web", "desktop", "mobile"], + }, + { + id: "com.acme.fun.theme", + kind: "select", + label: "Theme", + defaultValue: "arcade", + options: [ + { label: "Arcade", value: "arcade" }, + { label: "Calm", value: "calm" }, + ], + surfaces: ["web", "desktop"], + }, + ], + navigation: [ + { + id: "com.acme.fun.navigation", + label: "Fun room", + viewId: "com.acme.fun.dashboard", + surfaces: ["web", "desktop"], + }, + ], + views: [ + { + id: "com.acme.fun.dashboard", + label: "Fun room", + description: "A host-rendered plugin page.", + surfaces: ["web", "desktop"], + blocks: [ + { kind: "text", text: "Welcome", tone: "muted" }, + { + kind: "action", + id: "com.acme.fun.play", + label: "Play", + commandId: "com.acme.fun.play", + }, + ], + }, + ], + cards: [ + { + id: "com.acme.fun.score", + title: "Score", + value: "42", + tone: "success", + surfaces: ["web", "desktop", "mobile"], + }, + ], + statusItems: [ + { + id: "com.acme.fun.status", + label: "Arcade", + value: "Ready", + tone: "success", + surfaces: ["web", "desktop", "mobile"], + }, + ], + composerActions: [ + { + id: "com.acme.fun.composer", + label: "Add challenge", + commandId: "com.acme.fun.challenge", + surfaces: ["web", "desktop", "mobile"], + }, + ], + contextualActions: [ + { + id: "com.acme.fun.context", + label: "Celebrate thread", + commandId: "com.acme.fun.celebrate", + contexts: ["thread"], + surfaces: ["web", "desktop", "mobile"], + }, + ], +} as const; + +describe("PluginUi", () => { + it("decodes detached bounded host-rendered contribution metadata", () => { + expect(decodeContribution(completeContribution)).toEqual(completeContribution); + expect( + decodeCatalog({ + generation: 7, + packages: [{ pluginId: "com.acme.fun", ...completeContribution }], + }), + ).toMatchObject({ generation: 7, packages: [{ pluginId: "com.acme.fun" }] }); + }); + + it("rejects executable, oversized, malformed, and excess contribution metadata", () => { + for (const input of [ + { ...completeContribution, execute: () => {} }, + { ...completeContribution, cards: [{ id: "bad", title: "Bad", surfaces: ["web"] }] }, + { + ...completeContribution, + views: [ + { + id: "com.acme.fun.bad", + label: "Bad", + surfaces: ["web"], + blocks: [{ kind: "text", text: "x".repeat(2_001) }], + }, + ], + }, + { + ...completeContribution, + settings: [ + { + id: "com.acme.fun.bad", + kind: "select", + label: "Bad", + defaultValue: "missing", + options: [{ label: "Only", value: "only" }], + surfaces: ["web"], + }, + ], + }, + ]) { + expect(() => decodeContribution(input)).toThrow(); + } + }); + + it("bounds notification and setting write payloads", () => { + const decodeNotification = Schema.decodeUnknownSync(PluginUiNotification); + const decodeSettingWrite = Schema.decodeUnknownSync(PluginUiSettingWriteInput); + + expect( + decodeNotification({ + id: "notification-1", + pluginId: "com.acme.fun", + title: "Challenge complete", + message: "Nice work.", + tone: "success", + }), + ).toMatchObject({ pluginId: "com.acme.fun", tone: "success" }); + expect( + decodeSettingWrite({ + pluginId: "com.acme.fun", + settingId: "com.acme.fun.enabled", + value: true, + }), + ).toMatchObject({ value: true }); + expect(() => + decodeNotification({ + id: "notification-1", + pluginId: "com.acme.fun", + title: "x".repeat(121), + message: "nope", + tone: "info", + }), + ).toThrow(); + }); + + it("registers fixed ui, setting, notification, and subscription rpc methods", () => { + for (const method of [ + WS_METHODS.pluginUiList, + WS_METHODS.pluginUiSettingGet, + WS_METHODS.pluginUiSettingSet, + WS_METHODS.subscribePluginUi, + WS_METHODS.subscribePluginUiNotifications, + ]) { + expect(WsRpcGroup.requests.has(method)).toBe(true); + } + }); +}); diff --git a/packages/contracts/src/pluginUi.ts b/packages/contracts/src/pluginUi.ts new file mode 100644 index 000000000000..0bc91918bf0b --- /dev/null +++ b/packages/contracts/src/pluginUi.ts @@ -0,0 +1,308 @@ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PluginCommandId, PluginCommandSurface } from "./pluginCommands.ts"; +import { PluginPackageId, PluginUiId } from "./pluginPackages.ts"; + +const strict = (schema: S) => + schema.annotate({ parseOptions: { onExcessProperty: "error" } }); + +const ShortLabel = TrimmedNonEmptyString.check(Schema.isMaxLength(120)); +const Description = TrimmedNonEmptyString.check(Schema.isMaxLength(500)); +const DisplayText = TrimmedNonEmptyString.check(Schema.isMaxLength(2_000)); +const SettingText = Schema.String.check(Schema.isMaxLength(2_000)); + +export const PluginUiTone = Schema.Literals([ + "neutral", + "muted", + "info", + "success", + "warning", + "danger", +]); +export type PluginUiTone = typeof PluginUiTone.Type; + +const Surfaces = Schema.Array(PluginCommandSurface).check( + Schema.isMinLength(1), + Schema.isMaxLength(3), +); + +const SettingBase = { + id: PluginUiId, + label: ShortLabel, + description: Schema.optional(Description), + surfaces: Surfaces, +} as const; + +export const PluginUiBooleanSetting = strict( + Schema.Struct({ + ...SettingBase, + kind: Schema.Literal("boolean"), + defaultValue: Schema.Boolean, + }), +); + +export const PluginUiTextSetting = strict( + Schema.Struct({ + ...SettingBase, + kind: Schema.Literal("text"), + defaultValue: SettingText, + placeholder: Schema.optional(ShortLabel), + }), +); + +const SelectOption = strict( + Schema.Struct({ + label: ShortLabel, + value: TrimmedNonEmptyString.check(Schema.isMaxLength(120)), + }), +); + +export const PluginUiSelectSetting = strict( + strict( + Schema.Struct({ + ...SettingBase, + kind: Schema.Literal("select"), + defaultValue: TrimmedNonEmptyString.check(Schema.isMaxLength(120)), + options: Schema.Array(SelectOption).check(Schema.isMinLength(1), Schema.isMaxLength(50)), + }), + ).check( + Schema.makeFilter( + (setting) => + setting.options.some((option) => option.value === setting.defaultValue) || + "select defaultValue must name an option", + ), + ), +); + +export const PluginUiSetting = Schema.Union([ + PluginUiBooleanSetting, + PluginUiTextSetting, + PluginUiSelectSetting, +]); +export type PluginUiSetting = typeof PluginUiSetting.Type; + +export const PluginUiAction = strict( + Schema.Struct({ + id: PluginUiId, + label: ShortLabel, + description: Schema.optional(Description), + commandId: PluginCommandId, + surfaces: Surfaces, + }), +); +export type PluginUiAction = typeof PluginUiAction.Type; + +export const PluginUiContextualAction = strict( + Schema.Struct({ + id: PluginUiId, + label: ShortLabel, + description: Schema.optional(Description), + commandId: PluginCommandId, + contexts: Schema.Array(Schema.Literals(["thread", "project", "file", "diff"])).check( + Schema.isMinLength(1), + Schema.isMaxLength(4), + ), + surfaces: Surfaces, + }), +); +export type PluginUiContextualAction = typeof PluginUiContextualAction.Type; + +export const PluginUiCard = strict( + Schema.Struct({ + id: PluginUiId, + title: ShortLabel, + description: Schema.optional(Description), + value: Schema.optional(DisplayText), + tone: Schema.optional(PluginUiTone), + actionId: Schema.optional(PluginUiId), + surfaces: Surfaces, + }), +); +export type PluginUiCard = typeof PluginUiCard.Type; + +export const PluginUiStatusItem = strict( + Schema.Struct({ + id: PluginUiId, + label: ShortLabel, + value: DisplayText, + tone: Schema.optional(PluginUiTone), + surfaces: Surfaces, + }), +); +export type PluginUiStatusItem = typeof PluginUiStatusItem.Type; + +const TextBlock = strict( + Schema.Struct({ + kind: Schema.Literal("text"), + text: DisplayText, + tone: Schema.optional(PluginUiTone), + }), +); +const ActionBlock = strict( + Schema.Struct({ + kind: Schema.Literal("action"), + id: PluginUiId, + label: ShortLabel, + description: Schema.optional(Description), + commandId: PluginCommandId, + }), +); +const CardBlock = strict( + Schema.Struct({ + kind: Schema.Literal("card"), + id: PluginUiId, + title: ShortLabel, + description: Schema.optional(Description), + value: Schema.optional(DisplayText), + tone: Schema.optional(PluginUiTone), + commandId: Schema.optional(PluginCommandId), + }), +); +const StatusBlock = strict( + Schema.Struct({ + kind: Schema.Literal("status"), + id: PluginUiId, + label: ShortLabel, + value: DisplayText, + tone: Schema.optional(PluginUiTone), + }), +); + +export const PluginUiBlock = Schema.Union([TextBlock, ActionBlock, CardBlock, StatusBlock]); +export type PluginUiBlock = typeof PluginUiBlock.Type; + +export const PluginUiView = strict( + Schema.Struct({ + id: PluginUiId, + label: ShortLabel, + description: Schema.optional(Description), + surfaces: Surfaces, + blocks: Schema.Array(PluginUiBlock).check(Schema.isMaxLength(100)), + }), +); +export type PluginUiView = typeof PluginUiView.Type; + +export const PluginUiNavigationItem = strict( + Schema.Struct({ + id: PluginUiId, + label: ShortLabel, + viewId: PluginUiId, + surfaces: Surfaces, + }), +); +export type PluginUiNavigationItem = typeof PluginUiNavigationItem.Type; + +const bounded = (schema: S) => + Schema.Array(schema).check(Schema.isMaxLength(100)); +const uniqueIds = (items: ReadonlyArray<{ readonly id: string }>) => + new Set(items.map((item) => item.id)).size === items.length; + +export const PluginUiContribution = strict( + strict( + Schema.Struct({ + settings: bounded(PluginUiSetting), + navigation: bounded(PluginUiNavigationItem), + views: bounded(PluginUiView), + cards: bounded(PluginUiCard), + statusItems: bounded(PluginUiStatusItem), + composerActions: bounded(PluginUiAction), + contextualActions: bounded(PluginUiContextualAction), + }), + ).check( + Schema.makeFilter( + (input) => + [ + input.settings, + input.navigation, + input.views, + input.cards, + input.statusItems, + input.composerActions, + input.contextualActions, + ].every(uniqueIds) || "plugin ui contribution ids must be unique within each slot", + ), + Schema.makeFilter((input) => { + const viewIds = new Set(input.views.map((view) => view.id)); + return ( + input.navigation.every((item) => viewIds.has(item.viewId)) || + "plugin navigation must reference a contributed view" + ); + }), + ), +); +export type PluginUiContribution = typeof PluginUiContribution.Type; + +export const PluginUiPackageContribution = strict( + Schema.Struct({ + pluginId: PluginPackageId, + settings: bounded(PluginUiSetting), + navigation: bounded(PluginUiNavigationItem), + views: bounded(PluginUiView), + cards: bounded(PluginUiCard), + statusItems: bounded(PluginUiStatusItem), + composerActions: bounded(PluginUiAction), + contextualActions: bounded(PluginUiContextualAction), + }), +); +export type PluginUiPackageContribution = typeof PluginUiPackageContribution.Type; + +export const PluginUiCatalog = strict( + Schema.Struct({ + generation: NonNegativeInt, + packages: Schema.Array(PluginUiPackageContribution).check(Schema.isMaxLength(1_000)), + }), +); +export type PluginUiCatalog = typeof PluginUiCatalog.Type; + +export const PluginUiNotification = strict( + Schema.Struct({ + id: TrimmedNonEmptyString.check(Schema.isMaxLength(64)), + pluginId: PluginPackageId, + title: ShortLabel, + message: DisplayText, + tone: Schema.Literals(["info", "success", "warning", "error"]), + commandId: Schema.optional(PluginCommandId), + }), +); +export type PluginUiNotification = typeof PluginUiNotification.Type; + +export const PluginUiNotificationInput = strict( + Schema.Struct({ + id: TrimmedNonEmptyString.check(Schema.isMaxLength(64)), + title: ShortLabel, + message: DisplayText, + tone: Schema.Literals(["info", "success", "warning", "error"]), + commandId: Schema.optional(PluginCommandId), + }), +); +export type PluginUiNotificationInput = typeof PluginUiNotificationInput.Type; + +export const PluginUiSettingReadInput = strict( + Schema.Struct({ pluginId: PluginPackageId, settingId: PluginUiId }), +); +export type PluginUiSettingReadInput = typeof PluginUiSettingReadInput.Type; + +export const PluginUiSettingReadResult = strict( + Schema.Struct({ value: Schema.optional(Schema.Json) }), +); +export type PluginUiSettingReadResult = typeof PluginUiSettingReadResult.Type; + +export const PluginUiSettingWriteInput = strict( + Schema.Struct({ pluginId: PluginPackageId, settingId: PluginUiId, value: Schema.Json }), +); +export type PluginUiSettingWriteInput = typeof PluginUiSettingWriteInput.Type; + +export class PluginUiSettingError extends Schema.TaggedErrorClass()( + "PluginUiSettingError", + { + pluginId: PluginPackageId, + settingId: PluginUiId, + detail: TrimmedNonEmptyString.check(Schema.isMaxLength(2_000)), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Plugin setting ${this.settingId} failed for ${this.pluginId}: ${this.detail}`; + } +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f63638f52ac4..9002779456bb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -86,6 +86,14 @@ import { PluginPackageOperationError, PluginPackageStatusSnapshot, } from "./pluginPackages.ts"; +import { + PluginUiCatalog, + PluginUiNotification, + PluginUiSettingError, + PluginUiSettingReadInput, + PluginUiSettingReadResult, + PluginUiSettingWriteInput, +} from "./pluginUi.ts"; import { PullRequestActionInput, PullRequestActivity, @@ -300,6 +308,11 @@ export const WS_METHODS = { pluginCommandsList: "pluginCommands.list", pluginCommandsInvoke: "pluginCommands.invoke", + // Plugin declarative UI methods + pluginUiList: "pluginUi.list", + pluginUiSettingGet: "pluginUi.setting.get", + pluginUiSettingSet: "pluginUi.setting.set", + // Plugin package lifecycle methods pluginPackagesStatus: "pluginPackages.status", pluginPackagesEnable: "pluginPackages.enable", @@ -346,6 +359,8 @@ export const WS_METHODS = { subscribeBackgroundPolicy: "subscribeBackgroundPolicy", subscribeResourceTelemetry: "subscribeResourceTelemetry", subscribePluginCommands: "subscribePluginCommands", + subscribePluginUi: "subscribePluginUi", + subscribePluginUiNotifications: "subscribePluginUiNotifications", } as const; export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -389,6 +404,24 @@ export const WsPluginCommandsInvokeRpc = Rpc.make(WS_METHODS.pluginCommandsInvok ]), }); +export const WsPluginUiListRpc = Rpc.make(WS_METHODS.pluginUiList, { + payload: Schema.Struct({}), + success: PluginUiCatalog, + error: EnvironmentAuthorizationError, +}); + +export const WsPluginUiSettingGetRpc = Rpc.make(WS_METHODS.pluginUiSettingGet, { + payload: PluginUiSettingReadInput, + success: PluginUiSettingReadResult, + error: Schema.Union([PluginUiSettingError, EnvironmentAuthorizationError]), +}); + +export const WsPluginUiSettingSetRpc = Rpc.make(WS_METHODS.pluginUiSettingSet, { + payload: PluginUiSettingWriteInput, + success: Schema.Void, + error: Schema.Union([PluginUiSettingError, EnvironmentAuthorizationError]), +}); + export const WsPluginPackagesStatusRpc = Rpc.make(WS_METHODS.pluginPackagesStatus, { payload: Schema.Struct({}), success: PluginPackageStatusSnapshot, @@ -1066,11 +1099,31 @@ export const WsSubscribePluginCommandsRpc = Rpc.make(WS_METHODS.subscribePluginC stream: true, }); +export const WsSubscribePluginUiRpc = Rpc.make(WS_METHODS.subscribePluginUi, { + payload: Schema.Struct({}), + success: PluginUiCatalog, + error: EnvironmentAuthorizationError, + stream: true, +}); + +export const WsSubscribePluginUiNotificationsRpc = Rpc.make( + WS_METHODS.subscribePluginUiNotifications, + { + payload: Schema.Struct({}), + success: PluginUiNotification, + error: EnvironmentAuthorizationError, + stream: true, + }, +); + export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, WsPluginCommandsListRpc, WsPluginCommandsInvokeRpc, + WsPluginUiListRpc, + WsPluginUiSettingGetRpc, + WsPluginUiSettingSetRpc, WsPluginPackagesStatusRpc, WsPluginPackagesEnableRpc, WsPluginPackagesDisableRpc, @@ -1166,6 +1219,8 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeBackgroundPolicyRpc, WsSubscribeResourceTelemetryRpc, WsSubscribePluginCommandsRpc, + WsSubscribePluginUiRpc, + WsSubscribePluginUiNotificationsRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetWorkflowScriptRpc, WsOrchestrationGetTurnDiffRpc, diff --git a/packages/plugin-runtime/src/manifest.ts b/packages/plugin-runtime/src/manifest.ts index 3f7c31854ef3..54f34a4687be 100644 --- a/packages/plugin-runtime/src/manifest.ts +++ b/packages/plugin-runtime/src/manifest.ts @@ -27,6 +27,7 @@ const Permission = Schema.Union([ "state:read-write", "cache:read-write", "filesystem:data", + "notifications:send", ]), Schema.String.check( Schema.isPattern(/^secrets:[a-z0-9][a-z0-9._-]{0,127}$/), @@ -47,7 +48,12 @@ const Permission = Schema.Union([ const ContributionCatalog = Schema.Struct({ commands: Schema.optional(Schema.Array(CommandId)), settings: Schema.optional(Schema.Array(NamespacedId)), + navigation: Schema.optional(Schema.Array(NamespacedId)), views: Schema.optional(Schema.Array(NamespacedId)), + cards: Schema.optional(Schema.Array(NamespacedId)), + statusItems: Schema.optional(Schema.Array(NamespacedId)), + composerActions: Schema.optional(Schema.Array(NamespacedId)), + contextualActions: Schema.optional(Schema.Array(NamespacedId)), mobileCards: Schema.optional(Schema.Array(NamespacedId)), }); diff --git a/packages/plugin-runtime/test/manifest.test.ts b/packages/plugin-runtime/test/manifest.test.ts index 0a90d1282cba..a78b16a0c3c4 100644 --- a/packages/plugin-runtime/test/manifest.test.ts +++ b/packages/plugin-runtime/test/manifest.test.ts @@ -17,11 +17,16 @@ const validManifest = { capabilities: ["t3.commands@1"], requires: ["t3.commands@1", "t3.secrets@1"], provides: ["com.acme.linear@1"], - permissions: ["network:https://api.linear.app", "secrets:linear-token"], + permissions: ["network:https://api.linear.app", "secrets:linear-token", "notifications:send"], contributes: { commands: ["linear.create-issue"], settings: ["linear.settings"], - views: ["thread.right-panel"], + navigation: ["linear.navigation"], + views: ["linear.right-panel"], + cards: ["linear.summary"], + statusItems: ["linear.status"], + composerActions: ["linear.create-from-composer"], + contextualActions: ["linear.create-from-thread"], }, }; From a994f5ef9fd790f87c648907db3dc31d4c6c15df Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:45:30 +0000 Subject: [PATCH 39/45] fix: address declarative ui review Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/plugins/PluginCommandCatalog.test.ts | 5 +- .../src/plugins/PluginCommandCatalog.ts | 46 ++++++--- .../src/plugins/PluginPackageManager.ts | 70 ++++++++------ apps/web/src/components/plugins/PluginUi.tsx | 94 ++++++++++++------- .../src/components/sidebar/SidebarChrome.tsx | 8 +- 5 files changed, 146 insertions(+), 77 deletions(-) diff --git a/apps/server/src/plugins/PluginCommandCatalog.test.ts b/apps/server/src/plugins/PluginCommandCatalog.test.ts index 6bfec692ab26..ab60d88998c7 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.test.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.test.ts @@ -139,7 +139,10 @@ describe("plugin command catalog", () => { tone: "info", }), ); - expect(rateLimited.detail).toBe("notification rate limit exceeded"); + expect(rateLimited._tag).toBe("PluginUiNotificationRateLimitError"); + if (rateLimited._tag === "PluginUiNotificationRateLimitError") { + expect(rateLimited.windowMillis).toBe(250); + } }).pipe(Effect.provide(PluginCommandCatalog.layer)), ); diff --git a/apps/server/src/plugins/PluginCommandCatalog.ts b/apps/server/src/plugins/PluginCommandCatalog.ts index 72822a4219f0..23ea67b51fdd 100644 --- a/apps/server/src/plugins/PluginCommandCatalog.ts +++ b/apps/server/src/plugins/PluginCommandCatalog.ts @@ -136,15 +136,38 @@ export class PluginUiDefinitionError extends Schema.TaggedErrorClass()( - "PluginUiNotificationError", - { cause: Schema.optional(Schema.Defect()), pluginId: Schema.String, detail: Schema.String }, +export class PluginUiNotificationInactiveError extends Schema.TaggedErrorClass()( + "PluginUiNotificationInactiveError", + { pluginId: Schema.String }, ) { override get message(): string { - return `Plugin notification failed for ${this.pluginId}: ${this.detail}`; + return `Plugin ${this.pluginId} is not active.`; } } +export class PluginUiNotificationRateLimitError extends Schema.TaggedErrorClass()( + "PluginUiNotificationRateLimitError", + { pluginId: Schema.String, windowMillis: Schema.Int }, +) { + override get message(): string { + return `Plugin ${this.pluginId} exceeded the ${this.windowMillis}ms notification window.`; + } +} + +export class PluginUiNotificationDecodeError extends Schema.TaggedErrorClass()( + "PluginUiNotificationDecodeError", + { cause: Schema.Defect(), pluginId: Schema.String, notificationId: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} sent invalid notification ${this.notificationId}.`; + } +} + +type PluginUiNotificationError = + | PluginUiNotificationInactiveError + | PluginUiNotificationRateLimitError + | PluginUiNotificationDecodeError; + const builtInPlugin: PluginDefinition = { id: "t3.plugin-runtime.commands", version: "1.0.0", @@ -284,23 +307,24 @@ export const make = Effect.gen(function* () { ) { const snapshot = yield* runtime.snapshot; if (!snapshot.active.includes(pluginId)) { - return yield* new PluginUiNotificationError({ - pluginId, - detail: "plugin is not active", - }); + return yield* new PluginUiNotificationInactiveError({ pluginId }); } const now = yield* Clock.currentTimeMillis; const previous = lastNotificationAt.get(pluginId); if (previous !== undefined && now - previous < 250) { - return yield* new PluginUiNotificationError({ + return yield* new PluginUiNotificationRateLimitError({ pluginId, - detail: "notification rate limit exceeded", + windowMillis: 250, }); } const decoded = yield* decodePluginUiNotification({ ...notification, pluginId }).pipe( Effect.mapError( (cause) => - new PluginUiNotificationError({ pluginId, detail: "invalid notification", cause }), + new PluginUiNotificationDecodeError({ + pluginId, + notificationId: notification.id, + cause, + }), ), ); lastNotificationAt.set(pluginId, now); diff --git a/apps/server/src/plugins/PluginPackageManager.ts b/apps/server/src/plugins/PluginPackageManager.ts index af55a58fd20a..67eb68d6626f 100644 --- a/apps/server/src/plugins/PluginPackageManager.ts +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -452,7 +452,7 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { new PluginHostCapabilityBroker.PluginHostCapabilityError({ pluginId: discovered.manifest.id, operation: "notification send", - detail: cause.detail, + detail: "notification was rejected by the host", cause, }), ), @@ -898,26 +898,28 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { ); } - const settingError = (pluginId: string, settingId: string, detail: string, cause?: unknown) => - new PluginUiSettingError({ - pluginId, - settingId, - detail, - ...(cause === undefined ? {} : { cause }), - }); - const resolveSetting = Effect.fn("PluginPackageManager.resolveSetting")(function* ( pluginId: string, settingId: string, ) { const host = activeHosts.get(pluginId); - if (host === undefined) return yield* settingError(pluginId, settingId, "plugin is not active"); + if (host === undefined) { + return yield* new PluginUiSettingError({ + pluginId, + settingId, + detail: "plugin is not active", + }); + } const ui = yield* catalog.ui; const setting = ui.packages .find((pluginPackage) => pluginPackage.pluginId === pluginId) ?.settings.find((candidate) => candidate.id === settingId); if (setting === undefined) { - return yield* settingError(pluginId, settingId, "setting is not declared"); + return yield* new PluginUiSettingError({ + pluginId, + settingId, + detail: "setting is not declared", + }); } return { host, setting }; }); @@ -939,13 +941,17 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { semaphore.withPermits(1)( Effect.gen(function* () { const { host } = yield* resolveSetting(pluginId, settingId); - const value = yield* host.settings - .get(settingId) - .pipe( - Effect.mapError((cause) => - settingError(pluginId, settingId, detailFromUnknown(cause), cause), - ), - ); + const value = yield* host.settings.get(settingId).pipe( + Effect.mapError( + (cause) => + new PluginUiSettingError({ + pluginId, + settingId, + detail: "settings store read failed", + cause, + }), + ), + ); return value as Schema.Json | undefined; }), ); @@ -955,15 +961,23 @@ export const make = Effect.fn("PluginPackageManager.make")(function* () { Effect.gen(function* () { const { host, setting } = yield* resolveSetting(pluginId, settingId); if (!valueMatchesSetting(setting, value)) { - return yield* settingError(pluginId, settingId, "value does not match setting schema"); + return yield* new PluginUiSettingError({ + pluginId, + settingId, + detail: "value does not match setting schema", + }); } - yield* host.settings - .set(settingId, value) - .pipe( - Effect.mapError((cause) => - settingError(pluginId, settingId, detailFromUnknown(cause), cause), - ), - ); + yield* host.settings.set(settingId, value).pipe( + Effect.mapError( + (cause) => + new PluginUiSettingError({ + pluginId, + settingId, + detail: "settings store write failed", + cause, + }), + ), + ); }), ); @@ -1011,7 +1025,7 @@ const unavailableService = (error: PluginPackageOperationError) => new PluginUiSettingError({ pluginId, settingId, - detail: error.message, + detail: "plugin package manager is unavailable", cause: error, }), ), @@ -1020,7 +1034,7 @@ const unavailableService = (error: PluginPackageOperationError) => new PluginUiSettingError({ pluginId, settingId, - detail: error.message, + detail: "plugin package manager is unavailable", cause: error, }), ), diff --git a/apps/web/src/components/plugins/PluginUi.tsx b/apps/web/src/components/plugins/PluginUi.tsx index 651123c3eaa9..fa4ca15d1ad8 100644 --- a/apps/web/src/components/plugins/PluginUi.tsx +++ b/apps/web/src/components/plugins/PluginUi.tsx @@ -18,16 +18,19 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { PuzzleIcon, SparklesIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; import { SidebarMenuItem, SidebarMenuButton } from "../ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingsRow, SettingsSection } from "../settings/settingsLayout"; const EMPTY_PLUGIN_UI_CATALOG: PluginUiCatalog = Object.freeze({ @@ -50,10 +53,10 @@ const surface = (): "web" | "desktop" => const toneClass = { neutral: "border-border bg-card text-card-foreground", muted: "border-border/60 bg-muted/35 text-muted-foreground", - info: "border-info/30 bg-info/10 text-info", - success: "border-success/30 bg-success/10 text-success", - warning: "border-warning/30 bg-warning/10 text-warning", - danger: "border-destructive/30 bg-destructive/10 text-destructive", + info: "border-info/30 bg-info/10 text-info-foreground", + success: "border-success/30 bg-success/10 text-success-foreground", + warning: "border-warning/30 bg-warning/10 text-warning-foreground", + danger: "border-destructive/30 bg-destructive/10 text-destructive-foreground", } as const; export function usePluginUiCatalog(environmentId: EnvironmentId | null): PluginUiCatalog { @@ -126,6 +129,7 @@ export function PluginUiNavigationItems({ closeMobile }: { readonly closeMobile: const environmentId = usePrimaryEnvironmentId(); const catalog = usePluginUiCatalog(environmentId); const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); const currentSurface = surface(); const items = catalog.packages.flatMap((pluginPackage) => pluginPackage.navigation @@ -134,20 +138,28 @@ export function PluginUiNavigationItems({ closeMobile }: { readonly closeMobile: ); return items.map(({ item, pluginId }) => ( - - { - closeMobile(); - void navigate({ - to: "/plugins/$pluginId/$viewId", - params: { pluginId, viewId: item.viewId }, - }); - }} - > - - {item.label} - + + + { + closeMobile(); + void navigate({ + to: "/plugins/$pluginId/$viewId", + params: { pluginId, viewId: item.viewId }, + }); + }} + > + + + } + /> + {item.label} + )); } @@ -311,6 +323,7 @@ function PluginUiSettingControl({ const read = useAtomCommand(serverEnvironment.readPluginUiSetting, { reportFailure: false }); const write = useAtomCommand(serverEnvironment.writePluginUiSetting, { reportFailure: false }); const [value, setValue] = useState(setting.defaultValue); + const [committedValue, setCommittedValue] = useState(setting.defaultValue); const [busy, setBusy] = useState(false); useEffect(() => { @@ -319,6 +332,7 @@ function PluginUiSettingControl({ if (cancelled || result._tag !== "Success" || result.value.value === undefined) return; if (typeof result.value.value === "boolean" || typeof result.value.value === "string") { setValue(result.value.value); + setCommittedValue(result.value.value); } }); return () => { @@ -327,6 +341,7 @@ function PluginUiSettingControl({ }, [environmentId, pluginId, read, setting.id]); const update = async (next: boolean | string) => { + const previous = committedValue; setValue(next); setBusy(true); const result = await write({ @@ -334,7 +349,12 @@ function PluginUiSettingControl({ input: { pluginId, settingId: setting.id, value: next }, }); setBusy(false); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + if (result._tag === "Success") { + setCommittedValue(next); + return; + } + setValue(previous); + if (!isAtomCommandInterrupted(result)) { const failure = squashAtomCommandFailure(result); toastManager.add({ type: "error", @@ -352,21 +372,28 @@ function PluginUiSettingControl({ onCheckedChange={(checked) => void update(checked)} /> ) : setting.kind === "select" ? ( - + + + + + {setting.options.map((option) => ( + + {option.label} + + ))} + + ) : ( - pluginPackage.contextualActions.filter( - (action) => action.surfaces.includes(currentSurface) && action.contexts.includes("thread"), + (action) => + context.threadId !== undefined && + action.surfaces.includes(currentSurface) && + action.contexts.includes("thread"), ), ); const statuses = catalog.packages.flatMap((pluginPackage) => diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index d6b15539a454..1b39b5094a9b 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -154,11 +154,9 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { ? "settings" : location.pathname === "/usage" ? "usage" - : location.pathname.startsWith("/plugins/") - ? "plugin" - : location.pathname === "/pull-requests" - ? "pull-requests" - : null, + : location.pathname === "/pull-requests" + ? "pull-requests" + : null, }); const { environments } = useEnvironments(); // The page reads every connected server, so one of them offering pull requests is enough for From e9a598fb75eea573931a41deb935a83c4d894601 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:53:56 +0000 Subject: [PATCH 40/45] fix: harden declarative ui boundaries Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/plugins/PluginWorkerRuntime.mjs | 8 ++++- apps/web/src/components/plugins/PluginUi.tsx | 32 ++++++++++++++----- packages/contracts/src/pluginPackages.ts | 2 +- packages/contracts/src/pluginUi.test.ts | 7 ++++ 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/server/src/plugins/PluginWorkerRuntime.mjs b/apps/server/src/plugins/PluginWorkerRuntime.mjs index 07e961a425d8..47549b82251f 100644 --- a/apps/server/src/plugins/PluginWorkerRuntime.mjs +++ b/apps/server/src/plugins/PluginWorkerRuntime.mjs @@ -110,7 +110,13 @@ const hostCall = (operation, fields) => reject(error); }, }); - write({ type: "hostCall", callId, operation, ...fields }); + try { + write({ type: "hostCall", callId, operation, ...fields }); + } catch (error) { + pendingHostCalls.delete(callId); + signal.removeEventListener("abort", onAbort); + reject(error); + } }), ); diff --git a/apps/web/src/components/plugins/PluginUi.tsx b/apps/web/src/components/plugins/PluginUi.tsx index fa4ca15d1ad8..814caa5d6c7f 100644 --- a/apps/web/src/components/plugins/PluginUi.tsx +++ b/apps/web/src/components/plugins/PluginUi.tsx @@ -325,11 +325,19 @@ function PluginUiSettingControl({ const [value, setValue] = useState(setting.defaultValue); const [committedValue, setCommittedValue] = useState(setting.defaultValue); const [busy, setBusy] = useState(false); + const [loading, setLoading] = useState(true); + const readVersion = useRef(0); useEffect(() => { + const version = ++readVersion.current; let cancelled = false; + setValue(setting.defaultValue); + setCommittedValue(setting.defaultValue); + setLoading(true); void read({ environmentId, input: { pluginId, settingId: setting.id } }).then((result) => { - if (cancelled || result._tag !== "Success" || result.value.value === undefined) return; + if (cancelled || version !== readVersion.current) return; + setLoading(false); + if (result._tag !== "Success" || result.value.value === undefined) return; if (typeof result.value.value === "boolean" || typeof result.value.value === "string") { setValue(result.value.value); setCommittedValue(result.value.value); @@ -338,9 +346,11 @@ function PluginUiSettingControl({ return () => { cancelled = true; }; - }, [environmentId, pluginId, read, setting.id]); + }, [environmentId, pluginId, read, setting.defaultValue, setting.id]); const update = async (next: boolean | string) => { + if (loading || busy) return; + readVersion.current += 1; const previous = committedValue; setValue(next); setBusy(true); @@ -368,13 +378,13 @@ function PluginUiSettingControl({ setting.kind === "boolean" ? ( void update(checked)} /> ) : setting.kind === "select" ? ( { if (next !== null) void update(next); }} @@ -429,10 +430,10 @@ function PluginUiSettingControl({ className="w-56" value={String(value)} placeholder={setting.placeholder} - disabled={busy || loading} + disabled={readOnly || busy || loading} onChange={(event) => setValue(event.target.value)} onBlur={() => { - if (!loading && !busy) void update(String(value)); + if (!readOnly && !loading && !busy) void update(String(value)); }} /> ); @@ -440,7 +441,7 @@ function PluginUiSettingControl({ return ; } -export function PluginUiSettingsSections() { +export function PluginUiSettingsSections({ readOnly }: { readonly readOnly: boolean }) { const environmentId = usePrimaryEnvironmentId(); const catalog = usePluginUiCatalog(environmentId); const currentSurface = surface(); @@ -459,6 +460,7 @@ export function PluginUiSettingsSections() { environmentId={environmentId} pluginId={pluginPackage.pluginId} setting={setting} + readOnly={readOnly} /> ))} @@ -498,7 +500,7 @@ export function PluginComposerContributions({ return (
{statuses.map((item) => ( diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index 7f164d42116f..b14e5aec608c 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -351,7 +351,7 @@ export function PluginsSettingsPanel() { ))}
- + ); } diff --git a/apps/web/src/routes/plugins.$pluginId.$viewId.tsx b/apps/web/src/routes/plugins.$pluginId.$viewId.tsx index 38123479c7db..1e212121a2a9 100644 --- a/apps/web/src/routes/plugins.$pluginId.$viewId.tsx +++ b/apps/web/src/routes/plugins.$pluginId.$viewId.tsx @@ -31,7 +31,7 @@ function PluginPageRoute() { {pluginId}
- + From c25025cf0f3adc7e0923609291371c2c60e1c18b Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:23:02 +0000 Subject: [PATCH 43/45] fix: consolidate plugin ui treatments Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- apps/web/src/components/plugins/PluginUi.tsx | 169 ++++++++++-------- .../components/settings/PluginsSettings.tsx | 3 +- .../src/components/sidebar/SidebarChrome.tsx | 28 +-- .../components/sidebar/SidebarUtilityItem.tsx | 31 ++++ 4 files changed, 124 insertions(+), 107 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarUtilityItem.tsx diff --git a/apps/web/src/components/plugins/PluginUi.tsx b/apps/web/src/components/plugins/PluginUi.tsx index 5752fb2089fb..04ce76379747 100644 --- a/apps/web/src/components/plugins/PluginUi.tsx +++ b/apps/web/src/components/plugins/PluginUi.tsx @@ -29,9 +29,9 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; -import { toastManager } from "../ui/toast"; -import { SidebarMenuItem, SidebarMenuButton } from "../ui/sidebar"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SidebarUtilityItem } from "../sidebar/SidebarUtilityItem"; import { SettingsRow, SettingsSection } from "../settings/settingsLayout"; const EMPTY_PLUGIN_UI_CATALOG: PluginUiCatalog = Object.freeze({ @@ -71,7 +71,7 @@ export function usePluginUiCatalog(environmentId: EnvironmentId | null): PluginU function usePluginAction(environmentId: EnvironmentId | null, catalog: PluginUiCatalog) { const invoke = useAtomCommand(serverEnvironment.invokePluginCommand, { reportFailure: false }); return useCallback( - async (commandId: string, context?: PluginCommandInvocationContext) => { + async (commandId: string, label: string, context?: PluginCommandInvocationContext) => { if (environmentId === null) return; const result = await invoke({ environmentId, @@ -82,10 +82,13 @@ function usePluginAction(environmentId: EnvironmentId | null, catalog: PluginUiC }, }); if (result._tag === "Success") { - toastManager.add({ - type: result.value.tone, - title: result.value.message, - }); + toastManager.add( + stackedThreadToast({ + type: result.value.tone, + title: label, + description: result.value.message, + }), + ); return; } if (!isAtomCommandInterrupted(result)) { @@ -138,29 +141,19 @@ export function PluginUiNavigationItems({ closeMobile }: { readonly closeMobile: ); return items.map(({ item, pluginId }) => ( - - - { - closeMobile(); - void navigate({ - to: "/plugins/$pluginId/$viewId", - params: { pluginId, viewId: item.viewId }, - }); - }} - > - - - } - /> - {item.label} - - + } + label={item.label} + isActive={pathname === `/plugins/${pluginId}/${item.viewId}`} + onClick={() => { + closeMobile(); + void navigate({ + to: "/plugins/$pluginId/$viewId", + params: { pluginId, viewId: item.viewId }, + }); + }} + /> )); } @@ -169,26 +162,58 @@ function ActionButton({ onAction, }: { readonly action: Pick; - readonly onAction: (commandId: string) => void; + readonly onAction: (commandId: string, label: string) => void; }) { return ( - ); } +function PluginCard({ + title, + value, + description, + tone, + action, + onAction, +}: { + readonly title: string; + readonly value?: string; + readonly description?: string; + readonly tone?: "neutral" | "muted" | "info" | "success" | "warning" | "danger"; + readonly action?: Pick; + readonly onAction: (commandId: string, label: string) => void; +}) { + return ( +
+
{title}
+ {value ?
{value}
: null} + {description ?

{description}

: null} + {action ? ( +
+ +
+ ) : null} +
+ ); +} + function StatusBadge({ label, value, tone, + valueOnly = false, }: { readonly label: string; readonly value: string; readonly tone?: "neutral" | "muted" | "info" | "success" | "warning" | "danger"; + readonly valueOnly?: boolean; }) { const text = `${label}: ${value}`; + const visibleText = valueOnly ? value : text; return ( - {text} + {visibleText} } /> @@ -213,7 +238,7 @@ function RenderBlock({ onAction, }: { readonly block: PluginUiBlock; - readonly onAction: (commandId: string) => void; + readonly onAction: (commandId: string, label: string) => void; }) { switch (block.kind) { case "text": @@ -226,37 +251,27 @@ function RenderBlock({ return ; case "card": return ( -
-
{block.title}
- {block.value ?
{block.value}
: null} - {block.description ? ( -

{block.description}

- ) : null} - {block.commandId ? ( -
- -
- ) : null} -
+ ); case "status": return (
- {block.label} - - {block.value} - + {block.label} +
); } @@ -269,7 +284,7 @@ export function PluginUiViewContent({ }: { readonly pluginPackage: PluginUiPackageContribution; readonly view: PluginUiView; - readonly onAction: (commandId: string) => void; + readonly onAction: (commandId: string, label: string) => void; }) { const currentSurface = surface(); const cards = pluginPackage.cards.filter((card) => card.surfaces.includes(currentSurface)); @@ -303,21 +318,17 @@ export function PluginUiViewContent({ {cards.length > 0 ? (
{cards.map((card) => ( -
-
{card.title}
- {card.value ?
{card.value}
: null} - {card.description ? ( -

{card.description}

- ) : null} - {card.actionId && actions.get(card.actionId) ? ( -
- -
- ) : null} -
+ title={card.title} + onAction={onAction} + {...(card.value === undefined ? {} : { value: card.value })} + {...(card.description === undefined ? {} : { description: card.description })} + {...(card.tone === undefined ? {} : { tone: card.tone })} + {...(card.actionId === undefined || actions.get(card.actionId) === undefined + ? {} + : { action: actions.get(card.actionId)! })} + /> ))}
) : null} @@ -500,7 +511,7 @@ export function PluginComposerContributions({ return (
{statuses.map((item) => ( @@ -516,7 +527,7 @@ export function PluginComposerContributions({ key={action.id} size="xs" variant="ghost-muted" - onClick={() => void invoke(action.commandId, context)} + onClick={() => void invoke(action.commandId, action.label, context)} > {action.label} @@ -552,7 +563,7 @@ export function PluginUiPage({ void invoke(commandId, { viewId })} + onAction={(commandId, label) => void invoke(commandId, label, { viewId })} /> ); } diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx index b14e5aec608c..9af7614677a0 100644 --- a/apps/web/src/components/settings/PluginsSettings.tsx +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -283,7 +283,8 @@ export function PluginsSettingsPanel() { Limited permissions - This session can inspect plugins, but it cannot enable, disable, or reload them. + This session can inspect plugins, but it cannot enable, disable, reload, or change + their settings. ) : null} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 1b39b5094a9b..6df5de8f05db 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -4,7 +4,6 @@ import { GitPullRequestIcon, SettingsIcon, } from "lucide-react"; -import type { ReactNode } from "react"; import { memo, useCallback } from "react"; import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; @@ -28,8 +27,8 @@ import { SidebarTrigger, useSidebar, } from "../ui/sidebar"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PluginUiNavigationItems } from "../plugins/PluginUi"; +import { SidebarUtilityItem } from "./SidebarUtilityItem"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; @@ -119,31 +118,6 @@ function T3Wordmark() { ); } -function SidebarUtilityItem({ - icon, - label, - onClick, -}: { - icon: ReactNode; - label: string; - onClick: () => void; -}) { - return ( - - - - {icon} - - } - /> - {label} - - - ); -} - export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const navigate = useNavigate(); const canGoBack = useCanGoBack(); diff --git a/apps/web/src/components/sidebar/SidebarUtilityItem.tsx b/apps/web/src/components/sidebar/SidebarUtilityItem.tsx new file mode 100644 index 000000000000..1396e9fa5f8b --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUtilityItem.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; + +import { SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function SidebarUtilityItem({ + icon, + label, + onClick, + isActive = false, +}: { + readonly icon: ReactNode; + readonly label: string; + readonly onClick: () => void; + readonly isActive?: boolean; +}) { + return ( + + + + {icon} + + } + /> + {label} + + + ); +} From 68c92fb4e9634242420912af925f50b49f3fcc01 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:29:47 +0000 Subject: [PATCH 44/45] fix: attach plugin composer drawer state Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 26 +++++++++--- apps/web/src/components/plugins/PluginUi.tsx | 42 +++++++++++++++----- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 026d9e6951bf..246b5bed35fc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -270,7 +270,10 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; -import { PluginComposerContributions } from "./plugins/PluginUi"; +import { + PluginComposerContributions, + usePluginComposerContributionState, +} from "./plugins/PluginUi"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -6565,8 +6568,21 @@ function ChatViewContent(props: ChatViewProps) { setDragActive: setIsWorkspaceFileDragActive, addFiles: (files) => composerRef.current?.addDroppedFiles(files), }); + const pluginComposerContext = useMemo( + () => ({ + ...(activeThreadId === null ? {} : { threadId: String(activeThreadId) }), + ...(activeProject === null ? {} : { projectId: String(activeProject.id) }), + }), + [activeProject, activeThreadId], + ); + const pluginComposerContributions = usePluginComposerContributionState( + environmentId, + pluginComposerContext, + ); const externalComposerDrawerAttached = - composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable); + composerBannerItems.length > 0 || + Boolean(threadSyncPhase && !activeEnvironmentUnavailable) || + pluginComposerContributions.isAttached; return (
@@ -6756,10 +6772,8 @@ function ChatViewContent(props: ChatViewProps) { ) : null}
pluginPackage.composerActions.filter((action) => action.surfaces.includes(currentSurface)), @@ -507,7 +511,27 @@ export function PluginComposerContributions({ const statuses = catalog.packages.flatMap((pluginPackage) => pluginPackage.statusItems.filter((item) => item.surfaces.includes(currentSurface)), ); - if (composer.length === 0 && contextual.length === 0 && statuses.length === 0) return null; + return { + catalog, + composer, + contextual, + statuses, + isAttached: composer.length > 0 || contextual.length > 0 || statuses.length > 0, + }; +} + +export function PluginComposerContributions({ + environmentId, + context, + contributions, +}: { + readonly environmentId: EnvironmentId | null; + readonly context: PluginCommandInvocationContext; + readonly contributions: PluginComposerContributionState; +}) { + const { catalog, composer, contextual, statuses, isAttached } = contributions; + const invoke = usePluginAction(environmentId, catalog); + if (!isAttached) return null; return (
Date: Mon, 24 Aug 2026 04:35:16 +0000 Subject: [PATCH 45/45] fix: keep plugin composer hooks stable Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 25 ++++++++++---------- apps/web/src/components/plugins/PluginUi.tsx | 16 +++++++++---- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 246b5bed35fc..30f3bac8cba1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6411,6 +6411,18 @@ function ChatViewContent(props: ChatViewProps) { void onRevertToTurnCountRef.current(targetTurnCount); }, []); + const pluginComposerContext = useMemo( + () => ({ + ...(activeThreadId === null ? {} : { threadId: String(activeThreadId) }), + ...(activeProject === null ? {} : { projectId: String(activeProject.id) }), + }), + [activeProject, activeThreadId], + ); + const pluginComposerContributions = usePluginComposerContributionState( + activeThread?.environmentId ?? null, + pluginComposerContext, + ); + // Empty state: no active thread if (!activeThread) { return ; @@ -6568,17 +6580,6 @@ function ChatViewContent(props: ChatViewProps) { setDragActive: setIsWorkspaceFileDragActive, addFiles: (files) => composerRef.current?.addDroppedFiles(files), }); - const pluginComposerContext = useMemo( - () => ({ - ...(activeThreadId === null ? {} : { threadId: String(activeThreadId) }), - ...(activeProject === null ? {} : { projectId: String(activeProject.id) }), - }), - [activeProject, activeThreadId], - ); - const pluginComposerContributions = usePluginComposerContributionState( - environmentId, - pluginComposerContext, - ); const externalComposerDrawerAttached = composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable) || @@ -6771,7 +6772,7 @@ function ChatViewContent(props: ChatViewProps) { ) : null} diff --git a/apps/web/src/components/plugins/PluginUi.tsx b/apps/web/src/components/plugins/PluginUi.tsx index 057c4a89979b..15c15589b420 100644 --- a/apps/web/src/components/plugins/PluginUi.tsx +++ b/apps/web/src/components/plugins/PluginUi.tsx @@ -59,6 +59,15 @@ const toneClass = { danger: "border-destructive/30 bg-destructive/10 text-destructive-foreground", } as const; +const badgeVariant = { + neutral: "outline", + muted: "secondary", + info: "info", + success: "success", + warning: "warning", + danger: "error", +} as const; + export function usePluginUiCatalog(environmentId: EnvironmentId | null): PluginUiCatalog { const result = useAtomValue( environmentId === null @@ -218,10 +227,7 @@ function StatusBadge({ + {visibleText} } @@ -535,7 +541,7 @@ export function PluginComposerContributions({ return (
{statuses.map((item) => (