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/package.json b/apps/server/package.json index eb4dc7dd35ec..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" @@ -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 25971b0c0aec..329947568434 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -37,6 +37,31 @@ 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, + ); + 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", () => { expect(requiredScopeForRpcMethod(WS_METHODS.providerUploadFeedback)).toBe( AuthOrchestrationOperateScope, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 70227cdd4ebf..558fe2ce6b4f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -50,6 +50,18 @@ 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.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 new file mode 100644 index 000000000000..ab60d88998c7 --- /dev/null +++ b/apps/server/src/plugins/PluginCommandCatalog.test.ts @@ -0,0 +1,390 @@ +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 { PluginRuntime, type PluginDefinition } from "@t3tools/plugin-runtime"; + +import * as PluginCommandCatalog from "./PluginCommandCatalog.ts"; + +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); + PluginCommandCatalog.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("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; + 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(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._tag).toBe("PluginUiNotificationRateLimitError"); + if (rateLimited._tag === "PluginUiNotificationRateLimitError") { + expect(rateLimited.windowMillis).toBe(250); + } + }).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; + 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(PluginCommandCatalog.layer)), + ); + + it.effect("does not republish an unchanged command catalog", () => + Effect.gen(function* () { + 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(PluginCommandCatalog.layer)), + ); + + it.effect("rolls back invalid command metadata before publishing a generation", () => + Effect.gen(function* () { + const catalog = yield* PluginCommandCatalog.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(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.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(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 new file mode 100644 index 000000000000..23ea67b51fdd --- /dev/null +++ b/apps/server/src/plugins/PluginCommandCatalog.ts @@ -0,0 +1,397 @@ +import { + PluginCommand as PluginCommandSchema, + type PluginCommand, + type PluginCommandInvocationContext, + type PluginCommandCatalog as PluginCommandCatalogSnapshot, + PluginCommandCatalogChangedError, + PluginCommandId, + type PluginCommandInvocationResult, + 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, + PluginActivationContext, + PluginDefinition, + 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 * 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 : {}; + 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 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()( + "PluginCommandExecutionError", + { cause: Schema.Defect(), id: PluginCommandId }, +) { + override get message(): string { + return `Plugin command ${this.id} failed during execution.`; + } +} + +type PluginCommandHandler = ( + context?: PluginCommandInvocationContext, +) => Effect.Effect; + +export class PluginCommandDefinitionError extends Schema.TaggedErrorClass()( + "PluginCommandDefinitionError", + { 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; + readonly handler: + | PluginCommandHandler + | Effect.Effect; +} + +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, + }, + }, + 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 PluginUiNotificationInactiveError extends Schema.TaggedErrorClass()( + "PluginUiNotificationInactiveError", + { pluginId: Schema.String }, +) { + override get message(): string { + 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", + 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((cause) => new PluginCommandDefinitionError({ cause, id: entry.id })), + ), + ); + 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; +}); + +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< + PluginCommandInvocationResult, + PluginCommandCatalogChangedError | PluginCommandInvocationError | PluginCommandNotFoundError + >; + readonly reconcile: ( + definitions: ReadonlyArray, + ) => Effect.Effect< + PluginCommandCatalogSnapshot, + | PluginCommandDefinitionError + | PluginUiDefinitionError + | 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 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")( + (definitions: ReadonlyArray) => + 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 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); + } + return published; + }), + ), + ), + ); + + 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 PluginUiNotificationInactiveError({ pluginId }); + } + const now = yield* Clock.currentTimeMillis; + const previous = lastNotificationAt.get(pluginId); + if (previous !== undefined && now - previous < 250) { + return yield* new PluginUiNotificationRateLimitError({ + pluginId, + windowMillis: 250, + }); + } + const decoded = yield* decodePluginUiNotification({ ...notification, pluginId }).pipe( + Effect.mapError( + (cause) => + new PluginUiNotificationDecodeError({ + pluginId, + notificationId: notification.id, + cause, + }), + ), + ); + lastNotificationAt.set(pluginId, now); + yield* PubSub.publish(notificationPubSub, deepFreeze(decoded)); + }); + + const invoke = Effect.fn("PluginCommandCatalog.invoke")(function* ( + input: PluginCommandInvokeInput, + ) { + return yield* reconcileSemaphore.withPermits(1)( + runtime + .useContribution< + PluginCommandHandler, + PluginCommandInvocationResult, + PluginCommandExecutionError, + never + >(COMMAND_SLOT, input.id, input.generation, (handler) => handler(input.context)) + .pipe( + 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, + }), + ), + }), + ), + ); + }); + + return PluginCommandCatalog.of({ + changes: SubscriptionRef.changes(state), + 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 })), +); diff --git a/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts b/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts new file mode 100644 index 000000000000..72c48edae2ee --- /dev/null +++ b/apps/server/src/plugins/PluginHostCapabilityBroker.test.ts @@ -0,0 +1,198 @@ +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"); + 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( + 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/new/sub/leak.txt", "leak")))._tag, + ).toBe("Failure"); + expect(yield* fileSystem.exists(path.join(outsideDirectory, "new"))).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..ec29f0a76b40 --- /dev/null +++ b/apps/server/src/plugins/PluginHostCapabilityBroker.ts @@ -0,0 +1,758 @@ +import { NodeHttpClient } from "@effect/platform-node"; +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"; +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 + >; + }; + readonly ui: { + readonly notify: ( + notification: PluginUiNotificationInput, + ) => Effect.Effect; + }; +} + +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") {} + +export 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 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( + 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", + ); + 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* () { + 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", + ); + // 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 ${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 ${parsed.origin}`, + 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; + }), + }, + ui: { + notify: (_notification) => + requirePermission(pluginId, requested, "notifications:send", "notification send").pipe( + Effect.flatMap(() => + Effect.fail(fail(pluginId, "notification send", "notification sink unavailable")), + ), + ), + }, + } 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 new file mode 100644 index 000000000000..e3d77e56a67a --- /dev/null +++ b/apps/server/src/plugins/PluginPackageManager.test.ts @@ -0,0 +1,1244 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ServerSettingsError } from "@t3tools/contracts"; +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 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"; + +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"; +import * as PluginWorkerSupervisor from "./PluginWorkerSupervisor.ts"; + +const packageId = "com.acme.runtime-status"; +const commandId = "acme.runtime-status"; + +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, + 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 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 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 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, + attemptsFile: string, + failThroughAttempt = 1, +) => ` +import { readFileSync, writeFileSync } from "node:fs"; +export default function activate(api) { + 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( + { + id: ${encodeJsonString(id)}, + label: ${encodeJsonString(label)}, + surfaces: ["web", "desktop"] + }, + () => ({ message: ${encodeJsonString(label)}, tone: "success" }) + ); +} +`; + +const gatedCommandPluginSource = ( + id: string, + gateFile: string, + startedFile: string, + releaseFile: string, +) => ` +import { existsSync, writeFileSync } from "node:fs"; +export default async function activate(api) { + 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"] }, + () => ({ message: "gated", tone: "success" }) + ); +} +`; + +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 = (startedFile: string, releaseFile: string) => ` +import { existsSync, writeFileSync } from "node:fs"; +export default function activate(api) { + api.registerCommand( + { + id: "${commandId}", + label: "External runtime status", + surfaces: ["web", "desktop", "mobile"] + }, + () => ({ message: "retirement gate", tone: "success" }) + ); + api.onDispose(async () => { + writeFileSync(${encodeJsonString(startedFile)}, "started"); + while (!existsSync(${encodeJsonString(releaseFile)})) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + }); +} +`; + +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 }; + readonly startupFailure?: boolean; +} + +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), + ); + const persistenceFailures = options?.persistenceFailures; + const startupFailure = options?.startupFailure === true; + const settingsLayer = + persistenceFailures === undefined && !startupFailure + ? liveSettingsLayer + : Layer.effect( + ServerSettings.ServerSettingsService, + Effect.gen(function* () { + 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 !== undefined && 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(capabilityBrokerLayer), + Layer.provideMerge(PluginWorkerSupervisor.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("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("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; + 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; + 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("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 attemptsFile = `${baseDir}/startup-attempts.txt`; + 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", attemptsFile), + ); + yield* fileSystem.writeFileString(attemptsFile, "1"); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + yield* manager.enable(packageId); + }), + ); + yield* fileSystem.writeFileString(attemptsFile, "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(yield* fileSystem.readFileString(attemptsFile)).toBe("2"); + }), + ); + + 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 attemptsFile = `${baseDir}/failing-attempts.txt`; + 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, attemptsFile, 2) + : commandPluginSource(declaredCommand, id), + ); + } + yield* fileSystem.writeFileString(attemptsFile, "2"); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + yield* manager.enable(failingId); + yield* manager.enable(workingId); + }), + ); + yield* fileSystem.writeFileString(attemptsFile, "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(yield* fileSystem.readFileString(attemptsFile)).toBe("2"); + }), + ); + + 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 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`, + encodeManifest(manifest), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + gatedCommandPluginSource(commandId, gateFile, startedFile, releaseFile), + ); + + yield* useEnvironment( + baseDir, + Effect.gen(function* () { + const manager = yield* PluginPackageManager.PluginPackageManager; + yield* manager.enable(packageId); + }), + ); + + yield* fileSystem.writeFileString(gateFile, "enabled"); + const startup = yield* Effect.forkChild( + useEnvironment(baseDir, Effect.asVoid(PluginPackageManager.PluginPackageManager)), + ); + yield* waitForFile(fileSystem, startedFile, true); + const interrupting = yield* Effect.forkChild(Fiber.interrupt(startup)); + yield* Effect.yieldNow; + 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); + }), + ); + + 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; + 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* Effect.exit(manager.reload(packageId)))._tag).toBe("Failure"); + 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("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); + + yield* manager.enable(providerId); + const restoredCatalog = yield* catalog.list; + expect( + yield* catalog.invoke({ + generation: restoredCatalog.generation, + id: consumerCommandId, + }), + ).toEqual({ message: "issues consumer", tone: "success" }); + }), + ); + }), + ); + + 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", + error: "activation failed", + }, + ], + }); + yield* manager.enable(packageId); + expect(yield* manager.status).toMatchObject({ + packages: [{ id: packageId, state: "error", error: "activation failed" }], + }); + + 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, permissions: ["state:read-write"] }), + ); + 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, grantedPermissions: [] }], + }); + }), + { 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; + 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(["); + }), + ); + }), + ); + + 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 startedFile = `${baseDir}/retirement-started`; + const releaseFile = `${baseDir}/retirement-release`; + yield* fileSystem.makeDirectory(packageDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + `${packageDirectory}/t3-plugin.json`, + encodeManifest(manifest), + ); + yield* fileSystem.writeFileString( + `${packageDirectory}/index.mjs`, + pluginSourceWithRetirementGate(startedFile, releaseFile), + ); + + 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* waitForFile(fileSystem, startedFile, true); + + const interrupting = yield* Effect.forkChild(Fiber.interrupt(disabling)); + yield* Effect.yieldNow; + yield* fileSystem.writeFileString(releaseFile, "release"); + yield* Fiber.join(interrupting); + yield* waitForFile(fileSystem, `${baseDir}/userdata/plugin-cache/${packageId}/0`, false); + + 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..67eb68d6626f --- /dev/null +++ b/apps/server/src/plugins/PluginPackageManager.ts @@ -0,0 +1,1052 @@ +import { + PluginCommandInvocationResult, + PluginPackageNotFoundError, + PluginPackageOperationError, + type PluginPackageDiscoveryError, + type PluginPackageOperation, + type PluginPackageStatus, + type PluginPackageStatusSnapshot, + type PluginUiSetting, + PluginUiSettingError, +} from "@t3tools/contracts"; +import type { PluginActivationContext, PluginDefinition } from "@t3tools/plugin-runtime"; +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"; +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"; +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"; +const UI_CAPABILITY = "t3.ui@1"; +const NOTIFICATION_PERMISSION = "notifications:send"; + +interface DiscoveredPackage { + readonly directory: string; + readonly manifest: PluginManifestType; +} + +interface DiscoveryResult { + readonly errors: ReadonlyArray; + readonly packages: ReadonlyMap; +} + +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 `Command ${this.commandId} is not declared in the manifest`; + } +} + +class PluginPackageUiReferenceError 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)); +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); + } + 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, + id?: string, +): PluginPackageOperationError => { + if (isPluginPackageOperationError(error)) return error; + return new PluginPackageOperationError({ + ...(id === undefined ? {} : { id }), + operation, + ...(typeof error === "string" ? { detail: error } : { cause: error }), + }); +}; + +const makeDefinition = ( + discovered: DiscoveredPackage, + worker: PluginWorkerSupervisor.SupervisedPluginWorker, + 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) + ) { + throw new PluginPackageMissingCapabilityError({ + id: discovered.manifest.id, + capability: COMMAND_CAPABILITY, + }); + } + for (const command of worker.commands) { + if (!declaredCommands.has(command.id)) { + throw new PluginPackageUndeclaredCommandError({ + id: discovered.manifest.id, + commandId: command.id, + }); + } + } + 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); + if (uiEntries.length > 0) { + PluginCommandCatalog.registerPluginUi(context, discovered.manifest.id, ui); + } + for (const command of worker.commands) { + PluginCommandCatalog.registerPluginCommand(context, { + command, + handler: (invocationContext) => + worker.invoke(command.id, invocationContext).pipe( + Effect.flatMap(decodeInvocationResult), + Effect.mapError( + (cause) => + new PluginCommandCatalog.PluginCommandExecutionError({ + cause, + id: command.id, + }), + ), + ), + }); + } + }, + }; +}; + +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 + >; + readonly settingRead: ( + pluginId: string, + settingId: string, + ) => Effect.Effect; + readonly settingWrite: ( + pluginId: string, + settingId: string, + value: Schema.Json, + ) => Effect.Effect; + } +>()("t3/plugins/PluginPackageManager") {} + +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 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 activeHosts = 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 stopWorker = (id: string, worker: PluginWorkerSupervisor.SupervisedPluginWorker) => + 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, + ) { + 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: detailFromCause(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 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: "notification was rejected by the host", + cause, + }), + ), + ); + }, + }, + }; + 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 workerExit = yield* Effect.exit( + workerSupervisor + .start({ + pluginId: discovered.manifest.id, + entrypointPath, + host, + }) + .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id))), + ); + if (workerExit._tag === "Failure") { + yield* removeCacheDirectory(cacheDirectory); + 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: definitionExit.value, + retired, + worker, + host, + } 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, 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" }, + ); + 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 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]; + 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" + : 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) => + grantedPermissionSet.has(permission), + ), + 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 }), + }); + } + + 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)) { + return yield* statusUnlocked(operation); + } + 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 previousWorker = activeWorkers.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; + 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* restorePreviousGrants; + yield* stopWorker(id, loaded.worker); + 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), + ); + 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, + error: rolledBack.cause, + }); + return yield* Effect.failCause(reconciled.cause); + } + } + packageErrors.set(id, detailFromCause(reconciled.cause)); + 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); + activeHosts.set(id, loaded.host); + 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); + 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") { + 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); + } + } + + const worker = activeWorkers.get(id); + 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); + activeRetirements.delete(id); + if (worker !== undefined) yield* stopWorker(id, worker); + 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; + } + 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* 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); + activeHosts.set(id, loaded.host); + 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 }); + }), + ); + } + + const resolveSetting = Effect.fn("PluginPackageManager.resolveSetting")(function* ( + pluginId: string, + settingId: string, + ) { + const host = activeHosts.get(pluginId); + 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* new PluginUiSettingError({ + pluginId, + settingId, + detail: "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) => + new PluginUiSettingError({ + pluginId, + settingId, + detail: "settings store read failed", + 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* new PluginUiSettingError({ + pluginId, + settingId, + detail: "value does not match setting schema", + }); + } + yield* host.settings.set(settingId, value).pipe( + Effect.mapError( + (cause) => + new PluginUiSettingError({ + pluginId, + settingId, + detail: "settings store write failed", + cause, + }), + ), + ); + }), + ); + + 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: detailFromCause(shutdown.cause), + }); + } + yield* Effect.forEach(activeWorkers, ([id, worker]) => stopWorker(id, worker), { + concurrency: "unbounded", + discard: true, + }); + activeWorkers.clear(); + activeHosts.clear(); + 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)), + disable: (id: string) => semaphore.withPermits(1)(disableUnlocked(id)), + reload: (id: string) => semaphore.withPermits(1)(transition("reload", id)), + settingRead, + settingWrite, + } as const; +}); + +const unavailableService = (error: PluginPackageOperationError) => + PluginPackageManager.of({ + status: Effect.fail(error), + enable: () => Effect.fail(error), + disable: () => Effect.fail(error), + reload: () => Effect.fail(error), + settingRead: (pluginId, settingId) => + Effect.fail( + new PluginUiSettingError({ + pluginId, + settingId, + detail: "plugin package manager is unavailable", + cause: error, + }), + ), + settingWrite: (pluginId, settingId) => + Effect.fail( + new PluginUiSettingError({ + pluginId, + settingId, + detail: "plugin package manager is unavailable", + cause: 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/plugins/PluginWorkerProtocol.test.ts b/apps/server/src/plugins/PluginWorkerProtocol.test.ts new file mode 100644 index 000000000000..729e2127d5f1 --- /dev/null +++ b/apps/server/src/plugins/PluginWorkerProtocol.test.ts @@ -0,0 +1,81 @@ +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"], + }, + ], + ui: { + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, + }), + ).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 }, + }); + + 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"] }], ui: {} }, + { 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..c7f496980604 --- /dev/null +++ b/apps/server/src/plugins/PluginWorkerProtocol.ts @@ -0,0 +1,197 @@ +import { + PluginCommand, + type PluginCommandInvocationContext, + PluginUiContribution, + PluginUiNotificationInput, +} 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), + ui: PluginUiContribution, + }), +); +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), + ), + }), +); +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"), + 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, + UiNotify, +]); +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 context?: PluginCommandInvocationContext; + } + | { 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..47549b82251f --- /dev/null +++ b/apps/server/src/plugins/PluginWorkerRuntime.mjs @@ -0,0 +1,310 @@ +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]; +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 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) { + throw new Error("plugin worker protocol message exceeds limit"); + } + 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 : NodeUtil.inspect(value))) + .join(" "); + process.stderr.write(`${detail.slice(0, 4_000)}\n`); +}; +for (const method of ["log", "info", "warn", "error", "debug"]) { + console[method] = writeDiagnostic; +} + +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 (isRemoteEffect(value)) return await value.run(signal); + return await value; +}; + +const hostCall = (operation, fields) => + 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); + }, + }); + try { + write({ type: "hostCall", callId, operation, ...fields }); + } catch (error) { + pendingHostCalls.delete(callId); + signal.removeEventListener("abort", onAbort); + reject(error); + } + }), + ); + +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 }), + }, + ui: { + notify: (notification) => hostCall("ui.notify", { notification }), + }, + }, + effect: { + succeed: (value) => remoteEffect(async () => value), + map: (effect, f) => remoteEffect(async (signal) => f(await runValue(effect, signal))), + flatMap: (effect, f) => + 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 }); + }, + registerUi: (contribution) => { + if (uiRegistered) throw new Error("plugin ui contribution already registered"); + uiContribution = detachJson(contribution); + uiRegistered = true; + }, +}; + +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.toReversed()) { + 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(message.context)) + .then((result) => runValue(result, controller.signal)) + .then( + (value) => writeInvocationResult(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 = 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"); + 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(NodeURL.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), + ui: uiContribution, + }); +} 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..f7f430d65875 --- /dev/null +++ b/apps/server/src/plugins/PluginWorkerSupervisor.test.ts @@ -0,0 +1,569 @@ +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"; + +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"; + +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 = (notifications: Array = []): 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), + }, + ui: { + notify: (notification) => + Effect.sync(() => { + notifications.push(structuredClone(notification)); + }), + }, +}); + +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"); + 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.flatMap( + api.host.ui.notify({ + id: "counter-updated", + title: "Counter updated", + message: String(next), + tone: "success" + }), + () => 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(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)), + ), + ); + + 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)), + ), + ); + + 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* () { + 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* 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* 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* 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* worker.dispose; + }).pipe(Effect.provide(PluginWorkerSupervisor.layer)), + ), + ); + + 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* () { + 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* 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..9b79ef2be048 --- /dev/null +++ b/apps/server/src/plugins/PluginWorkerSupervisor.ts @@ -0,0 +1,708 @@ +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"; +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)); +const encodeUi = Schema.encodeSync(Schema.fromJsonString(PluginUiContribution)); + +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 ui: PluginUiContributionType; + readonly invoke: ( + commandId: string, + context?: PluginCommandInvocationContext, + ) => Effect.Effect; + readonly dispose: Effect.Effect; + 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 ui: PluginUiContributionType; + readonly invoke: ( + commandId: string, + context?: PluginCommandInvocationContext, + ) => 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(); + 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 expectedUi: PluginUiContributionType | undefined; + + 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); + case "ui.notify": + return input.host.ui.notify(call.notification); + } + 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< + { + readonly commands: ReadonlyArray; + readonly ui: PluginUiContributionType; + }, + PluginWorkerError + >(); + const hostCallSemaphore = yield* Semaphore.make(HOST_CALL_CONCURRENCY); + const encoder = new TextEncoder(); + const protocolDecoder = 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 stderrBytes = 0; + + 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, cause?: unknown) => { + if (closing || crashReported || disposed) return; + crashReported = true; + health = { state: "restarting", detail, restartCount }; + failPending(workerError("invocation", detail, cause)); + Deferred.doneUnsafe(activation, Effect.fail(workerError("activation", detail, cause))); + 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({ commands: message.commands, ui: message.ui }), + ); + 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)}`, Cause.squash(cause)), + ).pipe(Effect.tap(() => handle.kill().pipe(Effect.ignore))), + ), + ); + yield* Effect.forkIn(reader, sessionScope); + + const stderrReader = handle.stderr.pipe( + Stream.runForEach((chunk) => + Effect.sync(() => { + stderrBytes = Math.min(MAX_STDERR_BYTES, stderrBytes + chunk.byteLength); + }), + ), + Effect.ignore, + ); + yield* Effect.forkIn(stderrReader, sessionScope); + + const exitWatcher = handle.exitCode.pipe( + Effect.tap((exitCode) => + Effect.sync(() => { + if (!closing) { + reportCrash( + `worker exited with code ${String(exitCode)}${stderrBytes === 0 ? "" : ` after ${String(stderrBytes)} stderr bytes`}`, + ); + } + }), + ), + 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 context?: PluginCommandInvocationContext; + } + | { 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.commands, + ui: activated.ui, + invoke: (commandId, context) => + request({ + type: "invoke", + commandId, + ...(context === undefined ? {} : { context }), + }).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; + expectedUi = current.ui; + 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) || + encodeUi(restarted.value.ui) !== encodeUi(expectedUi) + ) { + yield* restarted.value.close; + health = { + state: "crashed", + detail: "restarted worker changed its contribution 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), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + workerError("dispose", "worker disposal timed out", cause), + ), + }), + ), + ); + yield* Scope.close(workerScope, Exit.void); + if (disposeExit?._tag === "Failure") { + const failure = Cause.squash(disposeExit.cause); + return yield* isPluginWorkerError(failure) + ? failure + : workerError("dispose", "worker dispose failed", failure); + } + }), + ), + ), + ), + Effect.tap((exit) => + Effect.sync(() => { + Deferred.doneUnsafe(disposeResult, exit); + }), + ), + ); + yield* Effect.forkIn(disposeFiber, parentScope); + + const invoke = (commandId: string, context?: PluginCommandInvocationContext) => + 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, context).pipe( + Effect.timeout(options.invocationTimeout), + Effect.catchTags({ + 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")), + ); + 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, + ui: expectedUi, + invoke, + dispose: Effect.sync(() => Deferred.doneUnsafe(disposeRequest, Effect.void)).pipe( + Effect.flatMap(() => Deferred.await(disposeResult)), + ), + 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/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index f7ae95d8a927..1a943410e8fe 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 02a367c08792..a345b402e00b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4683,6 +4683,69 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("publishes plugin commands and declarative ui 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 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, 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", + }); + }).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 11c659e28a70..6c1206eaaa88 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -68,6 +68,10 @@ 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 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, @@ -386,6 +390,8 @@ const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientOrigin: OrchestrationClientOrigin, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], + pluginCommands: PluginCommandCatalog.PluginCommandCatalog["Service"], + pluginPackages: PluginPackageManager.PluginPackageManager["Service"], ) => WsRpcGroup.toLayer( Effect.gen(function* () { @@ -1528,6 +1534,48 @@ 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.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", + }), + [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, @@ -2391,6 +2439,20 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "server" }, ), + [WS_METHODS.subscribePluginCommands]: (_input) => + 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" }, + ), }); }), ); @@ -2398,6 +2460,8 @@ const makeWsRpcLayer = ( 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( @@ -2423,7 +2487,13 @@ export const websocketRpcRouteLayer = Layer.unwrap( disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe( + makeWsRpcLayer( + session, + clientOrigin, + previewAutomationBroker, + pluginCommands, + pluginPackages, + ).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), @@ -2467,4 +2537,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), ); }), +).pipe( + Layer.provide( + PluginPackageManager.layer.pipe( + Layer.provideMerge(PluginCommandCatalog.layer), + Layer.provideMerge(PluginHostCapabilityBroker.layer), + Layer.provideMerge(PluginWorkerSupervisor.layer), + ), + ), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 46ed051154a6..30f3bac8cba1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -270,6 +270,10 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { + PluginComposerContributions, + usePluginComposerContributionState, +} from "./plugins/PluginUi"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -6407,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 ; @@ -6565,7 +6581,9 @@ function ChatViewContent(props: ChatViewProps) { addFiles: (files) => composerRef.current?.addDroppedFiles(files), }); const externalComposerDrawerAttached = - composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable); + composerBannerItems.length > 0 || + Boolean(threadSyncPhase && !activeEnvironmentUnavailable) || + pluginComposerContributions.isAttached; return (
@@ -6753,6 +6771,11 @@ function ChatViewContent(props: ChatViewProps) { {threadSyncPhase && !activeEnvironmentUnavailable ? ( ) : null} +
{ + 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 688a8a8ea791..336f62ffe011 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"; @@ -105,6 +108,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 c5ec3f095167..644bf8e140a7 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -26,6 +26,8 @@ import { type DesktopWslState, type EnvironmentId, type FilesystemBrowseResult, + type PluginCommand, + type PluginCommandCatalog, type ProjectId, type SourceControlDiscoveryResult, type SourceControlProviderKind, @@ -43,6 +45,7 @@ import { LinkIcon, MessageSquareIcon, PaletteIcon, + PuzzleIcon, ServerIcon, SettingsIcon, SquarePenIcon, @@ -61,6 +64,8 @@ import { type ReactNode, } from "react"; import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { isElectron } from "../env"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; @@ -115,6 +120,7 @@ import { browseInputEndPaddingClass, buildBrowseGroups, buildProjectActionItems, + buildPluginCommandActionItems, buildRootGroups, buildThreadActionItems, enumerateCommandPaletteItems, @@ -129,6 +135,7 @@ import { ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, reduceCommandPaletteUiState, + resolvePluginCommandEnvironmentId, type SearchOverlayMode, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; @@ -146,7 +153,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, @@ -169,6 +180,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 ( @@ -587,6 +602,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(); @@ -1650,6 +1680,34 @@ function OpenCommandPaletteDialog(props: { }); } + if (pluginCommandEnvironmentId !== null) { + const commandEnvironmentId = pluginCommandEnvironmentId; + actionItems.push( + ...buildPluginCommandActionItems({ + commands: pluginCommandCatalog.commands, + icon: , + surface: isElectron ? "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/apps/web/src/components/plugins/PluginUi.test.tsx b/apps/web/src/components/plugins/PluginUi.test.tsx new file mode 100644 index 000000000000..4c2885ef435d --- /dev/null +++ b/apps/web/src/components/plugins/PluginUi.test.tsx @@ -0,0 +1,74 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { PluginUiViewContent } from "./PluginUi"; + +describe("PluginUiViewContent", () => { + 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..15c15589b420 --- /dev/null +++ b/apps/web/src/components/plugins/PluginUi.tsx @@ -0,0 +1,599 @@ +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, useLocation } from "@tanstack/react-router"; + +import { isElectron } from "../../env"; +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 { 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({ + 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" => (isElectron ? "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-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; + +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 + ? 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, label: 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( + stackedThreadToast({ + type: result.value.tone, + title: label, + description: 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 pathname = useLocation({ select: (location) => location.pathname }); + 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 }) => ( + } + label={item.label} + isActive={pathname === `/plugins/${pluginId}/${item.viewId}`} + onClick={() => { + closeMobile(); + void navigate({ + to: "/plugins/$pluginId/$viewId", + params: { pluginId, viewId: item.viewId }, + }); + }} + /> + )); +} + +function ActionButton({ + action, + onAction, +}: { + readonly action: Pick; + 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 ( + + + {visibleText} + + } + /> + + {text} + + + ); +} + +function RenderBlock({ + block, + onAction, +}: { + readonly block: PluginUiBlock; + readonly onAction: (commandId: string, label: string) => void; +}) { + switch (block.kind) { + case "text": + return ( +

+ {block.text} +

+ ); + case "action": + return ; + case "card": + return ( + + ); + case "status": + return ( +
+ {block.label} + +
+ ); + } +} + +export function PluginUiViewContent({ + pluginPackage, + view, + onAction, +}: { + readonly pluginPackage: PluginUiPackageContribution; + readonly view: PluginUiView; + readonly onAction: (commandId: string, label: 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) => ( + + ))} +
+ ) : null} + {cards.length > 0 ? ( +
+ {cards.map((card) => ( + + ))} +
+ ) : null} +
+ {view.blocks.map((block, index) => ( + + ))} +
+
+ ); +} + +function PluginUiSettingControl({ + environmentId, + pluginId, + setting, + readOnly, +}: { + readonly environmentId: EnvironmentId; + readonly pluginId: string; + readonly setting: PluginUiSetting; + readonly readOnly: boolean; +}) { + 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); + 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 || 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); + } + }); + return () => { + cancelled = true; + }; + }, [environmentId, pluginId, read, setting.defaultValue, setting.id]); + + const update = async (next: boolean | string) => { + if (readOnly || loading || busy) return; + const previous = committedValue; + setValue(next); + setBusy(true); + const result = await write({ + environmentId, + input: { pluginId, settingId: setting.id, value: next }, + }); + setBusy(false); + if (result._tag === "Success") { + setCommittedValue(next); + return; + } + setValue(previous); + if (!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={() => { + if (!readOnly && !loading && !busy) void update(String(value)); + }} + /> + ); + + return ; +} + +export function PluginUiSettingsSections({ readOnly }: { readonly readOnly: boolean }) { + 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 interface PluginComposerContributionState { + readonly catalog: PluginUiCatalog; + readonly composer: PluginUiPackageContribution["composerActions"]; + readonly contextual: PluginUiPackageContribution["contextualActions"]; + readonly statuses: PluginUiPackageContribution["statusItems"]; + readonly isAttached: boolean; +} + +export function usePluginComposerContributionState( + environmentId: EnvironmentId | null, + context: PluginCommandInvocationContext, +): PluginComposerContributionState { + const catalog = usePluginUiCatalog(environmentId); + 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.some( + (kind) => + (kind === "thread" && context.threadId !== undefined) || + (kind === "project" && context.projectId !== undefined) || + ((kind === "file" || kind === "diff") && context.filePath !== undefined), + ), + ), + ); + const statuses = catalog.packages.flatMap((pluginPackage) => + pluginPackage.statusItems.filter((item) => item.surfaces.includes(currentSurface)), + ); + 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 ( +
+ {statuses.map((item) => ( + + ))} + {[...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, label, { viewId })} + /> + ); +} 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..826fe02b217e --- /dev/null +++ b/apps/web/src/components/settings/PluginsSettings.test.tsx @@ -0,0 +1,450 @@ +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"; +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; + }, +})); + +vi.mock("../ui/toast", () => ({ + toastManager: { add: vi.fn() }, +})); + +vi.mock("../plugins/PluginUi", () => ({ + PluginUiSettingsSections: () => null, +})); + +import { toastManager } from "../ui/toast"; +import { TooltipPopup } from "../ui/tooltip"; + +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", + runtimeState: "running", + restartCount: 0, + capabilities: ["t3.commands@1"], + permissions: ["state:read-write", "network:https://api.acme.test"], + grantedPermissions: ["state:read-write"], + contributions: { + commands: ["acme.active.run"], + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, + }, + { + id: "com.acme.disabled", + version: "2.0.0", + apiVersion: 1, + enabled: false, + state: "disabled", + runtimeState: "stopped", + restartCount: 0, + capabilities: ["t3.commands@1"], + permissions: ["filesystem:data"], + grantedPermissions: [], + contributions: { + commands: [], + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, + }, + ], +}; + +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(); + 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 }); + }); + + 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("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("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"); + + expect( + visitElements( + activeRow, + (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, + (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 && + 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(); + }); + + 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("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("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"); + 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).toHaveBeenCalledTimes(1); + expect(toastManager.add).not.toHaveBeenCalled(); + }); + + 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("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(); + 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(); + }); + + 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 new file mode 100644 index 000000000000..9af7614677a0 --- /dev/null +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -0,0 +1,358 @@ +import type { PluginPackageStatus } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + CircleAlertIcon, + FolderCodeIcon, + RefreshCwIcon, + RotateCwIcon, + ShieldAlertIcon, +} from "lucide-react"; +import { Fragment, 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 { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +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"; +import { PluginUiSettingsSections } from "../plugins/PluginUi"; + +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; + +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 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 = ( +
+ {state.label} + v{pluginPackage.version} + + worker: {pluginPackage.runtimeState}, restarts: {pluginPackage.restartCount} + + {pluginPackage.capabilities.map((capability) => ( + + + + {capability} + + } + /> + + {capability} + + + + ))} + {pluginPackage.permissions.map((permission) => { + const granted = grantedPermissions.has(permission); + const label = `${permission}${granted ? " granted" : " approval required"}`; + return ( + + + + {label} + + } + /> + + {label} + + + + ); + })} +
+ ); + + return ( + {pluginPackage.id}} + description={ + 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" + 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); + status.refresh(); + if (result._tag === "Success") { + 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 ( + + + {countLabel} + + + {status.isPending ? ( + + ) : ( + + )} + + } + /> + Refresh plugins + +
+ } + > + + + Trusted local code + + 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. + + + + {operateAccess === "denied" ? ( + + + Limited permissions + + This session can inspect plugins, but it cannot enable, disable, reload, or change + their settings. + + + ) : 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 && + (status.data?.errors.length ?? 0) === 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 734c2989d917..66af301d3690 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -14,6 +14,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 5213cb55a503..3791750b97da 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", @@ -208,6 +210,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/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 8fc6b835bf1a..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,7 +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"; @@ -118,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(); @@ -209,6 +184,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { label="Settings" onClick={handleSettingsClick} /> + {pullRequestsSupported ? ( } 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} + + + ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f7c47ace6840..07807fe2dcf8 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' @@ -27,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' @@ -69,6 +71,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', @@ -119,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', @@ -147,10 +159,12 @@ 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 '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/plugins/$pluginId/$viewId': typeof PluginsPluginIdViewIdRoute } export interface FileRoutesByTo { '/connect': typeof ConnectRoute @@ -167,11 +181,13 @@ 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 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/plugins/$pluginId/$viewId': typeof PluginsPluginIdViewIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -190,11 +206,13 @@ 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 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute + '/plugins/$pluginId/$viewId': typeof PluginsPluginIdViewIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -214,10 +232,12 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/plugins' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/plugins/$pluginId/$viewId' fileRoutesByTo: FileRoutesByTo to: | '/connect' @@ -234,11 +254,13 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/plugins' | '/settings/providers' | '/settings/source-control' | '/' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/plugins/$pluginId/$viewId' id: | '__root__' | '/_chat' @@ -256,11 +278,13 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/plugins' | '/settings/providers' | '/settings/source-control' | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' + | '/plugins/$pluginId/$viewId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -271,6 +295,7 @@ export interface RootRouteChildren { UsageRoute: typeof UsageRoute ConnectCallbackRoute: typeof ConnectCallbackRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute + PluginsPluginIdViewIdRoute: typeof PluginsPluginIdViewIdRoute } declare module '@tanstack/react-router' { @@ -331,6 +356,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' @@ -401,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' @@ -442,6 +481,7 @@ interface SettingsRouteChildren { SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsPluginsRoute: typeof SettingsPluginsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -454,6 +494,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsGeneralRoute: SettingsGeneralRoute, SettingsIntegrationsRoute: SettingsIntegrationsRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsPluginsRoute: SettingsPluginsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } @@ -470,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..1e212121a2a9 --- /dev/null +++ b/apps/web/src/routes/plugins.$pluginId.$viewId.tsx @@ -0,0 +1,46 @@ +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 { WorkspacePageContainer } from "../components/WorkspacePageContainer"; +import { ScrollArea } from "../components/ui/scroll-area"; +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/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/examples/plugins/runtime-status/README.md b/examples/plugins/runtime-status/README.md new file mode 100644 index 000000000000..e951c07867ee --- /dev/null +++ b/examples/plugins/runtime-status/README.md @@ -0,0 +1,38 @@ +# runtime status example plugin + +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: + +```text +/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. 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 + +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. +- `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. + +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. + +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. + +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 new file mode 100644 index 000000000000..9615024255fe --- /dev/null +++ b/examples/plugins/runtime-status/index.mjs @@ -0,0 +1,109 @@ +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", + label: "external runtime status", + description: "report status from an external local plugin package.", + surfaces: ["web", "desktop", "mobile"], + }, + (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 new file mode 100644 index 000000000000..9cddb0a7425c --- /dev/null +++ b/examples/plugins/runtime-status/t3-plugin.json @@ -0,0 +1,21 @@ +{ + "manifestVersion": 1, + "id": "com.t3code.runtime-status-example", + "version": "1.1.0", + "apiVersion": 1, + "entrypoints": { + "server": "./index.mjs" + }, + "capabilities": ["t3.commands@1", "t3.ui@1"], + "permissions": ["settings:read-write", "notifications:send"], + "contributes": { + "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 bfe57a6c0dd5..2a840de00f26 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -50,6 +50,9 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribePreviewEvents | 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 2fef689a9bbb..1ebce291c707 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -479,6 +479,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 = { @@ -716,6 +717,26 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.subscribeResourceTelemetry, idleTtlMs: 0, }), + pluginCommands: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:plugin-commands", + 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, + staleTimeMs: 1_000, + }), resourceTelemetryHistory: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:resource-telemetry-history", tag: WS_METHODS.serverGetResourceTelemetryHistory, @@ -745,6 +766,45 @@ 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, + }, + }), + 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, + 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, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..9ec0d4a6f119 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -19,6 +19,9 @@ export * from "./git.ts"; export * from "./vcs.ts"; 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.test.ts b/packages/contracts/src/pluginCommands.test.ts new file mode 100644 index 000000000000..85eaa55f5998 --- /dev/null +++ b/packages/contracts/src/pluginCommands.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + PluginCommandCatalog, + PluginCommandInvokeInput, + PluginCommandInvocationError, + 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("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); + 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..c4444ffd898f --- /dev/null +++ b/packages/contracts/src/pluginCommands.ts @@ -0,0 +1,78 @@ +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 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; + +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", + { + cause: Schema.Defect(), + id: PluginCommandId, + }, +) { + override get message(): string { + return `Plugin command failed: ${this.id}`; + } +} diff --git a/packages/contracts/src/pluginPackages.test.ts b/packages/contracts/src/pluginPackages.test.ts new file mode 100644 index 000000000000..9430eb15fd7c --- /dev/null +++ b/packages/contracts/src/pluginPackages.test.ts @@ -0,0 +1,206 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + PluginPackageActionInput, + PluginPackageOperationError, + 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", + runtimeState: "running", + restartCount: 0, + capabilities: ["t3.commands@1"], + permissions: ["state:read-write", "network:https://api.acme.test"], + grantedPermissions: ["state:read-write"], + contributions: { + commands: ["acme.runtime-status"], + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, + }, + ], + }), + ).toEqual({ + errors: [], + packages: [ + { + id: "com.acme.runtime-status", + version: "1.0.0", + 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"], + contributions: { + commands: ["acme.runtime-status"], + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, + }, + ], + }); + }); + + 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("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", + runtimeState: "stopped", + restartCount: 0, + capabilities: ["t3.commands@1"], + permissions: [], + grantedPermissions: [], + contributions: { + commands: ["acme.issues.create"], + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, + 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(); + expect(() => decodeAction({ id: "com.acme.runtime-status", extra: true })).toThrow(); + }); + + it("rejects unsupported host permissions in package status", () => { + 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", + runtimeState: "stopped", + restartCount: 0, + capabilities: [], + permissions: [permission], + grantedPermissions: [], + contributions: { + commands: [], + settings: [], + navigation: [], + views: [], + cards: [], + statusItems: [], + composerActions: [], + contextualActions: [], + }, + }, + ], + }), + ).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", + runtimeState: "stopped", + restartCount: 0, + capabilities: ["t3.commands@1"], + permissions: [], + grantedPermissions: [], + 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" }); + 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); + 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..b11a44b79cd4 --- /dev/null +++ b/packages/contracts/src/pluginPackages.ts @@ -0,0 +1,138 @@ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, 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-]*)+$/), + Schema.isMaxLength(255), +); +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(255), +); +export type PluginUiId = typeof PluginUiId.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 PluginHostPermission = Schema.Union([ + Schema.Literals([ + "settings:read-write", + "state:read-write", + "cache:read-write", + "filesystem:data", + "notifications:send", + ]), + 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.-]+(?::(?!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( + 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", + "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), + 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; + +export const PluginPackageStatus = Schema.Struct({ + id: PluginPackageId, + version: TrimmedNonEmptyString, + 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), + 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: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(2_000))), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const packageName = this.id === undefined ? "plugin packages" : `plugin package ${this.id}`; + const detail = this.detail === undefined ? "" : `: ${this.detail}`; + return `${this.operation} failed for ${packageName}${detail}`; + } +} diff --git a/packages/contracts/src/pluginUi.test.ts b/packages/contracts/src/pluginUi.test.ts new file mode 100644 index 000000000000..ab799b9690b3 --- /dev/null +++ b/packages/contracts/src/pluginUi.test.ts @@ -0,0 +1,191 @@ +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"; +import { PluginUiId } from "./pluginPackages.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("matches the manifest contribution id boundary", () => { + const decode = Schema.decodeUnknownSync(PluginUiId); + expect(decode(`com.${"a".repeat(251)}`)).toHaveLength(255); + expect(() => decode(`com.${"a".repeat(252)}`)).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 45bf581de084..9002779456bb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -72,6 +72,28 @@ import { ProviderUploadFeedbackResult, } from "./provider.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { + PluginCommandCatalog, + PluginCommandCatalogChangedError, + PluginCommandInvocationError, + PluginCommandInvocationResult, + PluginCommandInvokeInput, + PluginCommandNotFoundError, +} from "./pluginCommands.ts"; +import { + PluginPackageActionInput, + PluginPackageNotFoundError, + PluginPackageOperationError, + PluginPackageStatusSnapshot, +} from "./pluginPackages.ts"; +import { + PluginUiCatalog, + PluginUiNotification, + PluginUiSettingError, + PluginUiSettingReadInput, + PluginUiSettingReadResult, + PluginUiSettingWriteInput, +} from "./pluginUi.ts"; import { PullRequestActionInput, PullRequestActivity, @@ -282,6 +304,21 @@ export const WS_METHODS = { serverGetBackgroundPolicy: "server.getBackgroundPolicy", serverGetUsageSummary: "server.getUsageSummary", + // Plugin command 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", + pluginPackagesDisable: "pluginPackages.disable", + pluginPackagesReload: "pluginPackages.reload", + // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -321,6 +358,9 @@ export const WS_METHODS = { subscribeAuthAccess: "subscribeAuthAccess", subscribeBackgroundPolicy: "subscribeBackgroundPolicy", subscribeResourceTelemetry: "subscribeResourceTelemetry", + subscribePluginCommands: "subscribePluginCommands", + subscribePluginUi: "subscribePluginUi", + subscribePluginUiNotifications: "subscribePluginUiNotifications", } as const; export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -347,6 +387,62 @@ 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 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, + 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({ /** @@ -996,9 +1092,42 @@ 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 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, + WsPluginPackagesReloadRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, @@ -1089,6 +1218,9 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeAuthAccessRpc, WsSubscribeBackgroundPolicyRpc, WsSubscribeResourceTelemetryRpc, + WsSubscribePluginCommandsRpc, + WsSubscribePluginUiRpc, + WsSubscribePluginUiNotificationsRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetWorkflowScriptRpc, WsOrchestrationGetTurnDiffRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 55023bcc48e7..c76cbd176faa 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -248,6 +248,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 80e03b8c879e..57108e6067f2 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, @@ -639,6 +640,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/README.md b/packages/plugin-runtime/README.md new file mode 100644 index 000000000000..73ea3a9aa778 --- /dev/null +++ b/packages/plugin-runtime/README.md @@ -0,0 +1,21 @@ +# plugin runtime + +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. + +## 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. + +`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/package.json b/packages/plugin-runtime/package.json new file mode 100644 index 000000000000..07316e7e33dd --- /dev/null +++ b/packages/plugin-runtime/package.json @@ -0,0 +1,27 @@ +{ + "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": { + "@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 new file mode 100644 index 000000000000..7c626cea3a1d --- /dev/null +++ b/packages/plugin-runtime/src/contract.ts @@ -0,0 +1,60 @@ +export type ContributionData = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: ContributionData }; + +export interface Contribution { + readonly id: string; + readonly label: string; + readonly data?: Data; +} + +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; + }; + readonly onDispose: (finalizer: () => void | Promise) => void; +} + +export interface PluginRuntimeContributionSnapshot { + readonly generation: number; + readonly entries: ReadonlyArray; +} + +export interface PluginRuntimeSnapshot { + readonly active: ReadonlyArray; + readonly blocked: Readonly>>; + readonly contributions: Readonly>>>; +} + +export interface PluginRuntimeOptions { + readonly validateSnapshot?: (snapshot: PluginRuntimeSnapshot) => void; + 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; +} diff --git a/packages/plugin-runtime/src/index.ts b/packages/plugin-runtime/src/index.ts new file mode 100644 index 000000000000..21e731b191c1 --- /dev/null +++ b/packages/plugin-runtime/src/index.ts @@ -0,0 +1,9 @@ +export type { + Contribution, + PluginActivationContext, + PluginDefinition, + PluginRuntimeContributionSnapshot, + PluginRuntimeOptions, + PluginRuntimeSnapshot, +} from "./contract.ts"; +export * as PluginRuntime 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..54f34a4687be --- /dev/null +++ b/packages/plugin-runtime/src/manifest.ts @@ -0,0 +1,79 @@ +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 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-]+)*)?$/, + ), +); + +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.Union([ + Schema.Literals([ + "settings:read-write", + "state:read-write", + "cache:read-write", + "filesystem:data", + "notifications:send", + ]), + 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.-]+(?::(?!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( + Schema.isPattern(/^process:[A-Za-z0-9._+-]{1,128}$/), + Schema.isMaxLength(136), + ), +]); + +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)), +}); + +export const PluginManifest = Schema.Struct({ + manifestVersion: Schema.Literal(1), + id: NamespacedId, + version: SemanticVersion, + 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/src/planner.ts b/packages/plugin-runtime/src/planner.ts new file mode 100644 index 000000000000..698e8e9f5bd3 --- /dev/null +++ b/packages/plugin-runtime/src/planner.ts @@ -0,0 +1,339 @@ +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; + +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 => { + 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: orderWithOptionalDependencies(ordered, providersByCapability) }; +}; + +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; + if (!sameStrings(left.optional ?? [], right.optional ?? [])) 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 ?? []), ...(definition.optional ?? [])]) { + 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..f2078c2d77d4 --- /dev/null +++ b/packages/plugin-runtime/src/runtime.ts @@ -0,0 +1,821 @@ +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 Scheduler from "effect/Scheduler"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; + +import type { + Contribution, + ContributionData, + PluginActivationContext, + PluginDefinition, + PluginRuntimeContributionSnapshot, + PluginRuntimeOptions, + PluginRuntimeSnapshot, +} from "./contract.ts"; +import { + affectedPluginIds, + isPluginPlanningError, + planComposition, + 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 cleanupErrors: Array; +} + +interface LiveComposition { + readonly generation: number; + readonly plugins: ReadonlyArray; + readonly snapshot: PluginRuntimeSnapshot; +} + +type RuntimeOperation = "reconcile" | "dispose" | "invoke"; +type PluginLifecycleCallback = "activate" | "finalizer"; +type PluginCallback = PluginLifecycleCallback | "contribution"; + +interface PluginCallbackContext { + active: boolean; + readonly callback: PluginCallback; + 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; +} + +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", "resolveOptional", "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`; + } +} + +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", "invoke"]) }, +) { + override get message(): string { + return `Plugin runtime is disposed; cannot ${this.operation}`; + } +} + +class PluginRuntimeReentrancyError extends Schema.TaggedErrorClass()( + "PluginRuntimeReentrancyError", + { + callback: Schema.Literals(["activate", "contribution", "finalizer"]), + operation: Schema.Literals(["reconcile", "dispose", "invoke"]), + 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"})`; + } +} + +export type PluginRuntimeReconcileError = + | PluginPlanningError + | PluginCallbackError + | PluginDuplicateContributionError + | PluginRuntimeDisposedError + | PluginRuntimeReentrancyError + | PluginSnapshotValidationError + | PluginStagingError; + +export class PluginSnapshotValidationError 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, + { + readonly reconcile: ( + 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") {} + +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 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: cloneContributionData(contribution.data) }), + }); + +const snapshotDefinitions = ( + definitions: ReadonlyArray, +): ReadonlyArray => + 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 + : Object.freeze(Object.assign(createNullPrototypeRecord(), definition.provides)); + return Object.freeze({ + id: definition.id, + version: definition.version, + activate: definition.activate, + ...(requires === undefined ? {} : { requires }), + ...(optional === undefined ? {} : { optional }), + ...(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) => registration.contribution), + ]); + } + } + return Object.freeze({ + active: Object.freeze(plugins.map((plugin) => plugin.definition.id)), + blocked: Object.freeze(Object.assign(createNullPrototypeRecord(), blocked)), + contributions: Object.freeze(contributions), + }); +}; + +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); + 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(); + + 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: PluginLifecycleCallback, + pluginId: string, + invoke: () => Result | PromiseLike, + onSettled?: () => void, + ): Effect.Effect => { + const callbackState: PluginCallbackContext = { active: true, callback, pluginId }; + 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(settleCallback); + } + settleCallback(); + return result; + } catch (error) { + settleCallback(); + throw error; + } + }, + catch: (cause) => new PluginCallbackError({ callback, cause, pluginId }), + }).pipe(Effect.ensuring(Effect.sync(expire))); + }; + + const activatePlugin = ( + definition: PluginDefinition, + capabilities: ReadonlyMap, + ): Effect.Effect => + 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" | "resolveOptional" | "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; + }, + 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, + ...registeredValues: [] | [unknown] + ) => { + assertActivating("register"); + const values = contributions.get(slot) ?? []; + const detachedContribution = detachContribution(contribution); + values.push({ + contribution: detachedContribution, + value: registeredValues.length === 0 ? detachedContribution : registeredValues[0], + }); + 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; + }, + ), + ), + ); + 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 (disposalStarted) { + 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, + ); + 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]), + ); + 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); + } + } + + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + 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); + } + 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, + }; + }), + ), + ); + 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.uninterruptible( + Effect.gen(function* () { + if (disposed) return; + disposalStarted = true; + const previous = current.plugins; + const failures = [...(yield* closePlugins(previous, true))]; + current = { + generation: current.generation + 1, + 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, + 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({ + PluginRuntimeCleanupError: (error) => + reportCleanupErrors( + "retire", + error.failures.map(({ cause, pluginId }) => ({ error: cause, pluginId })), + ), + PluginRuntimeReentrancyError: () => Effect.void, + }), + ), + ); + + 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), + 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"]; + }); + +export const layer = (options: PluginRuntimeOptions = {}) => + Layer.effect(PluginRuntime, make(options)); diff --git a/packages/plugin-runtime/test/contributions.test.ts b/packages/plugin-runtime/test/contributions.test.ts new file mode 100644 index 000000000000..0f6a51a531ca --- /dev/null +++ b/packages/plugin-runtime/test/contributions.test.ts @@ -0,0 +1,448 @@ +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("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(); + 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/packages/plugin-runtime/test/manifest.test.ts b/packages/plugin-runtime/test/manifest.test.ts new file mode 100644 index 000000000000..a78b16a0c3c4 --- /dev/null +++ b/packages/plugin-runtime/test/manifest.test.ts @@ -0,0 +1,117 @@ +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 = { + manifestVersion: 1, + id: "com.acme.linear", + version: "1.2.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", "notifications:send"], + contributes: { + commands: ["linear.create-issue"], + settings: ["linear.settings"], + navigation: ["linear.navigation"], + views: ["linear.right-panel"], + cards: ["linear.summary"], + statusItems: ["linear.status"], + composerActions: ["linear.create-from-composer"], + contextualActions: ["linear.create-from-thread"], + }, +}; + +describe("PluginManifest", () => { + 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, + contributes: { ...validManifest.contributes, commands: ["create-issue"] }, + }), + ).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, 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(); + expect(decodeManifest({ ...validManifest, version: "1.2.3+build.7" }).version).toBe( + "1.2.3+build.7", + ); + expect(() => decodeManifest({ ...validManifest, requires: ["t3.commands"] })).toThrow(); + for (const permission of [ + "settings:read", + "filesystem:/tmp", + "network:file:///tmp/secret", + "network:https://example.com:443", + "network:https://example.com:99999", + "process:../sh", + "secrets:UPPERCASE", + "unknown:anything", + ]) { + expect(() => decodeManifest({ ...validManifest, permissions: [permission] })).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..8d3d81dee47a --- /dev/null +++ b/packages/plugin-runtime/test/runtime.test.ts @@ -0,0 +1,296 @@ +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"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; + +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", () => { + 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.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 = []; + + yield* PluginRuntime.PluginRuntime.use((runtime) => + runtime.reconcile([ + { + id: "acme.layer-owned", + version: "1.0.0", + activate(context) { + context.onDispose(() => { + disposed = true; + }); + }, + }, + ]), + ).pipe( + Effect.provide( + PluginRuntime.layer({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), + }), + ), + ); + + expect(disposed).toBe(true); + 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( + PluginRuntime.layer({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), + }), + runtimeScope, + ); + const runtime = Context.get(services, PluginRuntime.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 earlyFinalizerRan = false; + let lateFinalizerRan = false; + let releaseActivation!: () => void; + const activationGate = new Promise((resolve) => { + 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([ + { + id: "acme.interrupted-activation", + version: "1.0.0", + async activate(context) { + context.onDispose(() => { + earlyFinalizerRan = true; + }); + markActivationStarted(); + await activationGate; + try { + context.onDispose(() => { + lateFinalizerRan = true; + }); + lateRegistrationSucceeded = true; + } catch (error) { + lateFailure = error; + } + nested.resume(Effect.exit(runtime.reconcile([]))); + }, + }, + ]), + ); + 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))), + ), + ); + for (let attempt = 0; attempt < 10; attempt += 1) { + yield* Effect.yieldNow; + } + expect(interruptCompleted).toBe(true); + + releaseActivation(); + yield* Fiber.join(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", + }); + } + }), + ), + ); +}); diff --git a/packages/plugin-runtime/test/runtimeContract.ts b/packages/plugin-runtime/test/runtimeContract.ts new file mode 100644 index 000000000000..094b72fefd06 --- /dev/null +++ b/packages/plugin-runtime/test/runtimeContract.ts @@ -0,0 +1,1156 @@ +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 type { PluginRuntime, PluginRuntimeReconcileError } from "../src/runtime.ts"; + +import type { + PluginDefinition, + PluginRuntimeOptions, + PluginRuntimeSnapshot, +} from "../src/contract.ts"; + +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 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 + : 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 }); + }, +}); + +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", () => + 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("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* () { + const lifecycle: Array = []; + const runtime = yield* createRuntime({ + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), + }); + 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.push("first"); + }); + 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(() => { + disposals += 1; + }); + }, + }; + 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(() => { + throw cleanupError; + }); + throw activationError; + }, + }; + + 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"); + }, + }); + 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; + }); + 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([]); + + 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; + }), + ), + ); + + 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 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* () { + 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/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 4b550bebb15e..620374cc6eb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -497,6 +497,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 @@ -838,6 +841,22 @@ 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: + '@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 + 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': @@ -5148,10 +5167,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==} 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",