diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts
index 07942b67356b..7b043882d6a2 100644
--- a/apps/desktop/src/window/DesktopWindow.ts
+++ b/apps/desktop/src/window/DesktopWindow.ts
@@ -33,6 +33,7 @@ import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
import { makeQuitShortcutHandler } from "./QuitHold.ts";
+import { shouldVetoViewFrameNavigation } from "./pluginViewNavigation.ts";
const TITLEBAR_HEIGHT = 40;
// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS
@@ -647,6 +648,30 @@ export const make = Effect.gen(function* () {
void runPromise(electronShell.openExternal(url));
}
});
+ // Plugin views may not navigate themselves; see pluginViewNavigation.ts.
+ window.webContents.on("will-frame-navigate", (event) => {
+ if (event.isMainFrame) return;
+ if (
+ !shouldVetoViewFrameNavigation({
+ url: event.url,
+ frame: event.frame,
+ initiator: event.initiator,
+ mainFrame: window.webContents.mainFrame,
+ })
+ )
+ return;
+ event.preventDefault();
+ // Only where it tried to go: a path or query can carry the view's data.
+ const target = URL.parse(event.url);
+ void runPromise(
+ logWindowInfo(
+ "refused a plugin view navigation",
+ target === null
+ ? { protocol: "invalid" }
+ : { protocol: target.protocol, host: target.host },
+ ),
+ );
+ });
// Electron's windowMenu close role owns CmdOrCtrl+W. Holding the
// close-terminal shortcut can outlive the terminal that handled its first
diff --git a/apps/desktop/src/window/pluginViewNavigation.test.ts b/apps/desktop/src/window/pluginViewNavigation.test.ts
new file mode 100644
index 000000000000..795f12416afe
--- /dev/null
+++ b/apps/desktop/src/window/pluginViewNavigation.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { type NavigationFrame, shouldVetoViewFrameNavigation } from "./pluginViewNavigation.ts";
+
+const frame = (
+ frameToken: string,
+ url: string,
+ origin: string,
+ parent: NavigationFrame | null,
+): NavigationFrame => ({ frameToken, url, origin, parent });
+
+const app = frame("app", "t3code-dev://app/", "t3code-dev://app", null);
+const view = frame("view", "about:srcdoc", "null", app);
+const wrapper = frame("wrapper", "about:srcdoc", "null", app);
+const wrappedView = frame("inner", "about:srcdoc", "null", wrapper);
+
+describe("shouldVetoViewFrameNavigation", () => {
+ it("refuses a view navigating itself, including through a policy wrapper", () => {
+ for (const target of [view, wrappedView]) {
+ for (const url of ["about:blank", "http://127.0.0.1:7391/page.html", "data:text/html,x"]) {
+ expect(
+ shouldVetoViewFrameNavigation({ url, frame: target, initiator: target, mainFrame: app }),
+ ).toBe(true);
+ }
+ }
+ });
+
+ it("refuses a navigation with no known initiator", () => {
+ expect(
+ shouldVetoViewFrameNavigation({
+ url: "http://127.0.0.1:7391/redirect",
+ frame: view,
+ initiator: null,
+ mainFrame: app,
+ }),
+ ).toBe(true);
+ });
+
+ it("lets the app mount, wrap and dispose views", () => {
+ const fresh = frame("fresh", "about:blank", "t3code-dev://app", app);
+ expect(
+ shouldVetoViewFrameNavigation({
+ url: "about:srcdoc",
+ frame: fresh,
+ initiator: app,
+ mainFrame: app,
+ }),
+ ).toBe(false);
+ const freshInner = frame("fresh-inner", "about:blank", "null", wrapper);
+ expect(
+ shouldVetoViewFrameNavigation({
+ url: "about:srcdoc",
+ frame: freshInner,
+ initiator: wrapper,
+ mainFrame: app,
+ }),
+ ).toBe(false);
+ expect(
+ shouldVetoViewFrameNavigation({
+ url: "about:blank",
+ frame: view,
+ initiator: app,
+ mainFrame: app,
+ }),
+ ).toBe(false);
+ });
+
+ it("leaves frames outside views alone", () => {
+ const other = frame("other", "https://example.com/", "https://example.com", app);
+ expect(
+ shouldVetoViewFrameNavigation({
+ url: "https://example.com/next",
+ frame: other,
+ initiator: other,
+ mainFrame: app,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/apps/desktop/src/window/pluginViewNavigation.ts b/apps/desktop/src/window/pluginViewNavigation.ts
new file mode 100644
index 000000000000..8bcea62c9458
--- /dev/null
+++ b/apps/desktop/src/window/pluginViewNavigation.ts
@@ -0,0 +1,35 @@
+/**
+ * Main-process navigation veto for isolated plugin view frames.
+ *
+ * A plugin view runs in an opaque-origin `about:srcdoc` frame inside a
+ * policy wrapper frame of the same kind. A document's own CSP cannot stop its
+ * frame navigating, so the main process refuses any navigation of a frame in
+ * such a subtree unless the app's main frame started it. Loading
+ * `about:srcdoc` stays allowed so the wrapper can mount its view. Electron
+ * does not report `about:blank` or `data:` here: the wrapper's CSP refuses
+ * `data:`, and the host's ping liveness check ends a view that blanked itself.
+ */
+
+export interface NavigationFrame {
+ readonly url: string;
+ readonly origin: string;
+ readonly frameToken: string;
+ readonly parent: NavigationFrame | null;
+}
+
+const isViewDocument = (frame: NavigationFrame) =>
+ frame.url === "about:srcdoc" && frame.origin === "null";
+
+export function shouldVetoViewFrameNavigation(input: {
+ readonly url: string;
+ readonly frame: NavigationFrame | null;
+ readonly initiator: NavigationFrame | null | undefined;
+ readonly mainFrame: NavigationFrame;
+}): boolean {
+ let insideView = false;
+ for (let frame = input.frame; frame !== null; frame = frame.parent) {
+ if (isViewDocument(frame)) insideView = true;
+ }
+ if (!insideView || input.url === "about:srcdoc") return false;
+ return input.initiator?.frameToken !== input.mainFrame.frameToken;
+}
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index ec4b2565e174..b23f64d6e022 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -33,6 +33,7 @@ import {
type RenderFailureProps,
} from "./components/RenderErrorBoundary";
import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
+import { PluginNotificationBannerHost } from "./features/plugins/PluginNotificationBannerHost";
import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation";
@@ -95,6 +96,10 @@ import {
SettingsScheduledTaskNewRouteScreen,
SettingsScheduledTaskEditRouteScreen,
} from "./features/settings/SettingsScheduledTasksRouteScreen";
+import {
+ SettingsPluginRouteScreen,
+ SettingsPluginsRouteScreen,
+} from "./features/settings/SettingsPluginsRouteScreen";
import {
ScheduledTaskModelPickerRouteScreen,
ScheduledTaskBranchPickerRouteScreen,
@@ -326,6 +331,15 @@ const SettingsContentStack = createV5SheetStackNavigator({
headerTitleStyle: { fontSize: 16, fontWeight: "800" },
},
}),
+ SettingsPlugins: createNativeStackScreen({
+ screen: SettingsPluginsRouteScreen,
+ linking: "plugins",
+ options: { title: "Plugins" },
+ }),
+ SettingsPlugin: createNativeStackScreen({
+ screen: SettingsPluginRouteScreen,
+ options: { title: "Plugin" },
+ }),
SettingsScheduledTaskNew: createNativeStackScreen({
screen: SettingsScheduledTaskNewRouteScreen,
linking: "scheduled-tasks/new",
@@ -634,6 +648,7 @@ function RootStackLayout(props: {
{props.children}
+
);
diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx
index 5c5126865e55..a25de5ea92fc 100644
--- a/apps/mobile/src/features/keyboard/CommandPalette.tsx
+++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx
@@ -1,6 +1,6 @@
import { useNavigation } from "@react-navigation/native";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
-import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
+import { AuthOrchestrationOperateScope, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
@@ -25,13 +25,16 @@ import { cn } from "../../lib/cn";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
import { useProjects, useThreadShell, useThreadShells } from "../../state/entities";
+import { runPluginAction, usePluginActions } from "../../state/plugin-actions";
import { useThreadSearch } from "../../state/queries";
+import { useEnvironmentScope } from "../../state/session";
import { useWorkspaceEnvironments } from "../../state/workspace";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match";
import {
+ buildPluginActionPaletteItems,
filterCommandPaletteItems,
nextPaletteIndex,
type CommandPaletteItem,
@@ -66,6 +69,7 @@ const ACTION_ICONS: Record = {
function itemIcon(item: CommandPaletteItem): AppSymbolName {
if (item.kind === "project") return "folder";
if (item.kind === "thread") return "text.bubble";
+ if (item.key.startsWith("plugin-action:")) return "cube";
return ACTION_ICONS[item.key] ?? "ellipsis";
}
@@ -146,6 +150,17 @@ export function CommandPalette(props: {
const activeThreadRef = useMemo(() => parseActiveThreadPath(props.pathname), [props.pathname]);
const activeThread = useThreadShell(activeThreadRef);
const environments = useWorkspaceEnvironments();
+ // Plugin actions belong to the open thread's environment, else the first connected one.
+ const pluginActionEnvironmentId =
+ activeThreadRef?.environmentId ??
+ environments.find((environment) => environment.connectionState === "connected")
+ ?.environmentId ??
+ null;
+ const pluginActions = usePluginActions(pluginActionEnvironmentId);
+ const canRunPluginActions = useEnvironmentScope(
+ pluginActionEnvironmentId,
+ AuthOrchestrationOperateScope,
+ );
const { savedConnectionsById } = useSavedRemoteConnections();
const [query, setQuery] = useState("");
const [selection, setSelection] = useState(null);
@@ -300,6 +315,18 @@ export function CommandPalette(props: {
})),
);
}
+ if (pluginActionEnvironmentId !== null) {
+ actions.push(
+ ...buildPluginActionPaletteItems({
+ actions: pluginActions,
+ canOperate: canRunPluginActions,
+ environmentId: pluginActionEnvironmentId,
+ threadId: activeThread?.id ?? null,
+ projectId: activeThread?.projectId ?? null,
+ runAction: (input) => void runPluginAction(input),
+ }),
+ );
+ }
const projectItems: CommandPaletteItem[] = projects.map((project) => ({
key: `project:${scopedProjectKey(project.environmentId, project.id)}`,
kind: "project",
@@ -346,6 +373,9 @@ export function CommandPalette(props: {
activeThread,
activeThreadRef,
navigation,
+ canRunPluginActions,
+ pluginActionEnvironmentId,
+ pluginActions,
projects,
runCommand,
savedConnectionsById,
diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts
index 2a383b80369d..89c23fcccd99 100644
--- a/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts
+++ b/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts
@@ -1,6 +1,14 @@
-import { describe, expect, it } from "vite-plus/test";
+import {
+ EnvironmentId,
+ PluginActionId,
+ ProjectId,
+ ThreadId,
+ type PluginAction,
+} from "@t3tools/contracts";
+import { describe, expect, it, vi } from "vite-plus/test";
import {
+ buildPluginActionPaletteItems,
filterCommandPaletteItems,
nextPaletteIndex,
type CommandPaletteItem,
@@ -74,3 +82,79 @@ describe("nextPaletteIndex", () => {
expect(nextPaletteIndex(0, 1, 0)).toBe(0);
});
});
+
+describe("buildPluginActionPaletteItems", () => {
+ const environmentId = EnvironmentId.make("environment-1");
+ const thread = {
+ environmentId,
+ threadId: ThreadId.make("thread-1"),
+ projectId: ProjectId.make("project-1"),
+ };
+ const deploy: PluginAction = {
+ id: PluginActionId.make("installation-1:1:deploy"),
+ pluginId: "acme.deploy",
+ pluginName: "Deploy",
+ name: "deploy",
+ title: "Deploy this branch",
+ target: "thread",
+ placements: ["command-palette"],
+ };
+ const refresh: PluginAction = {
+ ...deploy,
+ id: PluginActionId.make("installation-1:1:refresh"),
+ name: "refresh",
+ title: "Refresh caches",
+ target: "environment",
+ };
+
+ it("runs an offered action in the open thread's environment", () => {
+ const runAction = vi.fn();
+ const offered = buildPluginActionPaletteItems({
+ actions: [deploy],
+ canOperate: true,
+ ...thread,
+ runAction,
+ });
+ expect(offered.map((item) => item.title)).toEqual(["Deploy this branch"]);
+
+ offered[0]?.run();
+
+ expect(runAction).toHaveBeenCalledWith({
+ environmentId: thread.environmentId,
+ action: deploy,
+ target: { _tag: "thread", threadId: thread.threadId },
+ });
+ });
+
+ it("offers environment actions when no thread is open", () => {
+ const runAction = vi.fn();
+ const offered = buildPluginActionPaletteItems({
+ actions: [deploy, refresh],
+ canOperate: true,
+ environmentId,
+ threadId: null,
+ projectId: null,
+ runAction,
+ });
+ expect(offered.map((item) => item.title)).toEqual(["Refresh caches"]);
+
+ offered[0]?.run();
+
+ expect(runAction).toHaveBeenCalledWith({
+ environmentId,
+ action: refresh,
+ target: { _tag: "environment" },
+ });
+ });
+
+ it("offers nothing to a connection that cannot operate the environment", () => {
+ expect(
+ buildPluginActionPaletteItems({
+ actions: [deploy],
+ canOperate: false,
+ ...thread,
+ runAction: vi.fn(),
+ }),
+ ).toEqual([]);
+ });
+});
diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.ts
index 6a06fd4e3387..69f3bd7b393f 100644
--- a/apps/mobile/src/features/keyboard/commandPaletteItems.ts
+++ b/apps/mobile/src/features/keyboard/commandPaletteItems.ts
@@ -1,3 +1,12 @@
+import { pluginActionLabels, pluginActionsAt } from "@t3tools/client-runtime/state/pluginActions";
+import type {
+ EnvironmentId,
+ PluginAction,
+ PluginActionTarget,
+ ProjectId,
+ ThreadId,
+} from "@t3tools/contracts";
+
export interface CommandPaletteItem {
readonly key: string;
readonly kind: "action" | "project" | "thread";
@@ -44,3 +53,37 @@ export function filterCommandPaletteItems(
export function nextPaletteIndex(index: number, direction: -1 | 1, count: number) {
return count === 0 ? 0 : (index + direction + count) % count;
}
+
+/**
+ * The palette plugin actions of one environment, for the open thread and its
+ * project when there is one. Running one needs `orchestration:operate`, so a
+ * connection without it is offered none.
+ */
+export function buildPluginActionPaletteItems(input: {
+ readonly actions: ReadonlyArray;
+ readonly canOperate: boolean;
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId | null;
+ readonly projectId: ProjectId | null;
+ readonly runAction: (input: {
+ readonly environmentId: EnvironmentId;
+ readonly action: PluginAction;
+ readonly target: PluginActionTarget;
+ }) => void;
+}): CommandPaletteItem[] {
+ if (!input.canOperate) return [];
+ const { environmentId } = input;
+ const entries = pluginActionsAt(input.actions, "command-palette", {
+ threadId: input.threadId,
+ projectId: input.projectId,
+ });
+ const labels = pluginActionLabels(entries.map((entry) => entry.action));
+ return entries.map(({ action, target }, index) => ({
+ key: `plugin-action:${action.id}`,
+ kind: "action",
+ title: labels[index] ?? action.title,
+ detail: action.description ?? action.pluginName,
+ searchTerms: [action.name, action.pluginName, "plugin"],
+ run: () => input.runAction({ environmentId, action, target }),
+ }));
+}
diff --git a/apps/mobile/src/features/plugins/PluginNotificationBannerHost.tsx b/apps/mobile/src/features/plugins/PluginNotificationBannerHost.tsx
new file mode 100644
index 000000000000..4810cd71845d
--- /dev/null
+++ b/apps/mobile/src/features/plugins/PluginNotificationBannerHost.tsx
@@ -0,0 +1,170 @@
+import { useAtomValue } from "@effect/atom-react";
+import { useLinkTo } from "@react-navigation/native";
+import {
+ pluginNotificationChanges,
+ pluginNotificationDescription,
+ type PluginNotificationMark,
+ type ReceivedPluginNotification,
+} from "@t3tools/client-runtime/state/plugin-notifications";
+import type { ContributionStatusTone, EnvironmentId } from "@t3tools/contracts";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { AccessibilityInfo, Platform, Pressable, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AppText as Text } from "../../components/AppText";
+import { cn } from "../../lib/cn";
+import { useEnvironments } from "../../state/environments";
+import { pluginNotificationEnvironment } from "../../state/plugin-notifications";
+import {
+ type Banner,
+ queueBannerChanges,
+ removeEnvironmentBanners,
+} from "./plugin-notification-banners";
+
+const SHOWN_MS = 5_000;
+
+const TONE_DOT_CLASS = {
+ neutral: "bg-foreground-muted",
+ info: "bg-adaptive-sky-600-400",
+ success: "bg-adaptive-emerald-600-400",
+ warning: "bg-adaptive-amber-700-400",
+ error: "bg-adaptive-rose-600-400",
+} as const satisfies Record;
+
+/**
+ * Plugin notifications as an in-app banner under the status bar, one at a
+ * time for a few seconds each. Only notifications that arrive while the app
+ * is open are shown; tapping one about a thread opens it, and a banner goes
+ * away once the server no longer retains its notification (its plugin
+ * stopped, it expired or was evicted, or the server restarted) or its
+ * environment is removed.
+ */
+export function PluginNotificationBannerHost() {
+ const { environments } = useEnvironments();
+ const [queue, setQueue] = useState>([]);
+ const onChanges = useCallback(
+ (
+ environmentId: EnvironmentId,
+ show: ReadonlyArray,
+ keep: ReadonlySet,
+ ) => setQueue((current) => queueBannerChanges(current, environmentId, show, keep)),
+ [],
+ );
+ const onRemoved = useCallback(
+ (environmentId: EnvironmentId) =>
+ setQueue((current) => removeEnvironmentBanners(current, environmentId)),
+ [],
+ );
+ const dismiss = useCallback(
+ (banner: Banner) => setQueue((current) => current.filter((each) => each !== banner)),
+ [],
+ );
+ const head = queue[0];
+
+ useEffect(() => {
+ if (head === undefined) return;
+ const timer = setTimeout(() => dismiss(head), SHOWN_MS);
+ return () => clearTimeout(timer);
+ }, [dismiss, head]);
+
+ return (
+ <>
+ {environments.map((environment) => (
+
+ ))}
+ {head === undefined ? null : (
+ // A fresh mount per notification, so two identical ones are both announced.
+
+ )}
+ >
+ );
+}
+
+function EnvironmentPluginNotifications(props: {
+ readonly environmentId: EnvironmentId;
+ readonly onChanges: (
+ environmentId: EnvironmentId,
+ show: ReadonlyArray,
+ keep: ReadonlySet,
+ ) => void;
+ readonly onRemoved: (environmentId: EnvironmentId) => void;
+}) {
+ const { environmentId, onChanges, onRemoved } = props;
+ const frame = useAtomValue(pluginNotificationEnvironment.frame(environmentId));
+ // The newest notification seen; banners themselves live in the host's queue.
+ const mark = useRef(undefined);
+
+ // Unmounting means the environment was removed, so no later frame will drop its banners.
+ useEffect(() => () => onRemoved(environmentId), [environmentId, onRemoved]);
+
+ useEffect(() => {
+ const changes = pluginNotificationChanges(mark.current, frame);
+ mark.current = changes.mark;
+ onChanges(environmentId, changes.show, changes.keep);
+ }, [environmentId, frame, onChanges]);
+
+ return null;
+}
+
+function PluginNotificationBanner(props: {
+ readonly banner: Banner;
+ readonly onDone: (banner: Banner) => void;
+}) {
+ const { banner, onDone } = props;
+ const insets = useSafeAreaInsets();
+ const linkTo = useLinkTo();
+ const { threadId, title, tone } = banner.entry.notification;
+ const description = pluginNotificationDescription(banner.entry);
+ // The banner leaves on its own, so it is announced rather than waiting for
+ // focus: accessibilityLiveRegion reaches TalkBack, VoiceOver needs this.
+ useEffect(() => {
+ if (Platform.OS === "ios")
+ AccessibilityInfo.announceForAccessibility(`${title}. ${description}`);
+ }, [title, description]);
+
+ return (
+
+ {
+ onDone(banner);
+ if (threadId !== undefined)
+ linkTo(
+ `/threads/${encodeURIComponent(banner.environmentId)}/${encodeURIComponent(threadId)}`,
+ );
+ }}
+ >
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/plugins/PluginSettingsSections.tsx b/apps/mobile/src/features/plugins/PluginSettingsSections.tsx
new file mode 100644
index 000000000000..6a1ead9a1e29
--- /dev/null
+++ b/apps/mobile/src/features/plugins/PluginSettingsSections.tsx
@@ -0,0 +1,51 @@
+import { useAtomValue } from "@effect/atom-react";
+import { supportsPluginSettings } from "@t3tools/client-runtime/state/pluginSettings";
+import type { EnvironmentId, ExecutionEnvironmentCapabilities } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { AsyncResult } from "effect/reactivity";
+
+import { AppText as Text } from "../../components/AppText";
+import { pluginEnvironment } from "../../state/plugins";
+import { PluginSettingsValues } from "./PluginSettingsValues";
+
+/**
+ * The saved settings of each installed plugin that declares any, read-only:
+ * mobile always pairs with standard scopes, so it cannot save them. Renders
+ * nothing on a server without plugin settings or when no plugin declares any.
+ */
+export function PluginSettingsSections({
+ environmentId,
+ capabilities,
+}: {
+ readonly environmentId: EnvironmentId;
+ readonly capabilities: ExecutionEnvironmentCapabilities | undefined;
+}) {
+ if (!supportsPluginSettings(capabilities)) return null;
+ return ;
+}
+
+function InstalledPluginSettings({ environmentId }: { readonly environmentId: EnvironmentId }) {
+ const catalog = Option.getOrNull(
+ AsyncResult.value(useAtomValue(pluginEnvironment.catalog({ environmentId, input: {} }))),
+ );
+ if (catalog?._tag !== "available") return null;
+ const installations = catalog.installations.filter(
+ (installation) => (installation.manifest?.settings?.length ?? 0) > 0,
+ );
+ if (installations.length === 0) return null;
+ return (
+ <>
+ {installations.map((installation) => (
+
+ ))}
+
+ Plugin settings are view-only on mobile. Edit them from an administrative web or desktop
+ connection.
+
+ >
+ );
+}
diff --git a/apps/mobile/src/features/plugins/PluginSettingsValues.logic.test.ts b/apps/mobile/src/features/plugins/PluginSettingsValues.logic.test.ts
new file mode 100644
index 000000000000..e3051a18f0c1
--- /dev/null
+++ b/apps/mobile/src/features/plugins/PluginSettingsValues.logic.test.ts
@@ -0,0 +1,58 @@
+import { pluginSettingRows } from "@t3tools/client-runtime/state/pluginSettings";
+import {
+ type PluginSettingField,
+ PluginInstallationId,
+ type PluginSettingsValues,
+} from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { describePluginSettingValue } from "./PluginSettingsValues.logic";
+
+const fields: ReadonlyArray = [
+ { type: "number", key: "retries", label: "Retries", min: 0, max: 5, integer: true, default: 2 },
+ {
+ type: "select",
+ key: "mode",
+ label: "Mode",
+ options: [
+ { value: "safe", label: "Safe" },
+ { value: "fast", label: "Fast" },
+ ],
+ default: "safe",
+ },
+ { type: "secret", key: "token", label: "Token" },
+];
+const shown = (saved: PluginSettingsValues["values"], secrets: ReadonlyArray = []) =>
+ pluginSettingRows(fields, {
+ installationId: PluginInstallationId.make("installation-1"),
+ values: saved,
+ secrets,
+ }).map(describePluginSettingValue);
+
+describe("describePluginSettingValue", () => {
+ it("marks defaults when nothing is saved", () => {
+ expect(shown([])).toEqual(["2 (default)", "Safe (default)", "Not set"]);
+ });
+
+ it("shows saved values that still fit without a default label", () => {
+ expect(
+ shown(
+ [
+ { key: "retries", value: 4 },
+ { key: "mode", value: "fast" },
+ ],
+ ["token"],
+ ),
+ ).toEqual(["4", "Fast", "Saved"]);
+ });
+
+ it("marks the default a saved value fell back to once it no longer fits", () => {
+ // Saved before an update narrowed the range and removed the option.
+ expect(
+ shown([
+ { key: "retries", value: 9 },
+ { key: "mode", value: "turbo" },
+ ]),
+ ).toEqual(["2 (default)", "Safe (default)", "Not set"]);
+ });
+});
diff --git a/apps/mobile/src/features/plugins/PluginSettingsValues.logic.ts b/apps/mobile/src/features/plugins/PluginSettingsValues.logic.ts
new file mode 100644
index 000000000000..c515c1a88dc6
--- /dev/null
+++ b/apps/mobile/src/features/plugins/PluginSettingsValues.logic.ts
@@ -0,0 +1,17 @@
+import type { PluginSettingRow } from "@t3tools/client-runtime/state/pluginSettings";
+
+/** A field's value as text; a secret shows only whether one is saved. */
+export function describePluginSettingValue({ field, value, saved, isDefault }: PluginSettingRow) {
+ if (field.type === "secret") return saved ? "Saved" : "Not set";
+ if (value === undefined) return "Not set";
+ const text =
+ field.type === "boolean"
+ ? value === true
+ ? "On"
+ : "Off"
+ : field.type === "select"
+ ? (field.options.find((option) => option.value === value)?.label ?? String(value))
+ : String(value);
+ // A saved value that no longer fits falls back to the default, and says so.
+ return isDefault ? `${text} (default)` : text;
+}
diff --git a/apps/mobile/src/features/plugins/PluginSettingsValues.tsx b/apps/mobile/src/features/plugins/PluginSettingsValues.tsx
new file mode 100644
index 000000000000..d16758c99832
--- /dev/null
+++ b/apps/mobile/src/features/plugins/PluginSettingsValues.tsx
@@ -0,0 +1,50 @@
+import { useAtomValue } from "@effect/atom-react";
+import { pluginSettingRows } from "@t3tools/client-runtime/state/pluginSettings";
+import type { EnvironmentId, PluginInstallation } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { AsyncResult } from "effect/reactivity";
+import { View } from "react-native";
+
+import { AppText as Text } from "../../components/AppText";
+import { pluginSettingsEnvironment } from "../../state/plugins";
+import { SettingsSection } from "../settings/components/SettingsSection";
+import { describePluginSettingValue } from "./PluginSettingsValues.logic";
+
+/**
+ * The settings one installation declares and what is saved for them, read-only.
+ * Renders nothing when the plugin declares none or the server cannot store them.
+ */
+export function PluginSettingsValues({
+ environmentId,
+ installation,
+}: {
+ readonly environmentId: EnvironmentId;
+ readonly installation: PluginInstallation;
+}) {
+ const fields = installation.manifest?.settings ?? [];
+ const result = useAtomValue(
+ pluginSettingsEnvironment.values({
+ environmentId,
+ input: { installationId: installation.installationId },
+ }),
+ );
+ const view = Option.getOrNull(AsyncResult.value(result));
+ if (fields.length === 0 || view === null || view._tag === "unsupported") return null;
+ return (
+
+ {pluginSettingRows(fields, view.values).map((row, index) => (
+ 0 ? "gap-1 border-t border-border-subtle px-4 py-3" : "gap-1 px-4 py-3"
+ }
+ >
+ {row.field.label}
+
+ {describePluginSettingValue(row)}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/mobile/src/features/plugins/plugin-notification-banners.test.ts b/apps/mobile/src/features/plugins/plugin-notification-banners.test.ts
new file mode 100644
index 000000000000..8ced7a29728f
--- /dev/null
+++ b/apps/mobile/src/features/plugins/plugin-notification-banners.test.ts
@@ -0,0 +1,73 @@
+import {
+ pluginNotificationChanges,
+ type PluginNotificationMark,
+} from "@t3tools/client-runtime/state/plugin-notifications";
+import { EnvironmentId, type PluginNotificationFrame } from "@t3tools/contracts";
+import { expect, it } from "vite-plus/test";
+
+import {
+ type Banner,
+ queueBannerChanges,
+ removeEnvironmentBanners,
+} from "./plugin-notification-banners";
+
+const A = EnvironmentId.make("env-a");
+const B = EnvironmentId.make("env-b");
+
+/** The same epoch and sequences in each environment, so only the environment differs. */
+const frame = (titles: ReadonlyArray): PluginNotificationFrame => ({
+ epoch: "epoch-1",
+ notifications: titles.map((title, index) => ({
+ sequence: index + 1,
+ pluginId: "acme.notifier",
+ pluginName: "Notifier",
+ title,
+ createdAt: "2026-10-04T00:00:00.000Z",
+ })),
+});
+
+/** Feeds frames through the shared helper as each environment's host component does. */
+function receive(
+ queue: ReadonlyArray,
+ marks: Map,
+ environmentId: EnvironmentId,
+ next: PluginNotificationFrame,
+) {
+ const changes = pluginNotificationChanges(marks.get(environmentId), next);
+ marks.set(environmentId, changes.mark);
+ return queueBannerChanges(queue, environmentId, changes.show, changes.keep);
+}
+
+const shown = (queue: ReadonlyArray) =>
+ queue.map((banner) => `${banner.environmentId}:${banner.entry.notification.title}`);
+
+it("drops a removed environment's queued banners and keeps the others", () => {
+ const marks = new Map();
+ let queue: ReadonlyArray = [];
+ queue = receive(queue, marks, A, frame([]));
+ queue = receive(queue, marks, B, frame([]));
+ queue = receive(queue, marks, A, frame(["a1", "a2"]));
+ queue = receive(queue, marks, B, frame(["b1"]));
+ expect(shown(queue)).toEqual(["env-a:a1", "env-a:a2", "env-b:b1"]);
+
+ queue = removeEnvironmentBanners(queue, A);
+ expect(shown(queue)).toEqual(["env-b:b1"]);
+ expect(removeEnvironmentBanners(queue, A)).toBe(queue);
+});
+
+it("queues each notification once across reconnects and drops what left the set", () => {
+ const marks = new Map();
+ let queue: ReadonlyArray = [];
+ // Launch: what the server already held is not news.
+ queue = receive(queue, marks, A, frame(["old"]));
+ expect(shown(queue)).toEqual([]);
+ queue = receive(queue, marks, A, frame(["old", "first"]));
+ // Back online after missing one, then a second reconnect with nothing new.
+ queue = receive(queue, marks, A, frame(["old", "first", "missed"]));
+ const settled = receive(queue, marks, A, frame(["old", "first", "missed"]));
+ expect(settled).toBe(queue);
+ expect(shown(queue)).toEqual(["env-a:first", "env-a:missed"]);
+ // The plugin was disabled while the app was away: its banners go on reconnect.
+ queue = receive(queue, marks, A, frame(["old"]));
+ expect(shown(queue)).toEqual([]);
+});
diff --git a/apps/mobile/src/features/plugins/plugin-notification-banners.ts b/apps/mobile/src/features/plugins/plugin-notification-banners.ts
new file mode 100644
index 000000000000..886253269a2a
--- /dev/null
+++ b/apps/mobile/src/features/plugins/plugin-notification-banners.ts
@@ -0,0 +1,36 @@
+import type { ReceivedPluginNotification } from "@t3tools/client-runtime/state/plugin-notifications";
+import type { EnvironmentId } from "@t3tools/contracts";
+
+/** Banners waiting their turn; older ones are dropped past this. */
+const MAX_QUEUED_BANNERS = 5;
+
+export interface Banner {
+ readonly environmentId: EnvironmentId;
+ readonly entry: ReceivedPluginNotification;
+}
+
+/**
+ * Applies one environment's frame: drops its banners the server no longer
+ * retains and queues the new ones. Returns `queue` itself when nothing changed.
+ */
+export function queueBannerChanges(
+ queue: ReadonlyArray,
+ environmentId: EnvironmentId,
+ show: ReadonlyArray,
+ keep: ReadonlySet,
+): ReadonlyArray {
+ const kept = queue.filter(
+ (banner) => banner.environmentId !== environmentId || keep.has(banner.entry.key),
+ );
+ if (show.length === 0 && kept.length === queue.length) return queue;
+ return [...kept, ...show.map((entry) => ({ environmentId, entry }))].slice(-MAX_QUEUED_BANNERS);
+}
+
+/** Drops a removed environment's banners, which no later frame would. */
+export function removeEnvironmentBanners(
+ queue: ReadonlyArray,
+ environmentId: EnvironmentId,
+): ReadonlyArray {
+ const kept = queue.filter((banner) => banner.environmentId !== environmentId);
+ return kept.length === queue.length ? queue : kept;
+}
diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx
index 1065a860a5e4..e094d8000f2a 100644
--- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx
@@ -22,6 +22,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { useRemoteConnections } from "../../state/use-remote-environment-registry";
import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
import { EnvironmentRoutesSection } from "./EnvironmentRoutesSection";
+import { PluginSettingsSections } from "../plugins/PluginSettingsSections";
import { SettingsActionRow } from "./components/SettingsActionRow";
import { SettingsScreen } from "./components/SettingsScreen";
import { SettingsSection } from "./components/SettingsSection";
@@ -372,6 +373,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
))}
+
>
) : null}
>
diff --git a/apps/mobile/src/features/settings/SettingsPluginsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsPluginsRouteScreen.tsx
new file mode 100644
index 000000000000..1f2bf4a47bf2
--- /dev/null
+++ b/apps/mobile/src/features/settings/SettingsPluginsRouteScreen.tsx
@@ -0,0 +1,586 @@
+import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
+import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
+import {
+ type EnvironmentId,
+ type PluginInstallation,
+ type PluginInstallationId,
+ type PluginInstallationManifest,
+ type PluginNpmPackage,
+ resolveEnvironmentMachineKind,
+} from "@t3tools/contracts";
+import {
+ describePluginCapabilities,
+ describePluginContributions,
+ type PluginOfferedViews,
+} from "@t3tools/client-runtime/state/pluginContributions";
+import {
+ describePluginNpmSource,
+ PLUGIN_NPM_INTEGRITY_STATEMENT,
+ pluginNpmListKey,
+ pluginNpmRowLabel,
+ resolvePluginNpmPackagesState,
+ resolvePluginNpmProvenance,
+ supportsPluginNpm,
+} from "@t3tools/client-runtime/state/pluginNpmPresentation";
+import {
+ describePluginSource,
+ presentPluginInstallation,
+ resolvePluginCatalogState,
+ resolvePluginDetail,
+ type PluginStateTone,
+} from "@t3tools/client-runtime/state/pluginPresentation";
+import { useEffect, useRef } from "react";
+import { Platform, Pressable, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AppText as Text } from "../../components/AppText";
+import { SymbolView } from "../../components/AppSymbol";
+import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
+import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
+import { usePluginActionsSnapshot } from "../../state/plugin-actions";
+import {
+ pluginEnvironment,
+ pluginNpmEnvironment,
+ pluginViewEnvironment,
+} from "../../state/plugins";
+import { useEnvironmentQuery } from "../../state/query";
+import { SettingsActionRow } from "./components/SettingsActionRow";
+import {
+ AndroidSettingsEnvironmentFilter,
+ SettingsEnvironmentFilterHeader,
+} from "./components/SettingsEnvironmentFilterHeader";
+import { SettingsScreen } from "./components/SettingsScreen";
+import { SettingsSection } from "./components/SettingsSection";
+import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter";
+
+type PluginRoutes = {
+ SettingsPlugin: {
+ readonly environmentId: EnvironmentId;
+ readonly installationId: PluginInstallationId;
+ };
+};
+
+const TONE_TEXT: Record = {
+ neutral: "text-foreground-muted",
+ success: "text-foreground",
+ info: "text-foreground",
+ warning: "text-warning-foreground",
+ error: "text-danger-foreground",
+};
+
+/** Whether this environment's server has the plugin catalogue; older servers never get a plugin call. */
+export function supportsPlugins(target: SettingsTarget): boolean {
+ return target.serverConfig.environment.capabilities.plugins === true;
+}
+
+/**
+ * Mobile always pairs with standard scopes, so it never has the access:write that
+ * plugin management needs; it shows plugins without controls.
+ */
+const PLUGINS_VIEW_ONLY =
+ "Plugins are view-only on mobile. Add, install, approve, enable, update, or remove them from an administrative web or desktop connection.";
+
+function ViewOnlyNotice() {
+ return {PLUGINS_VIEW_ONLY};
+}
+
+/** The catalogue of a connected environment that supports plugins; anything else gets no call. */
+function usePluginCatalog(environmentId: EnvironmentId, environment: SettingsTarget | undefined) {
+ const supported = environment !== undefined && supportsPlugins(environment);
+ const catalog = useEnvironmentQuery(
+ supported ? pluginEnvironment.catalog({ environmentId, input: {} }) : null,
+ );
+ const state = resolvePluginCatalogState({
+ connected: environment !== undefined,
+ data: supported ? catalog.data : { _tag: "unsupported" },
+ error: catalog.error,
+ });
+ return { state, retry: catalog.refresh };
+}
+
+/** Calls `refresh` when `key` changes from one known value to another; never on a timer. */
+function useRefreshOnChange(key: string | null, refresh: () => void) {
+ const last = useRef(key);
+ useEffect(() => {
+ const previous = last.current;
+ last.current = key;
+ if (previous !== null && key !== null && previous !== key) refresh();
+ }, [key, refresh]);
+}
+
+/**
+ * Where an environment's plugins came from on npm. An older server gets no call.
+ * The list is a query, so it is read again when installations or their files change.
+ */
+function usePluginNpmPackages(
+ environmentId: EnvironmentId,
+ environment: SettingsTarget | undefined,
+ installations: ReadonlyArray | null,
+) {
+ const supported =
+ environment !== undefined &&
+ supportsPluginNpm(environment.serverConfig.environment.capabilities);
+ const query = useEnvironmentQuery(
+ supported ? pluginNpmEnvironment.packages({ environmentId, input: {} }) : null,
+ );
+ useRefreshOnChange(
+ supported && installations !== null ? pluginNpmListKey(installations) : null,
+ query.refresh,
+ );
+ return {
+ state: resolvePluginNpmPackagesState({ supported, data: query.data, error: query.error }),
+ refresh: query.refresh,
+ };
+}
+
+const SCROLL_PROPS = {
+ keyboardShouldPersistTaps: "handled",
+ contentInsetAdjustmentBehavior: "automatic",
+ showsVerticalScrollIndicator: false,
+ className: "flex-1",
+ contentContainerClassName: "gap-5 px-5 pt-4",
+} as const;
+
+export function SettingsPluginsRouteScreen() {
+ const insets = useSafeAreaInsets();
+ const { availableTargets, selectedTargets } = useSettingsEnvironmentFilter();
+ const targets = selectedTargets.filter(supportsPlugins);
+ return (
+ <>
+
+ }>
+
+ {targets.length > 0 ? (
+ targets.map((target) => (
+
+ ))
+ ) : (
+
+ {availableTargets.length === 0
+ ? "Connect an environment to see its plugins."
+ : selectedTargets.length === 0
+ ? "Select an environment to see its plugins."
+ : "Update T3 Code on the selected environments to see their plugins."}
+
+ )}
+
+
+ >
+ );
+}
+
+function EnvironmentPlugins({ environment }: { readonly environment: SettingsTarget }) {
+ const navigation = useNavigation>();
+ const environmentId = environment.environmentId;
+ const catalog = usePluginCatalog(environmentId, environment);
+ const installations =
+ catalog.state._tag === "available" ? catalog.state.view.installations : null;
+ const npm = usePluginNpmPackages(environmentId, environment, installations);
+ if (catalog.state._tag === "unsupported") return null;
+ return (
+
+
+ }
+ >
+ {catalog.state._tag === "failed" ? (
+ <>
+ {catalog.state.message}
+
+
+
+ >
+ ) : installations === null ? (
+ Loading plugins…
+ ) : installations.length === 0 ? (
+ No plugins yet.
+ ) : (
+ installations.map((installation, index) => (
+
+ navigation.navigate("SettingsPlugin", {
+ environmentId,
+ installationId: installation.installationId,
+ })
+ }
+ />
+ ))
+ )}
+
+ {installations !== null ? : null}
+
+ );
+}
+
+/** The npm package behind a listed installation, from the list alone. */
+function npmPackageOf(
+ state: ReturnType,
+ installationId: PluginInstallationId,
+): PluginNpmPackage | null {
+ const provenance = resolvePluginNpmProvenance({ state, installationId, step: null });
+ return provenance._tag === "found" ? provenance.package : null;
+}
+
+function PluginListRow({
+ installation,
+ npmPackage,
+ first,
+ onPress,
+}: {
+ readonly installation: PluginInstallation;
+ readonly npmPackage: PluginNpmPackage | null;
+ readonly first: boolean;
+ readonly onPress: () => void;
+}) {
+ const view = presentPluginInstallation(installation);
+ return (
+
+
+
+ {view.title}
+ {installation.manifest ? (
+ {installation.manifest.version}
+ ) : null}
+
+
+ {view.stateLabel}
+ {view.detail ? ` · ${view.detail}` : ""}
+
+ {view.delivery ? (
+
+ {view.delivery.label}
+ {view.delivery.detail ? ` · ${view.delivery.detail}` : ""}
+
+ ) : null}
+ {npmPackage ? (
+
+ {pluginNpmRowLabel(npmPackage)}
+
+ ) : null}
+
+ {installation.directory}
+
+
+
+
+ );
+}
+
+/** Each declared capability on its own line, with what it lets the plugin do. */
+function capabilityLines(capabilities: ReadonlyArray): string {
+ if (capabilities.length === 0) return "None declared";
+ return describePluginCapabilities(capabilities)
+ .map((capability) =>
+ capability.meaning ? `${capability.name}: ${capability.meaning}` : capability.name,
+ )
+ .join("\n");
+}
+
+function DetailField({
+ label,
+ value,
+ mono = false,
+ first = false,
+}: {
+ readonly label: string;
+ readonly value: string;
+ readonly mono?: boolean;
+ readonly first?: boolean;
+}) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
+
+export function SettingsPluginRouteScreen({
+ route,
+}: StaticScreenProps) {
+ return (
+
+ );
+}
+
+function PluginDetail({
+ environmentId,
+ installationId,
+}: {
+ readonly environmentId: EnvironmentId;
+ readonly installationId: PluginInstallationId;
+}) {
+ const insets = useSafeAreaInsets();
+ const { availableTargets } = useSettingsEnvironmentFilter();
+ const environment = availableTargets.find((target) => target.environmentId === environmentId);
+ const label = environment?.label ?? "this environment";
+ const catalog = usePluginCatalog(environmentId, environment);
+ const detail = resolvePluginDetail({ catalog: catalog.state, installationId, added: null });
+ const npm = usePluginNpmPackages(
+ environmentId,
+ environment,
+ catalog.state._tag === "available" ? catalog.state.view.installations : null,
+ );
+
+ if (detail._tag !== "found") {
+ return (
+
+
+ {detail._tag === "failed" ? (
+
+
+ {detail.message}
+
+
+
+
+
+ ) : (
+
+ {detail._tag === "missing"
+ ? `This plugin is no longer installed on ${label}.`
+ : detail._tag === "loading"
+ ? "Loading plugin…"
+ : detail._tag === "disconnected"
+ ? `Reconnect ${label} to see its plugins.`
+ : `${label} does not support plugins. Update T3 Code there.`}
+
+ )}
+
+
+ );
+ }
+
+ const installation = detail.installation;
+ const view = presentPluginInstallation(installation);
+ const manifest = installation.manifest;
+ const digest = installation.source?.digest ?? null;
+ const provenance = resolvePluginNpmProvenance({ state: npm.state, installationId, step: null });
+ const npmPackage = provenance._tag === "found" ? provenance.package : null;
+
+ return (
+
+
+
+ {view.stateLabel}
+ {view.detail ? (
+
+ {view.detail}
+
+ ) : null}
+ {view.delivery ? (
+ <>
+
+ {view.delivery.label}
+
+ {view.delivery.detail ? (
+
+ {view.delivery.detail}
+
+ ) : null}
+ >
+ ) : null}
+
+
+ {manifest ? (
+ <>
+
+
+ {manifest.description ? (
+
+ ) : null}
+ >
+ ) : null}
+ {npmPackage ? (
+ <>
+
+
+ >
+ ) : provenance._tag === "unknown" ? (
+
+ ) : null}
+
+
+ {digest !== null ? : null}
+ {manifest ? (
+
+ ) : null}
+ {installation.problem ? (
+
+ ) : null}
+
+ {manifest ? (
+
+ ) : null}
+
+ {npm.state._tag === "failed" ? (
+
+
+ {npm.state.message}
+
+
+
+
+
+ ) : null}
+
+
+
+
+ );
+}
+
+/**
+ * What a plugin adds to T3 Code, from its manifest summary. For an enabled
+ * installation, the environment's action and view snapshots say what is offered
+ * now; listing them never starts the plugin.
+ */
+function PluginContributionsSection({
+ environmentId,
+ environment,
+ manifest,
+ installation,
+}: {
+ readonly environmentId: EnvironmentId;
+ readonly environment: SettingsTarget | undefined;
+ readonly manifest: PluginInstallationManifest;
+ readonly installation: PluginInstallation;
+}) {
+ const capabilities = environment?.serverConfig.environment.capabilities;
+ const offered = installation.enabled;
+ const actions = usePluginActionsSnapshot(
+ offered && capabilities?.pluginActions === true ? environmentId : null,
+ );
+ const views = useEnvironmentQuery(
+ offered && capabilities?.pluginViews === true
+ ? pluginViewEnvironment.views({ environmentId, input: {} })
+ : null,
+ ).data;
+ const offeredViews: PluginOfferedViews | null =
+ views?._tag === "available"
+ ? {
+ views: views.views.filter(
+ (view) =>
+ view.installationId === installation.installationId &&
+ view.generation === installation.generation,
+ ),
+ problems: views.problems.filter(
+ (problem) =>
+ problem.installationId === installation.installationId &&
+ problem.generation === installation.generation,
+ ),
+ }
+ : null;
+ const groups = describePluginContributions({
+ manifest,
+ installation,
+ actions: actions ?? null,
+ views: offeredViews,
+ });
+ return (
+
+ {groups.length === 0 ? (
+ Nothing declared
+ ) : (
+ groups.map((group, index) => (
+
+ {group.label}
+ {group.items.map((item) => (
+
+
+ {item.title}
+
+ {item.detail ? (
+
+ {item.detail}
+
+ ) : null}
+
+ ))}
+ {group.notice ? (
+ {group.notice}
+ ) : null}
+
+ ))
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index 1f474a9d56b8..536536564f12 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -17,6 +17,7 @@ import {
SettingsEnvironmentFilterHeader,
} from "./components/SettingsEnvironmentFilterHeader";
import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
+import { supportsPlugins } from "./SettingsPluginsRouteScreen";
export function SettingsRouteScreen() {
const navigation = useNavigation();
@@ -208,6 +209,9 @@ function SettingsIndexSections() {
target="SettingsEnvironmentMaintenance"
disabled={noServerTargets}
/>
+ {selectedTargets.some(supportsPlugins) ? (
+
+ ) : null}
diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts
index ad0197d634d8..5cdce284b468 100644
--- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts
+++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts
@@ -15,6 +15,7 @@ export type SettingsSheetTarget =
| "SettingsKeyboard"
| "SettingsFollowUp"
| "SettingsScheduledTasks"
+ | "SettingsPlugins"
| "SettingsProjectGrouping"
| "SettingsClientStorage"
| "SettingsDiagnostics"
diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
index 7b85e8e52273..b88d99187e54 100644
--- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
+++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
@@ -3,6 +3,8 @@ import {
type ProviderSkillSourceKind,
} from "@t3tools/client-runtime/providerSkills";
import type {
+ PluginAction,
+ PluginActionTarget,
PullRequestContextMetadata,
ScopedThreadRef,
ServerProviderSkill,
@@ -59,6 +61,14 @@ export type ComposerCommandItem =
readonly skill: ServerProviderSkill;
readonly label: string;
readonly description: string;
+ }
+ | {
+ readonly id: string;
+ readonly type: "plugin-action";
+ readonly action: PluginAction;
+ readonly target: PluginActionTarget;
+ readonly label: string;
+ readonly description: string;
};
interface ComposerCommandPopoverProps {
@@ -109,6 +119,8 @@ function itemIcon(item: ComposerCommandItem): AppSymbolName | null {
return null;
case "thread":
return "text.bubble";
+ case "plugin-action":
+ return "cube";
}
}
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
index 181c257cbc17..6d431d92cd0e 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -490,6 +490,8 @@ export function NewTaskDraftScreen(props: {
draftMessage: flow.prompt,
ownerKey: flow.draftKey,
environmentId: selectedProject?.environmentId ?? null,
+ // Project actions run on the selected project; a draft has no thread yet.
+ projectId: selectedProject?.id ?? null,
threadShells: useThreadShells(),
pullRequestProjectId: selectedEnvironmentServerConfig?.environment.capabilities.pullRequests
? (selectedProject?.id ?? null)
diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx
index 6873926fd5dd..529e04dff80a 100644
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -491,6 +491,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
environmentId: props.environmentId,
threadShells: useThreadShells(),
currentThreadId: props.selectedThread.id,
+ projectId: props.selectedThread.projectId,
projectCwd: props.projectCwd,
pullRequestProjectId: props.serverConfig?.environment.capabilities.pullRequests
? (project?.id ?? null)
diff --git a/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx b/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx
new file mode 100644
index 000000000000..3dda063eecd7
--- /dev/null
+++ b/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx
@@ -0,0 +1,122 @@
+import { useAtomValue } from "@effect/atom-react";
+import type {
+ ContributionStatusTone,
+ EnvironmentId,
+ ProviderDriverKind,
+ ThreadId,
+} from "@t3tools/contracts";
+import { useMemo } from "react";
+import { Alert, Platform, Pressable, ScrollView, View } from "react-native";
+
+import { AppText as Text } from "../../components/AppText";
+import { ProviderIcon } from "../../components/ProviderIcon";
+import { cn } from "../../lib/cn";
+import { contributionStatusEnvironment } from "../../state/contribution-status";
+import {
+ reservesContributionStatusBand,
+ threadContributionStatusChips,
+ threadHasContributionStatus,
+} from "./thread-contribution-status-presentation";
+
+/** The strip's height: one row of chip touch targets (44pt iOS, 48dp Android). */
+const STRIP_HEIGHT = Platform.OS === "android" ? 48 : 44;
+
+// Neutral, the default, has no dot: Pi status text often brings its own glyph.
+const TONE_DOT_CLASS = {
+ neutral: null,
+ info: "bg-adaptive-sky-600-400",
+ success: "bg-adaptive-emerald-600-400",
+ warning: "bg-adaptive-amber-700-400",
+ error: "bg-adaptive-rose-600-400",
+} as const satisfies Record;
+
+/**
+ * The band the feed reserves above its first row for the status strip, passed
+ * to ThreadFeed's `topOverlayInset`. See `reservesContributionStatusBand`.
+ */
+export function useThreadContributionStatusStripInset(input: {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ readonly supported: boolean;
+ readonly driver: ProviderDriverKind | undefined;
+}): number {
+ const hasStatus = useAtomValue(
+ contributionStatusEnvironment.threadStatus(input.environmentId, input.threadId),
+ threadHasContributionStatus,
+ );
+ return reservesContributionStatusBand({ ...input, hasStatus }) ? STRIP_HEIGHT : 0;
+}
+
+/**
+ * Advisory statuses the thread's provider set, such as Pi extension
+ * `setStatus` text, and statuses plugins set, as one row of chips floating
+ * just under the navigation header. It overlays the feed like the header
+ * does, so a status changing never resizes the composer; the feed reserves
+ * its band through `useThreadContributionStatusStripInset`. Renders nothing
+ * when the server lacks the capability or the thread has no statuses.
+ * Pressing a chip shows its tooltip and where it came from.
+ */
+export function ThreadContributionStatusStrip(props: {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ /** Distance from the screen's top edge to the bottom of the navigation header. */
+ readonly top: number;
+ readonly contentMaxWidth: number | undefined;
+}) {
+ const entries = useAtomValue(
+ contributionStatusEnvironment.threadStatus(props.environmentId, props.threadId),
+ );
+ const chips = useMemo(() => threadContributionStatusChips(entries), [entries]);
+ if (chips.length === 0) return null;
+
+ return (
+
+
+ {/* Sized to its chips, so the feed under the empty rest of the row
+ still takes touches. */}
+
+ {chips.map((chip) => {
+ const dotClass = TONE_DOT_CLASS[chip.tone];
+ // The compact pill sits inside a full-size touch target (44pt
+ // iOS, 48dp Android); neighbours abut without overlapping.
+ return (
+ Alert.alert(chip.details.title, chip.details.message)}
+ >
+
+ {chip.leadsSource && chip.driver !== null ? (
+
+ ) : null}
+ {dotClass === null ? null : (
+
+ )}
+
+ {chip.text}
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index a3d7a6f4e0c6..bc20dd883689 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -123,6 +123,10 @@ import { ComposerFeedback } from "./ComposerFeedback";
import { ComposerUsageLimits } from "./ComposerUsageLimits";
import { PendingUserInputCard } from "./PendingUserInputCard";
import { ProviderSubagentBar } from "./ProviderSubagentBar";
+import {
+ ThreadContributionStatusStrip,
+ useThreadContributionStatusStripInset,
+} from "./ThreadContributionStatusStrip";
import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard";
import {
FLOATING_WORKING_CONTROL_COVERAGE,
@@ -849,6 +853,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
providerSubagentProvider.models,
)
: null;
+ const statusStripInset = useThreadContributionStatusStripInset({
+ environmentId: props.environmentId,
+ threadId: props.selectedThread.id,
+ supported: props.serverConfig?.environment.capabilities.contributionStatus === true,
+ driver: providerSubagentProvider?.driver,
+ });
const providerSubagentCatalogModel = providerSubagentProvider?.models.find(
(model) => model.slug === providerSubagentModelSlug,
);
@@ -1172,6 +1182,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
submittedMessageId={submittedMessageId}
contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
contentTopInset={0}
+ topOverlayInset={statusStripInset}
contentBottomInset={
estimatedOverlayHeight +
(showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
@@ -1204,6 +1215,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
/>
) : null}
+ {showContent ? (
+
+ ) : null}
+
{/* Floating composer — sticks to keyboard via KeyboardStickyView */}
{showContent ? (
;
readonly contentTopInset?: number;
+ /** Height of UI floating over the feed's top edge below the header (the status strip). */
+ readonly topOverlayInset?: number;
readonly contentBottomInset?: number;
readonly historyControls?: ThreadFeedHistoryControls;
readonly contentMaxWidth?: number;
@@ -2306,6 +2309,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const anchorTopInset = usesNativeAutomaticInsets
? (navigationHeaderHeight ?? insets.top + IOS_NAV_BAR_HEIGHT)
: topContentInset;
+ const feedTop = deriveThreadFeedTopGeometry({
+ usesNativeAutomaticInsets,
+ headerInset: anchorTopInset,
+ topOverlayInset: props.topOverlayInset ?? 0,
+ });
const theme = useUniwindTheme();
const iconSubtleColor = theme["--color-icon-subtle"];
@@ -2753,9 +2761,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
presentedFeed,
props.anchorMessageId,
(entry) => (entry.type === "message" ? entry.id : null),
- { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
+ { anchorOffset: feedTop.anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
),
- [presentedFeed, props.anchorMessageId, anchorTopInset],
+ [presentedFeed, props.anchorMessageId, feedTop.anchorTopInset],
);
const failedRunIds = useMemo(
() => failedFeedRunIds(props.feed, props.latestRun),
@@ -3252,7 +3260,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
scrollEventThrottle={16}
ListHeaderComponent={
<>
- {usesNativeAutomaticInsets ? null : }
+ {feedTop.spacerHeight > 0 ? (
+
+ ) : null}
{setupAnchorIndex < 0 && props.worktreeSetup ? (
) : null}
diff --git a/apps/mobile/src/features/threads/thread-contribution-status-presentation.test.ts b/apps/mobile/src/features/threads/thread-contribution-status-presentation.test.ts
new file mode 100644
index 000000000000..a590c116ec9c
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-contribution-status-presentation.test.ts
@@ -0,0 +1,138 @@
+import {
+ type ContributionStatusEntry,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ ProviderSessionId,
+ ThreadId,
+} from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ reservesContributionStatusBand,
+ threadContributionStatusChips,
+ threadHasContributionStatus,
+} from "./thread-contribution-status-presentation";
+
+const entry = (
+ session: string,
+ items: ContributionStatusEntry["items"],
+ driver = "pi",
+): ContributionStatusEntry => ({
+ threadId: ThreadId.make("thread-1"),
+ source: {
+ kind: "provider-session",
+ providerSessionId: ProviderSessionId.make(session),
+ providerInstanceId: ProviderInstanceId.make(driver),
+ driver: ProviderDriverKind.make(driver),
+ },
+ items,
+});
+
+describe("threadContributionStatusChips", () => {
+ it("names a plugin as the source of its chips, without a provider icon", () => {
+ const [chip] = threadContributionStatusChips([
+ {
+ threadId: ThreadId.make("thread-1"),
+ source: { kind: "plugin", pluginId: "acme.ci", name: "CI watcher" },
+ items: [{ key: "ci", text: "passing", tone: "success" }],
+ },
+ ]);
+ expect(chip).toMatchObject({
+ driver: null,
+ text: "passing",
+ tone: "success",
+ accessibilityLabel: "CI watcher status: passing",
+ help: "Set by the CI watcher plugin.",
+ });
+ });
+
+ it("shows nothing for a thread without statuses", () => {
+ expect(threadContributionStatusChips([])).toEqual([]);
+ });
+
+ it("keys chips by source and item key, in the server's order", () => {
+ const chips = threadContributionStatusChips([
+ entry("session-a", [
+ { key: "mode", text: "● plan" },
+ { key: "tokens", text: "12k", tone: "warning", tooltip: "Context is filling up" },
+ ]),
+ entry("session-b", [{ key: "mode", text: "● plan" }], "codex"),
+ ]);
+
+ expect(chips.map((chip) => [chip.text, chip.leadsSource, chip.tone, chip.tooltip])).toEqual([
+ ["● plan", true, "neutral", null],
+ ["12k", false, "warning", "Context is filling up"],
+ ["● plan", true, "neutral", null],
+ ]);
+ // The same item key under two sources must stay two distinct rows.
+ expect(new Set(chips.map((chip) => chip.id)).size).toBe(3);
+ });
+
+ it("re-keys a chip when another provider session takes the thread over", () => {
+ const [before] = threadContributionStatusChips([entry("old", [{ key: "mode", text: "x" }])]);
+ const [after] = threadContributionStatusChips([entry("new", [{ key: "mode", text: "x" }])]);
+
+ expect(after?.id).not.toBe(before?.id);
+ });
+
+ it("attributes the status to its producer without claiming freshness", () => {
+ const [pi] = threadContributionStatusChips([entry("s", [{ key: "mode", text: "● startup" }])]);
+ const [codex] = threadContributionStatusChips([
+ entry("s", [{ key: "mode", text: "busy" }], "codex"),
+ ]);
+
+ expect(pi?.accessibilityLabel).toBe("Pi status: ● startup");
+ expect(pi?.help).toBe("Set by a Pi extension. It can lag a session change.");
+ expect(codex?.help).toBe("Set by Codex. It can lag a session change.");
+ });
+
+ it("puts the full status text in the details body, where it is never truncated", () => {
+ const text = "● build — indexing 1,284 files in packages/client-runtime before the next turn";
+ const [plain, withTooltip] = threadContributionStatusChips([
+ entry("s", [
+ { key: "mode", text },
+ { key: "tokens", text: "12k", tooltip: "Context is filling up" },
+ ]),
+ ]);
+
+ expect(plain?.details).toEqual({
+ title: "Pi status",
+ message: `${text}\n\nSet by a Pi extension. It can lag a session change.`,
+ });
+ expect(withTooltip?.details.message).toBe(
+ "12k\n\nContext is filling up\n\nSet by a Pi extension. It can lag a session change.",
+ );
+ });
+});
+
+describe("status strip band", () => {
+ it("counts a thread as having statuses only when an entry has items", () => {
+ expect(threadHasContributionStatus([])).toBe(false);
+ expect(threadHasContributionStatus([entry("s", [])])).toBe(false);
+ expect(threadHasContributionStatus([entry("s", [{ key: "mode", text: "x" }])])).toBe(true);
+ });
+
+ it("keeps a Pi thread's band through set, replace and clear", () => {
+ const pi = ProviderDriverKind.make("pi");
+ const band = [false, true, true, false].map((hasStatus) =>
+ reservesContributionStatusBand({ supported: true, driver: pi, hasStatus }),
+ );
+
+ expect(band).toEqual([true, true, true, true]);
+ });
+
+ it("reserves nothing for other providers without statuses or on servers without the stream", () => {
+ const codex = ProviderDriverKind.make("codex");
+ const pi = ProviderDriverKind.make("pi");
+
+ expect(
+ reservesContributionStatusBand({ supported: true, driver: codex, hasStatus: false }),
+ ).toBe(false);
+ expect(
+ reservesContributionStatusBand({ supported: true, driver: codex, hasStatus: true }),
+ ).toBe(true);
+ expect(reservesContributionStatusBand({ supported: false, driver: pi, hasStatus: false })).toBe(
+ false,
+ );
+ });
+});
diff --git a/apps/mobile/src/features/threads/thread-contribution-status-presentation.ts b/apps/mobile/src/features/threads/thread-contribution-status-presentation.ts
new file mode 100644
index 000000000000..c98f2697e10d
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-contribution-status-presentation.ts
@@ -0,0 +1,91 @@
+import {
+ type ContributionStatusEntry,
+ type ContributionStatusSource,
+ type ContributionStatusTone,
+ contributionStatusSourceKey,
+ PROVIDER_DISPLAY_NAMES,
+ type ProviderDriverKind,
+} from "@t3tools/contracts";
+
+export interface ThreadContributionStatusChip {
+ /** Source key plus item key, so a provider session taking over re-keys the chip. */
+ readonly id: string;
+ /** The provider that set it, or null for a plugin's status. */
+ readonly driver: ProviderDriverKind | null;
+ /** The first chip of each provider source carries that provider's icon. */
+ readonly leadsSource: boolean;
+ readonly text: string;
+ readonly tone: ContributionStatusTone;
+ readonly tooltip: string | null;
+ readonly accessibilityLabel: string;
+ /** Says where the status came from and that it can lag; never "current session". */
+ readonly help: string;
+ /** The details alert. The full text goes in the body: native alert titles truncate. */
+ readonly details: { readonly title: string; readonly message: string };
+}
+
+function providerLabel(driver: ProviderDriverKind): string {
+ return PROVIDER_DISPLAY_NAMES[driver] ?? driver;
+}
+
+function sourceLabel(source: ContributionStatusSource): string {
+ return source.kind === "plugin" ? source.name : providerLabel(source.driver);
+}
+
+function statusHelp(source: ContributionStatusSource): string {
+ if (source.kind === "plugin") return `Set by the ${source.name} plugin.`;
+ const origin = source.driver === "pi" ? "a Pi extension" : providerLabel(source.driver);
+ return `Set by ${origin}. It can lag a session change.`;
+}
+
+/** Flattens a thread's status entries into chips, in the server's order. */
+export function threadContributionStatusChips(
+ entries: ReadonlyArray,
+): ReadonlyArray {
+ return entries.flatMap((entry) => {
+ const { source } = entry;
+ const sourceKey = contributionStatusSourceKey(source);
+ const label = sourceLabel(source);
+ const help = statusHelp(source);
+ return entry.items.map((item, index) => ({
+ id: JSON.stringify([sourceKey, item.key]),
+ driver: source.kind === "plugin" ? null : source.driver,
+ leadsSource: index === 0,
+ text: item.text,
+ tone: item.tone ?? "neutral",
+ tooltip: item.tooltip ?? null,
+ accessibilityLabel: `${label} status: ${item.text}`,
+ help,
+ details: {
+ title: `${label} status`,
+ message: [item.text, item.tooltip, help].filter(Boolean).join("\n\n"),
+ },
+ }));
+ });
+}
+
+export function threadHasContributionStatus(
+ entries: ReadonlyArray,
+): boolean {
+ return entries.some((entry) => entry.items.length > 0);
+}
+
+/** Drivers whose sessions publish statuses (Pi's `setStatus`). */
+const STATUS_PUBLISHING_DRIVERS: ReadonlySet = new Set(["pi"]);
+
+/**
+ * Whether the feed reserves the status strip's band above its first row.
+ * Threads of a publishing provider keep it from the start, so a status
+ * appearing, changing or clearing never shifts the feed; other threads only
+ * get it while they have a status.
+ */
+export function reservesContributionStatusBand(input: {
+ readonly supported: boolean;
+ readonly driver: ProviderDriverKind | undefined;
+ readonly hasStatus: boolean;
+}): boolean {
+ if (!input.supported) return false;
+ return (
+ input.hasStatus || (input.driver !== undefined && STATUS_PUBLISHING_DRIVERS.has(input.driver))
+ );
+}
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index d7596305cff2..1e76f5040cd1 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -10,6 +10,8 @@ import {
import { RowPressable } from "../../components/RowPressable";
import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
import { appAtomRegistry } from "../../state/atom-registry";
+import { runPluginAction, usePluginActions } from "../../state/plugin-actions";
+import { pluginActionLabels, pluginActionsAt } from "@t3tools/client-runtime/state/pluginActions";
import { threadArrangementOpenAtom } from "../../state/thread-order";
import type { ThreadMoveDestination } from "./threadOrder";
import type {
@@ -791,6 +793,37 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
],
[props.titleRegenerationSupported, thread.titleRegeneration],
);
+ const pluginActions = usePluginActions(thread.environmentId);
+ const pluginMenuEntries = useMemo(() => {
+ const entries = pluginActionsAt(pluginActions, "thread-menu", {
+ threadId: thread.id,
+ projectId: thread.projectId,
+ });
+ const labels = pluginActionLabels(entries.map((entry) => entry.action));
+ return entries.map((entry, index) => ({
+ ...entry,
+ menuId: `plugin-action:${entry.action.id}`,
+ title: labels[index] ?? entry.action.title,
+ }));
+ }, [pluginActions, thread.id, thread.projectId]);
+ // One submenu keeps the long-press menu short however many plugins add actions.
+ const pluginMenuActions = useMemo(
+ () =>
+ pluginMenuEntries.length === 0
+ ? []
+ : [
+ {
+ id: "plugin-actions",
+ title: "Plugin actions",
+ image: "puzzlepiece.extension",
+ subactions: pluginMenuEntries.map((entry) => ({
+ id: entry.menuId,
+ title: entry.title,
+ })),
+ },
+ ],
+ [pluginMenuEntries],
+ );
const snoozableCardMenuActions = useMemo(
() => [
{ id: "settle", title: "Settle", image: "checkmark" },
@@ -851,6 +884,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
);
const handleMenuAction = useCallback(
({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
+ const pluginEntry = pluginMenuEntries.find((entry) => entry.menuId === nativeEvent.event);
+ if (pluginEntry) {
+ void runPluginAction({
+ environmentId: thread.environmentId,
+ action: pluginEntry.action,
+ target: pluginEntry.target,
+ });
+ return;
+ }
if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);
if (nativeEvent.event === "settle") handleSettle();
if (nativeEvent.event === "unsettle") handleUnsettle();
@@ -900,6 +942,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handleUnpin,
handleUnsettle,
handleUnsnooze,
+ pluginMenuEntries,
setCustomSnoozeOpen,
snoozePresets,
],
@@ -1341,6 +1384,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
]
: []),
{ id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" },
+ ...pluginMenuActions,
...(snoozedRow
? snoozedMenuActions
: !props.settlementSupported
diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.plugin-actions.test.tsx b/apps/mobile/src/features/threads/use-composer-command-menu.plugin-actions.test.tsx
new file mode 100644
index 000000000000..4b76a3dd3dec
--- /dev/null
+++ b/apps/mobile/src/features/threads/use-composer-command-menu.plugin-actions.test.tsx
@@ -0,0 +1,254 @@
+// @vitest-environment jsdom
+import {
+ EnvironmentId,
+ PluginActionId,
+ ProjectId,
+ ThreadId,
+ type PluginAction,
+} from "@t3tools/contracts";
+import { act, createElement, useState } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+const fixture = vi.hoisted(() => ({
+ actions: [] as ReadonlyArray,
+ canOperate: true,
+ // The live operate grant, which can change after the menu was offered.
+ canRunNow: true,
+ runPluginAction: vi.fn(async (_input: unknown) => true),
+ onChangeDraftMessage: vi.fn((_draft: string) => {}),
+}));
+vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
+vi.mock("../../state/queries", () => ({
+ useComposerPathSearch: () => ({ entries: [], isPending: false }),
+ useComposerPullRequestSearch: () => ({ entries: [], isPending: false, error: null }),
+}));
+vi.mock("../../state/use-composer-drafts", () => ({
+ getComposerDraftSnapshot: vi.fn(),
+ readComposerDraftSelection: () => null,
+ setComposerDraftContext: vi.fn(),
+}));
+vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" }));
+vi.mock("../../state/server", () => ({
+ serverEnvironment: { refreshProviders: Symbol("refreshProviders") },
+}));
+vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
+vi.mock("../../state/session", () => ({
+ useEnvironmentScope: () => fixture.canOperate,
+}));
+vi.mock("../../state/plugin-actions", () => ({
+ usePluginActions: () => fixture.actions,
+ runPluginAction: fixture.runPluginAction,
+ canRunPluginActionsNow: () => fixture.canRunNow,
+}));
+
+import { useComposerCommandMenu } from "./use-composer-command-menu";
+
+const action = (name: string, title: string, target: PluginAction["target"]) =>
+ ({
+ id: PluginActionId.make(`installation-1:1:${name}`),
+ pluginId: "acme.deploy",
+ pluginName: "Deploy",
+ name,
+ title,
+ target,
+ placements: ["composer-slash"],
+ }) satisfies PluginAction;
+const deploy = action("deploy", "Deploy this branch", "thread");
+const dashboard = action("open-dashboard", "Open dashboard", "project");
+const environmentId = EnvironmentId.make("environment-1");
+const threadId = ThreadId.make("thread-1");
+const projectId = ProjectId.make("project-1");
+
+type Menu = ReturnType;
+const latest: { menu: Menu | null; draft: string } = { menu: null, draft: "" };
+const report = (menu: Menu, draft: string) => {
+ latest.menu = menu;
+ latest.draft = draft;
+};
+const onUpdateInteractionMode = vi.fn();
+const onUsageLimits = vi.fn();
+
+/** A composer: the draft lives in state and the menu edits it, as in ThreadComposer and New Task. */
+function Composer(props: {
+ initialDraft: string;
+ currentThreadId: ThreadId | null;
+ report: (menu: Menu, draft: string) => void;
+}) {
+ const [draft, setDraft] = useState(props.initialDraft);
+ const menu = useComposerCommandMenu({
+ draftMessage: draft,
+ ownerKey: "draft-1",
+ environmentId,
+ currentThreadId: props.currentThreadId,
+ projectId,
+ projectCwd: null,
+ selectedProviderStatus: null,
+ hasThread: props.currentThreadId !== null,
+ hasCompactableConversation: false,
+ onChangeDraftMessage: (next) => {
+ fixture.onChangeDraftMessage(next);
+ setDraft(next);
+ },
+ onUpdateInteractionMode,
+ onUsageLimits,
+ });
+ props.report(menu, draft);
+ return null;
+}
+
+let root: Root;
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ fixture.actions = [deploy, dashboard];
+ fixture.canOperate = true;
+ fixture.canRunNow = true;
+ fixture.runPluginAction.mockReset().mockResolvedValue(true);
+ fixture.onChangeDraftMessage.mockClear();
+ onUpdateInteractionMode.mockClear();
+ onUsageLimits.mockClear();
+ root = createRoot(document.createElement("div"));
+});
+
+afterEach(async () => {
+ await act(async () => root.unmount());
+ vi.unstubAllGlobals();
+});
+
+/** Types `draft` with the caret after `/`, then returns the plugin entries offered. */
+async function openMenu(draft: string, caret: number, currentThreadId: ThreadId | null) {
+ await act(async () =>
+ root.render(createElement(Composer, { initialDraft: draft, currentThreadId, report })),
+ );
+ await act(async () => latest.menu!.onSelectionChange({ start: caret, end: caret }));
+ return latest.menu!.items.filter((item) => item.type === "plugin-action");
+}
+
+function offered(label: string) {
+ const item = latest.menu!.items.find((candidate) => candidate.label === label);
+ if (!item) throw new Error(`Expected ${label} in the menu`);
+ return item;
+}
+
+async function pick(label: string) {
+ const item = offered(label);
+ await act(async () => latest.menu!.onSelect(item));
+}
+
+describe("picking a plugin action from the slash menu", () => {
+ it("runs it on the open thread and keeps the rest of the message", async () => {
+ const draft = "ship it\n/depl\nthen tell me";
+ const offered = await openMenu(draft, "ship it\n/depl".length, threadId);
+ expect(offered.map((item) => item.label)).toContain("/deploy");
+
+ await pick("/deploy");
+
+ expect(latest.draft).toBe("ship it\n\nthen tell me");
+ expect(fixture.runPluginAction).toHaveBeenCalledExactlyOnceWith({
+ environmentId,
+ action: deploy,
+ target: { _tag: "thread", threadId },
+ });
+ // The pick is the action itself: nothing else changes the message or the turn.
+ expect(onUpdateInteractionMode).not.toHaveBeenCalled();
+ expect(onUsageLimits).not.toHaveBeenCalled();
+ });
+
+ it("in a New Task draft offers project actions on the selected project, not thread actions", async () => {
+ const offered = await openMenu("check this\n/", "check this\n/".length, null);
+ expect(offered.map((item) => item.label)).toEqual(["/open-dashboard"]);
+
+ await pick("/open-dashboard");
+
+ expect(latest.draft).toBe("check this\n");
+ expect(fixture.runPluginAction).toHaveBeenCalledExactlyOnceWith({
+ environmentId,
+ action: dashboard,
+ target: { _tag: "project", projectId },
+ });
+ });
+
+ it("offers no plugin actions to a read-only connection and keeps the typed command", async () => {
+ fixture.canOperate = false;
+ const draft = "ship it\n/depl";
+ const offered = await openMenu(draft, draft.length, threadId);
+ expect(offered).toEqual([]);
+
+ // A stale entry picked after the grant changed is refused before the draft is touched.
+ await act(async () =>
+ latest.menu!.onSelect({
+ id: `plugin-action:${deploy.id}`,
+ type: "plugin-action",
+ action: deploy,
+ target: { _tag: "thread", threadId },
+ label: "/deploy",
+ description: deploy.title,
+ }),
+ );
+
+ expect(latest.draft).toBe(draft);
+ expect(fixture.runPluginAction).not.toHaveBeenCalled();
+ });
+
+ it("keeps the typed command when the grant is lost while the menu is open", async () => {
+ const draft = "ship it\n/depl";
+ await openMenu(draft, draft.length, threadId);
+ const stale = offered("/deploy");
+
+ fixture.canOperate = false;
+ await act(async () =>
+ root.render(
+ createElement(Composer, { initialDraft: draft, currentThreadId: threadId, report }),
+ ),
+ );
+ await act(async () => latest.menu!.onSelect(stale));
+
+ expect(fixture.runPluginAction).not.toHaveBeenCalled();
+ expect(latest.draft).toBe(draft);
+ });
+
+ it("reads the live grant when the action is picked and keeps the draft if it is gone", async () => {
+ const draft = "ship it\n/depl";
+ await openMenu(draft, draft.length, threadId);
+
+ // The cached grant still offers the action, but the connection lost it.
+ fixture.canRunNow = false;
+ await pick("/deploy");
+
+ expect(fixture.runPluginAction).not.toHaveBeenCalled();
+ expect(latest.draft).toBe(draft);
+ });
+
+ it("removes the command when it is picked, before the action settles, and runs it once", async () => {
+ let finish: (ran: boolean) => void = () => {};
+ fixture.runPluginAction.mockReturnValue(new Promise((resolve) => (finish = resolve)));
+ await openMenu("ship it\n/depl", "ship it\n/depl".length, threadId);
+ const stale = offered("/deploy");
+ const staleMenu = latest.menu!;
+
+ // Two taps land on the same render before the draft updates.
+ await act(async () => {
+ staleMenu.onSelect(stale);
+ staleMenu.onSelect(stale);
+ });
+ expect(latest.draft).toBe("ship it\n");
+ await act(async () => finish(true));
+
+ expect(fixture.runPluginAction).toHaveBeenCalledOnce();
+ });
+
+ it("writes no draft when the action settles after the composer is gone", async () => {
+ let finish: (ran: boolean) => void = () => {};
+ fixture.runPluginAction.mockReturnValue(new Promise((resolve) => (finish = resolve)));
+ await openMenu("ship it\n/depl", "ship it\n/depl".length, threadId);
+ await pick("/deploy");
+ expect(fixture.onChangeDraftMessage).toHaveBeenCalledExactlyOnceWith("ship it\n");
+
+ await act(async () => root.unmount());
+ await act(async () => finish(true));
+
+ expect(fixture.onChangeDraftMessage).toHaveBeenCalledOnce();
+ root = createRoot(document.createElement("div"));
+ });
+});
diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts
index 79965f76e924..51e87732f0ed 100644
--- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts
+++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts
@@ -1,8 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import {
EnvironmentId,
+ PluginActionId,
+ ProjectId,
ProviderDriverKind,
ProviderInstanceId,
+ ThreadId,
+ type PluginAction,
type ServerProvider,
} from "@t3tools/contracts";
import { act, createElement } from "react";
@@ -27,9 +31,15 @@ vi.mock("../../state/server", () => ({
vi.mock("../../state/use-atom-command", () => ({
useAtomCommand: () => refreshProviders,
}));
+vi.mock("../../state/session", () => ({ useEnvironmentScope: () => true }));
+vi.mock("../../state/plugin-actions", () => ({
+ usePluginActions: () => [],
+ runPluginAction: vi.fn(),
+}));
import {
buildComposerSlashCommandItems,
+ buildPluginActionSlashItems,
resolveComposerCommandSelection,
useComposerCommandMenu,
} from "./use-composer-command-menu";
@@ -145,6 +155,7 @@ describe("workspace command discovery retry", () => {
draftMessage: "/project",
ownerKey: null,
environmentId,
+ projectId: null,
projectCwd: cwd,
selectedProviderStatus: status,
hasThread: false,
@@ -322,3 +333,43 @@ describe("workspace command discovery retry", () => {
expect(vi.getTimerCount()).toBe(0);
});
});
+
+describe("mobile plugin action slash entries", () => {
+ const action = (name: string, title: string, target: PluginAction["target"]) =>
+ ({
+ id: PluginActionId.make(`installation-1:1:${name}`),
+ pluginId: "acme.deploy",
+ pluginName: "Deploy",
+ name,
+ title,
+ target,
+ placements: ["composer-slash"],
+ }) satisfies PluginAction;
+ const actions = [
+ action("deploy", "Deploy this branch", "thread"),
+ action("open-dashboard", "Open dashboard", "project"),
+ ];
+ const threadId = ThreadId.make("thread-1");
+ const projectId = ProjectId.make("project-1");
+
+ it("match by name or title and run on the thread they were picked on", () => {
+ expect(
+ buildPluginActionSlashItems(actions, "branch", { threadId, projectId }).map((item) =>
+ item.type === "plugin-action" ? [item.label, item.target] : null,
+ ),
+ ).toEqual([["/deploy", { _tag: "thread", threadId }]]);
+ expect(
+ buildPluginActionSlashItems(actions, "dash", { threadId, projectId }).map(
+ (item) => item.label,
+ ),
+ ).toEqual(["/open-dashboard"]);
+ });
+
+ it("leave out actions whose target the composer cannot supply", () => {
+ expect(
+ buildPluginActionSlashItems(actions, "", { threadId: null, projectId }).map(
+ (item) => item.label,
+ ),
+ ).toEqual(["/open-dashboard"]);
+ });
+});
diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts
index a02b95b50666..6d83830a5228 100644
--- a/apps/mobile/src/features/threads/use-composer-command-menu.ts
+++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts
@@ -1,9 +1,11 @@
-import type {
- EnvironmentId,
- ProjectId,
- ProviderInteractionMode,
- ServerProvider,
- ThreadId,
+import {
+ AuthOrchestrationOperateScope,
+ type EnvironmentId,
+ type PluginAction,
+ type ProjectId,
+ type ProviderInteractionMode,
+ type ServerProvider,
+ type ThreadId,
} from "@t3tools/contracts";
import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
@@ -48,6 +50,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ComposerEditorSelection } from "../../components/ComposerEditor";
import { serverEnvironment } from "../../state/server";
+import { useEnvironmentScope } from "../../state/session";
+import {
+ canRunPluginActionsNow,
+ runPluginAction,
+ usePluginActions,
+} from "../../state/plugin-actions";
+import {
+ type PluginActionContext,
+ pluginActionsAt,
+} from "@t3tools/client-runtime/state/pluginActions";
import { useAtomCommand } from "../../state/use-atom-command";
import { useComposerPathSearch, useComposerPullRequestSearch } from "../../state/queries";
import type { ComposerCommandItem } from "./ComposerCommandPopover";
@@ -169,6 +181,31 @@ export function resolveComposerCommandSelection(input: {
};
}
+/**
+ * Slash entries for the plugin actions matching `query` (lowercase) by name or title.
+ * They run when picked, so they are offered anywhere in the message.
+ */
+export function buildPluginActionSlashItems(
+ actions: ReadonlyArray,
+ query: string,
+ context: PluginActionContext,
+): ComposerCommandItem[] {
+ return pluginActionsAt(actions, "composer-slash", context)
+ .filter(
+ // Names are lowercase today, but the wire accepts any string from a newer server.
+ ({ action }) =>
+ action.name.toLowerCase().includes(query) || action.title.toLowerCase().includes(query),
+ )
+ .map(({ action, target }) => ({
+ id: `plugin-action:${action.id}`,
+ type: "plugin-action" as const,
+ action,
+ target,
+ label: `/${action.name}`,
+ description: `${action.title} · ${action.pluginName}`,
+ }));
+}
+
/** Shared autocomplete for thread composers and unsent new-task drafts. */
export function useComposerCommandMenu({
draftMessage,
@@ -176,6 +213,7 @@ export function useComposerCommandMenu({
environmentId,
threadShells = EMPTY_THREAD_SHELLS,
currentThreadId = null,
+ projectId,
projectCwd,
pullRequestProjectId = null,
pullRequestRepository = null,
@@ -195,6 +233,8 @@ export function useComposerCommandMenu({
readonly threadShells?: ReadonlyArray;
/** Left out of `@` thread suggestions: a thread is never context for itself. */
readonly currentThreadId?: ThreadId | null;
+ /** The project plugin actions with a project target run on; required so no composer drops them. */
+ readonly projectId: ProjectId | null;
readonly projectCwd: string | null;
readonly pullRequestProjectId?: ProjectId | null;
readonly pullRequestRepository?: string | null;
@@ -211,6 +251,7 @@ export function useComposerCommandMenu({
}) {
const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage));
const previousOwnerKeyRef = useRef(ownerKey);
+ const pluginActionRunningRef = useRef(false);
const onSelectionChange = useCallback((nextSelection: ComposerEditorSelection) => {
setSelection(nextSelection);
}, []);
@@ -360,6 +401,9 @@ export function useComposerCommandMenu({
query: trigger?.kind === "pull-request" ? trigger.query : null,
});
+ const pluginActions = usePluginActions(environmentId);
+ // Running a plugin action needs `orchestration:operate`.
+ const canRunPluginActions = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
const items = useMemo(() => {
if (!trigger) return [];
@@ -412,7 +456,16 @@ export function useComposerCommandMenu({
description: skill.shortDescription ?? skill.description ?? "",
}));
- return [...commandItems, ...skillItems];
+ return [
+ ...commandItems,
+ ...skillItems,
+ ...(canRunPluginActions
+ ? buildPluginActionSlashItems(pluginActions, q, {
+ threadId: currentThreadId,
+ projectId,
+ })
+ : []),
+ ];
}
if (trigger.kind === "skill") {
@@ -531,7 +584,10 @@ export function useComposerCommandMenu({
hasThread,
hasCompactableConversation,
onUpdateInteractionMode,
+ canRunPluginActions,
pathSearch.entries,
+ pluginActions,
+ projectId,
pullRequestSearch.entries,
projectCwd,
selectedProviderStatus,
@@ -612,6 +668,29 @@ export function useComposerCommandMenu({
return;
}
+ if (item.type === "plugin-action") {
+ // Keep the typed command when this connection may no longer run actions.
+ // The live grant is read because the menu may predate a permission change.
+ if (
+ environmentId === null ||
+ !canRunPluginActions ||
+ !canRunPluginActionsNow(environmentId)
+ ) {
+ return;
+ }
+ // A second tap before this render's draft updates must not run it twice.
+ if (pluginActionRunningRef.current) return;
+ pluginActionRunningRef.current = true;
+ const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, "");
+ setSelection({ start: cleared.cursor, end: cleared.cursor });
+ onChangeDraftMessage(cleared.text);
+ void runPluginAction({ environmentId, action: item.action, target: item.target }).finally(
+ () => {
+ pluginActionRunningRef.current = false;
+ },
+ );
+ return;
+ }
if (
item.type === "provider-slash-command" &&
item.command.name === USAGE_LIMITS_COMMAND.name &&
@@ -639,7 +718,9 @@ export function useComposerCommandMenu({
}
},
[
+ canRunPluginActions,
draftMessage,
+ environmentId,
ownerKey,
items,
onChangeDraftMessage,
diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts
index 7d288b1352a5..d655781a8f42 100644
--- a/apps/mobile/src/lib/layout.test.ts
+++ b/apps/mobile/src/lib/layout.test.ts
@@ -6,6 +6,7 @@ import {
deriveFileInspectorPaneLayout,
deriveLayout,
deriveThreadFeedInitialContentInset,
+ deriveThreadFeedTopGeometry,
deriveThreadWorkLogSizing,
deriveWorkspacePaneLayout,
SPLIT_LAYOUT_MIN_HEIGHT,
@@ -72,6 +73,45 @@ describe("deriveThreadFeedInitialContentInset", () => {
});
});
+describe("deriveThreadFeedTopGeometry", () => {
+ it("keeps the first row and an anchored message below a strip under an opaque header", () => {
+ expect(
+ deriveThreadFeedTopGeometry({
+ usesNativeAutomaticInsets: false,
+ headerInset: 0,
+ topOverlayInset: 48,
+ }),
+ ).toEqual({ spacerHeight: 48, anchorTopInset: 48 });
+ });
+
+ it("adds only the strip to content under a glass header, whose inset UIKit applies", () => {
+ expect(
+ deriveThreadFeedTopGeometry({
+ usesNativeAutomaticInsets: true,
+ headerInset: 106,
+ topOverlayInset: 44,
+ }),
+ ).toEqual({ spacerHeight: 44, anchorTopInset: 150 });
+ });
+
+ it("leaves the feed where it was when nothing floats over it", () => {
+ expect(
+ deriveThreadFeedTopGeometry({
+ usesNativeAutomaticInsets: true,
+ headerInset: 106,
+ topOverlayInset: 0,
+ }),
+ ).toEqual({ spacerHeight: 0, anchorTopInset: 106 });
+ expect(
+ deriveThreadFeedTopGeometry({
+ usesNativeAutomaticInsets: false,
+ headerInset: 0,
+ topOverlayInset: 0,
+ }),
+ ).toEqual({ spacerHeight: 0, anchorTopInset: 0 });
+ });
+});
+
describe("resizable pane constraints", () => {
it("preserves a useful main pane while constraining a trailing pane", () => {
expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440);
diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts
index ac4cde7789b0..53afe9e51471 100644
--- a/apps/mobile/src/lib/layout.ts
+++ b/apps/mobile/src/lib/layout.ts
@@ -87,6 +87,24 @@ export function deriveThreadFeedInitialContentInset(input: {
return { bottom: Math.max(0, input.bottomContentInset) };
}
+/**
+ * Where the thread feed's first row and a just-sent message rest. The header
+ * inset is UIKit's under native automatic insets and a content spacer
+ * otherwise. UI floating over the feed's top edge below the header, such as
+ * the status strip, adds to both, so it never covers the oldest loaded row,
+ * the load-earlier control or an anchored message.
+ */
+export function deriveThreadFeedTopGeometry(input: {
+ readonly usesNativeAutomaticInsets: boolean;
+ readonly headerInset: number;
+ readonly topOverlayInset: number;
+}): { readonly spacerHeight: number; readonly anchorTopInset: number } {
+ return {
+ spacerHeight: (input.usesNativeAutomaticInsets ? 0 : input.headerInset) + input.topOverlayInset,
+ anchorTopInset: input.headerInset + input.topOverlayInset,
+ };
+}
+
export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector";
export function deriveLayout(input: {
diff --git a/apps/mobile/src/state/contribution-status.ts b/apps/mobile/src/state/contribution-status.ts
new file mode 100644
index 000000000000..8a83addc96ab
--- /dev/null
+++ b/apps/mobile/src/state/contribution-status.ts
@@ -0,0 +1,9 @@
+import { createContributionStatusEnvironmentAtoms } from "@t3tools/client-runtime/state/contribution-status";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+import { serverEnvironment } from "./server";
+
+export const contributionStatusEnvironment = createContributionStatusEnvironmentAtoms(
+ connectionAtomRuntime,
+ { configValueAtom: serverEnvironment.configValueAtom },
+);
diff --git a/apps/mobile/src/state/plugin-actions.test.ts b/apps/mobile/src/state/plugin-actions.test.ts
new file mode 100644
index 000000000000..542003f432e9
--- /dev/null
+++ b/apps/mobile/src/state/plugin-actions.test.ts
@@ -0,0 +1,100 @@
+import {
+ AuthOrchestrationOperateScope,
+ EnvironmentId,
+ PluginActionId,
+ ProjectId,
+ ThreadId,
+ type PluginAction,
+} from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
+import { AsyncResult } from "effect/reactivity";
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+const state = vi.hoisted(() => ({
+ canOperate: true,
+ invoke: vi.fn(),
+ alert: vi.fn(),
+}));
+
+// The session grant, the invoke RPC and the alert are the boundaries.
+vi.mock("react-native", () => ({ Alert: { alert: state.alert } }));
+vi.mock("expo-haptics", () => ({
+ notificationAsync: async () => {},
+ NotificationFeedbackType: { Success: "success" },
+}));
+vi.mock("./session", () => ({
+ readEnvironmentScope: (_environmentId: string, scope: string) =>
+ scope === AuthOrchestrationOperateScope && state.canOperate,
+}));
+vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({
+ ...(await importOriginal()),
+ runAtomCommand: state.invoke,
+}));
+vi.mock("@t3tools/client-runtime/state/pluginActions", async (importOriginal) => ({
+ ...(await importOriginal()),
+ createPluginActionEnvironmentAtoms: () => ({ invoke: "invoke", snapshot: () => null }),
+}));
+vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} }));
+vi.mock("./atom-registry", () => ({ appAtomRegistry: {} }));
+vi.mock("./query", () => ({ useEnvironmentQuery: () => ({ data: null }) }));
+
+import { buildPluginActionPaletteItems } from "../features/keyboard/commandPaletteItems";
+import { runPluginAction } from "./plugin-actions";
+
+const environmentId = EnvironmentId.make("environment-1");
+const threadId = ThreadId.make("thread-1");
+const deploy: PluginAction = {
+ id: PluginActionId.make("installation-1:1:deploy"),
+ pluginId: "acme.deploy",
+ pluginName: "Deploy",
+ name: "deploy",
+ title: "Deploy this branch",
+ target: "thread",
+ placements: ["command-palette"],
+};
+const run = () =>
+ runPluginAction({ environmentId, action: deploy, target: { _tag: "thread", threadId } });
+
+beforeEach(() => {
+ state.canOperate = true;
+ state.invoke.mockReset().mockResolvedValue(AsyncResult.success({ message: "Deployed" }));
+ state.alert.mockReset();
+});
+
+describe("runPluginAction", () => {
+ it("reports that the plugin ran the action", async () => {
+ await expect(run()).resolves.toBe(true);
+ expect(state.invoke).toHaveBeenCalledOnce();
+ expect(state.alert).toHaveBeenCalledWith("Deploy this branch", "Deployed");
+ });
+
+ it("reports a refused action as not run", async () => {
+ state.invoke.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("Forbidden"))));
+ await expect(run()).resolves.toBe(false);
+ expect(state.alert).toHaveBeenCalledWith("Deploy this branch failed", "Forbidden");
+ });
+
+ it("does not invoke once the connection has lost its operate grant", async () => {
+ state.canOperate = false;
+ await expect(run()).resolves.toBe(false);
+ expect(state.invoke).not.toHaveBeenCalled();
+ });
+
+ it("rechecks the grant when a palette entry runs after the palette closes", async () => {
+ // The palette stores the picked entry and runs it once its modal is dismissed.
+ const [entry] = buildPluginActionPaletteItems({
+ actions: [deploy],
+ canOperate: true,
+ environmentId,
+ threadId,
+ projectId: ProjectId.make("project-1"),
+ runAction: (input) => void runPluginAction(input),
+ });
+ state.canOperate = false;
+
+ entry?.run();
+ await Promise.resolve();
+
+ expect(state.invoke).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/mobile/src/state/plugin-actions.ts b/apps/mobile/src/state/plugin-actions.ts
new file mode 100644
index 000000000000..88fc243f315b
--- /dev/null
+++ b/apps/mobile/src/state/plugin-actions.ts
@@ -0,0 +1,86 @@
+import { createPluginActionEnvironmentAtoms } from "@t3tools/client-runtime/state/pluginActions";
+import {
+ isAtomCommandInterrupted,
+ runAtomCommand,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import {
+ AuthOrchestrationOperateScope,
+ type EnvironmentId,
+ type PluginAction,
+ type PluginActionTarget,
+} from "@t3tools/contracts";
+import * as Haptics from "expo-haptics";
+import { Alert } from "react-native";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+import { appAtomRegistry } from "./atom-registry";
+import { useEnvironmentQuery } from "./query";
+import { readEnvironmentScope } from "./session";
+
+const pluginActionEnvironment = createPluginActionEnvironmentAtoms(connectionAtomRuntime);
+
+const NO_ACTIONS: ReadonlyArray = [];
+
+/** The environment's plugin actions; none on servers without them. */
+export function usePluginActions(environmentId: EnvironmentId | null): ReadonlyArray {
+ return (
+ useEnvironmentQuery(
+ environmentId === null
+ ? null
+ : pluginActionEnvironment.snapshot({ environmentId, input: {} }),
+ ).data?.actions ?? NO_ACTIONS
+ );
+}
+
+/** The environment's plugin actions snapshot, including what its limit left out; null until it arrives. */
+export function usePluginActionsSnapshot(environmentId: EnvironmentId | null) {
+ return useEnvironmentQuery(
+ environmentId === null ? null : pluginActionEnvironment.snapshot({ environmentId, input: {} }),
+ ).data;
+}
+
+/** Whether this connection may run plugin actions now, read from the live grant. */
+export function canRunPluginActionsNow(environmentId: EnvironmentId): boolean {
+ return readEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
+}
+
+/**
+ * Runs a plugin action in the environment that listed it. A message from the
+ * plugin or a failure is shown in an alert; a silent success taps a haptic.
+ * The grant is read when the action runs, not when it was offered, so a
+ * palette entry picked after the grant changed is refused. Resolves `true`
+ * only when the plugin ran the action.
+ */
+export async function runPluginAction(input: {
+ readonly environmentId: EnvironmentId;
+ readonly action: PluginAction;
+ readonly target: PluginActionTarget;
+}): Promise {
+ const { action } = input;
+ if (!canRunPluginActionsNow(input.environmentId)) {
+ Alert.alert(`${action.title} unavailable`, "This connection cannot run plugin actions.");
+ return false;
+ }
+ const result = await runAtomCommand(
+ appAtomRegistry,
+ pluginActionEnvironment.invoke,
+ { environmentId: input.environmentId, input: { actionId: action.id, target: input.target } },
+ { reportFailure: false },
+ );
+ if (result._tag === "Success") {
+ if (result.value.message === null) {
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
+ } else {
+ Alert.alert(action.title, result.value.message);
+ }
+ return true;
+ }
+ if (isAtomCommandInterrupted(result)) return false;
+ const error = squashAtomCommandFailure(result);
+ Alert.alert(
+ `${action.title} failed`,
+ error instanceof Error ? error.message : "The plugin action failed.",
+ );
+ return false;
+}
diff --git a/apps/mobile/src/state/plugin-notifications.ts b/apps/mobile/src/state/plugin-notifications.ts
new file mode 100644
index 000000000000..11611914e0cb
--- /dev/null
+++ b/apps/mobile/src/state/plugin-notifications.ts
@@ -0,0 +1,9 @@
+import { createPluginNotificationEnvironmentAtoms } from "@t3tools/client-runtime/state/plugin-notifications";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+import { serverEnvironment } from "./server";
+
+export const pluginNotificationEnvironment = createPluginNotificationEnvironmentAtoms(
+ connectionAtomRuntime,
+ { configValueAtom: serverEnvironment.configValueAtom },
+);
diff --git a/apps/mobile/src/state/plugins.ts b/apps/mobile/src/state/plugins.ts
new file mode 100644
index 000000000000..288261924ee7
--- /dev/null
+++ b/apps/mobile/src/state/plugins.ts
@@ -0,0 +1,13 @@
+import { createPluginEnvironmentAtoms } from "@t3tools/client-runtime/state/plugins";
+import { createPluginNpmEnvironmentAtoms } from "@t3tools/client-runtime/state/pluginNpm";
+import { createPluginSettingsEnvironmentAtoms } from "@t3tools/client-runtime/state/pluginSettings";
+import { createPluginViewEnvironmentAtoms } from "@t3tools/client-runtime/state/pluginViews";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const pluginEnvironment = createPluginEnvironmentAtoms(connectionAtomRuntime);
+export const pluginSettingsEnvironment =
+ createPluginSettingsEnvironmentAtoms(connectionAtomRuntime);
+export const pluginNpmEnvironment = createPluginNpmEnvironmentAtoms(connectionAtomRuntime);
+/** Mobile shows no views; it reads which views a plugin offers for its details. */
+export const pluginViewEnvironment = createPluginViewEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index 511fd77cd147..a67c28d159b9 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -5,6 +5,7 @@ import {
authScopeRequiredResponse,
AssetCreateUrlInput,
AuthAccessReadScope,
+ AuthAccessWriteScope,
ServerSettingsPatch,
ProviderInstanceMutation,
requiredScopesForServerSettingsPatch,
@@ -116,6 +117,35 @@ export const RPC_REQUIRED_SCOPES = {
// Delivery logs hold request bodies, so they need the same scope as the URL.
[WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope,
[WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope,
+ // Plugins run as the server's OS user, so changing what runs takes the administrative scope
+ // that also manages pairing and sessions; standard pairing never grants it.
+ [WS_METHODS.pluginsList]: AuthOrchestrationReadScope,
+ [WS_METHODS.pluginsSubscribe]: AuthOrchestrationReadScope,
+ [WS_METHODS.pluginsAdd]: AuthAccessWriteScope,
+ [WS_METHODS.pluginsRefresh]: AuthAccessWriteScope,
+ [WS_METHODS.pluginsConsent]: AuthAccessWriteScope,
+ [WS_METHODS.pluginsEnable]: AuthAccessWriteScope,
+ [WS_METHODS.pluginsDisable]: AuthAccessWriteScope,
+ [WS_METHODS.pluginsRemove]: AuthAccessWriteScope,
+ [WS_METHODS.pluginsResume]: AuthAccessWriteScope,
+ // Saved setting values are readable like the catalogue; secrets are never sent. Saving one
+ // configures code that runs as the server's OS user, so it takes the administrative scope.
+ [WS_METHODS.pluginsSettingsSubscribe]: AuthOrchestrationReadScope,
+ [WS_METHODS.pluginsSettingsUpdate]: AuthAccessWriteScope,
+ // Running an action the administrator already enabled is ordinary operation, like starting a
+ // turn; it cannot change what code runs.
+ [WS_METHODS.pluginActionsSubscribe]: AuthOrchestrationReadScope,
+ [WS_METHODS.pluginActionsInvoke]: AuthOrchestrationOperateScope,
+ // A view call runs the plugin's own `view:` handlers, like an action a client takes.
+ [WS_METHODS.pluginViewsSubscribe]: AuthOrchestrationReadScope,
+ [WS_METHODS.pluginViewsReadBundle]: AuthOrchestrationReadScope,
+ [WS_METHODS.pluginViewsCall]: AuthOrchestrationOperateScope,
+ // Installing or updating from npm changes which code the server runs, so it is administrative.
+ [WS_METHODS.pluginsNpmList]: AuthOrchestrationReadScope,
+ [WS_METHODS.pluginsNpmAdd]: AuthAccessWriteScope,
+ [WS_METHODS.pluginsNpmStageUpdate]: AuthAccessWriteScope,
+ [WS_METHODS.pluginsNpmApplyUpdate]: AuthAccessWriteScope,
+ [WS_METHODS.pluginsNpmDiscardUpdate]: AuthAccessWriteScope,
[WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope,
[WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,
[WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
@@ -167,6 +197,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope,
[WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope,
+ [WS_METHODS.subscribeContributionStatus]: AuthOrchestrationReadScope,
+ [WS_METHODS.pluginsNotificationsSubscribe]: AuthOrchestrationReadScope,
[WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
[WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope,
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts
index 063093874503..57421c3e99d4 100644
--- a/apps/server/src/bin.ts
+++ b/apps/server/src/bin.ts
@@ -4,8 +4,9 @@
* Every ACP agent spawns `t3 acp-mcp-bridge` while opening its session, and
* terminal-fallback agents run `t3 acp-mcp-call` per tool call, so their
* startup sits on first-message latency. Both dispatch here before the full
- * CLI module graph (seconds of evaluation) loads; everything else defers to
- * the real CLI in ./binCli.ts.
+ * CLI module graph (seconds of evaluation) loads. Plugin children
+ * (`__plugin-host`) take the same shortcut. Everything else defers to the
+ * real CLI in ./binCli.ts.
*/
import { isEntrypoint } from "./entrypoint.ts";
@@ -20,6 +21,10 @@ if (
if (command === "acp-mcp-bridge" || command === "acp-mcp-call") {
const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts");
await runAcpMcpCliFastPath(command, process.argv.slice(3));
+ } else if (command === "__plugin-host") {
+ // One process per enabled plugin: keep its footprint to the child runtime.
+ const { runPluginHostChild } = await import("./plugins/pluginHostChild.ts");
+ runPluginHostChild();
} else {
const { runCli } = await import("./binCli.ts");
runCli();
diff --git a/apps/server/src/contributions/ContributionStatusRpc.test.ts b/apps/server/src/contributions/ContributionStatusRpc.test.ts
new file mode 100644
index 000000000000..34d999adf280
--- /dev/null
+++ b/apps/server/src/contributions/ContributionStatusRpc.test.ts
@@ -0,0 +1,206 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, describe, it } from "@effect/vitest";
+import {
+ AuthOrchestrationReadScope,
+ AuthRelayReadScope,
+ type AuthEnvironmentScope,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ ProviderSessionId,
+ ThreadId,
+ WS_METHODS,
+ WsRpcGroup,
+} from "@t3tools/contracts";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Queue from "effect/Queue";
+import * as TestClock from "effect/testing/TestClock";
+import { RpcMessage, RpcSerialization, RpcServer } from "effect/rpc";
+
+import * as RpcAuthorization from "../auth/RpcAuthorization.ts";
+import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts";
+import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
+import * as ProviderOrchestrationAdapterInfrastructure from "../provider/ProviderOrchestrationAdapterInfrastructure.ts";
+import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore";
+
+const TAG = WS_METHODS.subscribeContributionStatus;
+const THREAD = ThreadId.make("thread-1");
+const SOURCE = {
+ kind: "provider-session",
+ providerSessionId: ProviderSessionId.make("session-1"),
+ providerInstanceId: ProviderInstanceId.make("pi"),
+ driver: ProviderDriverKind.make("pi"),
+} as const;
+const encodedEntry = (texts: ReadonlyArray) => ({
+ threadId: THREAD,
+ source: SOURCE,
+ items: texts.map((text, index) => ({ key: `k${index}`, text })),
+});
+
+// The server group narrowed to this RPC; it keeps the group's scope middleware.
+const group = WsRpcGroup.omit(
+ ...[...WsRpcGroup.requests.keys()].filter(
+ (tag): tag is Exclude => tag !== TAG,
+ ),
+);
+
+/**
+ * Serves `subscribeContributionStatus` as ws.ts does (the store's subscription
+ * stream behind the connection's scope middleware) over an in-memory RPC protocol.
+ */
+const serveStatus = Effect.fn("serveStatus")(function* (
+ store: ContributionStatusStore.ContributionStatusStoreShape,
+ scopes: ReadonlyArray,
+) {
+ const responses = yield* Queue.unbounded();
+ const receive = yield* Deferred.make[0]>();
+ const protocol = yield* RpcServer.Protocol.make((write) =>
+ Effect.gen(function* () {
+ yield* Deferred.succeed(receive, write);
+ const serialization = yield* RpcSerialization.RpcSerialization;
+ return {
+ disconnects: yield* Queue.unbounded(),
+ send: (_clientId, response) => Queue.offer(responses, response),
+ end: () => Effect.void,
+ clientIds: Effect.succeed(new Set([0])),
+ initialMessage: Effect.succeedNone,
+ supportsAck: false,
+ supportsTransferables: false,
+ supportsSpanPropagation: false,
+ supportsNotifications: true,
+ codecFor: serialization.codecFor,
+ };
+ }),
+ );
+ yield* RpcServer.make(group).pipe(
+ Effect.provide(
+ Layer.merge(
+ group.toLayerHandler(TAG, () => ContributionStatusStore.subscriptionStream(store)),
+ RpcAuthorization.layer(scopes),
+ ),
+ ),
+ Effect.provideService(RpcServer.Protocol, protocol),
+ Effect.forkScoped,
+ );
+ const write = yield* Deferred.await(receive);
+ return {
+ subscribe: (id: string) =>
+ write(0, { _tag: "Request", id, tag: TAG, payload: {}, headers: [] }),
+ interrupt: (id: string) => write(0, { _tag: "Interrupt", requestId: id }),
+ /** The next frame the client receives; the store publishes one per visible change. */
+ next: Queue.take(responses),
+ /** Lets server fibers run so a frame that would be sent is in the queue. */
+ pending: TestClock.adjust(0).pipe(Effect.andThen(Queue.size(responses))),
+ };
+});
+
+const chunk = (
+ requestId: string,
+ entries: ReadonlyArray>,
+): RpcMessage.FromServerEncoded => ({ _tag: "Chunk", requestId, values: [{ entries }] });
+
+describe("subscribeContributionStatus", () => {
+ it.effect("streams the current snapshot, replacements, and a fresh snapshot on resubscribe", () =>
+ Effect.gen(function* () {
+ const store = yield* ContributionStatusStore.make();
+ const handle = yield* store.openSource(SOURCE);
+ yield* handle.bindThread(THREAD);
+ yield* handle.set({ key: "k0", text: "plan" });
+ const client = yield* serveStatus(store, [AuthOrchestrationReadScope]);
+
+ yield* client.subscribe("1");
+ assert.deepStrictEqual(yield* client.next, chunk("1", [encodedEntry(["plan"])]));
+
+ yield* handle.set({ key: "k0", text: "build" });
+ assert.deepStrictEqual(yield* client.next, chunk("1", [encodedEntry(["build"])]));
+ yield* handle.clear("k0");
+ assert.deepStrictEqual(yield* client.next, chunk("1", []));
+
+ // A reconnect is a new subscription whose first frame is the current state.
+ yield* handle.set({ key: "k0", text: "review" });
+ assert.deepStrictEqual(yield* client.next, chunk("1", [encodedEntry(["review"])]));
+ yield* client.subscribe("2");
+ assert.deepStrictEqual(yield* client.next, chunk("2", [encodedEntry(["review"])]));
+
+ // An interrupted subscription stops receiving frames; the other keeps going.
+ yield* client.interrupt("1");
+ assert.strictEqual((yield* client.next)._tag, "Exit");
+ yield* handle.set({ key: "k0", text: "done" });
+ assert.deepStrictEqual(yield* client.next, chunk("2", [encodedEntry(["done"])]));
+ assert.strictEqual(yield* client.pending, 0);
+ }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped),
+ );
+
+ it.effect("refuses a client without the orchestration read scope", () =>
+ Effect.gen(function* () {
+ const store = yield* ContributionStatusStore.make();
+ const handle = yield* store.openSource(SOURCE);
+ yield* handle.bindThread(THREAD);
+ yield* handle.set({ key: "k0", text: "plan" });
+ const client = yield* serveStatus(store, [AuthRelayReadScope]);
+
+ yield* client.subscribe("1");
+ assert.deepStrictEqual(yield* client.next, {
+ _tag: "Exit",
+ requestId: "1",
+ exit: {
+ _tag: "Failure",
+ cause: [
+ {
+ _tag: "Fail",
+ error: {
+ _tag: "EnvironmentAuthorizationError",
+ message: `The authenticated token is missing required scope: ${AuthOrchestrationReadScope}.`,
+ requiredPermission: AuthOrchestrationReadScope,
+ requiredScope: AuthOrchestrationReadScope,
+ },
+ },
+ ],
+ },
+ });
+ }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped),
+ );
+
+ it.effect("shares one store between provider adapters and the WebSocket stream", () =>
+ Effect.gen(function* () {
+ // Mirrors server.ts: adapters get the store through the provider
+ // infrastructure inside an unwrapped instance-registry layer, while the
+ // WebSocket layer reads it from the runtime's own reference.
+ const producer = Layer.unwrap(
+ Effect.succeed(
+ Layer.effectDiscard(
+ Effect.gen(function* () {
+ const store = yield* ContributionStatusStore.ContributionStatusStore;
+ const handle = yield* store.openSource(SOURCE);
+ yield* handle.bindThread(THREAD);
+ yield* handle.set({ key: "k0", text: "from adapter" });
+ }),
+ ).pipe(Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer)),
+ ),
+ );
+ // As in server.ts, the registry layer sits below the store reference, so
+ // the producer only sees the store its own infrastructure provides.
+ const runtime = ContributionStatusStore.layer.pipe(
+ Layer.provideMerge(producer),
+ Layer.provide(
+ Layer.merge(
+ NodeServices.layer,
+ Layer.succeed(
+ ProviderEventLoggers.ProviderEventLoggers,
+ ProviderEventLoggers.NoOpProviderEventLoggers,
+ ),
+ ),
+ ),
+ );
+
+ const snapshot = yield* Effect.gen(function* () {
+ const store = yield* ContributionStatusStore.ContributionStatusStore;
+ return yield* store.snapshot;
+ }).pipe(Effect.provide(runtime));
+ assert.deepStrictEqual(snapshot.entries, [
+ { threadId: THREAD, source: SOURCE, items: [{ key: "k0", text: "from adapter" }] },
+ ]);
+ }),
+ );
+});
diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts
index 60589ac9106f..4d351ade2516 100644
--- a/apps/server/src/environment/ServerEnvironment.ts
+++ b/apps/server/src/environment/ServerEnvironment.ts
@@ -251,6 +251,13 @@ export const make = Effect.gen(function* () {
serverResolvedCommandContext: true,
environmentIcon: true,
projectCloneTracking: true,
+ contributionStatus: true,
+ plugins: true,
+ pluginSettings: true,
+ pluginActions: true,
+ pluginViews: true,
+ pluginNpm: true,
+ pluginNotifications: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverInstallation === null ? {} : { serverInstallation }),
// V2 restart recovery uses the environment-owned opt-in. The old
diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts
index 0e2b52696712..47a746b3e89f 100644
--- a/apps/server/src/mcp/McpHttpServer.ts
+++ b/apps/server/src/mcp/McpHttpServer.ts
@@ -50,6 +50,8 @@ import { WorktreeToolkit } from "./toolkits/worktree/tools.ts";
import * as WorktreeMcpService from "./WorktreeMcpService.ts";
import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts";
import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
+import * as PluginToolsHandlers from "./toolkits/pluginTools/handlers.ts";
+import { PluginToolsToolkit } from "./toolkits/pluginTools/tools.ts";
import * as DeviceHandlers from "./toolkits/device/handlers.ts";
import {
DeviceScreenshotTool,
@@ -843,6 +845,11 @@ export const layerPullRequestsToolkit = toolkitRegistration(
PullRequestsHandlers.layer,
);
+export const layerPluginToolsToolkit = toolkitRegistration(
+ PluginToolsToolkit,
+ PluginToolsHandlers.layer,
+);
+
const layerDeviceStandardToolkitRegistration = toolkitRegistration(
DeviceStandardToolkit,
DeviceHandlers.layerStandard,
@@ -878,4 +885,5 @@ export const layer = Layer.mergeAll(
layerPullRequestsToolkit,
layerDeviceToolkit,
layerHtmlToolkit,
+ layerPluginToolsToolkit,
).pipe(Layer.provideMerge(layerMcpTransport));
diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts
index c53dac6695c0..db29890b2758 100644
--- a/apps/server/src/mcp/McpInvocationContext.ts
+++ b/apps/server/src/mcp/McpInvocationContext.ts
@@ -11,6 +11,8 @@ import {
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
+import type { PluginToolGrant } from "../plugins/PluginTools.ts";
+
const ALL_MCP_CAPABILITIES = [
"preview",
"orchestration",
@@ -58,6 +60,8 @@ export interface McpInvocationScope {
readonly requestNamespace: string;
readonly thread: McpThreadCaller | undefined;
readonly client: McpClientCaller | undefined;
+ /** Tool plugins enabled when the session was prepared; see PluginTools.ts. */
+ readonly pluginToolGrants?: ReadonlyArray;
}
export class McpInvocationContext extends Context.Service<
diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts
index 6b6207dc2660..b2c1f8eda6e0 100644
--- a/apps/server/src/mcp/McpSessionRegistry.test.ts
+++ b/apps/server/src/mcp/McpSessionRegistry.test.ts
@@ -1,6 +1,11 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
-import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
+import {
+ EnvironmentId,
+ PluginInstallationId,
+ ProviderInstanceId,
+ ThreadId,
+} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import { HttpServer } from "effect/http";
import * as NetAddress from "effect/net/NetAddress";
@@ -180,3 +185,30 @@ it.effect("does not keep credentials of other threads alive", () =>
expect(yield* registry.resolve(token)).toBeUndefined();
}),
);
+
+it.effect("keeps a credential's plugin tool grants and replaces them without rotating it", () =>
+ Effect.gen(function* () {
+ const registry = yield* makeRegistry(() => 1_000);
+ const grant = (generation: number) => ({
+ installationId: PluginInstallationId.make("installation-1"),
+ generation,
+ });
+ const issued = yield* registry.issue({
+ threadId: ThreadId.make("thread-plugin-tools"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ pluginToolGrants: [grant(1)],
+ });
+ const other = yield* registry.issue({
+ threadId: ThreadId.make("thread-other"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ });
+ const tokenOf = (credential: typeof issued) =>
+ credential.config.authorizationHeader.replace(/^Bearer\s+/, "");
+ expect((yield* registry.resolve(tokenOf(issued)))?.pluginToolGrants).toEqual([grant(1)]);
+ expect((yield* registry.resolve(tokenOf(other)))?.pluginToolGrants).toBeUndefined();
+
+ yield* registry.setPluginToolGrants(issued.config.providerSessionId, [grant(2)]);
+ expect((yield* registry.resolve(tokenOf(issued)))?.pluginToolGrants).toEqual([grant(2)]);
+ expect((yield* registry.resolve(tokenOf(other)))?.pluginToolGrants).toBeUndefined();
+ }),
+);
diff --git a/apps/server/src/mcp/McpSessionRegistry.testkit.ts b/apps/server/src/mcp/McpSessionRegistry.testkit.ts
index 1ac02357a878..b11d7e7440fc 100644
--- a/apps/server/src/mcp/McpSessionRegistry.testkit.ts
+++ b/apps/server/src/mcp/McpSessionRegistry.testkit.ts
@@ -21,6 +21,7 @@ export const layer = Layer.succeed(
}),
resolve: () => Effect.succeed(undefined),
touch: () => Effect.void,
+ setPluginToolGrants: () => Effect.void,
revokeProviderSession: () => Effect.void,
revokeThread: () => Effect.void,
revokeAll: Effect.void,
diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts
index f1e027f9eb85..3088c9257542 100644
--- a/apps/server/src/mcp/McpSessionRegistry.ts
+++ b/apps/server/src/mcp/McpSessionRegistry.ts
@@ -9,6 +9,7 @@ import { HttpServer } from "effect/http";
import * as NetAddress from "effect/net/NetAddress";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
+import type { PluginToolGrant } from "../plugins/PluginTools.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
@@ -22,6 +23,8 @@ export interface McpCredentialRequest {
*/
readonly browserToolsAvailable?: boolean;
readonly capabilities?: ReadonlySet;
+ /** Tool plugins the session may use; none when omitted. */
+ readonly pluginToolGrants?: ReadonlyArray;
}
export interface McpIssuedCredential {
@@ -39,6 +42,14 @@ export interface McpSessionRegistryShape {
* credential even when it goes a long time without touching an MCP tool.
*/
readonly touch: (threadId: ThreadId) => Effect.Effect;
+ /**
+ * Replaces the tool plugin grants of a live credential without rotating it,
+ * for a session prepared again on a credential its provider still holds.
+ */
+ readonly setPluginToolGrants: (
+ providerSessionId: string,
+ grants: ReadonlyArray,
+ ) => Effect.Effect;
readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect;
readonly revokeThread: (threadId: ThreadId) => Effect.Effect;
readonly revokeAll: Effect.Effect;
@@ -144,6 +155,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
...(request.capabilities ?? (browserToolsAvailable ? (["preview"] as const) : [])),
]),
issuedAt,
+ ...(request.pluginToolGrants === undefined
+ ? {}
+ : { pluginToolGrants: request.pluginToolGrants }),
};
yield* SynchronizedRef.update(state, ({ records }) => {
const next = new Map(pruneDead(records, issuedAt));
@@ -206,6 +220,22 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
issue,
resolve,
touch,
+ setPluginToolGrants: Effect.fn("McpSessionRegistry.setPluginToolGrants")(
+ function* (providerSessionId, grants) {
+ yield* SynchronizedRef.update(state, ({ records }) => {
+ const next = new Map(records);
+ for (const [tokenHash, record] of records) {
+ if (record.scope.thread.providerSessionId === providerSessionId) {
+ next.set(tokenHash, {
+ ...record,
+ scope: { ...record.scope, pluginToolGrants: grants },
+ });
+ }
+ }
+ return { records: next };
+ });
+ },
+ ),
revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")(
function* (providerSessionId) {
yield* revokeWhere((record) => record.scope.thread.providerSessionId === providerSessionId);
diff --git a/apps/server/src/mcp/toolkits/pluginTools/handlers.test.ts b/apps/server/src/mcp/toolkits/pluginTools/handlers.test.ts
new file mode 100644
index 000000000000..4343a95f596d
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/pluginTools/handlers.test.ts
@@ -0,0 +1,165 @@
+import { expect, it } from "@effect/vitest";
+import {
+ EnvironmentId,
+ PluginInstallationId,
+ PluginToolError,
+ ProviderInstanceId,
+ ThreadId,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import { McpSchema, McpServer } from "effect/ai";
+
+import * as PluginTools from "../../../plugins/PluginTools.ts";
+import * as McpHttpServer from "../../McpHttpServer.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+import { liveThreadsLayer } from "../../McpToolAccess.testkit.ts";
+
+const grants = [{ installationId: PluginInstallationId.make("installation-1"), generation: 3 }];
+const invocation = {
+ environmentId: EnvironmentId.make("environment-plugin-tools"),
+ capabilities: new Set(),
+ issuedAt: 1,
+ requestNamespace: "thread:thread-plugin-tools",
+ thread: {
+ threadId: ThreadId.make("thread-plugin-tools"),
+ providerSessionId: "provider-session-plugin-tools",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ },
+ client: undefined,
+ pluginToolGrants: grants,
+} satisfies McpInvocationContext.McpInvocationScope;
+// An MCP OAuth client signed in from outside T3 Code: no thread and no grants.
+const outsideClient = {
+ environmentId: invocation.environmentId,
+ capabilities: new Set(),
+ issuedAt: 1,
+ requestNamespace: "client:session-outside",
+ thread: undefined,
+ client: { sessionId: "session-outside", label: "outside", access: "full-access" },
+} satisfies McpInvocationContext.McpInvocationScope;
+const client = McpSchema.McpServerClient.of({
+ clientId: 1,
+ clientCapabilities: {},
+ clientInfo: { name: "mcp-test", version: "1.0.0" },
+ protocolVersion: "2025-06-18",
+ initializePayload: {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "mcp-test", version: "1.0.0" },
+ },
+ getClient: Effect.die("unused"),
+});
+
+it.effect(
+ "takes grants and thread from the credential and marks the call tool conservatively",
+ () => {
+ const seen: Array = [];
+ const tools = Layer.mock(PluginTools.PluginTools)({
+ list: (granted, options) =>
+ Effect.sync(() => {
+ seen.push({ granted, options });
+ return { tools: [], notInThisSession: [] };
+ }),
+ call: (granted, request) =>
+ Effect.suspend(() => {
+ seen.push({ granted, request });
+ return request.tool === "acme.search/lookup"
+ ? Effect.succeed({ hits: 1 })
+ : Effect.fail(
+ new PluginToolError({ reason: "unknown-tool", message: "No such tool." }),
+ );
+ }),
+ });
+ return Effect.gen(function* () {
+ const server = yield* McpServer.McpServer;
+ const registered = Object.fromEntries(server.tools.map(({ tool }) => [tool.name, tool]));
+ expect(registered.plugin_tools_list?.annotations).toMatchObject({
+ readOnlyHint: true,
+ destructiveHint: false,
+ });
+ expect(registered.plugin_tool_call?.annotations).toMatchObject({
+ readOnlyHint: false,
+ destructiveHint: true,
+ openWorldHint: true,
+ });
+ const callTool = (
+ name: string,
+ args: Record,
+ scope: McpInvocationContext.McpInvocationScope = invocation,
+ ) =>
+ server
+ .callTool({ name, arguments: args })
+ .pipe(
+ Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
+ Effect.provideService(McpSchema.McpServerClient, client),
+ );
+
+ const listed = yield* callTool("plugin_tools_list", { plugin: "acme.search", cursor: "a" });
+ expect(listed.isError).toBe(false);
+ // A context the agent passes is only input; the plugin gets the credential's.
+ const called = yield* callTool("plugin_tool_call", {
+ tool: "acme.search/lookup",
+ input: { q: "x", context: { threadId: "spoofed" } },
+ });
+ expect(called).toMatchObject({ isError: false, structuredContent: { result: { hits: 1 } } });
+ const failed = yield* callTool("plugin_tool_call", { tool: "acme.search/nope" });
+ expect(failed.isError).toBe(true);
+ expect(failed.content).toEqual([{ type: "text", text: "No such tool." }]);
+
+ expect(seen).toEqual([
+ { granted: grants, options: { plugin: "acme.search", cursor: "a" } },
+ {
+ granted: grants,
+ request: {
+ tool: "acme.search/lookup",
+ input: { q: "x", context: { threadId: "spoofed" } },
+ context: {
+ environmentId: invocation.environmentId,
+ threadId: invocation.thread.threadId,
+ },
+ },
+ },
+ {
+ granted: grants,
+ request: {
+ tool: "acme.search/nope",
+ input: {},
+ context: {
+ environmentId: invocation.environmentId,
+ threadId: invocation.thread.threadId,
+ },
+ },
+ },
+ ]);
+
+ // Outside clients see no plugin tools and cannot call one; the plugin is never reached.
+ seen.length = 0;
+ const outsideList = yield* callTool("plugin_tools_list", {}, outsideClient);
+ expect(outsideList.isError).toBe(false);
+ const outsideCall = yield* callTool(
+ "plugin_tool_call",
+ { tool: "acme.search/lookup" },
+ outsideClient,
+ );
+ expect(outsideCall.isError).toBe(true);
+ expect(outsideCall.content).toEqual([
+ {
+ type: "text",
+ text: "This tool acts as the calling T3 thread, so it needs an agent running inside T3 Code. This MCP client signed in from outside a thread.",
+ },
+ ]);
+ expect(outsideList.structuredContent).toEqual({ tools: [], notInThisSession: [] });
+ expect(seen).toEqual([]);
+ }).pipe(
+ Effect.scoped,
+ Effect.provide(
+ McpHttpServer.layerPluginToolsToolkit.pipe(
+ Layer.provideMerge(McpServer.McpServer.layer),
+ Layer.provide(tools),
+ Layer.provide(liveThreadsLayer),
+ ),
+ ),
+ );
+ },
+);
diff --git a/apps/server/src/mcp/toolkits/pluginTools/handlers.ts b/apps/server/src/mcp/toolkits/pluginTools/handlers.ts
new file mode 100644
index 000000000000..9d2b896c69d9
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/pluginTools/handlers.ts
@@ -0,0 +1,45 @@
+import * as Effect from "effect/Effect";
+
+import * as PluginTools from "../../../plugins/PluginTools.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+import * as McpToolAccess from "../../McpToolAccess.ts";
+import { PluginToolsToolkit } from "./tools.ts";
+
+const make = Effect.gen(function* () {
+ const tools = yield* PluginTools.PluginTools;
+ return {
+ // Scope and grants come from the session's credential, never from the agent.
+ // A caller without a thread can call no plugin tool, so it learns of no plugins either.
+ plugin_tools_list: McpToolAccess.reads((input) =>
+ McpInvocationContext.McpInvocationContext.pipe(
+ Effect.flatMap((scope) =>
+ scope.thread === undefined
+ ? Effect.succeed({ tools: [], notInThisSession: [] })
+ : tools.list(scope.pluginToolGrants ?? [], {
+ ...(input.plugin === undefined ? {} : { plugin: input.plugin }),
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
+ }),
+ ),
+ ),
+ ),
+ // A plugin tool runs on behalf of the calling thread while its run is live, so a client
+ // signed in from outside T3 Code (no thread, no grants) cannot call one.
+ plugin_tool_call: McpToolAccess.actsAsCaller((input) =>
+ McpInvocationContext.McpInvocationContext.pipe(
+ Effect.flatMap((scope) =>
+ McpInvocationContext.requireThreadScope(scope, "plugin_tool_call"),
+ ),
+ Effect.flatMap((scope) =>
+ tools.call(scope.pluginToolGrants ?? [], {
+ tool: input.tool,
+ input: input.input ?? {},
+ context: { environmentId: scope.environmentId, threadId: scope.thread.threadId },
+ }),
+ ),
+ Effect.map((result) => ({ result })),
+ ),
+ ),
+ } satisfies McpToolAccess.Handlers;
+});
+
+export const layer = McpToolAccess.toLayer(PluginToolsToolkit, make);
diff --git a/apps/server/src/mcp/toolkits/pluginTools/tools.ts b/apps/server/src/mcp/toolkits/pluginTools/tools.ts
new file mode 100644
index 000000000000..8cfbdc395c09
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/pluginTools/tools.ts
@@ -0,0 +1,64 @@
+import { OrchestratorMcpFailure, PluginToolError, PluginToolsListResult } from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+import * as Tool from "effect/ai/Tool";
+import * as Toolkit from "effect/ai/Toolkit";
+
+import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+
+const dependencies = [McpInvocationContext.McpInvocationContext];
+
+const PluginToolsListTool = Tool.make("plugin_tools_list", {
+ description:
+ "List the tools offered by the user's trusted local T3 Code plugins that this session may use. Each entry has the tool name to pass to plugin_tool_call, its inputSchema, and its sideEffect (read, write or destructive). Listing starts no plugin. Results come in pages ordered by plugin id: when nextCursor is present, pass it as cursor for more. Pass plugin to list one plugin's tools. Plugins enabled after this session started appear under notInThisSession and need a new session.",
+ parameters: Schema.Struct({
+ plugin: Schema.optionalKey(
+ Schema.String.check(Schema.isMaxLength(128)).annotate({
+ description: "A plugin id, such as acme.search, to list only that plugin's tools.",
+ }),
+ ),
+ cursor: Schema.optionalKey(
+ Schema.String.check(Schema.isMaxLength(128)).annotate({
+ description: "The nextCursor of the previous page.",
+ }),
+ ),
+ }),
+ success: PluginToolsListResult,
+ failure: PluginToolError,
+ dependencies,
+})
+ .annotate(Tool.Title, "List plugin tools")
+ .annotate(Tool.Readonly, true)
+ .annotate(Tool.Destructive, false)
+ .annotate(Tool.Idempotent, true)
+ .annotate(Tool.OpenWorld, false);
+
+/**
+ * One fixed tool calls every plugin tool, so its hints describe the most a
+ * plugin tool may do. Each tool's own sideEffect is in plugin_tools_list.
+ */
+const PluginToolCallTool = Tool.make("plugin_tool_call", {
+ description:
+ "Call a tool from plugin_tools_list. Pass its tool name and an input object that matches its inputSchema. Check the tool's sideEffect first: write and destructive tools change things, so follow the user's instructions about such changes.",
+ parameters: Schema.Struct({
+ tool: Schema.String.check(Schema.isMaxLength(200)).annotate({
+ description: "The tool value from plugin_tools_list, such as acme.search/lookup.",
+ }),
+ input: Schema.optionalKey(
+ Schema.Record(Schema.String, Schema.Unknown).annotate({
+ description: "Arguments matching the tool's inputSchema. Defaults to {}.",
+ }),
+ ),
+ }),
+ success: Schema.Struct({ result: Schema.Unknown }),
+ // The caller check refuses an outside client or a thread whose run has ended.
+ failure: Schema.Union([PluginToolError, OrchestratorMcpFailure]),
+ dependencies: [...dependencies, ThreadManagementService.ThreadManagementService],
+})
+ .annotate(Tool.Title, "Call plugin tool")
+ .annotate(Tool.Readonly, false)
+ .annotate(Tool.Destructive, true)
+ .annotate(Tool.Idempotent, false)
+ .annotate(Tool.OpenWorld, true);
+
+export const PluginToolsToolkit = Toolkit.make(PluginToolsListTool, PluginToolCallTool);
diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts
index 91257c2fdb80..65fa2121cec5 100644
--- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts
+++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts
@@ -15,6 +15,7 @@ import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts";
import * as GitWorkflowService from "../../../git/GitWorkflowService.ts";
import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdapterRegistry.ts";
import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as PluginTools from "../../../plugins/PluginTools.ts";
import * as ProjectService from "../../../project/ProjectService.ts";
import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts";
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
@@ -50,6 +51,7 @@ const layerStubServices = Layer.mergeAll(
Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({}),
Layer.mock(GitVcsDriver.GitVcsDriver)({}),
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }),
+ Layer.mock(PluginTools.PluginTools)({}),
Layer.mock(PreviewManager.PreviewManager)({}),
Layer.mock(ServerSecretStore.ServerSecretStore)({}),
Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}),
diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts
index 2ad903ff792a..62b164385d01 100644
--- a/apps/server/src/observability/RpcInstrumentation.ts
+++ b/apps/server/src/observability/RpcInstrumentation.ts
@@ -88,6 +88,28 @@ const RPC_AGGREGATES = {
[WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks",
[WS_METHODS.scheduledTasksDelete]: "scheduledTasks",
[WS_METHODS.scheduledTasksRunNow]: "scheduledTasks",
+ [WS_METHODS.pluginsList]: "plugins",
+ [WS_METHODS.pluginsSubscribe]: "plugins",
+ [WS_METHODS.pluginsAdd]: "plugins",
+ [WS_METHODS.pluginsRefresh]: "plugins",
+ [WS_METHODS.pluginsConsent]: "plugins",
+ [WS_METHODS.pluginsEnable]: "plugins",
+ [WS_METHODS.pluginsDisable]: "plugins",
+ [WS_METHODS.pluginsRemove]: "plugins",
+ [WS_METHODS.pluginsResume]: "plugins",
+ [WS_METHODS.pluginsSettingsSubscribe]: "plugins",
+ [WS_METHODS.pluginsSettingsUpdate]: "plugins",
+ [WS_METHODS.pluginActionsSubscribe]: "plugins",
+ [WS_METHODS.pluginActionsInvoke]: "plugins",
+ [WS_METHODS.pluginsNpmList]: "plugins",
+ [WS_METHODS.pluginsNpmAdd]: "plugins",
+ [WS_METHODS.pluginsNpmStageUpdate]: "plugins",
+ [WS_METHODS.pluginsNpmApplyUpdate]: "plugins",
+ [WS_METHODS.pluginsNpmDiscardUpdate]: "plugins",
+ [WS_METHODS.pluginsNotificationsSubscribe]: "plugins",
+ [WS_METHODS.pluginViewsSubscribe]: "pluginViews",
+ [WS_METHODS.pluginViewsReadBundle]: "pluginViews",
+ [WS_METHODS.pluginViewsCall]: "pluginViews",
[WS_METHODS.scheduledTasksRotateWebhookToken]: "scheduledTasks",
[WS_METHODS.scheduledTasksListWebhookDeliveries]: "scheduledTasks",
[WS_METHODS.scheduledTasksGetWebhookDelivery]: "scheduledTasks",
@@ -154,6 +176,7 @@ const RPC_AGGREGATES = {
[WS_METHODS.subscribeWorktreeSetup]: "vcs",
[WS_METHODS.worktreeSetupCancel]: "vcs",
[WS_METHODS.subscribeResourceTelemetry]: "server",
+ [WS_METHODS.subscribeContributionStatus]: "server",
[WS_METHODS.vcsRefreshStatus]: "vcs",
[WS_METHODS.vcsPull]: "git",
[WS_METHODS.gitRunStackedAction]: "vcs",
diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts
index d9b21038cf71..6ed5a0000e36 100644
--- a/apps/server/src/orchestration-v2/EffectOutbox.ts
+++ b/apps/server/src/orchestration-v2/EffectOutbox.ts
@@ -214,6 +214,10 @@ export interface EffectOutboxV2Shape {
readonly listByCommandId: (
commandId: CommandId,
) => Effect.Effect, EffectOutboxError>;
+ /**
+ * Cancelling a checkpoint capture abandons its run's finalization. Do that
+ * through `EventSink.commitCommand`, which records the failure.
+ */
readonly cancelUnsettled: (input: {
readonly threadId: ThreadId;
readonly effectTypes: ReadonlyArray;
diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts
index d9bd8689f092..8bceed22e008 100644
--- a/apps/server/src/orchestration-v2/EffectWorker.test.ts
+++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts
@@ -130,7 +130,10 @@ function layerExecutorFor(input: {
),
Layer.succeed(
RunFinalizationService.RunFinalizationService,
- RunFinalizationService.RunFinalizationService.of({ finalize: () => Effect.void }),
+ RunFinalizationService.RunFinalizationService.of({
+ finalize: () => Effect.void,
+ abandon: () => Effect.succeed(true),
+ }),
),
Layer.succeed(
CheckpointRollbackService.CheckpointRollbackServiceV2,
diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts
index 9ae94910b7c8..19d6a263b484 100644
--- a/apps/server/src/orchestration-v2/EffectWorker.ts
+++ b/apps/server/src/orchestration-v2/EffectWorker.ts
@@ -71,6 +71,18 @@ export interface OrchestrationEffectExecutorV2Shape {
effect: EffectOutbox.OrchestrationEffectV2,
options?: { readonly willRetry: boolean },
) => Effect.Effect;
+ /**
+ * Settles an effect whose last attempt failed, in place of `outbox.fail`, so
+ * its failure record commits with the terminal status. Returns undefined
+ * for effects that keep no record, and false when the worker no longer
+ * owns the lease.
+ */
+ readonly fail?: (input: {
+ readonly effect: EffectOutbox.OrchestrationEffectV2;
+ readonly workerId: string;
+ readonly error: string;
+ readonly cause: Cause.Cause;
+ }) => Effect.Effect | undefined;
}
export class OrchestrationEffectExecutorV2 extends Context.Service<
@@ -497,6 +509,36 @@ export const layerExecutor: Layer.Layer<
);
}
},
+ fail: ({ effect, workerId, error, cause }) => {
+ if (effect.request.type !== "checkpoint.capture") return undefined;
+ const failure = Cause.findErrorOption(cause).pipe(
+ Option.map((executionError) => executionError.cause),
+ Option.filter(RunFinalizationService.isRunFinalizationError),
+ );
+ return runFinalization
+ .abandon({
+ threadId: effect.threadId,
+ runId: effect.request.runId,
+ scopeId: effect.request.scopeId,
+ operation: Option.match(failure, {
+ onNone: () => "capture-checkpoint" as const,
+ onSome: (finalizationError) => finalizationError.operation,
+ }),
+ effectId: effect.id,
+ workerId,
+ error,
+ })
+ .pipe(
+ Effect.mapError(
+ (abandonCause) =>
+ new OrchestrationEffectExecutionError({
+ effectId: effect.id,
+ effectType: effect.request.type,
+ cause: abandonCause,
+ }),
+ ),
+ );
+ },
});
}),
);
@@ -558,9 +600,12 @@ export const layerWithOptions = (
}),
),
);
+ const retryDelayMs = (effect: EffectOutbox.OrchestrationEffectV2) =>
+ Math.min(30_000, 100 * 2 ** Math.max(0, effect.attemptCount - 1));
const requeueClaim = (
effect: EffectOutbox.OrchestrationEffectV2,
cause: Cause.Cause,
+ delayMs = 0,
) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
@@ -569,7 +614,7 @@ export const layerWithOptions = (
effectId: effect.id,
workerId,
error: `Worker failed before settling the claimed effect: ${Cause.pretty(cause)}`,
- delayMs: 0,
+ delayMs,
})
.pipe(
Effect.flatMap((requeued) =>
@@ -722,24 +767,36 @@ export const layerWithOptions = (
nonRetryable,
error,
});
+ const retry = outbox
+ .retry({
+ effectId: effect.id,
+ workerId,
+ error,
+ delayMs: retryDelayMs(effect),
+ })
+ .pipe(Effect.onError((cause) => requeueClaim(effect, cause)));
+ const recordedFailure = executor.fail?.({ effect, workerId, error, cause: exit.cause });
// Prefer succeed for terminal interrupt races so the outbox does not
// keep a failed interrupt around; fail only when we must not retry.
+ // An effect that records its failure stays recoverable until that
+ // record commits, retried with backoff so a record that keeps failing
+ // does not rerun the effect at once; an interruption is replayed, not
+ // recorded.
const updated = nonRetryable
? yield* outbox
.succeed({ effectId: effect.id, workerId })
.pipe(Effect.onError((cause) => terminalizeClaim(effect, cause)))
- : effect.attemptCount >= maxAttempts
- ? yield* outbox
- .fail({ effectId: effect.id, workerId, error })
- .pipe(Effect.onError((cause) => terminalizeClaim(effect, cause)))
- : yield* outbox
- .retry({
- effectId: effect.id,
- workerId,
- error,
- delayMs: Math.min(30_000, 100 * 2 ** Math.max(0, effect.attemptCount - 1)),
- })
- .pipe(Effect.onError((cause) => requeueClaim(effect, cause)));
+ : effect.attemptCount < maxAttempts
+ ? yield* retry
+ : recordedFailure === undefined
+ ? yield* outbox
+ .fail({ effectId: effect.id, workerId, error })
+ .pipe(Effect.onError((cause) => terminalizeClaim(effect, cause)))
+ : Cause.hasInterruptsOnly(exit.cause)
+ ? yield* retry
+ : yield* recordedFailure.pipe(
+ Effect.onError((cause) => requeueClaim(effect, cause, retryDelayMs(effect))),
+ );
if (!updated) {
if (yield* wasCancelled(effect.id)) return true;
return yield* new OrchestrationEffectWorkerError({
diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts
index 039d1872da6f..e3368a4064f7 100644
--- a/apps/server/src/orchestration-v2/EventSink.ts
+++ b/apps/server/src/orchestration-v2/EventSink.ts
@@ -31,6 +31,7 @@ import * as EffectOutbox from "./EffectOutbox.ts";
import * as EventStore from "./EventStore.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
import * as ProjectStore from "./ProjectStore.ts";
+import * as RunFinalized from "./RunFinalized.ts";
import * as TurnItemPositionStore from "./TurnItemPositionStore.ts";
/**
@@ -139,6 +140,25 @@ export interface EventSinkV2Shape {
},
EventSinkV2Error
>;
+ /**
+ * Fails a claimed effect and records `events` in one transaction. Commits
+ * nothing when `workerId` no longer holds the effect's lease.
+ */
+ readonly failEffect: (input: {
+ readonly commandId: CommandId;
+ readonly effectId: string;
+ readonly workerId: string;
+ readonly error: string;
+ readonly events: ReadonlyArray;
+ }) => Effect.Effect<
+ {
+ readonly committed: boolean;
+ readonly storedEvents: ReadonlyArray;
+ },
+ EventSinkV2Error
+ >;
+ /** Whether the run recorded `run.finalized` or `run.finalization-failed`. */
+ readonly hasRunFinalization: (runId: RunId) => Effect.Effect;
readonly commitRejectedCommand: (input: {
readonly commandId: CommandId;
readonly threadId: ThreadId;
@@ -306,7 +326,110 @@ const layerBase: Layer.Layer<
});
});
- const normalizeEvents = (events: ReadonlyArray) => {
+ const isRunFinalizationRecorded = (runId: RunId) =>
+ sql<{ readonly found: number }>`
+ SELECT 1 AS found
+ FROM orchestration_events
+ WHERE event_id = ${RunFinalized.runFinalizedEventId(runId)}
+ LIMIT 1
+ `.pipe(Effect.map((rows) => rows.length > 0));
+
+ // A run records one finalization. A run whose checkpoint capture is due,
+ // running or done finalizes through RunFinalizationService. A run that
+ // never enqueued one finalizes in the commit that writes its terminal
+ // status, and one whose capture was abandoned without a record reports
+ // that failure there instead. Events are checked against stored state so
+ // a repeated write cannot record twice.
+ const withRunFinalizedEvents = (
+ events: ReadonlyArray,
+ effects: ReadonlyArray,
+ ) =>
+ Effect.gen(function* () {
+ const isFinalizationRecord = (event: OrchestrationV2DomainEvent) =>
+ event.type === "run.finalized" || event.type === "run.finalization-failed";
+ if (!events.some((event) => event.type === "run.updated" || isFinalizationRecord(event))) {
+ return events;
+ }
+ const captureStatus = (runId: RunId) =>
+ effects.some(
+ (effect) =>
+ effect.request.type === "checkpoint.capture" && effect.request.runId === runId,
+ )
+ ? Effect.succeed(Option.some("pending"))
+ : effectOutbox
+ .get(RunFinalized.checkpointCaptureEffectId(runId))
+ .pipe(Effect.map(Option.map((effect) => effect.status)));
+ const statuses = new Map();
+ const previousStatus = (runId: RunId) =>
+ statuses.has(runId)
+ ? Effect.succeed(statuses.get(runId))
+ : sql<{ readonly status: string }>`
+ SELECT status
+ FROM orchestration_v2_projection_runs
+ WHERE run_id = ${runId}
+ LIMIT 1
+ `.pipe(Effect.map((rows) => rows[0]?.status));
+ const finalized = new Set();
+ const result: Array = [];
+ // Appended last so the milestone follows every write in its commit.
+ const milestones: Array = [];
+ for (const event of events) {
+ if (event.type === "run.finalized" || event.type === "run.finalization-failed") {
+ const runId = event.payload.runId;
+ if (!finalized.has(runId) && !(yield* isRunFinalizationRecorded(runId))) {
+ finalized.add(runId);
+ result.push(event);
+ }
+ continue;
+ }
+ result.push(event);
+ if (event.type === "run.created") {
+ statuses.set(event.payload.id, event.payload.status);
+ continue;
+ }
+ if (event.type !== "run.updated") continue;
+ const run = event.payload;
+ const previous = yield* previousStatus(run.id);
+ statuses.set(run.id, run.status);
+ const outcome = RunFinalized.runFinalizedOutcome(run.status);
+ // Only the transition into a final status finalizes, so later
+ // updates to old runs never produce a late milestone.
+ if (
+ outcome === null ||
+ finalized.has(run.id) ||
+ (previous !== undefined && RunFinalized.isSettledRunStatus(previous)) ||
+ (yield* isRunFinalizationRecorded(run.id))
+ ) {
+ continue;
+ }
+ const capture = yield* captureStatus(run.id);
+ const abandoned =
+ Option.isSome(capture) && (capture.value === "failed" || capture.value === "cancelled");
+ if (Option.isSome(capture) && !abandoned) continue;
+ finalized.add(run.id);
+ milestones.push(
+ abandoned
+ ? RunFinalized.makeRunFinalizationFailedEvent({
+ run,
+ operation: RunFinalized.abandonedOperation(run),
+ occurredAt: event.occurredAt,
+ })
+ : RunFinalized.makeRunFinalizedEvent({ run, outcome, occurredAt: event.occurredAt }),
+ );
+ }
+ return [...result, ...milestones];
+ });
+
+ const normalizeEvents = (
+ input: ReadonlyArray,
+ effects: ReadonlyArray = [],
+ ) =>
+ Effect.gen(function* () {
+ const events = yield* withRunFinalizedEvents(input, effects);
+ return yield* normalizeTurnItemPositions(events);
+ });
+
+ const normalizeTurnItemPositions = (events: ReadonlyArray) => {
const runOrdinals = new Map(
events.flatMap((event) =>
event.type === "run.created" || event.type === "run.updated"
@@ -374,6 +497,7 @@ const layerBase: Layer.Layer<
input.guardPendingUserInputCancellations === true
? yield* guardUserInputCancellations(input.events)
: input.events,
+ input.effects,
);
const committed = yield* eventStore.append({
...(input.commandId === undefined ? {} : { commandId: input.commandId }),
@@ -428,10 +552,13 @@ const layerBase: Layer.Layer<
};
}
+ // The checkpoint capture enqueued below decides how a run that
+ // settles here finalizes, so it must be visible to normalization.
const normalized = yield* normalizeEvents(
input.guardPendingUserInputCancellations === true
? yield* guardUserInputCancellations(input.events)
: input.events,
+ input.effects,
);
const storedEvents = yield* eventStore.append({
...(input.commandId === undefined ? {} : { commandId: input.commandId }),
@@ -524,6 +651,79 @@ const layerBase: Layer.Layer<
return { receipt: existing.value, storedEvents };
});
+ // Cancelling a checkpoint capture abandons its run's finalization, so the
+ // cancelling commit records `run.finalization-failed` for that run.
+ const recordAbandonedCaptures = (
+ commandId: CommandId,
+ cancelledEffectIds: ReadonlyArray,
+ occurredAt: DateTime.Utc,
+ ) =>
+ Effect.gen(function* () {
+ const events: Array = [];
+ for (const effectId of cancelledEffectIds) {
+ const effect = yield* effectOutbox.get(effectId);
+ if (Option.isNone(effect) || effect.value.request.type !== "checkpoint.capture") continue;
+ const { run } = yield* projectionStore.getCheckpointCaptureContext(
+ effect.value.threadId,
+ effect.value.request,
+ );
+ if (run === undefined || run.status === "rolled_back") continue;
+ events.push(
+ RunFinalized.makeRunFinalizationFailedEvent({
+ run,
+ operation: RunFinalized.abandonedOperation(run),
+ occurredAt,
+ }),
+ );
+ }
+ if (events.length === 0) return [];
+ const storedEvents = yield* eventStore.append({
+ commandId,
+ events: yield* normalizeEvents(events),
+ });
+ yield* applyStoredEvents(storedEvents);
+ return storedEvents;
+ });
+
+ const failEffectEffect = Effect.fn("orchestrationV2.EventSink.failEffect")(function* (
+ input: Parameters[0],
+ ) {
+ yield* Effect.annotateCurrentSpan({
+ "orchestration_v2.command_id": input.commandId,
+ "orchestration_v2.effect_id": input.effectId,
+ "orchestration_v2.event_count": input.events.length,
+ });
+
+ return yield* commitThenPublish(
+ Effect.gen(function* () {
+ const failed = yield* effectOutbox.fail({
+ effectId: input.effectId,
+ workerId: input.workerId,
+ error: input.error,
+ });
+ if (!failed) {
+ return {
+ committed: false as const,
+ storedEvents: [] as ReadonlyArray,
+ };
+ }
+ const normalized = yield* normalizeEvents(input.events);
+ const storedEvents =
+ normalized.length === 0
+ ? []
+ : yield* eventStore.append({ commandId: input.commandId, events: normalized });
+ yield* applyStoredEvents(storedEvents);
+ return { committed: true as const, storedEvents };
+ }),
+ (result) =>
+ result.committed
+ ? effectOutbox
+ .notifyAvailable()
+ .pipe(Effect.andThen(publishStoredEvents(result.storedEvents)))
+ : Effect.void,
+ );
+ });
+
const commitCommandEffect = Effect.fn("orchestrationV2.EventSink.commitCommand")(function* (
input: Parameters[0],
) {
@@ -543,36 +743,47 @@ const layerBase: Layer.Layer<
return { ...existing, committed: false as const, cancelledEffectIds: [] };
}
- const normalized = yield* normalizeEvents(input.events);
- const storedEvents = yield* eventStore.append({
+ const normalized = yield* normalizeEvents(input.events, input.effects);
+ const appended = yield* eventStore.append({
commandId: input.commandId,
events: normalized,
});
- const sequence = storedEvents.at(-1)?.sequence;
- if (sequence === undefined) {
+ if (appended.length === 0) {
return yield* Effect.die(
new Error(`Command ${input.commandId} produced no orchestration events.`),
);
}
- yield* applyStoredEvents(storedEvents);
+ yield* applyStoredEvents(appended);
yield* effectOutbox.enqueue(input.effects);
+ const cancelledEffectIds =
+ input.cancelUnsettledEffects === undefined
+ ? []
+ : yield* effectOutbox.cancelUnsettled({
+ threadId: input.threadId,
+ ...input.cancelUnsettledEffects,
+ });
+ const storedEvents = input.cancelUnsettledEffects?.effectTypes.includes(
+ "checkpoint.capture",
+ )
+ ? [
+ ...appended,
+ ...(yield* recordAbandonedCaptures(
+ input.commandId,
+ cancelledEffectIds,
+ input.acceptedAt,
+ )),
+ ]
+ : appended;
const receipt: CommandReceiptStore.CommandReceiptV2 = {
commandId: input.commandId,
threadId: input.threadId,
commandType: input.commandType,
acceptedAt: input.acceptedAt,
- resultSequence: sequence,
+ resultSequence: storedEvents.at(-1)?.sequence ?? 0,
status: "accepted",
error: null,
};
yield* commandReceipts.upsert(receipt);
- const cancelledEffectIds =
- input.cancelUnsettledEffects === undefined
- ? []
- : yield* effectOutbox.cancelUnsettled({
- threadId: input.threadId,
- ...input.cancelUnsettledEffects,
- });
return { receipt, storedEvents, committed: true as const, cancelledEffectIds };
}),
(result) =>
@@ -815,6 +1026,21 @@ const layerBase: Layer.Layer<
}),
),
),
+ failEffect: (input) =>
+ failEffectEffect(input).pipe(
+ Effect.mapError(
+ (cause) =>
+ new EventSinkWriteError({
+ commandId: input.commandId,
+ eventCount: input.events.length,
+ cause,
+ }),
+ ),
+ ),
+ hasRunFinalization: (runId) =>
+ isRunFinalizationRecorded(runId).pipe(
+ Effect.mapError((cause) => new EventSinkStreamError({ cause })),
+ ),
commitRejectedCommand: (input) =>
commitRejectedCommandEffect(input).pipe(
Effect.mapError(
diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts
index 0a39c5cf374a..790b56115629 100644
--- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts
+++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts
@@ -119,6 +119,7 @@ const layerMcpRegistry = Layer.succeed(
}),
resolve: () => Effect.succeed(undefined),
touch: () => Effect.void,
+ setPluginToolGrants: () => Effect.void,
revokeProviderSession: () => Effect.void,
revokeThread: () => Effect.void,
revokeAll: Effect.void,
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts
index 51343fd8af91..0a431bc5738b 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.ts
@@ -750,6 +750,10 @@ export function applyToProjection(
),
),
});
+ // Milestones over state the run rows already hold.
+ case "run.finalized":
+ case "run.finalization-failed":
+ return projection;
case "run.background-work-cancelled":
return {
...base,
@@ -1929,6 +1933,9 @@ export const layer: Layer.Layer =
`;
break;
}
+ case "run.finalized":
+ case "run.finalization-failed":
+ break;
case "run.background-work-cancelled": {
// Only this field changes, so a concurrent lifecycle write is never regressed.
const workJson = yield* encodeRestartCancelledBackgroundWork(
@@ -2662,7 +2669,9 @@ export const layer: Layer.Layer =
event.type !== "thread.runtime-mode-updated" &&
event.type !== "thread.interaction-mode-updated" &&
event.type !== "thread.model-selection-updated" &&
- event.type !== "thread.provider-switched"
+ event.type !== "thread.provider-switched" &&
+ event.type !== "run.finalized" &&
+ event.type !== "run.finalization-failed"
) {
const rows = yield* sql`
SELECT payload_json
diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts
index 04310257a71e..a2faafd5db6e 100644
--- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts
@@ -10,6 +10,7 @@ import {
type OrchestrationV2ProviderCapabilities,
type OrchestrationV2ProviderSession,
type OrchestrationV2ProviderThread,
+ PluginInstallationId,
type Project,
ProjectId,
ProviderDriverKind,
@@ -35,6 +36,7 @@ import { HttpServer } from "effect/http";
import { ProviderWorkspaceMissingError } from "../provider/Errors.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
+import * as PluginTools from "../plugins/PluginTools.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
@@ -458,11 +460,8 @@ function layerTest(input: {
readonly failReleaseEventWrites?: boolean;
readonly flakyReleaseWrites?: FlakyReleaseWrites;
readonly pauseAttachWrite?: Parameters[0];
- /** Once armed, holds the next credential lookup until it is interrupted. */
- readonly pauseResolve?: {
- readonly armed: Ref.Ref;
- readonly paused: Deferred.Deferred;
- };
+ /** Once armed, the next call of that registry step hangs until interrupted or crashes. */
+ readonly pauseMcpRegistry?: PauseMcpRegistry;
readonly hasPendingBackgroundWork?: Effect.Effect;
readonly hasPendingBackgroundWorkForThread?: Effect.Effect;
readonly hangSessionScopeClose?: boolean;
@@ -472,6 +471,7 @@ function layerTest(input: {
readonly scopeCloseReached?: Deferred.Deferred;
readonly serverSettingsLayer?: ReturnType;
readonly projectServiceLayer?: Layer.Layer;
+ readonly pluginToolsLayer?: Layer.Layer;
}) {
const layerConfiguredEventSink =
input.flakyReleaseWrites !== undefined
@@ -505,9 +505,9 @@ function layerTest(input: {
}).pipe(Effect.map(ProviderAdapterRegistry.layerSingle)),
);
const layerConfiguredMcpRegistry =
- input.pauseResolve === undefined
+ input.pauseMcpRegistry === undefined
? layerTestMcpRegistry
- : layerPausingMcpRegistry(input.pauseResolve);
+ : layerPausingMcpRegistry(input.pauseMcpRegistry);
const layerProviderEventIngestorTest = ProviderEventIngestor.layer.pipe(
Layer.provide(
Layer.mergeAll(
@@ -538,6 +538,7 @@ function layerTest(input: {
layerTestStores,
...(input.serverSettingsLayer === undefined ? [] : [input.serverSettingsLayer]),
...(input.projectServiceLayer === undefined ? [] : [input.projectServiceLayer]),
+ ...(input.pluginToolsLayer === undefined ? [] : [input.pluginToolsLayer]),
),
),
),
@@ -563,23 +564,41 @@ const layerTestMcpRegistry = Layer.effect(
Layer.provide(NodeServices.layer),
);
-const layerPausingMcpRegistry = (pause: {
+interface PauseMcpRegistry {
+ readonly step: "resolve" | "setPluginToolGrants";
+ readonly outcome: "hang" | "crash";
readonly armed: Ref.Ref;
readonly paused: Deferred.Deferred;
-}) =>
+}
+
+const layerPausingMcpRegistry = (pause: PauseMcpRegistry) =>
Layer.effect(
McpSessionRegistry.McpSessionRegistry,
Effect.gen(function* () {
const delegate = yield* McpSessionRegistry.McpSessionRegistry;
+ const holdIfArmed = (step: PauseMcpRegistry["step"], run: Effect.Effect) =>
+ step !== pause.step
+ ? run
+ : Ref.getAndSet(pause.armed, false).pipe(
+ Effect.flatMap((armed) =>
+ armed
+ ? Deferred.succeed(pause.paused, undefined).pipe(
+ Effect.andThen(
+ pause.outcome === "hang"
+ ? Effect.never
+ : Effect.die(new Error(`${step} crashed`)),
+ ),
+ )
+ : run,
+ ),
+ );
return McpSessionRegistry.McpSessionRegistry.of({
...delegate,
- resolve: (rawToken) =>
- Ref.getAndSet(pause.armed, false).pipe(
- Effect.flatMap((armed) =>
- armed
- ? Deferred.succeed(pause.paused, undefined).pipe(Effect.andThen(Effect.never))
- : delegate.resolve(rawToken),
- ),
+ resolve: (rawToken) => holdIfArmed("resolve", delegate.resolve(rawToken)),
+ setPluginToolGrants: (providerSessionId, grants) =>
+ holdIfArmed(
+ "setPluginToolGrants",
+ delegate.setPluginToolGrants(providerSessionId, grants),
),
});
}),
@@ -1913,6 +1932,49 @@ it.effect(
}),
);
+it.effect("ProviderSessionManagerV2 snapshots the enabled tool plugins into the credential", () =>
+ Effect.gen(function* () {
+ const state = yield* Ref.make(emptyState);
+ const mcpConfigs = yield* Ref.make<
+ ReadonlyArray
+ >([]);
+ const enabled = yield* Ref.make([
+ { installationId: PluginInstallationId.make("installation-1"), generation: 1 },
+ ]);
+ const pluginToolsLayer = Layer.mock(PluginTools.PluginTools)({ grants: Ref.get(enabled) });
+ yield* Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const idAllocator = yield* IdAllocator.IdAllocatorV2;
+ const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
+ const registry = yield* McpSessionRegistry.McpSessionRegistry;
+ const now = yield* DateTime.now;
+ const threadId = ThreadId.make("thread-provider-session-manager-plugin-tools");
+ yield* eventSink.write({
+ events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })],
+ });
+ const openAndResolve = Effect.gen(function* () {
+ const providerSessionId = yield* idAllocator.allocate.providerSession({
+ providerInstanceId: modelSelection.instanceId,
+ threadId,
+ });
+ yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy });
+ const config = (yield* Ref.get(mcpConfigs)).at(-1);
+ const token = config!.authorizationHeader.replace(/^Bearer\s+/, "");
+ const resolved = yield* registry.resolve(token);
+ yield* manager.close(providerSessionId);
+ return resolved?.pluginToolGrants;
+ });
+
+ assert.deepEqual(yield* openAndResolve, yield* Ref.get(enabled));
+ // A plugin enabled later reaches the next session, not the one already prepared.
+ yield* Ref.set(enabled, []);
+ assert.deepEqual(yield* openAndResolve, []);
+ }).pipe(
+ Effect.provide(layerTest({ state, idleTimeoutMs: 1_000, mcpConfigs, pluginToolsLayer })),
+ );
+ }),
+);
+
it.effect("ProviderSessionManagerV2 honors a project browser-access opt-out", () =>
Effect.gen(function* () {
const captured = yield* runBrowserAccessScenario({
@@ -2172,9 +2234,14 @@ it.effect(
}),
);
-it.effect(
- "ProviderSessionManagerV2 revokes a reused credential after a resume stopped while checking it",
- () =>
+it.effect.each([
+ ["stopped while checking it", "resolve", "hang"],
+ ["stopped while updating its plugin tool grants", "setPluginToolGrants", "hang"],
+ ["crashed while checking it", "resolve", "crash"],
+ ["crashed while updating its plugin tool grants", "setPluginToolGrants", "crash"],
+] as const)(
+ "ProviderSessionManagerV2 revokes a reused credential after a resume %s",
+ ([, step, outcome]) =>
Effect.gen(function* () {
const state = yield* Ref.make(emptyState);
const armed = yield* Ref.make(false);
@@ -2186,8 +2253,8 @@ it.effect(
const registry = yield* McpSessionRegistry.McpSessionRegistry;
const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
const now = yield* DateTime.now;
- const owner = ThreadId.make("thread-provider-session-manager-resolve-stop-owner");
- const threadId = ThreadId.make("thread-provider-session-manager-resolve-stop");
+ const owner = ThreadId.make(`thread-provider-session-manager-${step}-${outcome}-owner`);
+ const threadId = ThreadId.make(`thread-provider-session-manager-${step}-${outcome}`);
const providerSessionId = idAllocator.derive.providerSession({
providerInstanceId: modelSelection.instanceId,
});
@@ -2210,7 +2277,7 @@ it.effect(
threadId,
providerSessionId,
now,
- nativeThreadId: "native-resolve-stop",
+ nativeThreadId: `native-${step}-${outcome}`,
}),
});
// The thread gets a credential, then detaches and keeps it for a re-attach.
@@ -2220,18 +2287,23 @@ it.effect(
assert.isDefined(config);
const token = config!.authorizationHeader.replace(/^Bearer\s+/, "");
- // A re-attach is stopped while it checks whether that credential is reusable.
+ // A re-attach is stopped, or crashes, while it reuses that credential.
yield* Ref.set(armed, true);
const stopped = yield* resume.pipe(Effect.forkChild({ startImmediately: true }));
yield* Deferred.await(paused);
- yield* Fiber.interrupt(stopped);
+ // A crashed attach is logged and rolled back rather than failing the resume.
+ yield* outcome === "hang" ? Fiber.interrupt(stopped) : Fiber.await(stopped);
// Nothing holds the credential now, so a terminal release revokes it.
yield* manager.release({ providerSessionId, reason: "manual_shutdown" });
assert.isUndefined(yield* registry.resolve(token));
}).pipe(
Effect.provide(
- layerTest({ state, idleTimeoutMs: 60_000, pauseResolve: { armed, paused } }),
+ layerTest({
+ state,
+ idleTimeoutMs: 60_000,
+ pauseMcpRegistry: { step, outcome, armed, paused },
+ }),
),
);
}),
diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts
index 0dec68dc1ec9..fbf5e8638315 100644
--- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts
+++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts
@@ -44,6 +44,7 @@ import * as ProjectService from "../project/ProjectService.ts";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
import * as ServerSettings from "../serverSettings.ts";
import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts";
+import * as PluginTools from "../plugins/PluginTools.ts";
import * as EventSink from "./EventSink.ts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
@@ -355,6 +356,8 @@ export const layerWithOptions = (
*/
const serverSettings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService);
const projectService = yield* Effect.serviceOption(ProjectService.ProjectService);
+ // Optional for the same reason; without it a session gets no plugin tools.
+ const pluginTools = yield* Effect.serviceOption(PluginTools.PluginTools);
const eventSink = yield* EventSink.EventSinkV2;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const providerEventIngestor = yield* ProviderEventIngestor.ProviderEventIngestorV2;
@@ -495,6 +498,10 @@ export const layerWithOptions = (
>(["orchestration", "worktree", "pull-requests"]);
if (browserToolsAvailable) capabilities.add("preview");
if (deviceToolsAvailable) capabilities.add("device");
+ // Taken at every preparation; each call still checks the plugin is enabled now.
+ const pluginToolGrants = Option.isSome(pluginTools)
+ ? yield* pluginTools.value.grants
+ : [];
const existing = yield* mcpSessions.read(threadId);
if (existing !== undefined) {
// Reserve before the async resolve so a release cannot
@@ -502,25 +509,35 @@ export const layerWithOptions = (
reserveMcpCredential(threadId, existing.providerSessionId);
const rawToken = existing.authorizationHeader.replace(/^Bearer\s+/, "");
// The caller only learns of the reservation once this returns,
- // so a stop while resolving must drop it here.
- const resolved = yield* mcpSessionRegistry
- .resolve(rawToken)
- .pipe(
- Effect.onInterrupt(() =>
- Effect.sync(() =>
- dropMcpCredentialReservation(threadId, existing.providerSessionId),
- ),
- ),
+ // so a stop or crash while resolving or updating grants must drop it here.
+ const reused = yield* Effect.gen(function* () {
+ const resolved = yield* mcpSessionRegistry.resolve(rawToken);
+ if (
+ resolved === undefined ||
+ resolved.thread.threadId !== threadId ||
+ resolved.thread.providerInstanceId !== providerInstanceId ||
+ // A flipped browser-access setting must not survive through
+ // credential reuse: rotate so the new scope reflects it.
+ resolved.capabilities.has("preview") !== browserToolsAvailable ||
+ resolved.capabilities.has("device") !== deviceToolsAvailable
+ ) {
+ return false;
+ }
+ // The provider keeps this credential, and the plugin tools are fixed meta-tools,
+ // so new grants apply to it without rotating the token.
+ yield* mcpSessionRegistry.setPluginToolGrants(
+ existing.providerSessionId,
+ pluginToolGrants,
);
- if (
- resolved !== undefined &&
- resolved.thread.threadId === threadId &&
- resolved.thread.providerInstanceId === providerInstanceId &&
- // A flipped browser-access setting must not survive through
- // credential reuse: rotate so the new scope reflects it.
- resolved.capabilities.has("preview") === browserToolsAvailable &&
- resolved.capabilities.has("device") === deviceToolsAvailable
- ) {
+ return true;
+ }).pipe(
+ Effect.onError(() =>
+ Effect.sync(() =>
+ dropMcpCredentialReservation(threadId, existing.providerSessionId),
+ ),
+ ),
+ );
+ if (reused) {
return { mcpCredentialId: existing.providerSessionId, issued: false };
}
dropMcpCredentialReservation(threadId, existing.providerSessionId);
@@ -531,6 +548,7 @@ export const layerWithOptions = (
providerInstanceId,
browserToolsAvailable,
capabilities,
+ pluginToolGrants,
});
yield* mcpSessions.set(credential.config);
reserveMcpCredential(threadId, credential.config.providerSessionId);
diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts
index a316ce0ccd62..0d65e4f75f20 100644
--- a/apps/server/src/orchestration-v2/RunExecutionService.ts
+++ b/apps/server/src/orchestration-v2/RunExecutionService.ts
@@ -49,6 +49,7 @@ import {
makeProviderFailureTurnItem,
} from "@t3tools/provider-core/server/failure";
import * as RunFinalizationService from "./RunFinalizationService.ts";
+import { checkpointCaptureEffectId } from "./RunFinalized.ts";
export interface ProviderEventRoutingState {
readonly ownedThreadIds: ReadonlySet;
@@ -675,7 +676,7 @@ export const layer: Layer.Layer<
input.terminal.status === "cancelled"
? [
{
- id: `effect:checkpoint.capture:${input.run.id}`,
+ id: checkpointCaptureEffectId(input.run.id),
commandId: checkpointCaptureCommandId,
threadId: input.run.threadId,
request: {
diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts
index 9e43c7ddd30a..9c2aea2838ad 100644
--- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts
+++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts
@@ -1,8 +1,10 @@
import { assert, it, vi } from "@effect/vitest";
import {
CheckpointScopeId,
+ ProviderInstanceId,
RunId,
ThreadId,
+ type OrchestrationV2Run,
type OrchestrationV2ThreadShell,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
@@ -12,15 +14,18 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts";
import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts";
import * as CheckpointCapture from "./CheckpointCaptureService.ts";
+import * as EventSink from "./EventSink.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
import * as RunFinalization from "./RunFinalizationService.ts";
-it.effect("refreshes workspace after checkpoint capture without reading history", () => {
+it.effect("refreshes workspace after checkpoint capture, then records finalization", () => {
const threadId = ThreadId.make("thread_finalize");
const runId = RunId.make("run_finalize");
const scopeId = CheckpointScopeId.make("scope_finalize");
- const capture = vi.fn(() => Effect.void);
- const refresh = vi.fn(() => Effect.void);
+ const steps: Array = [];
+ const capture = vi.fn(() => Effect.sync(() => steps.push("capture")));
+ const refresh = vi.fn(() => Effect.sync(() => steps.push("refresh")));
+ const write = vi.fn(() => Effect.sync(() => (steps.push("record"), [])));
const checkpointContext = {
runs: [],
checkpointScopes: [{ id: scopeId, runId, kind: "root_run" as const, cwd: "/repo" }],
@@ -34,6 +39,25 @@ it.effect("refreshes workspace after checkpoint capture without reading history"
getThreadProjection: () =>
Effect.die("workspace refresh must not load transcript history"),
getCheckpointContext: () => Effect.succeed(checkpointContext),
+ getCheckpointCaptureContext: () =>
+ Effect.succeed({
+ run: {
+ id: runId,
+ threadId,
+ rootNodeId: null,
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ status: "completed",
+ checkpointId: null,
+ } as OrchestrationV2Run,
+ rootNode: undefined,
+ scope: undefined,
+ providerThread: undefined,
+ readyCheckpointOrdinals: [],
+ }),
+ }),
+ Layer.mock(EventSink.EventSinkV2)({
+ write,
+ hasRunFinalization: () => Effect.succeed(false),
}),
Layer.succeed(RunFinalization.RunFinalizationObserver, {
refresh,
@@ -47,6 +71,7 @@ it.effect("refreshes workspace after checkpoint capture without reading history"
yield* service.finalize({ threadId, runId, scopeId });
assert.equal(capture.mock.calls.length, 1);
assert.deepEqual(refresh.mock.calls[0], [{ cwd: "/repo", threadId, runId }]);
+ assert.deepEqual(steps, ["capture", "refresh", "record"]);
}).pipe(Effect.provide(layer));
});
diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts
index d56ae20dc9ef..5e36c81f61a7 100644
--- a/apps/server/src/orchestration-v2/RunFinalizationService.ts
+++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts
@@ -1,5 +1,14 @@
-import { CheckpointScopeId, ProjectId, RunId, ThreadId } from "@t3tools/contracts";
+import {
+ CheckpointScopeId,
+ CommandId,
+ OrchestrationV2RunFinalizationOperation,
+ ProjectId,
+ RunId,
+ ThreadId,
+} from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
+import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
@@ -8,7 +17,9 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts";
import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts";
import * as CheckpointCapture from "./CheckpointCaptureService.ts";
+import * as EventSink from "./EventSink.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
+import * as RunFinalized from "./RunFinalized.ts";
export class RunFinalizationError extends Schema.TaggedError()(
"RunFinalizationError",
@@ -16,11 +27,13 @@ export class RunFinalizationError extends Schema.TaggedError()(
"RunFinalizationRefreshError",
{ cwd: Schema.String, cause: Schema.Defect() },
@@ -40,49 +53,120 @@ export class RunFinalizationObserver extends Context.Reference<{
export class RunFinalizationService extends Context.Service<
RunFinalizationService,
{
+ /**
+ * Captures the run's checkpoint, refreshes its workspace, then records
+ * `run.finalized`. At-least-once: every step is safe to repeat, and a run
+ * that already recorded its finalization is left as recorded. Every
+ * failure that is not an interruption names the step that failed.
+ */
readonly finalize: (input: {
readonly threadId: ThreadId;
readonly runId: RunId;
readonly scopeId: CheckpointScopeId;
}) => Effect.Effect;
+ /**
+ * Gives up on a run's finalization after the worker's last attempt failed
+ * at `operation`: fails the claimed capture effect and records
+ * `run.finalization-failed` in one transaction. Returns false, recording
+ * nothing, when `workerId` no longer holds the effect's lease.
+ */
+ readonly abandon: (input: {
+ readonly threadId: ThreadId;
+ readonly runId: RunId;
+ readonly scopeId: CheckpointScopeId;
+ readonly operation: OrchestrationV2RunFinalizationOperation;
+ readonly effectId: string;
+ readonly workerId: string;
+ readonly error: string;
+ }) => Effect.Effect;
}
>()("t3/orchestration-v2/RunFinalizationService") {}
const make = Effect.gen(function* () {
const checkpointCapture = yield* CheckpointCapture.CheckpointCaptureServiceV2;
const projections = yield* ProjectionStore.ProjectionStoreV2;
+ const eventSink = yield* EventSink.EventSinkV2;
const observer = yield* RunFinalizationObserver;
- const finalize: RunFinalizationService["Service"]["finalize"] = Effect.fn(
- "RunFinalizationService.finalize",
- )(function* (input) {
- yield* checkpointCapture
- .execute(input)
- .pipe(
- Effect.mapError(
- (cause) => new RunFinalizationError({ ...input, operation: "capture-checkpoint", cause }),
- ),
- );
- const projection = yield* projections
- .getCheckpointContext(input.threadId)
- .pipe(
- Effect.mapError(
- (cause) => new RunFinalizationError({ ...input, operation: "refresh-workspace", cause }),
- ),
- );
- const cwd = projection.checkpointScopes.find((scope) => scope.id === input.scopeId)?.cwd;
- if (cwd !== undefined) {
- yield* observer
- .refresh({ cwd, threadId: input.threadId, runId: input.runId })
- .pipe(
- Effect.mapError(
- (cause) =>
- new RunFinalizationError({ ...input, operation: "refresh-workspace", cause }),
- ),
- );
- }
+ const finalize = Effect.fn("RunFinalizationService.finalize")(function* (input: {
+ readonly threadId: ThreadId;
+ readonly runId: RunId;
+ readonly scopeId: CheckpointScopeId;
+ }) {
+ // Unexpected defects fail the step too; an interruption is replayed.
+ const failStep =
+ (operation: OrchestrationV2RunFinalizationOperation) => (cause: Cause.Cause) =>
+ Cause.hasInterruptsOnly(cause)
+ ? Effect.interrupt
+ : Effect.fail(
+ new RunFinalizationError({ ...input, operation, cause: Cause.squash(cause) }),
+ );
+
+ // A replay after a crash honours the disposition already recorded.
+ if (
+ yield* eventSink
+ .hasRunFinalization(input.runId)
+ .pipe(Effect.catchCause(failStep("capture-checkpoint")))
+ )
+ return;
+ yield* checkpointCapture.execute(input).pipe(Effect.catchCause(failStep("capture-checkpoint")));
+ yield* Effect.gen(function* () {
+ const projection = yield* projections.getCheckpointContext(input.threadId);
+ const cwd = projection.checkpointScopes.find((scope) => scope.id === input.scopeId)?.cwd;
+ if (cwd !== undefined) {
+ yield* observer.refresh({ cwd, threadId: input.threadId, runId: input.runId });
+ }
+ }).pipe(Effect.catchCause(failStep("refresh-workspace")));
+ yield* Effect.gen(function* () {
+ const { run } = yield* projections.getCheckpointCaptureContext(input.threadId, input);
+ // A rolled-back run was discarded before its capture ran.
+ const outcome = run === undefined ? null : RunFinalized.runFinalizedOutcome(run.status);
+ if (run === undefined || outcome === null) return;
+ // EventSink drops the event when this run already recorded its finalization.
+ yield* eventSink.write({
+ commandId: CommandId.make(`command:effect:run.finalized:${run.id}`),
+ events: [
+ RunFinalized.makeRunFinalizedEvent({ run, outcome, occurredAt: yield* DateTime.now }),
+ ],
+ });
+ }).pipe(Effect.catchCause(failStep("record-finalized")));
});
- return RunFinalizationService.of({ finalize });
+
+ const abandon: RunFinalizationService["Service"]["abandon"] = (input) =>
+ Effect.gen(function* () {
+ const { run } = yield* projections.getCheckpointCaptureContext(input.threadId, input);
+ const { committed } = yield* eventSink.failEffect({
+ commandId: CommandId.make(`command:effect:run.finalization-failed:${input.runId}`),
+ effectId: input.effectId,
+ workerId: input.workerId,
+ error: input.error,
+ // A rolled-back run was discarded; there is nothing to report.
+ events:
+ run === undefined || run.status === "rolled_back"
+ ? []
+ : [
+ RunFinalized.makeRunFinalizationFailedEvent({
+ run,
+ operation: input.operation,
+ occurredAt: yield* DateTime.now,
+ }),
+ ],
+ });
+ return committed;
+ }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new RunFinalizationError({
+ threadId: input.threadId,
+ runId: input.runId,
+ scopeId: input.scopeId,
+ operation: input.operation,
+ cause,
+ }),
+ ),
+ );
+
+ return RunFinalizationService.of({ finalize, abandon });
});
export const layer = Layer.effect(RunFinalizationService, make);
diff --git a/apps/server/src/orchestration-v2/RunFinalized.test.ts b/apps/server/src/orchestration-v2/RunFinalized.test.ts
new file mode 100644
index 000000000000..de0fed312be3
--- /dev/null
+++ b/apps/server/src/orchestration-v2/RunFinalized.test.ts
@@ -0,0 +1,924 @@
+import { assert, it } from "@effect/vitest";
+import {
+ CheckpointId,
+ CheckpointScopeId,
+ CommandId,
+ EventId,
+ MessageId,
+ NodeId,
+ type OrchestrationV2DomainEvent,
+ type OrchestrationV2Run,
+ type OrchestrationV2StoredEvent,
+ ProjectId,
+ ProviderInstanceId,
+ ProviderThreadId,
+ RunAttemptId,
+ RunId,
+ ThreadId,
+} from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
+import * as Deferred from "effect/Deferred";
+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 Option from "effect/Option";
+import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
+
+import * as SqlitePersistence from "../persistence/Sqlite.ts";
+import * as ServerSettings from "../serverSettings.ts";
+import * as CheckpointCapture from "./CheckpointCaptureService.ts";
+import * as CheckpointRollbackService from "./CheckpointRollbackService.ts";
+import * as EffectOutbox from "./EffectOutbox.ts";
+import * as EffectWorker from "./EffectWorker.ts";
+import * as EventSink from "./EventSink.ts";
+import * as EventStore from "./EventStore.ts";
+import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
+import * as ProjectionStore from "./ProjectionStore.ts";
+import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts";
+import * as ProviderSessionManager from "./ProviderSessionManager.ts";
+import * as ProviderTurnControlService from "./ProviderTurnControlService.ts";
+import * as ProviderTurnStartService from "./ProviderTurnStartService.ts";
+import * as RunFinalization from "./RunFinalizationService.ts";
+import { checkpointCaptureEffectId, makeRunFinalizationFailedEvent } from "./RunFinalized.ts";
+import * as RuntimeRequestService from "./RuntimeRequestService.ts";
+import * as ThreadManagementService from "./ThreadManagementService.ts";
+import * as ThreadTitleRegenerationService from "./ThreadTitleRegenerationService.ts";
+
+const threadId = ThreadId.make("thread:run-finalized");
+const runId = RunId.make("run:run-finalized");
+const scopeId = CheckpointScopeId.make("scope:run-finalized");
+const rootNodeId = NodeId.make("node:run-finalized-root");
+const providerThreadId = ProviderThreadId.make("provider-thread:run-finalized");
+const providerInstanceId = ProviderInstanceId.make("codex");
+const checkpointId = CheckpointId.make("checkpoint:run-finalized");
+const attemptId = RunAttemptId.make("attempt:run-finalized");
+
+const maxAttempts = 2;
+/** The worker's backoff after a capture's first failed attempt. */
+const firstRetryDelay = "100 millis";
+/** The worker's backoff after a capture's last attempt, when its failure record cannot commit. */
+const lastRetryDelay = "200 millis";
+
+/**
+ * Real stores, outbox, worker, finalization and restart recovery. Checkpoint
+ * capture and workspace refresh are stubs. `finalizationSink` can inject
+ * faults into the event sink the finalization service writes through.
+ */
+const makeLayer = (
+ capture: Effect.Effect<
+ void,
+ CheckpointCapture.CheckpointCaptureExecutionError,
+ EventSink.EventSinkV2
+ >,
+ refresh: () => Effect.Effect = () =>
+ Effect.void,
+ finalizationSink: (sink: EventSink.EventSinkV2Shape) => EventSink.EventSinkV2Shape = (sink) =>
+ sink,
+) => {
+ const stores = Layer.mergeAll(
+ SqlitePersistence.layerMemory,
+ EventStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)),
+ ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)),
+ );
+ const eventSink = EventSink.layer.pipe(Layer.provide(stores));
+ const outbox = EffectOutbox.layer.pipe(Layer.provide(SqlitePersistence.layerMemory));
+ const finalization = RunFinalization.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ stores,
+ Layer.effect(
+ EventSink.EventSinkV2,
+ EventSink.EventSinkV2.pipe(Effect.map(finalizationSink)),
+ ).pipe(Layer.provide(eventSink)),
+ Layer.effect(
+ CheckpointCapture.CheckpointCaptureServiceV2,
+ Effect.gen(function* () {
+ const sink = yield* EventSink.EventSinkV2;
+ return { execute: () => Effect.provideService(capture, EventSink.EventSinkV2, sink) };
+ }),
+ ).pipe(Layer.provide(eventSink)),
+ Layer.succeed(RunFinalization.RunFinalizationObserver, {
+ refresh,
+ refreshAfterTurn: () => Effect.void,
+ }),
+ ),
+ ),
+ );
+ const executor = EffectWorker.layerExecutor.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ finalization,
+ Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}),
+ Layer.mock(CheckpointRollbackService.CheckpointRollbackServiceV2)({}),
+ Layer.mock(ProviderTurnControlService.ProviderTurnControlServiceV2)({}),
+ Layer.mock(ProviderTurnStartService.ProviderTurnStartServiceV2)({}),
+ Layer.mock(RuntimeRequestService.RuntimeRequestServiceV2)({}),
+ Layer.mock(ThreadTitleRegenerationService.ThreadTitleRegenerationService)({}),
+ Layer.mock(ThreadManagementService.ThreadManagementService)({}),
+ ServerSettings.layerTest(),
+ ),
+ ),
+ );
+ const worker = EffectWorker.layerWithOptions({
+ workerId: "worker:run-finalized",
+ maxAttempts,
+ }).pipe(Layer.provide(Layer.merge(outbox, executor)));
+ const recovery = ProviderRuntimeRecovery.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ stores,
+ eventSink,
+ outbox,
+ worker,
+ IdAllocator.layer,
+ ServerSettings.layerTest(),
+ ),
+ ),
+ );
+ return Layer.mergeAll(stores, eventSink, outbox, finalization, worker, recovery);
+};
+
+const makeRun = (
+ now: DateTime.Utc,
+ status: OrchestrationV2Run["status"],
+ overrides: Partial = {},
+): OrchestrationV2Run => ({
+ id: runId,
+ threadId,
+ ordinal: 1,
+ providerInstanceId,
+ modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" },
+ providerThreadId,
+ userMessageId: MessageId.make("message:run-finalized"),
+ rootNodeId,
+ activeAttemptId: null,
+ status,
+ requestedAt: now,
+ startedAt: now,
+ completedAt: null,
+ checkpointId: null,
+ contextHandoffId: null,
+ ...overrides,
+});
+
+let eventCounter = 0;
+const runEvent = (
+ type: "run.created" | "run.updated",
+ run: OrchestrationV2Run,
+ occurredAt: DateTime.Utc,
+): OrchestrationV2DomainEvent => ({
+ id: EventId.make(`event:run-finalized-test:${(eventCounter += 1)}`),
+ type,
+ threadId,
+ runId: run.id,
+ providerInstanceId,
+ occurredAt,
+ payload: run,
+});
+
+const captureEffect = {
+ id: checkpointCaptureEffectId(runId),
+ commandId: CommandId.make(`command:effect:checkpoint.capture:${runId}`),
+ threadId,
+ request: { type: "checkpoint.capture" as const, runId, scopeId },
+};
+
+const seedThread = Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const now = yield* DateTime.now;
+ yield* eventSink.write({
+ events: [
+ {
+ id: EventId.make("event:run-finalized-test:thread"),
+ type: "thread.created",
+ threadId,
+ providerInstanceId,
+ occurredAt: now,
+ payload: {
+ createdBy: "user",
+ creationSource: "web",
+ id: threadId,
+ projectId: ProjectId.make("project:run-finalized"),
+ title: "Run finalized",
+ providerInstanceId,
+ modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ activeProviderThreadId: null,
+ lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
+ forkedFrom: null,
+ createdAt: now,
+ updatedAt: now,
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ lastVisitedAt: null,
+ deletedAt: null,
+ },
+ },
+ {
+ id: EventId.make("event:run-finalized-test:scope"),
+ type: "checkpoint-scope.created",
+ threadId,
+ occurredAt: now,
+ payload: {
+ id: scopeId,
+ threadId,
+ runId,
+ nodeId: rootNodeId,
+ parentScopeId: null,
+ providerThreadId,
+ kind: "root_run",
+ ordinalWithinParent: 0,
+ advancesAppRunCount: true,
+ cwd: "/repo",
+ createdAt: now,
+ },
+ },
+ runEvent("run.created", makeRun(now, "running"), now),
+ ],
+ });
+});
+
+const storedEvents = Effect.gen(function* () {
+ const eventStore = yield* EventStore.EventStoreV2;
+ return Array.from(yield* eventStore.read({ threadId }).pipe(Stream.runCollect));
+});
+
+const finalizedEvents = (events: ReadonlyArray) =>
+ events.flatMap((stored) => (stored.event.type === "run.finalized" ? [stored] : []));
+
+/** Every finalization record for the thread, success or failure. */
+const finalizationRecords = storedEvents.pipe(
+ Effect.map((events) =>
+ events.flatMap((stored) =>
+ stored.event.type === "run.finalized" || stored.event.type === "run.finalization-failed"
+ ? [{ type: stored.event.type, payload: stored.event.payload }]
+ : [],
+ ),
+ ),
+);
+
+/** Runs every claimable effect on the real worker. */
+const drainWorker = EffectWorker.OrchestrationEffectWorkerV2.pipe(
+ Effect.flatMap((worker) => worker.drain()),
+);
+
+const captureStatus = EffectOutbox.EffectOutboxV2.pipe(
+ Effect.flatMap((outbox) => outbox.get(captureEffect.id)),
+ Effect.map(Option.map((effect) => effect.status)),
+);
+
+const runStatus = ProjectionStore.ProjectionStoreV2.pipe(
+ Effect.flatMap((projections) =>
+ projections.getCheckpointCaptureContext(threadId, { runId, scopeId }),
+ ),
+ Effect.map(({ run }) => run?.status),
+);
+
+/** The startup recovery a restarted server runs before its worker resumes. */
+const restartServer = ProviderRuntimeRecovery.ProviderRuntimeRecoveryService.pipe(
+ Effect.flatMap((recovery) => recovery.recover),
+);
+
+/** Stands in for CheckpointCaptureService: commits the checkpoint once, like the real one. */
+const commitCapture = (status: "completed" | "interrupted" | "cancelled") =>
+ Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const now = yield* DateTime.now;
+ yield* eventSink.commitCommand({
+ commandId: captureEffect.commandId,
+ threadId,
+ commandType: "checkpoint.capture",
+ acceptedAt: now,
+ effects: [],
+ events: [
+ runEvent("run.updated", makeRun(now, status, { checkpointId, completedAt: now }), now),
+ ],
+ });
+ }).pipe(Effect.orDie);
+
+const failCapture = Effect.fail(
+ new CheckpointCapture.CheckpointCaptureExecutionError({
+ threadId,
+ runId,
+ scopeId,
+ cause: "simulated capture failure",
+ }),
+);
+
+const failRefresh = () =>
+ Effect.fail(
+ new RunFinalization.RunFinalizationRefreshError({
+ cwd: "/repo",
+ cause: "simulated refresh failure",
+ }),
+ );
+
+/** Ends the run waiting on its capture, as RunExecutionService does. */
+const finishProviderTurn = (status: "waiting" | "interrupted" | "cancelled") =>
+ Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const now = yield* DateTime.now;
+ yield* eventSink.writeWithEffects({
+ events: [
+ runEvent(
+ "run.updated",
+ makeRun(now, status, status === "waiting" ? {} : { completedAt: now }),
+ now,
+ ),
+ ],
+ effects: [captureEffect],
+ });
+ });
+
+it.effect("finalizes a completed run once, after its checkpoint and workspace refresh", () => {
+ const refreshedAtSequence: Array = [];
+ let probe: Effect.Effect = Effect.void;
+ return Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ yield* seedThread;
+ yield* finishProviderTurn("waiting");
+ assert.lengthOf(yield* finalizationRecords, 0);
+
+ probe = eventSink.latestSequence({ threadId }).pipe(
+ Effect.map((sequence) => {
+ refreshedAtSequence.push(sequence);
+ }),
+ Effect.orDie,
+ );
+ yield* drainWorker;
+
+ const events = yield* storedEvents;
+ const finalized = finalizedEvents(events);
+ assert.lengthOf(finalized, 1);
+ const [stored] = finalized;
+ assert.deepEqual(stored?.event.payload, { runId, outcome: "completed", checkpointId });
+ assert.equal(stored?.event.id, EventId.make(`event:run-finalized:${runId}`));
+ const completedAt = events.find(
+ (event) => event.event.type === "run.updated" && event.event.payload.status === "completed",
+ )?.sequence;
+ assert.isDefined(completedAt);
+ assert.deepEqual(refreshedAtSequence, [completedAt!]);
+ assert.isTrue(stored!.sequence > completedAt!);
+ assert.deepEqual(yield* captureStatus, Option.some("succeeded"));
+ }).pipe(Effect.provide(makeLayer(commitCapture("completed"), () => probe)));
+});
+
+it.effect("recording the milestone leaves thread activity where the run left it", () =>
+ Effect.gen(function* () {
+ const projections = yield* ProjectionStore.ProjectionStoreV2;
+ yield* seedThread;
+ yield* finishProviderTurn("waiting");
+ yield* drainWorker;
+
+ const events = yield* storedEvents;
+ const completed = events.find(
+ (event) => event.event.type === "run.updated" && event.event.payload.status === "completed",
+ );
+ const [milestone] = finalizedEvents(events);
+ assert.isDefined(completed);
+ assert.isDefined(milestone);
+ // The slow refresh put the milestone a minute after the run's last write.
+ assert.isTrue(DateTime.isGreaterThan(milestone.event.occurredAt, completed.event.occurredAt));
+ const shell = yield* projections.getThreadShell(threadId);
+ assert.deepEqual(shell?.updatedAt, completed.event.occurredAt);
+ }).pipe(
+ Effect.provide(makeLayer(commitCapture("completed"), () => TestClock.adjust("60 seconds"))),
+ ),
+);
+
+it.effect("a restart after finalizing but before settling records one milestone", () => {
+ let captures = 0;
+ return Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const outbox = yield* EffectOutbox.EffectOutboxV2;
+ const finalization = yield* RunFinalization.RunFinalizationService;
+ yield* seedThread;
+ yield* finishProviderTurn("waiting");
+ // A worker finalized, then its process died before settling the effect.
+ yield* outbox.claimNext({ workerId: "worker:crashed", leaseDurationMs: 60_000 });
+ yield* finalization.finalize({ threadId, runId, scopeId });
+ assert.lengthOf(finalizedEvents(yield* storedEvents), 1);
+
+ // Restart requeues the capture; the worker settles it without capturing again.
+ const summary = yield* restartServer;
+ assert.equal(summary.requeuedEffects, 1);
+ yield* drainWorker;
+ assert.deepEqual(yield* captureStatus, Option.some("succeeded"));
+ assert.equal(captures, 1);
+ // A later write of the finished run adds nothing either.
+ const now = yield* DateTime.now;
+ yield* eventSink.write({
+ events: [
+ runEvent("run.updated", makeRun(now, "completed", { checkpointId, completedAt: now }), now),
+ ],
+ });
+ assert.deepEqual(yield* finalizationRecords, [
+ { type: "run.finalized", payload: { runId, outcome: "completed", checkpointId } },
+ ]);
+ }).pipe(
+ Effect.provide(
+ makeLayer(
+ Effect.suspend(() => {
+ captures += 1;
+ return commitCapture("completed");
+ }),
+ ),
+ ),
+ );
+});
+
+it.effect.each(["interrupted", "cancelled"] as const)(
+ "a stopped run with a capture finalizes as %s after the capture",
+ (status) =>
+ Effect.gen(function* () {
+ yield* seedThread;
+ yield* finishProviderTurn(status);
+ assert.lengthOf(yield* finalizationRecords, 0);
+ yield* drainWorker;
+ assert.deepEqual(yield* finalizationRecords, [
+ { type: "run.finalized", payload: { runId, outcome: status, checkpointId } },
+ ]);
+ }).pipe(Effect.provide(makeLayer(commitCapture(status)))),
+);
+
+/** Ends a running attempt through the ownership guard, as RunExecutionService does. */
+const finishGuardedTurn = (
+ status: "interrupted" | "cancelled",
+ guard: { readonly activeAttemptId: RunAttemptId; readonly expectedStatus: "running" | "waiting" },
+) =>
+ Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const now = yield* DateTime.now;
+ yield* eventSink.write({
+ events: [
+ runEvent("run.updated", makeRun(now, "running", { activeAttemptId: attemptId }), now),
+ ],
+ });
+ return yield* eventSink.writeIfRunCurrent({
+ threadId,
+ runId,
+ ...guard,
+ events: [
+ runEvent(
+ "run.updated",
+ makeRun(now, status, { activeAttemptId: attemptId, completedAt: now }),
+ now,
+ ),
+ ],
+ effects: [captureEffect],
+ });
+ });
+
+it.effect.each(["interrupted", "cancelled"] as const)(
+ "a guarded stop that queues a capture finalizes as %s after the capture",
+ (status) =>
+ Effect.gen(function* () {
+ yield* seedThread;
+ const result = yield* finishGuardedTurn(status, {
+ activeAttemptId: attemptId,
+ expectedStatus: "running",
+ });
+ assert.isTrue(result.committed);
+ assert.lengthOf(yield* finalizationRecords, 0);
+ yield* drainWorker;
+ assert.deepEqual(yield* finalizationRecords, [
+ { type: "run.finalized", payload: { runId, outcome: status, checkpointId } },
+ ]);
+ }).pipe(Effect.provide(makeLayer(commitCapture(status)))),
+);
+
+it.effect("a guarded stop that no longer owns the run commits and queues nothing", () =>
+ Effect.gen(function* () {
+ yield* seedThread;
+ const result = yield* finishGuardedTurn("interrupted", {
+ activeAttemptId: RunAttemptId.make("attempt:run-finalized-stale"),
+ expectedStatus: "running",
+ });
+ assert.isFalse(result.committed);
+ assert.deepEqual(yield* captureStatus, Option.none());
+ assert.equal(yield* drainWorker, 0);
+ assert.equal(yield* runStatus, "running");
+ assert.lengthOf(yield* finalizationRecords, 0);
+ }).pipe(Effect.provide(makeLayer(commitCapture("interrupted")))),
+);
+
+it.effect("a run that ends without a capture finalizes in its terminal commit", () =>
+ Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ yield* seedThread;
+ const now = yield* DateTime.now;
+ const failed = makeRun(now, "failed", { completedAt: now });
+ const [terminal, milestone] = yield* eventSink.write({
+ events: [runEvent("run.updated", failed, now)],
+ });
+ assert.equal(terminal?.event.type, "run.updated");
+ assert.equal(milestone?.event.type, "run.finalized");
+ assert.equal(milestone?.commandId, terminal?.commandId);
+ // A repeated terminal write and a later update to the finished run add nothing.
+ yield* eventSink.write({ events: [runEvent("run.updated", failed, now)] });
+ yield* eventSink.write({
+ events: [runEvent("run.updated", { ...failed, status: "cancelled" }, now)],
+ });
+ assert.deepEqual(
+ finalizedEvents(yield* storedEvents).map((stored) => stored.event.payload),
+ [{ runId, outcome: "failed", checkpointId: null }],
+ );
+ }).pipe(Effect.provide(makeLayer(Effect.void))),
+);
+
+it.effect("unfinished, discarded, and previously finished runs never finalize", () =>
+ Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const finalization = yield* RunFinalization.RunFinalizationService;
+ yield* seedThread;
+ const now = yield* DateTime.now;
+ // Waiting on a capture that never ran.
+ yield* finishProviderTurn("waiting");
+ assert.lengthOf(yield* finalizationRecords, 0);
+ // Rolled back before the capture ran: discarded, and the capture skips it.
+ yield* eventSink.write({
+ events: [runEvent("run.updated", makeRun(now, "rolled_back", { completedAt: now }), now)],
+ });
+ yield* finalization.finalize({ threadId, runId, scopeId });
+ assert.lengthOf(yield* finalizationRecords, 0);
+
+ // A run that finished before this milestone existed has no event. A later
+ // update to it must not invent one.
+ const historical = makeRun(now, "completed", {
+ id: RunId.make("run:run-finalized-historical"),
+ ordinal: 2,
+ completedAt: now,
+ });
+ yield* eventSink.write({ events: [runEvent("run.created", historical, now)] });
+ yield* eventSink.write({ events: [runEvent("run.updated", historical, now)] });
+ assert.lengthOf(yield* finalizationRecords, 0);
+ }).pipe(Effect.provide(makeLayer(Effect.void))),
+);
+
+it.effect.each(["waiting", "interrupted"] as const)(
+ "a capture that gives up after a %s turn records the failure, never run.finalized",
+ (status) =>
+ Effect.gen(function* () {
+ yield* seedThread;
+ yield* finishProviderTurn(status);
+ // The first failure will be retried, so nothing is recorded yet.
+ yield* drainWorker;
+ assert.deepEqual(yield* captureStatus, Option.some("pending"));
+ assert.lengthOf(yield* finalizationRecords, 0);
+
+ yield* TestClock.adjust(firstRetryDelay);
+ yield* drainWorker;
+ assert.deepEqual(yield* captureStatus, Option.some("failed"));
+ const failure = [
+ {
+ type: "run.finalization-failed" as const,
+ payload: { runId, operation: "capture-checkpoint" as const },
+ },
+ ];
+ assert.deepEqual(yield* finalizationRecords, failure);
+
+ // A restart cancels a run still waiting on the failed capture. That is
+ // not a finalization either.
+ yield* restartServer;
+ assert.equal(yield* runStatus, status === "waiting" ? "cancelled" : "interrupted");
+ assert.deepEqual(yield* finalizationRecords, failure);
+ }).pipe(Effect.provide(makeLayer(failCapture))),
+);
+
+it.effect("a refresh that gives up after the checkpoint commit records the failure", () =>
+ Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ yield* seedThread;
+ yield* finishProviderTurn("waiting");
+ yield* drainWorker;
+ yield* TestClock.adjust(firstRetryDelay);
+ yield* drainWorker;
+
+ assert.deepEqual(yield* captureStatus, Option.some("failed"));
+ assert.equal(yield* runStatus, "completed");
+ const failure = [
+ {
+ type: "run.finalization-failed" as const,
+ payload: { runId, operation: "refresh-workspace" as const },
+ },
+ ];
+ assert.deepEqual(yield* finalizationRecords, failure);
+ // Neither a restart nor a later write of the completed run finalizes it.
+ yield* restartServer;
+ const now = yield* DateTime.now;
+ yield* eventSink.write({
+ events: [
+ runEvent("run.updated", makeRun(now, "completed", { checkpointId, completedAt: now }), now),
+ ],
+ });
+ assert.deepEqual(yield* finalizationRecords, failure);
+ }).pipe(Effect.provide(makeLayer(commitCapture("completed"), failRefresh))),
+);
+
+it.effect("a refresh that fails once and then succeeds records only run.finalized", () => {
+ let refreshes = 0;
+ return Effect.gen(function* () {
+ yield* seedThread;
+ yield* finishProviderTurn("waiting");
+ yield* drainWorker;
+ yield* TestClock.adjust(firstRetryDelay);
+ yield* drainWorker;
+ assert.equal(refreshes, 2);
+ assert.deepEqual(yield* finalizationRecords, [
+ { type: "run.finalized", payload: { runId, outcome: "completed", checkpointId } },
+ ]);
+ }).pipe(
+ Effect.provide(
+ makeLayer(commitCapture("completed"), () =>
+ Effect.suspend(() => ((refreshes += 1) === 1 ? failRefresh() : Effect.void)),
+ ),
+ ),
+ );
+});
+
+it.effect(
+ "a capture cancelled outside a command records the failure when restart ends the run",
+ () =>
+ Effect.gen(function* () {
+ const outbox = yield* EffectOutbox.EffectOutboxV2;
+ yield* seedThread;
+ yield* finishProviderTurn("waiting");
+ yield* outbox.cancelUnsettled({
+ threadId,
+ effectTypes: ["checkpoint.capture"],
+ reason: "test",
+ });
+ assert.deepEqual(yield* captureStatus, Option.some("cancelled"));
+ assert.lengthOf(yield* finalizationRecords, 0);
+ yield* restartServer;
+ assert.equal(yield* runStatus, "cancelled");
+ assert.deepEqual(yield* finalizationRecords, [
+ {
+ type: "run.finalization-failed",
+ payload: { runId, operation: "capture-checkpoint" },
+ },
+ ]);
+ }).pipe(Effect.provide(makeLayer(Effect.void))),
+);
+
+it.effect("a command that cancels a capture records the failure in its commit", () =>
+ Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ yield* seedThread;
+ yield* finishProviderTurn("interrupted");
+ const now = yield* DateTime.now;
+ const commandId = CommandId.make("command:run-finalized-test:cancel-capture");
+ const { storedEvents, receipt } = yield* eventSink.commitCommand({
+ commandId,
+ threadId,
+ commandType: "test.cancel-capture",
+ acceptedAt: now,
+ events: [runEvent("run.updated", makeRun(now, "interrupted", { completedAt: now }), now)],
+ effects: [],
+ cancelUnsettledEffects: { effectTypes: ["checkpoint.capture"], reason: "test" },
+ });
+ const failure = [
+ {
+ type: "run.finalization-failed" as const,
+ payload: { runId, operation: "capture-checkpoint" as const },
+ },
+ ];
+ assert.deepEqual(yield* captureStatus, Option.some("cancelled"));
+ assert.deepEqual(yield* finalizationRecords, failure);
+ const recorded = storedEvents.at(-1);
+ assert.equal(recorded?.event.type, "run.finalization-failed");
+ assert.equal(recorded?.commandId, commandId);
+ assert.equal(receipt.resultSequence, recorded?.sequence);
+
+ // Nothing runs the cancelled capture, and a restart adds nothing.
+ assert.equal(yield* drainWorker, 0);
+ yield* restartServer;
+ assert.deepEqual(yield* finalizationRecords, failure);
+ }).pipe(Effect.provide(makeLayer(Effect.die("a cancelled capture must not run")))),
+);
+
+const captureDefect = Effect.die("simulated unexpected checkpoint defect");
+const refreshDefect = () => Effect.die("simulated unexpected refresh defect");
+
+it.effect.each([
+ {
+ label: "a capture defect after a waiting turn",
+ turn: "waiting" as const,
+ capture: captureDefect,
+ refresh: undefined,
+ operation: "capture-checkpoint" as const,
+ runAfterRestart: "cancelled",
+ },
+ {
+ label: "a capture defect after an interrupted turn",
+ turn: "interrupted" as const,
+ capture: captureDefect,
+ refresh: undefined,
+ operation: "capture-checkpoint" as const,
+ runAfterRestart: "interrupted",
+ },
+ {
+ label: "a refresh defect after a completed run's checkpoint",
+ turn: "waiting" as const,
+ capture: commitCapture("completed"),
+ refresh: refreshDefect,
+ operation: "refresh-workspace" as const,
+ runAfterRestart: "completed",
+ },
+ {
+ label: "a refresh defect after an interrupted run's checkpoint",
+ turn: "interrupted" as const,
+ capture: commitCapture("interrupted"),
+ refresh: refreshDefect,
+ operation: "refresh-workspace" as const,
+ runAfterRestart: "interrupted",
+ },
+])("$label records the failure when retries run out", (testCase) =>
+ Effect.gen(function* () {
+ yield* seedThread;
+ yield* finishProviderTurn(testCase.turn);
+ yield* drainWorker;
+ assert.deepEqual(yield* captureStatus, Option.some("pending"));
+ assert.lengthOf(yield* finalizationRecords, 0);
+
+ yield* TestClock.adjust(firstRetryDelay);
+ yield* drainWorker;
+ assert.deepEqual(yield* captureStatus, Option.some("failed"));
+ const failure = [
+ {
+ type: "run.finalization-failed" as const,
+ payload: { runId, operation: testCase.operation },
+ },
+ ];
+ assert.deepEqual(yield* finalizationRecords, failure);
+
+ yield* restartServer;
+ yield* drainWorker;
+ assert.equal(yield* runStatus, testCase.runAfterRestart);
+ assert.deepEqual(yield* captureStatus, Option.some("failed"));
+ assert.deepEqual(yield* finalizationRecords, failure);
+ }).pipe(Effect.provide(makeLayer(testCase.capture, testCase.refresh))),
+);
+
+it.effect("a failure record that cannot commit keeps the capture recoverable", () => {
+ let faults = 1;
+ let captures = 0;
+ return Effect.gen(function* () {
+ yield* seedThread;
+ yield* finishProviderTurn("waiting");
+ yield* drainWorker;
+ yield* TestClock.adjust(firstRetryDelay);
+ // The last attempt fails and its failure record cannot commit.
+ const exit = yield* Effect.exit(drainWorker);
+ assert.isTrue(Exit.isFailure(exit));
+ assert.equal(faults, 0);
+ // The rolled-back commit neither failed the capture nor recorded anything.
+ assert.deepEqual(yield* captureStatus, Option.some("pending"));
+ assert.lengthOf(yield* finalizationRecords, 0);
+ // The capture waits out the backoff instead of running again at once.
+ yield* drainWorker;
+ assert.equal(captures, 2);
+ assert.deepEqual(yield* captureStatus, Option.some("pending"));
+
+ // A restart keeps the work; the next attempt gives up and records it.
+ yield* restartServer;
+ yield* TestClock.adjust(lastRetryDelay);
+ yield* drainWorker;
+ assert.deepEqual(yield* captureStatus, Option.some("failed"));
+ assert.deepEqual(yield* finalizationRecords, [
+ {
+ type: "run.finalization-failed",
+ payload: { runId, operation: "capture-checkpoint" },
+ },
+ ]);
+ }).pipe(
+ Effect.provide(
+ makeLayer(
+ Effect.suspend(() => {
+ captures += 1;
+ return failCapture;
+ }),
+ undefined,
+ (sink) => ({
+ ...sink,
+ // Also append an event whose id is taken, so the commit fails after
+ // the capture's terminal status was written inside it.
+ failEffect: (input) =>
+ Effect.suspend(() => {
+ if (faults === 0) return sink.failEffect(input);
+ faults -= 1;
+ const at = input.events[0]!.occurredAt;
+ return sink.failEffect({
+ ...input,
+ events: [
+ ...input.events,
+ {
+ ...runEvent("run.updated", makeRun(at, "waiting"), at),
+ id: EventId.make("event:run-finalized-test:thread"),
+ },
+ ],
+ });
+ }),
+ }),
+ ),
+ ),
+ );
+});
+
+it.effect("a restart after a failure record but before settling never finalizes again", () => {
+ let captures = 0;
+ return Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const outbox = yield* EffectOutbox.EffectOutboxV2;
+ yield* seedThread;
+ yield* finishProviderTurn("waiting");
+ // A worker recorded the failure, then its process died before settling
+ // the capture (the state a non-atomic writer could leave).
+ yield* outbox.claimNext({ workerId: "worker:crashed", leaseDurationMs: 60_000 });
+ const context = yield* ProjectionStore.ProjectionStoreV2.pipe(
+ Effect.flatMap((projections) =>
+ projections.getCheckpointCaptureContext(threadId, { runId, scopeId }),
+ ),
+ );
+ yield* eventSink.write({
+ events: [
+ makeRunFinalizationFailedEvent({
+ run: context.run!,
+ operation: "capture-checkpoint",
+ occurredAt: yield* DateTime.now,
+ }),
+ ],
+ });
+
+ const summary = yield* restartServer;
+ assert.equal(summary.requeuedEffects, 1);
+ yield* drainWorker;
+ assert.equal(captures, 0);
+ assert.deepEqual(yield* captureStatus, Option.some("succeeded"));
+ assert.deepEqual(yield* finalizationRecords, [
+ {
+ type: "run.finalization-failed",
+ payload: { runId, operation: "capture-checkpoint" },
+ },
+ ]);
+ }).pipe(
+ Effect.provide(
+ makeLayer(
+ Effect.suspend(() => {
+ captures += 1;
+ return commitCapture("completed");
+ }),
+ ),
+ ),
+ );
+});
+
+it.effect("an interrupted capture is replayed, never recorded as a failure", () => {
+ let captures = 0;
+ const started = Deferred.makeUnsafe();
+ return Effect.gen(function* () {
+ yield* seedThread;
+ yield* finishProviderTurn("waiting");
+ // Interrupted on every attempt in a live worker: retried, not given up.
+ yield* drainWorker;
+ yield* TestClock.adjust(firstRetryDelay);
+ yield* drainWorker;
+ yield* TestClock.adjust("30 seconds");
+ assert.deepEqual(yield* captureStatus, Option.some("pending"));
+ assert.lengthOf(yield* finalizationRecords, 0);
+
+ // A shutdown interrupts the worker mid-capture and leaves the claim running.
+ const shuttingDown = yield* Effect.forkChild(drainWorker);
+ yield* Deferred.await(started);
+ yield* Fiber.interrupt(shuttingDown);
+ assert.deepEqual(yield* captureStatus, Option.some("running"));
+ assert.lengthOf(yield* finalizationRecords, 0);
+
+ // Restart replays it and the capture now succeeds.
+ yield* restartServer;
+ yield* drainWorker;
+ assert.equal(captures, 4);
+ assert.deepEqual(yield* finalizationRecords, [
+ { type: "run.finalized", payload: { runId, outcome: "completed", checkpointId } },
+ ]);
+ }).pipe(
+ Effect.provide(
+ makeLayer(
+ Effect.suspend(() => {
+ captures += 1;
+ return captures <= 2
+ ? Effect.interrupt
+ : captures === 3
+ ? Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never))
+ : commitCapture("completed");
+ }),
+ ),
+ ),
+ );
+});
diff --git a/apps/server/src/orchestration-v2/RunFinalized.ts b/apps/server/src/orchestration-v2/RunFinalized.ts
new file mode 100644
index 000000000000..c63292032030
--- /dev/null
+++ b/apps/server/src/orchestration-v2/RunFinalized.ts
@@ -0,0 +1,88 @@
+import {
+ EventId,
+ type OrchestrationV2DomainEvent,
+ type OrchestrationV2Run,
+ type OrchestrationV2RunFinalizationOperation,
+ type OrchestrationV2RunFinalizedOutcome,
+ type RunId,
+} from "@t3tools/contracts";
+import type * as DateTime from "effect/DateTime";
+
+/**
+ * The id of a run's one finalization record, `run.finalized` or
+ * `run.finalization-failed`. The event log's unique event id keeps a run to
+ * one of them, and consumers can deduplicate deliveries by it.
+ */
+export const runFinalizedEventId = (runId: RunId) => EventId.make(`event:run-finalized:${runId}`);
+
+/**
+ * The checkpoint capture a finished run enqueues. RunFinalizationService
+ * records `run.finalized` once capture and refresh succeed, or
+ * `run.finalization-failed` in the same commit that gives up on the capture.
+ * Cancelling the capture records that failure too.
+ */
+export const checkpointCaptureEffectId = (runId: RunId) => `effect:checkpoint.capture:${runId}`;
+
+const SETTLED_RUN_STATUSES: ReadonlySet = new Set([
+ "completed",
+ "failed",
+ "interrupted",
+ "cancelled",
+ "rolled_back",
+]);
+
+/** Whether a persisted run status is final, including a discarded (rolled-back) run. */
+export const isSettledRunStatus = (status: string) => SETTLED_RUN_STATUSES.has(status);
+
+/** The finalized outcome for a run status, or null when the run is not finished or was discarded. */
+export const runFinalizedOutcome = (
+ status: OrchestrationV2Run["status"],
+): OrchestrationV2RunFinalizedOutcome | null =>
+ status === "completed" ||
+ status === "failed" ||
+ status === "interrupted" ||
+ status === "cancelled"
+ ? status
+ : null;
+
+/**
+ * The step a run's finalization stopped at when its capture was abandoned
+ * without reporting one: before the checkpoint commit or after it.
+ */
+export const abandonedOperation = (
+ run: OrchestrationV2Run,
+): OrchestrationV2RunFinalizationOperation =>
+ run.checkpointId === null ? "capture-checkpoint" : "refresh-workspace";
+
+const recordEnvelope = (run: OrchestrationV2Run, occurredAt: DateTime.Utc) => ({
+ id: runFinalizedEventId(run.id),
+ threadId: run.threadId,
+ runId: run.id,
+ ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }),
+ providerInstanceId: run.providerInstanceId,
+ occurredAt,
+});
+
+export const makeRunFinalizedEvent = (input: {
+ readonly run: OrchestrationV2Run;
+ readonly outcome: OrchestrationV2RunFinalizedOutcome;
+ readonly occurredAt: DateTime.Utc;
+}): Extract => ({
+ ...recordEnvelope(input.run, input.occurredAt),
+ type: "run.finalized",
+ payload: {
+ runId: input.run.id,
+ outcome: input.outcome,
+ checkpointId: input.run.checkpointId,
+ },
+});
+
+export const makeRunFinalizationFailedEvent = (input: {
+ readonly run: OrchestrationV2Run;
+ readonly operation: OrchestrationV2RunFinalizationOperation;
+ readonly occurredAt: DateTime.Utc;
+}): Extract => ({
+ ...recordEnvelope(input.run, input.occurredAt),
+ type: "run.finalization-failed",
+ payload: { runId: input.run.id, operation: input.operation },
+});
diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts
index 2442a4fa010c..4de558749a9a 100644
--- a/apps/server/src/orchestration-v2/runtimeLayer.ts
+++ b/apps/server/src/orchestration-v2/runtimeLayer.ts
@@ -198,7 +198,13 @@ const layerCheckpointCaptureServiceProvided = CheckpointCaptureService.layer.pip
),
);
const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe(
- Layer.provide(Layer.merge(layerCheckpointCaptureServiceProvided, ProjectionStore.layer)),
+ Layer.provide(
+ Layer.mergeAll(
+ layerCheckpointCaptureServiceProvided,
+ layerEventSinkProvided,
+ ProjectionStore.layer,
+ ),
+ ),
);
const layerOrchestratorProvided = Orchestrator.layer.pipe(
diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts
index 3d8ef589050e..88e67ecd55c7 100644
--- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts
+++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts
@@ -208,8 +208,18 @@ function scenarioCommands(scenario: OrchestratorV2Scenario): ReadonlyArray
- projection.runtimeRequests.find((request) => request.status === "pending");
+ projection.runtimeRequests.find(
+ (request) =>
+ request.status === "pending" &&
+ projection.turnItems.some(
+ (item) =>
+ (item.type === "approval_request" || item.type === "user_input_request") &&
+ item.requestId === request.id,
+ ),
+ );
const hasActiveRun = (projection: OrchestrationV2ThreadProjection) =>
projection.runs.some((run) =>
diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts
index 27c1cc28ae5a..8c45c2a7b65b 100644
--- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts
+++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts
@@ -426,7 +426,9 @@ export function layerWithRegistry(
),
);
const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe(
- Layer.provide(Layer.merge(layerCheckpointCaptureServiceProvided, layerStores)),
+ Layer.provide(
+ Layer.mergeAll(layerCheckpointCaptureServiceProvided, layerEventSinkProvided, layerStores),
+ ),
);
const layerThreadTitleRegenerationTest = Layer.succeed(
ThreadTitleRegenerationService.ThreadTitleRegenerationService,
diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts
index 35c1a3fdce4b..1165722ddabf 100644
--- a/apps/server/src/persistence/Migrations.ts
+++ b/apps/server/src/persistence/Migrations.ts
@@ -74,6 +74,9 @@ import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts";
import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts";
import Migration0059 from "./Migrations/059_McpAppModelContext.ts";
import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts";
+import Migration0061 from "./Migrations/061_PluginInstallations.ts";
+import Migration0062 from "./Migrations/062_PluginEventCursors.ts";
+import Migration0063 from "./Migrations/063_PluginSettings.ts";
/**
* Migration loader with all migrations defined inline.
@@ -148,6 +151,9 @@ export const migrationEntries = [
[58, "WebhookRelayDeliveries", Migration0058],
[59, "McpAppModelContext", Migration0059],
[60, "ThreadSnapshotWindowIndexes", Migration0060],
+ [61, "PluginInstallations", Migration0061],
+ [62, "PluginEventCursors", Migration0062],
+ [63, "PluginSettings", Migration0063],
] as const;
export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts
index 5e45ed05d302..bb6d1ef5d693 100644
--- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts
+++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts
@@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => {
Effect.sync(() => {
assert.deepStrictEqual(
migrationEntries.map(([id]) => id),
- Array.from({ length: 60 }, (_, index) => index + 1),
+ Array.from({ length: 63 }, (_, index) => index + 1),
);
}),
);
@@ -32,6 +32,9 @@ layer("055_OrchestrationV2", (it) => {
[58, "WebhookRelayDeliveries"],
[59, "McpAppModelContext"],
[60, "ThreadSnapshotWindowIndexes"],
+ [61, "PluginInstallations"],
+ [62, "PluginEventCursors"],
+ [63, "PluginSettings"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
@@ -58,6 +61,9 @@ layer("055_OrchestrationV2", (it) => {
{ migration_id: 58, name: "WebhookRelayDeliveries" },
{ migration_id: 59, name: "McpAppModelContext" },
{ migration_id: 60, name: "ThreadSnapshotWindowIndexes" },
+ { migration_id: 61, name: "PluginInstallations" },
+ { migration_id: 62, name: "PluginEventCursors" },
+ { migration_id: 63, name: "PluginSettings" },
]);
const tables = yield* sql<{ readonly name: string }>`
diff --git a/apps/server/src/persistence/Migrations/061_PluginInstallations.ts b/apps/server/src/persistence/Migrations/061_PluginInstallations.ts
new file mode 100644
index 000000000000..fc8e31c2cd87
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/061_PluginInstallations.ts
@@ -0,0 +1,16 @@
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/sql/SqlClient";
+
+export default Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ // One row per plugin directory added to this environment. `record_json` holds the last
+ // inspection and the consent; the directory is unique so one folder is one installation.
+ yield* sql`
+ CREATE TABLE IF NOT EXISTS plugin_installations (
+ installation_id TEXT PRIMARY KEY,
+ directory TEXT NOT NULL UNIQUE,
+ record_json TEXT NOT NULL
+ )
+ `;
+});
diff --git a/apps/server/src/persistence/Migrations/062_PluginEventCursors.ts b/apps/server/src/persistence/Migrations/062_PluginEventCursors.ts
new file mode 100644
index 000000000000..cf24e4b05455
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/062_PluginEventCursors.ts
@@ -0,0 +1,16 @@
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/sql/SqlClient";
+
+export default Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ // One row per plugin installation that receives events: the event log sequence it has
+ // acknowledged through. Removing the installation removes its row.
+ yield* sql`
+ CREATE TABLE IF NOT EXISTS plugin_event_cursors (
+ installation_id TEXT PRIMARY KEY,
+ acknowledged_sequence INTEGER NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ `;
+});
diff --git a/apps/server/src/persistence/Migrations/063_PluginSettings.ts b/apps/server/src/persistence/Migrations/063_PluginSettings.ts
new file mode 100644
index 000000000000..0dde99c4fb12
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/063_PluginSettings.ts
@@ -0,0 +1,37 @@
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/sql/SqlClient";
+
+export default Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ // Saved plugin setting values per installation, secrets excepted.
+ yield* sql`
+ CREATE TABLE IF NOT EXISTS plugin_settings (
+ installation_id TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value_json TEXT NOT NULL,
+ PRIMARY KEY (installation_id, key)
+ )
+ `;
+ // Secrets whose value may be in the server secret store. A row is written before its file and
+ // deleted after it, so cleanup can always find the file; `saved` is 1 once the value is complete
+ // and 0 while it is written or deleted.
+ yield* sql`
+ CREATE TABLE IF NOT EXISTS plugin_setting_secrets (
+ installation_id TEXT NOT NULL,
+ key TEXT NOT NULL,
+ saved INTEGER NOT NULL,
+ PRIMARY KEY (installation_id, key)
+ )
+ `;
+ // Each installation's private key-value storage; `bytes` is the value's encoded size for quotas.
+ yield* sql`
+ CREATE TABLE IF NOT EXISTS plugin_storage (
+ installation_id TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value_json TEXT NOT NULL,
+ bytes INTEGER NOT NULL,
+ PRIMARY KEY (installation_id, key)
+ )
+ `;
+});
diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
index 675d5f5db021..aae0178bc1ea 100644
--- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
+++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
@@ -41,6 +41,9 @@ describe("V2 preview upgrade", () => {
[58, "WebhookRelayDeliveries"],
[59, "McpAppModelContext"],
[60, "ThreadSnapshotWindowIndexes"],
+ [61, "PluginInstallations"],
+ [62, "PluginEventCursors"],
+ [63, "PluginSettings"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
@@ -124,6 +127,9 @@ describe("V2 preview upgrade", () => {
[58, "WebhookRelayDeliveries"],
[59, "McpAppModelContext"],
[60, "ThreadSnapshotWindowIndexes"],
+ [61, "PluginInstallations"],
+ [62, "PluginEventCursors"],
+ [63, "PluginSettings"],
]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
diff --git a/apps/server/src/plugins/PluginActions.test.ts b/apps/server/src/plugins/PluginActions.test.ts
new file mode 100644
index 000000000000..94693dec3fa1
--- /dev/null
+++ b/apps/server/src/plugins/PluginActions.test.ts
@@ -0,0 +1,489 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import {
+ PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT,
+ PLUGIN_ACTIONS_MAX_PER_PLUGIN,
+ PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES,
+ PluginActionId,
+ PluginActionInvokeInput,
+ PluginId,
+ PluginInstallationId,
+ ProjectId,
+ ThreadId,
+ type OrchestrationProjectShell,
+ type OrchestrationV2ThreadShell,
+ type PluginActionsSnapshot,
+ type PluginInstallation,
+} from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+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 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 Scope from "effect/Scope";
+import * as Stream from "effect/Stream";
+
+import { ProjectStoreV2 } from "../orchestration-v2/ProjectStore.ts";
+import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts";
+import * as SqlitePersistence from "../persistence/Sqlite.ts";
+import * as PluginActions from "./PluginActions.ts";
+import * as PluginCatalog from "./PluginCatalog.ts";
+import { loadPluginDirectory } from "./PluginManifestLoader.ts";
+import * as PluginSupervisor from "./PluginSupervisor.ts";
+
+// Children run the real CLI entry, which routes `__plugin-host` to the child runtime.
+const BIN_PATH = `${import.meta.dirname}/../bin.ts`;
+const FIXTURE = `${import.meta.dirname}/testFixtures/actions`;
+
+const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
+const fromJson = Schema.decodeSync(
+ Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
+);
+
+const THREAD = ThreadId.make("thread-1");
+const PROJECT = ProjectId.make("project-1");
+
+/** One thread in one project; every other target does not exist. */
+const threadsAndProjects = Layer.merge(
+ Layer.mock(ThreadManagementService)({
+ getThreadShell: (threadId) =>
+ Effect.succeed(
+ threadId === THREAD
+ ? ({
+ projectId: PROJECT,
+ worktreePath: null,
+ branch: "main",
+ } as OrchestrationV2ThreadShell)
+ : null,
+ ),
+ }),
+ Layer.mock(ProjectStoreV2)({
+ getShell: (projectId) =>
+ Effect.succeed(
+ projectId === PROJECT
+ ? Option.some({ workspaceRoot: "/work/project" } as OrchestrationProjectShell)
+ : Option.none(),
+ ),
+ }),
+);
+
+const startActions = Effect.fn("startActions")(function* (scope: Scope.Scope) {
+ const supervisor = yield* PluginSupervisor.make({
+ heapLimitMb: 64,
+ activationTimeout: "10 seconds",
+ stopGrace: "1 second",
+ }).pipe(
+ Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]),
+ Effect.provideService(Scope.Scope, scope),
+ );
+ const catalog = yield* PluginCatalog.make().pipe(
+ Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor),
+ Effect.provideService(Scope.Scope, scope),
+ );
+ const actions = yield* PluginActions.make().pipe(
+ Effect.provideService(PluginCatalog.PluginCatalog, catalog),
+ Effect.provide(threadsAndProjects),
+ );
+ return { catalog, actions };
+});
+
+/** Copies the fixture (or a variant of its manifest) into a fresh directory. */
+const preparePlugin = Effect.fn("preparePlugin")(function* (
+ manifest?: (manifest: Record) => Record,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const directory = path.join(
+ yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-actions-" }),
+ "plugin",
+ );
+ yield* fs.makeDirectory(directory);
+ yield* fs.writeFileString(
+ path.join(directory, "main.mjs"),
+ yield* fs.readFileString(path.join(FIXTURE, "main.mjs")),
+ );
+ const original = fromJson(yield* fs.readFileString(path.join(FIXTURE, "t3-plugin.json")));
+ yield* fs.writeFileString(
+ path.join(directory, "t3-plugin.json"),
+ toJson(manifest ? manifest(original) : original),
+ );
+ return directory;
+});
+
+const enablePlugin = Effect.fn("enablePlugin")(function* (
+ catalog: PluginCatalog.PluginCatalog["Service"],
+ directory: string,
+) {
+ const { installation } = yield* catalog.add({ directory });
+ yield* catalog.consent({
+ installationId: installation.installationId,
+ digest: installation.source!.digest,
+ });
+ return (yield* catalog.enable({ installationId: installation.installationId })).installation;
+});
+
+const awaitActions = (
+ actions: PluginActions.PluginActions["Service"],
+ predicate: (snapshot: PluginActionsSnapshot) => boolean,
+) =>
+ actions.subscribe.pipe(
+ Stream.filter(predicate),
+ Stream.runHead,
+ Effect.map((snapshot) => Option.getOrThrow(snapshot).actions),
+ );
+
+const awaitHostState = (
+ catalog: PluginCatalog.PluginCatalog["Service"],
+ installationId: PluginInstallationId,
+ tags: ReadonlyArray,
+) =>
+ catalog.subscribe.pipe(
+ Stream.filter((snapshot) =>
+ snapshot.installations.some(
+ (installation) =>
+ installation.installationId === installationId &&
+ tags.includes(installation.hostState?._tag ?? ""),
+ ),
+ ),
+ Stream.runHead,
+ );
+
+const withDatabase = (effect: Effect.Effect) =>
+ effect.pipe(Effect.provide(SqlitePersistence.layerMemory));
+
+it.layer(NodeServices.layer)("PluginActions", (it) => {
+ it.effect(
+ "lists an enabled plugin's actions without starting it, and drops them on disable",
+ () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const { catalog, actions } = yield* startActions(yield* Scope.Scope);
+ const directory = yield* preparePlugin();
+ const installation = yield* enablePlugin(catalog, directory);
+ const { installationId } = installation;
+
+ const listed = yield* awaitActions(actions, (snapshot) => snapshot.actions.length > 0);
+ expect(listed.map((action) => [action.name, action.target, action.placements])).toEqual([
+ ["echo-target", "thread", ["command-palette", "thread-menu", "composer-slash"]],
+ ["say-hello", "environment", ["command-palette"]],
+ ["fail", "environment", ["command-palette"]],
+ ["wait", "environment", ["command-palette"]],
+ ]);
+ expect(listed[0]).toMatchObject({
+ pluginId: "test.actions",
+ pluginName: "Actions fixture",
+ title: "Echo target",
+ description: "Says which thread it ran on.",
+ });
+ // Listing read the manifest only: no process was started.
+ const [row] = (yield* catalog.list).installations;
+ expect(row?.hostState).toEqual({ _tag: "idle" });
+
+ const hello = listed.find((action) => action.name === "say-hello")!;
+ expect(
+ yield* actions.invoke({ actionId: hello.id, target: { _tag: "environment" } }),
+ ).toEqual({ message: "Hello from test.actions" });
+
+ yield* catalog.disable({ installationId });
+ expect(yield* awaitActions(actions, () => true)).toEqual([]);
+ const disabled = yield* actions
+ .invoke({ actionId: hello.id, target: { _tag: "environment" } })
+ .pipe(Effect.flip);
+ expect(disabled.reason).toBe("not-found");
+
+ // Enabled again: new ids, and the old one can never reach the new registration.
+ yield* catalog.enable({ installationId });
+ const relisted = yield* awaitActions(actions, (snapshot) => snapshot.actions.length > 0);
+ expect(relisted.find((action) => action.name === "say-hello")!.id).not.toBe(hello.id);
+ const stale = yield* actions
+ .invoke({ actionId: hello.id, target: { _tag: "environment" } })
+ .pipe(Effect.flip);
+ expect(stale.reason).toBe("stale");
+
+ yield* catalog.remove({ installationId });
+ expect(yield* awaitActions(actions, () => true)).toEqual([]);
+ }),
+ ),
+ );
+
+ it.effect("runs an action on its resolved target and reports typed failures", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const { catalog, actions } = yield* startActions(yield* Scope.Scope);
+ const { installationId } = yield* enablePlugin(catalog, yield* preparePlugin());
+ const listed = yield* awaitActions(actions, (snapshot) => snapshot.actions.length > 0);
+ const byName = (name: string) => listed.find((action) => action.name === name)!.id;
+
+ expect(
+ yield* actions.invoke({
+ actionId: byName("echo-target"),
+ target: { _tag: "thread", threadId: THREAD },
+ }),
+ ).toEqual({ message: "thread thread-1 in /work/project" });
+
+ const failures = yield* Effect.forEach(
+ [
+ { actionId: byName("echo-target"), target: { _tag: "environment" as const } },
+ {
+ actionId: byName("echo-target"),
+ target: { _tag: "thread" as const, threadId: ThreadId.make("elsewhere") },
+ },
+ { actionId: byName("fail"), target: { _tag: "environment" as const } },
+ {
+ actionId: PluginActionId.make(`${installationId}:1:missing`),
+ target: { _tag: "environment" as const },
+ },
+ {
+ actionId: PluginActionId.make("not-an-action"),
+ target: { _tag: "environment" as const },
+ },
+ ],
+ (input) => actions.invoke(input).pipe(Effect.flip),
+ );
+ expect(failures.map((error) => [error.reason, error.message])).toEqual([
+ ["target-mismatch", "Echo target runs on a thread."],
+ ["target-not-found", "That thread does not exist here."],
+ ["failed", "The fixture failed on purpose."],
+ ["not-found", "That action is not available now."],
+ ["not-found", "That action is not available here."],
+ ]);
+
+ // A disable while the action runs revokes it at once.
+ const running = yield* actions
+ .invoke({ actionId: byName("wait"), target: { _tag: "environment" } })
+ .pipe(Effect.flip, Effect.forkChild({ startImmediately: true }));
+ yield* awaitHostState(catalog, installationId, ["starting", "running"]);
+ yield* catalog.disable({ installationId });
+ expect((yield* Fiber.join(running)).reason).toBe("stopped");
+ }),
+ ),
+ );
+
+ it.effect("refuses malformed ids the wire accepts with a typed error, never a defect", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const { catalog, actions } = yield* startActions(yield* Scope.Scope);
+ const { installationId } = yield* enablePlugin(catalog, yield* preparePlugin());
+ const decode = Schema.decodeUnknownEffect(PluginActionInvokeInput);
+ const exits = yield* Effect.forEach(
+ [
+ ":1:go",
+ `${"x".repeat(65)}:1:go`,
+ "::",
+ `${installationId}::say-hello`,
+ `${installationId}:1.0:say-hello`,
+ `${installationId}:99999999999999999999:say-hello`,
+ `${installationId} :1:say-hello`,
+ ],
+ (actionId) =>
+ decode({ actionId, target: { _tag: "environment" } }).pipe(
+ Effect.flatMap((input) => actions.invoke(input).pipe(Effect.exit)),
+ ),
+ );
+ for (const exit of exits) {
+ expect(Exit.isFailure(exit) && !Cause.hasDies(exit.cause)).toBe(true);
+ if (Exit.isFailure(exit)) {
+ expect(Cause.squash(exit.cause)).toMatchObject({
+ _tag: "PluginActionError",
+ reason: "not-found",
+ });
+ }
+ }
+ }),
+ ),
+ );
+
+ it.effect("offers plugins whole up to the environment bound and runs only those", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const { catalog, actions } = yield* startActions(yield* Scope.Scope);
+ const pluginCount = PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT / PLUGIN_ACTIONS_MAX_PER_PLUGIN + 1;
+ const installations = yield* Effect.forEach(
+ Array.from({ length: pluginCount }, (_, index) => index),
+ (index) =>
+ preparePlugin((manifest) => ({
+ ...manifest,
+ id: `test.actions-${index}`,
+ actions: Array.from({ length: PLUGIN_ACTIONS_MAX_PER_PLUGIN }, (_, action) => ({
+ name: `go-${action}`,
+ title: `Go ${action}`,
+ target: "environment",
+ placements: ["command-palette"],
+ })),
+ })).pipe(Effect.flatMap((directory) => enablePlugin(catalog, directory))),
+ );
+ const full = yield* actions.subscribe.pipe(
+ Stream.filter((snapshot) => snapshot.omitted !== undefined),
+ Stream.runHead,
+ Effect.map(Option.getOrThrow),
+ );
+ expect(full.actions).toHaveLength(PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT);
+ expect(full.omitted).toEqual({ plugins: 1, actions: PLUGIN_ACTIONS_MAX_PER_PLUGIN });
+ const listedIds = new Set(full.actions.map((action) => action.id.split(":")[0]));
+ const left = installations.find(
+ (installation) => !listedIds.has(installation.installationId),
+ )!;
+
+ const refused = yield* actions
+ .invoke({
+ actionId: PluginActionId.make(`${left.installationId}:${left.generation}:go-0`),
+ target: { _tag: "environment" },
+ })
+ .pipe(Effect.flip);
+ expect(refused.reason).toBe("not-found");
+ expect(refused.message).toContain("more actions than it shows");
+
+ // Disabling a listed plugin makes room, and the left-out one is offered whole.
+ const listed = installations.find((installation) => installation !== left)!;
+ yield* catalog.disable({ installationId: listed.installationId });
+ const after = yield* awaitActions(actions, (snapshot) => snapshot.omitted === undefined);
+ expect(after).toHaveLength(PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT);
+ expect(
+ after.filter((action) => action.id.startsWith(`${left.installationId}:`)),
+ ).toHaveLength(PLUGIN_ACTIONS_MAX_PER_PLUGIN);
+ }),
+ ),
+ );
+
+ it.effect("refuses declarations the host cannot honour", () =>
+ Effect.gen(function* () {
+ const reasons = yield* Effect.forEach(
+ [
+ (manifest: Record) => ({ ...manifest, capabilities: [] }),
+ (manifest: Record) => ({ ...manifest, proposedApi: false }),
+ (manifest: Record) => ({
+ ...manifest,
+ actions: [
+ { name: "same", title: "One", target: "environment", placements: ["thread-menu"] },
+ { name: "same", title: "Two", target: "environment", placements: ["thread-menu"] },
+ ],
+ }),
+ (manifest: Record) => ({
+ ...manifest,
+ actions: [
+ {
+ name: "twice",
+ title: "Twice",
+ target: "environment",
+ placements: ["thread-menu", "thread-menu"],
+ },
+ ],
+ }),
+ ],
+ (variant) =>
+ preparePlugin(variant).pipe(
+ Effect.flatMap(loadPluginDirectory),
+ Effect.flip,
+ Effect.map((error) => error.reason),
+ ),
+ );
+ expect(reasons).toEqual([
+ "it declares actions without the actions capability.",
+ "it declares actions, which need proposedApi: true.",
+ "it declares the action same twice.",
+ "the action twice repeats a placement.",
+ ]);
+ }),
+ );
+});
+
+describe("pluginActionsFromCatalog", () => {
+ const digest = `sha256:${"a".repeat(64)}`;
+ const installation: PluginInstallation = {
+ installationId: PluginInstallationId.make("installation-1"),
+ generation: 3,
+ directory: "/srv/plugins/actions",
+ manifest: {
+ id: PluginId.make("test.actions"),
+ name: "Actions",
+ version: "1.0.0",
+ capabilities: ["actions"],
+ proposedApi: true,
+ actions: [{ name: "go", title: "Go", target: "environment", placements: ["thread-menu"] }],
+ },
+ source: { digest, files: 2, bytes: 10 },
+ problem: null,
+ inspectedAt: "2026-10-04T00:00:00.000Z",
+ consent: { digest, capabilities: ["actions"], grantedAt: "2026-10-04T00:00:00.000Z" },
+ enabled: true,
+ hostState: { _tag: "running" },
+ addedAt: "2026-10-04T00:00:00.000Z",
+ };
+ const names = (installations: ReadonlyArray) =>
+ PluginActions.pluginActionsFromCatalog({ installations }).actions.map((action) => action.id);
+
+ const many = (count: number, description?: string) =>
+ Array.from({ length: count }, (_, index): PluginInstallation => {
+ const installationId = PluginInstallationId.make(`installation-${index}`);
+ return {
+ ...installation,
+ installationId,
+ manifest: {
+ ...installation.manifest!,
+ id: PluginId.make(`test.actions-${index}`),
+ actions: Array.from({ length: PLUGIN_ACTIONS_MAX_PER_PLUGIN }, (_, action) => ({
+ name: `go-${action}`,
+ title: `Go ${action}`,
+ ...(description === undefined ? {} : { description }),
+ target: "environment" as const,
+ placements: ["command-palette" as const],
+ })),
+ },
+ };
+ });
+ const frameBytes = (snapshot: PluginActionsSnapshot) =>
+ Buffer.byteLength(JSON.stringify(snapshot));
+
+ it("bounds the actions and bytes one environment offers, counting what it leaves out", () => {
+ const byCount = PluginActions.pluginActionsFromCatalog({ installations: many(1000) });
+ expect(byCount.actions).toHaveLength(PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT);
+ const keptPlugins = PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT / PLUGIN_ACTIONS_MAX_PER_PLUGIN;
+ expect(byCount.omitted).toEqual({
+ plugins: 1000 - keptPlugins,
+ actions: (1000 - keptPlugins) * PLUGIN_ACTIONS_MAX_PER_PLUGIN,
+ });
+ // The first plugins in catalogue order are the ones kept.
+ expect(new Set(byCount.actions.map((action) => action.id.split(":")[0]))).toEqual(
+ new Set(Array.from({ length: keptPlugins }, (_, index) => `installation-${index}`)),
+ );
+ expect(frameBytes(byCount)).toBeLessThanOrEqual(PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES);
+
+ // Escaped control characters make each description six times its length on the wire.
+ const byBytes = PluginActions.pluginActionsFromCatalog({
+ installations: many(1000, "\u0001".repeat(240)),
+ });
+ expect(byBytes.actions.length).toBeLessThan(PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT);
+ expect(byBytes.actions.length % PLUGIN_ACTIONS_MAX_PER_PLUGIN).toBe(0);
+ expect(byBytes.omitted?.actions).toBe(
+ 1000 * PLUGIN_ACTIONS_MAX_PER_PLUGIN - byBytes.actions.length,
+ );
+ expect(frameBytes(byBytes)).toBeLessThanOrEqual(PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES);
+
+ // Within the bounds nothing is left out and `omitted` is absent.
+ expect(
+ PluginActions.pluginActionsFromCatalog({ installations: many(keptPlugins) }),
+ ).not.toHaveProperty("omitted");
+ });
+
+ it("offers only actions that can run now", () => {
+ expect(names([installation])).toEqual(["installation-1:3:go"]);
+ // A state this server version does not know is unknown, not a reason to hide the action.
+ const { hostState: _hostState, ...unknownState } = installation;
+ expect(names([unknownState])).toEqual(["installation-1:3:go"]);
+ for (const hidden of [
+ { ...installation, enabled: false },
+ { ...installation, consent: null },
+ { ...installation, source: null, problem: "gone" },
+ { ...installation, hostState: { _tag: "quarantined" as const, failures: 3, reason: "x" } },
+ { ...installation, hostState: { _tag: "incompatible" as const, reason: "x" } },
+ { ...installation, manifest: { ...installation.manifest!, capabilities: [] } },
+ ]) {
+ expect(names([hidden])).toEqual([]);
+ }
+ });
+});
diff --git a/apps/server/src/plugins/PluginActions.ts b/apps/server/src/plugins/PluginActions.ts
new file mode 100644
index 000000000000..4d8f55c5a24f
--- /dev/null
+++ b/apps/server/src/plugins/PluginActions.ts
@@ -0,0 +1,308 @@
+/**
+ * The actions enabled plugins offer, and running one.
+ *
+ * Actions come from the manifest of each enabled installation, so listing
+ * them never starts a plugin. Each listed action carries an id naming the
+ * installation's registration (`::`). An
+ * invoke with an id from an earlier registration is refused as `stale`, and
+ * the catalogue's own generation check refuses one that races a re-enable,
+ * so a click never reaches a plugin the list did not show. The list is
+ * bounded per environment (see `PluginActionsSnapshot`), and only listed
+ * actions run.
+ *
+ * Running an action calls the plugin's `action:` handler with the
+ * resolved target (see pluginApi.ts) under a deadline. Interrupting the
+ * caller (a client that goes away) cancels the call.
+ */
+import {
+ PLUGIN_ACTION_MESSAGE_MAX_LENGTH,
+ PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT,
+ PLUGIN_ACTIONS_MAX_PER_PLUGIN,
+ PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES,
+ PluginActionError,
+ PluginActionId,
+ PluginInstallationId,
+ pluginInstallationStatus,
+ type PluginAction,
+ type PluginActionInvokeInput,
+ type PluginActionInvokeResult,
+ type PluginActionsSnapshot,
+ type PluginActionTarget,
+ type PluginCatalogError,
+ type PluginCatalogSnapshot,
+ type PluginInstallation,
+ type ProjectId,
+ type ThreadId,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import type * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+
+import { ProjectStoreV2 } from "../orchestration-v2/ProjectStore.ts";
+import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts";
+import { PluginCatalog } from "./PluginCatalog.ts";
+import type { PluginInvokeError } from "./PluginSupervisor.ts";
+
+const PLUGIN_ACTION_TIMEOUT: Duration.Input = "30 seconds";
+
+/** What an action handler receives as `input.target`: the target the user picked, resolved. */
+type PluginActionTargetContext =
+ | { readonly kind: "environment" }
+ | { readonly kind: "project"; readonly projectId: string; readonly workspaceRoot: string }
+ | {
+ readonly kind: "thread";
+ readonly threadId: string;
+ readonly projectId: string;
+ /** The thread's worktree, or its project's workspace root. */
+ readonly cwd: string;
+ readonly branch: string | null;
+ };
+
+/** Looks a target up in this environment's projects and threads; none when it does not exist here. */
+const resolvePluginActionTargetFrom =
+ (lookups: {
+ readonly getThreadShell: (threadId: ThreadId) => Effect.Effect<
+ {
+ readonly projectId: ProjectId;
+ readonly worktreePath: string | null;
+ readonly branch: string | null;
+ } | null,
+ ThreadError
+ >;
+ readonly getProjectShell: (
+ projectId: ProjectId,
+ ) => Effect.Effect, ProjectError>;
+ }) =>
+ (
+ target: PluginActionTarget,
+ ): Effect.Effect, PluginActionError> =>
+ Effect.gen(function* () {
+ switch (target._tag) {
+ case "environment":
+ return Option.some({ kind: "environment" as const });
+ case "project": {
+ const project = yield* lookups.getProjectShell(target.projectId);
+ return Option.map(project, (shell) => ({
+ kind: "project" as const,
+ projectId: target.projectId,
+ workspaceRoot: shell.workspaceRoot,
+ }));
+ }
+ case "thread": {
+ const thread = yield* lookups.getThreadShell(target.threadId);
+ if (thread === null) return Option.none();
+ const project = yield* lookups.getProjectShell(thread.projectId);
+ if (Option.isNone(project)) return Option.none();
+ return Option.some({
+ kind: "thread" as const,
+ threadId: target.threadId,
+ projectId: thread.projectId,
+ cwd: thread.worktreePath ?? project.value.workspaceRoot,
+ branch: thread.branch,
+ });
+ }
+ }
+ }).pipe(
+ Effect.mapError(() => actionError("unavailable", "Could not look up the action's target.")),
+ );
+
+export class PluginActions extends Context.Service<
+ PluginActions,
+ {
+ /** The current actions now, then a new list whenever it changes. */
+ readonly subscribe: Stream.Stream;
+ readonly invoke: (
+ input: PluginActionInvokeInput,
+ ) => Effect.Effect;
+ }
+>()("t3/plugins/PluginActions") {}
+
+const pluginActionHandlerName = (name: string) => `action:${name}`;
+
+const actionId = (installationId: string, generation: number, name: string) =>
+ PluginActionId.make(`${installationId}:${generation}:${name}`);
+
+const decodeInstallationId = Schema.decodeUnknownOption(PluginInstallationId);
+
+/** The parts of an id this server issued; undefined for anything else. */
+const parseActionId = (id: PluginActionId) => {
+ const parts = id.split(":");
+ const name = parts.pop();
+ const generationText = parts.pop();
+ if (name === undefined || generationText === undefined || !/^[0-9]+$/.test(generationText))
+ return undefined;
+ const generation = Number(generationText);
+ if (!Number.isSafeInteger(generation)) return undefined;
+ const segment = parts.join(":");
+ const installationId = decodeInstallationId(segment);
+ if (Option.isNone(installationId) || installationId.value !== segment) return undefined;
+ return { installationId: installationId.value, generation, name };
+};
+
+/** The actions one installation offers on its own: enabled and able to run now. */
+const installationActions = (installation: PluginInstallation): ReadonlyArray => {
+ const { manifest } = installation;
+ if (manifest === null || pluginInstallationStatus(installation) !== "enabled") return [];
+ if (!manifest.capabilities.includes("actions")) return [];
+ // These wait for someone to resume them; an action could only fail.
+ const state = installation.hostState?._tag;
+ if (state === "quarantined" || state === "incompatible") return [];
+ return (manifest.actions ?? []).slice(0, PLUGIN_ACTIONS_MAX_PER_PLUGIN).map((declaration) => ({
+ id: actionId(installation.installationId, installation.generation, declaration.name),
+ pluginId: manifest.id,
+ pluginName: manifest.name,
+ name: declaration.name,
+ title: declaration.title,
+ ...(declaration.description === undefined ? {} : { description: declaration.description }),
+ target: declaration.target,
+ placements: declaration.placements,
+ }));
+};
+
+// Room for the frame around the actions: `{"actions":[`, `]`, and `omitted`.
+const SNAPSHOT_ENVELOPE_BYTES = 128;
+const ACTIONS_MAX_BYTES = PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES - SNAPSHOT_ENVELOPE_BYTES;
+
+/** Each action's JSON plus its separator. */
+const encodedBytes = (actions: ReadonlyArray) =>
+ actions.reduce((total, action) => total + Buffer.byteLength(JSON.stringify(action)) + 1, 0);
+
+/**
+ * The actions a catalogue snapshot offers. Plugins are taken whole, in
+ * catalogue order, until one would pass the environment's action or byte
+ * bound; it and every later plugin are counted in `omitted` instead.
+ */
+export const pluginActionsFromCatalog = (
+ snapshot: PluginCatalogSnapshot,
+): PluginActionsSnapshot => {
+ const actions: Array = [];
+ let bytes = 0;
+ const omitted = { plugins: 0, actions: 0 };
+ for (const installation of snapshot.installations) {
+ const offered = installationActions(installation);
+ if (offered.length === 0) continue;
+ if (omitted.plugins === 0) {
+ const size = encodedBytes(offered);
+ if (
+ actions.length + offered.length <= PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT &&
+ bytes + size <= ACTIONS_MAX_BYTES
+ ) {
+ actions.push(...offered);
+ bytes += size;
+ continue;
+ }
+ }
+ omitted.plugins += 1;
+ omitted.actions += offered.length;
+ }
+ return omitted.plugins === 0 ? { actions } : { actions, omitted };
+};
+
+const actionError = (reason: string, message: string) =>
+ new PluginActionError({ reason, message: bound(message) });
+
+/** At most the message bound, without splitting a surrogate pair. */
+const bound = (text: string) => {
+ const characters = Array.from(text);
+ return characters.length <= PLUGIN_ACTION_MESSAGE_MAX_LENGTH
+ ? text
+ : `${characters.slice(0, PLUGIN_ACTION_MESSAGE_MAX_LENGTH - 1).join("")}…`;
+};
+
+/** A plugin's `{ message }`, if it returned one. */
+const resultMessage = (value: Schema.Json): string | null => {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
+ const message = (value as { readonly [key: string]: Schema.Json })["message"];
+ if (typeof message !== "string" || message.trim() === "") return null;
+ return bound(message.trim());
+};
+
+const fromInvokeError = (
+ error: PluginCatalogError | PluginInvokeError,
+ title: string,
+): PluginActionError => {
+ switch (error._tag) {
+ case "PluginCatalogError":
+ return error.reason === "generation-changed"
+ ? actionError("stale", "The plugin was enabled again since this action was listed.")
+ : error.reason === "not-found"
+ ? actionError("not-found", "That plugin is no longer installed.")
+ : actionError("unavailable", error.message);
+ case "PluginTimeoutError":
+ return actionError("timeout", `${title} did not finish in time.`);
+ case "PluginBusyError":
+ return actionError("busy", `${title} could not start: the plugin is busy.`);
+ case "PluginStoppedError":
+ return actionError("stopped", "The plugin was disabled while the action ran.");
+ case "PluginCallFailedError":
+ return actionError("failed", error.reason);
+ default:
+ return actionError("unavailable", error.message);
+ }
+};
+
+export const make = Effect.fn("PluginActions.make")(function* () {
+ const catalog = yield* PluginCatalog;
+ const threads = yield* ThreadManagementService;
+ const projects = yield* ProjectStoreV2;
+ const resolveTarget = resolvePluginActionTargetFrom({
+ getThreadShell: threads.getThreadShell,
+ getProjectShell: projects.getShell,
+ });
+
+ const invoke = Effect.fn("PluginActions.invoke")(function* (input: PluginActionInvokeInput) {
+ const parsed = parseActionId(input.actionId);
+ if (parsed === undefined)
+ return yield* actionError("not-found", "That action is not available here.");
+ const snapshot = yield* catalog.list;
+ const installation = snapshot.installations.find(
+ (candidate) => candidate.installationId === parsed.installationId,
+ );
+ if (installation === undefined)
+ return yield* actionError("not-found", "That plugin is no longer installed.");
+ if (installation.generation !== parsed.generation)
+ return yield* actionError(
+ "stale",
+ "The plugin was enabled again since this action was listed.",
+ );
+ // Only what the list offers runs, so an action left out by the bounds cannot.
+ const action = pluginActionsFromCatalog(snapshot).actions.find(
+ (candidate) => candidate.id === input.actionId,
+ );
+ if (action === undefined)
+ return yield* installationActions(installation).some(
+ (candidate) => candidate.name === parsed.name,
+ )
+ ? actionError(
+ "not-found",
+ "That action is not offered: this environment's plugins declare more actions than it shows.",
+ )
+ : actionError("not-found", "That action is not available now.");
+ if (action.target !== input.target._tag)
+ return yield* actionError("target-mismatch", `${action.title} runs on a ${action.target}.`);
+ const target = yield* resolveTarget(input.target);
+ if (Option.isNone(target))
+ return yield* actionError("target-not-found", `That ${action.target} does not exist here.`);
+ const value = yield* catalog
+ .invoke(
+ installation.installationId,
+ pluginActionHandlerName(action.name),
+ { action: action.name, target: target.value },
+ { generation: parsed.generation, timeout: PLUGIN_ACTION_TIMEOUT },
+ )
+ .pipe(Effect.mapError((error) => fromInvokeError(error, action.title)));
+ return { message: resultMessage(value) };
+ });
+
+ return PluginActions.of({
+ // Process state changes reach the catalogue too; most leave the list as it was.
+ subscribe: catalog.subscribe.pipe(Stream.map(pluginActionsFromCatalog), Stream.changes),
+ invoke,
+ });
+});
+
+export const layer = Layer.effect(PluginActions, make());
diff --git a/apps/server/src/plugins/PluginActionsRpc.test.ts b/apps/server/src/plugins/PluginActionsRpc.test.ts
new file mode 100644
index 000000000000..335d9a3eb5c5
--- /dev/null
+++ b/apps/server/src/plugins/PluginActionsRpc.test.ts
@@ -0,0 +1,120 @@
+import {
+ type AuthEnvironmentScope,
+ AuthOrchestrationOperateScope,
+ AuthOrchestrationReadScope,
+ AuthRelayReadScope,
+ AuthStandardClientScopes,
+ PluginActionId,
+ type PluginActionsSnapshot,
+ WS_METHODS,
+ WsRpcGroup,
+} from "@t3tools/contracts";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Stream from "effect/Stream";
+import * as RpcTest from "effect/rpc/RpcTest";
+
+import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts";
+import * as RpcAuthorization from "../auth/RpcAuthorization.ts";
+
+type ActionMethod =
+ | typeof WS_METHODS.pluginActionsSubscribe
+ | typeof WS_METHODS.pluginActionsInvoke;
+const actionMethods: ReadonlySet = new Set([
+ WS_METHODS.pluginActionsSubscribe,
+ WS_METHODS.pluginActionsInvoke,
+]);
+
+const group = WsRpcGroup.omit(
+ ...[...WsRpcGroup.requests.keys()].filter(
+ (tag): tag is Exclude =>
+ !actionMethods.has(tag),
+ ),
+);
+
+const actionId = PluginActionId.make("installation-1:1:say-hello");
+const snapshot: PluginActionsSnapshot = {
+ actions: [
+ {
+ id: actionId,
+ pluginId: "test.actions",
+ pluginName: "Actions fixture",
+ name: "say-hello",
+ title: "Say hello",
+ target: "environment",
+ placements: ["command-palette"],
+ },
+ ],
+};
+const invoke = { actionId, target: { _tag: "environment" as const } };
+
+/** Serves the action RPCs through the real scope middleware; handlers record that they ran. */
+const makeClient = (scopes: ReadonlyArray, handled: Array) =>
+ RpcTest.makeClient(group).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ group.toLayerHandler(WS_METHODS.pluginActionsSubscribe, () =>
+ Stream.fromEffect(
+ Effect.sync(() => handled.push(WS_METHODS.pluginActionsSubscribe)).pipe(
+ Effect.as(snapshot),
+ ),
+ ),
+ ),
+ group.toLayerHandler(WS_METHODS.pluginActionsInvoke, () =>
+ Effect.sync(() => handled.push(WS_METHODS.pluginActionsInvoke)).pipe(
+ Effect.as({ message: "Hello" }),
+ ),
+ ),
+ RpcAuthorization.layer(scopes),
+ ),
+ ),
+ );
+
+describe("plugin action RPC scopes", () => {
+ it.effect("lets a standard pairing list and run the actions an administrator enabled", () =>
+ Effect.gen(function* () {
+ const handled: Array = [];
+ const client = yield* makeClient(AuthStandardClientScopes, handled);
+
+ expect(
+ yield* client[WS_METHODS.pluginActionsSubscribe]({}).pipe(
+ Stream.take(1),
+ Stream.runCollect,
+ ),
+ ).toEqual([snapshot]);
+ expect(yield* client[WS_METHODS.pluginActionsInvoke](invoke)).toEqual({ message: "Hello" });
+ expect(handled).toEqual([WS_METHODS.pluginActionsSubscribe, WS_METHODS.pluginActionsInvoke]);
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("refuses to run an action for a read-only session", () =>
+ Effect.gen(function* () {
+ const handled: Array = [];
+ const client = yield* makeClient([AuthOrchestrationReadScope], handled);
+
+ expect(yield* client[WS_METHODS.pluginActionsInvoke](invoke).pipe(Effect.flip)).toMatchObject(
+ {
+ _tag: "EnvironmentAuthorizationError",
+ requiredScope: AuthOrchestrationOperateScope,
+ },
+ );
+ expect(handled).toEqual([]);
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("refuses the list without the orchestration read scope", () =>
+ Effect.gen(function* () {
+ const handled: Array = [];
+ const client = yield* makeClient([AuthRelayReadScope], handled);
+
+ expect(
+ yield* client[WS_METHODS.pluginActionsSubscribe]({}).pipe(Stream.runCollect, Effect.flip),
+ ).toMatchObject({
+ _tag: "EnvironmentAuthorizationError",
+ requiredScope: AuthOrchestrationReadScope,
+ });
+ expect(handled).toEqual([]);
+ }).pipe(Effect.scoped),
+ );
+});
diff --git a/apps/server/src/plugins/PluginCatalog.test.ts b/apps/server/src/plugins/PluginCatalog.test.ts
new file mode 100644
index 000000000000..931c78c7f473
--- /dev/null
+++ b/apps/server/src/plugins/PluginCatalog.test.ts
@@ -0,0 +1,786 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import {
+ PluginInstallationId,
+ pluginInstallationStatus,
+ type PluginId,
+ type PluginInstallation,
+} from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as Fiber from "effect/Fiber";
+import * as FileSystem from "effect/FileSystem";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as PubSub from "effect/PubSub";
+import * as Queue from "effect/Queue";
+import * as Schema from "effect/Schema";
+import * as Scope from "effect/Scope";
+import * as Stream from "effect/Stream";
+import * as SqlClient from "effect/sql/SqlClient";
+
+import * as SqlitePersistence from "../persistence/Sqlite.ts";
+import * as PluginCatalog from "./PluginCatalog.ts";
+import type { PluginRegistration } from "./PluginManifestLoader.ts";
+import * as PluginSupervisor from "./PluginSupervisor.ts";
+
+// Children run the real CLI entry, which routes `__plugin-host` to the child runtime.
+const BIN_PATH = `${import.meta.dirname}/../bin.ts`;
+
+type Catalog = PluginCatalog.PluginCatalog["Service"];
+
+const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
+
+/** Starts a catalogue and its supervisor in `scope`, as one server start would. */
+const startCatalog = Effect.fn("startCatalog")(function* (scope: Scope.Scope) {
+ const supervisor = yield* PluginSupervisor.make({
+ heapLimitMb: 64,
+ activationTimeout: "10 seconds",
+ stopGrace: "1 second",
+ }).pipe(
+ Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]),
+ Effect.provideService(Scope.Scope, scope),
+ );
+ return yield* PluginCatalog.make().pipe(
+ Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor),
+ Effect.provideService(Scope.Scope, scope),
+ );
+});
+
+/**
+ * Writes a plugin whose activation leaves a marker outside its own directory:
+ * a plugin writing into its directory changes its own digest.
+ */
+const preparePlugin = Effect.fn("preparePlugin")(function* (id: string) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-catalog-" });
+ const directory = path.join(root, "plugin");
+ yield* fs.makeDirectory(directory);
+ const marker = path.join(root, "activated");
+ const entry = path.join(directory, "main.mjs");
+ yield* fs.writeFileString(
+ entry,
+ [
+ `import * as NodeFS from "node:fs";`,
+ `export function activate(context) {`,
+ ` NodeFS.writeFileSync(${toJson(marker)}, String(process.pid));`,
+ ` context.proposed.handle("ping", (input) => ({ pid: process.pid, input }));`,
+ `}`,
+ ``,
+ ].join("\n"),
+ );
+ yield* fs.writeFileString(
+ path.join(directory, "t3-plugin.json"),
+ toJson({
+ id,
+ name: id,
+ version: "1.0.0",
+ apiVersion: 1,
+ entry: "main.mjs",
+ proposedApi: true,
+ }),
+ );
+ const edit = (line: string) =>
+ fs
+ .readFileString(entry)
+ .pipe(Effect.flatMap((content) => fs.writeFileString(entry, `${content}// ${line}\n`)));
+ return { directory, marker, edit };
+});
+
+/** Waits, through the subscription, for a snapshot that satisfies `predicate`. */
+const awaitSnapshot = (
+ catalog: Catalog,
+ predicate: (installations: ReadonlyArray) => boolean,
+) =>
+ catalog.subscribe.pipe(
+ Stream.filter((snapshot) => predicate(snapshot.installations)),
+ Stream.runHead,
+ Effect.map((snapshot) => Option.getOrThrow(snapshot).installations),
+ );
+
+/** A step a test can stop at: `reached` completes when it is entered, `release` lets it go on. */
+interface Hold {
+ readonly reached: Deferred.Deferred;
+ readonly release: Deferred.Deferred;
+}
+
+const makeHold = Effect.gen(function* () {
+ const hold: Hold = {
+ reached: yield* Deferred.make(),
+ release: yield* Deferred.make(),
+ };
+ return hold;
+});
+
+const passHold = (hold: Hold | undefined) =>
+ hold === undefined
+ ? Effect.void
+ : Deferred.succeed(hold.reached, undefined).pipe(Effect.andThen(Deferred.await(hold.release)));
+
+/**
+ * A supervisor without processes that behaves like the real one at its
+ * boundary: registration by plugin id, revocation at the start of `disable`,
+ * and `invoke` answering with the registered directory. Tests can hold
+ * `enable` and `disable` open.
+ */
+const makeStubSupervisor = Effect.gen(function* () {
+ const registrations = new Map();
+ const invoked: Array = [];
+ const holds: { enable?: Hold; disable?: Hold } = {};
+ const events = yield* PubSub.unbounded();
+ const service = PluginSupervisor.PluginSupervisor.of({
+ enable: (registration) =>
+ Effect.suspend(() => {
+ const pluginId = registration.manifest.id;
+ if (registrations.has(pluginId))
+ return Effect.fail(new PluginSupervisor.PluginAlreadyEnabledError({ pluginId }));
+ registrations.set(pluginId, registration);
+ return passHold(holds.enable);
+ }),
+ disable: (pluginId) =>
+ Effect.suspend(() => {
+ registrations.delete(pluginId);
+ return passHold(holds.disable);
+ }),
+ resume: () => Effect.void,
+ invoke: (pluginId) =>
+ Effect.suspend(() => {
+ const registration = registrations.get(pluginId);
+ if (registration === undefined)
+ return Effect.fail(new PluginSupervisor.PluginNotEnabledError({ pluginId }));
+ invoked.push(registration.directory);
+ return Effect.succeed(registration.directory);
+ }),
+ state: (pluginId) =>
+ Effect.sync(() =>
+ registrations.has(pluginId) ? Option.some({ _tag: "idle" as const }) : Option.none(),
+ ),
+ subscribe: PubSub.subscribe(events),
+ serveHostMethod: () => Effect.void,
+ });
+ return { service, registrations, invoked, holds };
+});
+
+/** The real file system, except that the next read of a held directory waits for its hold. */
+const makeHoldingFileSystem = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ let held: { readonly directory: string; readonly hold: Hold } | undefined;
+ const fileSystem: FileSystem.FileSystem = {
+ ...fs,
+ realPath: (target) =>
+ Effect.suspend(() => {
+ if (held === undefined || !target.startsWith(held.directory)) return fs.realPath(target);
+ const { hold } = held;
+ held = undefined;
+ return passHold(hold).pipe(Effect.andThen(fs.realPath(target)));
+ }),
+ };
+ const holdDirectory = Effect.fn("holdDirectory")(function* (directory: string) {
+ const hold = yield* makeHold;
+ held = { directory, hold };
+ return hold;
+ });
+ return { fileSystem, holdDirectory };
+});
+
+/** A catalogue over the stub supervisor, reading files through `fileSystem`. */
+const startStubCatalog = Effect.fn("startStubCatalog")(function* (
+ scope: Scope.Scope,
+ supervisor: PluginSupervisor.PluginSupervisor["Service"],
+ fileSystem?: FileSystem.FileSystem,
+) {
+ return yield* PluginCatalog.make().pipe(
+ Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor),
+ Effect.provideService(FileSystem.FileSystem, fileSystem ?? (yield* FileSystem.FileSystem)),
+ Effect.provideService(Scope.Scope, scope),
+ );
+});
+
+const pidOf = (value: unknown) => (value as { readonly pid: number }).pid;
+
+const isProcessAlive = (pid: number) => {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+// Each test gets its own database; the restart test shares one between two starts.
+const withDatabase = (effect: Effect.Effect) =>
+ effect.pipe(Effect.provide(SqlitePersistence.layerMemory));
+
+it.layer(NodeServices.layer)("PluginCatalog", (it) => {
+ describe("consent", () => {
+ it.effect("runs nothing until the exact bytes are approved, then starts on first use", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const sql = yield* SqlClient.SqlClient;
+ const catalog = yield* startCatalog(yield* Scope.Scope);
+ const plugin = yield* preparePlugin("test.lazy");
+
+ const relative = yield* catalog.add({ directory: "plugins/lazy" }).pipe(Effect.flip);
+ expect(relative.reason).toBe("invalid-directory");
+
+ const { installation: added } = yield* catalog.add({ directory: plugin.directory });
+ const installationId = added.installationId;
+ expect(pluginInstallationStatus(added)).toBe("needs-consent");
+ expect(added).toMatchObject({ enabled: false, consent: null, generation: 0 });
+ expect(added.manifest?.id).toBe("test.lazy");
+ expect(added.hostState).toBeUndefined();
+ const again = yield* catalog.add({ directory: plugin.directory }).pipe(Effect.flip);
+ expect(again.reason).toBe("already-added");
+
+ const unapproved = yield* catalog.enable({ installationId }).pipe(Effect.flip);
+ expect(unapproved.reason).toBe("consent-required");
+ const wrongDigest = yield* catalog
+ .consent({ installationId, digest: `sha256:${"0".repeat(64)}` })
+ .pipe(Effect.flip);
+ expect(wrongDigest.reason).toBe("source-changed");
+
+ const digest = added.source!.digest;
+ const { installation: approved } = yield* catalog.consent({ installationId, digest });
+ expect(pluginInstallationStatus(approved)).toBe("disabled");
+ expect(approved.consent).toMatchObject({ digest, capabilities: [] });
+
+ const { installation: enabled } = yield* catalog.enable({ installationId });
+ expect(pluginInstallationStatus(enabled)).toBe("enabled");
+ expect(enabled).toMatchObject({ generation: 1, hostState: { _tag: "idle" } });
+ expect(yield* fs.exists(plugin.marker)).toBe(false);
+
+ const pid = pidOf(yield* catalog.invoke(installationId, "ping", null));
+ expect(isProcessAlive(pid)).toBe(true);
+ yield* awaitSnapshot(catalog, ([row]) => row?.hostState?._tag === "running");
+
+ const { installation: disabled } = yield* catalog.disable({ installationId });
+ expect(isProcessAlive(pid)).toBe(false);
+ expect(pluginInstallationStatus(disabled)).toBe("disabled");
+ expect(disabled.hostState).toBeUndefined();
+ const stopped = yield* catalog.invoke(installationId, "ping", null).pipe(Effect.flip);
+ expect(stopped._tag).toBe("PluginCatalogError");
+
+ expect((yield* catalog.enable({ installationId })).installation.generation).toBe(2);
+ expect(yield* catalog.remove({ installationId })).toEqual({ installationId });
+ expect((yield* catalog.list).installations).toEqual([]);
+ // Settings promises that removing an added directory leaves it in place.
+ expect(yield* fs.exists(plugin.directory)).toBe(true);
+ expect(yield* sql`SELECT installation_id FROM plugin_installations`).toEqual([]);
+ }),
+ ),
+ );
+
+ it.effect("stops a plugin and asks again when its bytes change", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const catalog = yield* startCatalog(yield* Scope.Scope);
+ const plugin = yield* preparePlugin("test.changed");
+ const { installation } = yield* catalog.add({ directory: plugin.directory });
+ const installationId = installation.installationId;
+ const firstDigest = installation.source!.digest;
+ yield* catalog.consent({ installationId, digest: firstDigest });
+ yield* catalog.enable({ installationId });
+ const pid = pidOf(yield* catalog.invoke(installationId, "ping", null));
+
+ yield* plugin.edit("changed while running");
+ const [refreshed] = (yield* catalog.refresh({ installationId })).installations;
+ expect(pluginInstallationStatus(refreshed!)).toBe("needs-consent");
+ expect(refreshed!.enabled).toBe(false);
+ expect(isProcessAlive(pid)).toBe(false);
+ const changed = yield* catalog.enable({ installationId }).pipe(Effect.flip);
+ expect(changed.reason).toBe("consent-required");
+ const stale = yield* catalog
+ .consent({ installationId, digest: firstDigest })
+ .pipe(Effect.flip);
+ expect(stale.reason).toBe("source-changed");
+
+ yield* catalog.consent({ installationId, digest: refreshed!.source!.digest });
+ yield* catalog.enable({ installationId });
+ // Changed again with no refresh: the check before a fresh process catches it.
+ yield* plugin.edit("changed before first use");
+ yield* fs.remove(plugin.marker);
+ const beforeStart = yield* catalog.invoke(installationId, "ping", null).pipe(Effect.flip);
+ expect(beforeStart).toMatchObject({ reason: "source-changed" });
+ expect(yield* fs.exists(plugin.marker)).toBe(false);
+ const [revoked] = (yield* catalog.list).installations;
+ expect(pluginInstallationStatus(revoked!)).toBe("needs-consent");
+ }),
+ ),
+ );
+
+ it.effect("checks the bytes when enabling an installation that is already enabled", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const catalog = yield* startCatalog(yield* Scope.Scope);
+ const plugin = yield* preparePlugin("test.enable-again");
+ const { installation } = yield* catalog.add({ directory: plugin.directory });
+ const installationId = installation.installationId;
+ const approve = (digest: string) =>
+ catalog
+ .consent({ installationId, digest })
+ .pipe(Effect.andThen(catalog.enable({ installationId })));
+ const { installation: enabled } = yield* approve(installation.source!.digest);
+
+ // Unchanged: the same registration, not a new generation.
+ const { installation: same } = yield* catalog.enable({ installationId });
+ expect(same).toMatchObject({ enabled: true, generation: enabled.generation });
+
+ // Changed while registered but idle.
+ yield* plugin.edit("changed while idle");
+ const idle = yield* catalog.enable({ installationId }).pipe(Effect.flip);
+ expect(idle.reason).toBe("consent-required");
+ const [afterIdle] = (yield* catalog.list).installations;
+ expect(pluginInstallationStatus(afterIdle!)).toBe("needs-consent");
+ expect(afterIdle!.enabled).toBe(false);
+
+ // Changed while its process runs: enabling again stops it.
+ yield* approve(afterIdle!.source!.digest);
+ const pid = pidOf(yield* catalog.invoke(installationId, "ping", null));
+ yield* plugin.edit("changed while running");
+ const running = yield* catalog.enable({ installationId }).pipe(Effect.flip);
+ expect(running.reason).toBe("consent-required");
+ expect(isProcessAlive(pid)).toBe(false);
+ const [afterRunning] = (yield* catalog.list).installations;
+ expect(pluginInstallationStatus(afterRunning!)).toBe("needs-consent");
+ expect(afterRunning!.hostState).toBeUndefined();
+ }),
+ ),
+ );
+
+ it.effect("runs one directory per plugin id at a time", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const catalog = yield* startCatalog(yield* Scope.Scope);
+ const approve = Effect.fn(function* (directory: string) {
+ const { installation } = yield* catalog.add({ directory });
+ const installationId = installation.installationId;
+ yield* catalog.consent({ installationId, digest: installation.source!.digest });
+ return installationId;
+ });
+ const first = yield* approve((yield* preparePlugin("test.same")).directory);
+ const second = yield* approve((yield* preparePlugin("test.same")).directory);
+
+ yield* catalog.enable({ installationId: first });
+ const conflict = yield* catalog.enable({ installationId: second }).pipe(Effect.flip);
+ expect(conflict.reason).toBe("plugin-id-conflict");
+ yield* catalog.disable({ installationId: first });
+ const { installation } = yield* catalog.enable({ installationId: second });
+ expect(installation.enabled).toBe(true);
+ }),
+ ),
+ );
+
+ it.effect("keeps disable and remove available when the directory is gone", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const catalog = yield* startCatalog(yield* Scope.Scope);
+ const plugin = yield* preparePlugin("test.gone");
+ const { installation } = yield* catalog.add({ directory: plugin.directory });
+ const installationId = installation.installationId;
+ yield* catalog.consent({ installationId, digest: installation.source!.digest });
+ yield* catalog.enable({ installationId });
+
+ yield* fs.remove(plugin.directory, { recursive: true });
+ const [missing] = (yield* catalog.refresh({})).installations;
+ expect(pluginInstallationStatus(missing!)).toBe("unavailable");
+ expect(missing).toMatchObject({ enabled: false, source: null });
+ expect(missing!.problem).toContain("does not exist");
+ expect(missing!.manifest?.id).toBe("test.gone");
+ expect((yield* catalog.enable({ installationId }).pipe(Effect.flip)).reason).toBe(
+ "unavailable",
+ );
+ expect((yield* catalog.disable({ installationId })).installation.enabled).toBe(false);
+ yield* catalog.remove({ installationId });
+ const unknown = yield* catalog
+ .disable({ installationId: PluginInstallationId.make("missing") })
+ .pipe(Effect.flip);
+ expect(unknown.reason).toBe("not-found");
+ }),
+ ),
+ );
+ });
+
+ describe("calls racing management", () => {
+ it.effect("fails a call whose installation is replaced while its bytes are checked", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const stub = yield* makeStubSupervisor;
+ const files = yield* makeHoldingFileSystem;
+ const catalog = yield* startStubCatalog(
+ yield* Scope.Scope,
+ stub.service,
+ files.fileSystem,
+ );
+ const approve = Effect.fn(function* (directory: string) {
+ const { installation } = yield* catalog.add({ directory });
+ yield* catalog.consent({
+ installationId: installation.installationId,
+ digest: installation.source!.digest,
+ });
+ return installation;
+ });
+ const first = yield* approve((yield* preparePlugin("test.same")).directory);
+ const second = yield* approve((yield* preparePlugin("test.same")).directory);
+ yield* catalog.enable({ installationId: first.installationId });
+
+ // The call stops in its byte check, before a fresh process would start.
+ const hold = yield* files.holdDirectory(first.directory);
+ const call = yield* catalog
+ .invoke(first.installationId, "ping", null)
+ .pipe(Effect.forkChild({ startImmediately: true }));
+ yield* Deferred.await(hold.reached);
+ yield* catalog.disable({ installationId: first.installationId });
+ yield* catalog.enable({ installationId: second.installationId });
+ yield* Deferred.succeed(hold.release, undefined);
+
+ const refused = yield* Fiber.join(call).pipe(Effect.flip);
+ expect(refused).toMatchObject({ _tag: "PluginCatalogError", reason: "unavailable" });
+ expect(stub.invoked).toEqual([]);
+ expect(yield* catalog.invoke(second.installationId, "ping", null)).toBe(second.directory);
+ }),
+ ),
+ );
+
+ it.effect(
+ "fails a call when its installation is enabled again or names an old generation",
+ () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const stub = yield* makeStubSupervisor;
+ const files = yield* makeHoldingFileSystem;
+ const catalog = yield* startStubCatalog(
+ yield* Scope.Scope,
+ stub.service,
+ files.fileSystem,
+ );
+ const plugin = yield* preparePlugin("test.generation");
+ const { installation } = yield* catalog.add({ directory: plugin.directory });
+ const installationId = installation.installationId;
+ yield* catalog.consent({ installationId, digest: installation.source!.digest });
+ const { installation: enabled } = yield* catalog.enable({ installationId });
+
+ const hold = yield* files.holdDirectory(installation.directory);
+ const call = yield* catalog
+ .invoke(installationId, "ping", null)
+ .pipe(Effect.forkChild({ startImmediately: true }));
+ yield* Deferred.await(hold.reached);
+ yield* catalog.disable({ installationId });
+ const { installation: again } = yield* catalog.enable({ installationId });
+ yield* Deferred.succeed(hold.release, undefined);
+ expect(yield* Fiber.join(call).pipe(Effect.flip)).toMatchObject({
+ reason: "unavailable",
+ });
+ expect(stub.invoked).toEqual([]);
+
+ expect(again.generation).toBe(enabled.generation + 1);
+ const stale = yield* catalog
+ .invoke(installationId, "ping", null, { generation: enabled.generation })
+ .pipe(Effect.flip);
+ expect(stale).toMatchObject({ reason: "generation-changed" });
+ expect(stub.invoked).toEqual([]);
+ expect(
+ yield* catalog.invoke(installationId, "ping", null, { generation: again.generation }),
+ ).toBe(installation.directory);
+ }),
+ ),
+ );
+ });
+
+ describe("subscription", () => {
+ it.effect("sends a snapshot only when a step changed what it shows", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const stub = yield* makeStubSupervisor;
+ const catalog = yield* startStubCatalog(yield* Scope.Scope, stub.service);
+ const plugin = yield* preparePlugin("test.quiet");
+ const { installation } = yield* catalog.add({ directory: plugin.directory });
+ const installationId = installation.installationId;
+ const digest = installation.source!.digest;
+ yield* catalog.consent({ installationId, digest });
+
+ const snapshots = yield* Queue.unbounded>();
+ yield* catalog.subscribe.pipe(
+ Stream.runForEach((snapshot) => Queue.offer(snapshots, snapshot.installations)),
+ Effect.forkScoped,
+ );
+ const [initial] = yield* Queue.take(snapshots);
+ expect(initial).toMatchObject({ enabled: false });
+
+ // Failures and steps that find nothing new.
+ yield* catalog.add({ directory: "relative" }).pipe(Effect.flip);
+ yield* catalog.add({ directory: plugin.directory }).pipe(Effect.flip);
+ yield* catalog
+ .consent({ installationId, digest: `sha256:${"0".repeat(64)}` })
+ .pipe(Effect.flip);
+ yield* catalog.resume({ installationId }).pipe(Effect.flip);
+ yield* catalog.refresh({});
+ yield* catalog.disable({ installationId });
+
+ yield* catalog.enable({ installationId });
+ const [enabled] = yield* Queue.take(snapshots);
+ expect(enabled).toMatchObject({ enabled: true, inspectedAt: initial!.inspectedAt });
+
+ yield* catalog.enable({ installationId });
+ yield* catalog.resume({ installationId });
+ yield* catalog.disable({ installationId });
+ const [disabled] = yield* Queue.take(snapshots);
+ expect(disabled).toMatchObject({ enabled: false });
+ }),
+ ),
+ );
+ });
+
+ describe("interrupted management", () => {
+ /** An approved, enabled installation in a catalogue that can be restarted on the same database. */
+ const enabledInStub = Effect.fn("enabledInStub")(function* () {
+ const stub = yield* makeStubSupervisor;
+ const before = yield* Scope.make();
+ const catalog = yield* startStubCatalog(before, stub.service);
+ const plugin = yield* preparePlugin("test.interrupted");
+ const { installation } = yield* catalog.add({ directory: plugin.directory });
+ const installationId = installation.installationId;
+ yield* catalog.consent({ installationId, digest: installation.source!.digest });
+ yield* catalog.enable({ installationId });
+ return { stub, before, catalog, installationId };
+ });
+
+ /** Starts the catalogue again and waits until its startup re-registration has run. */
+ const restart = Effect.fn("restart")(function* () {
+ const stub = yield* makeStubSupervisor;
+ const catalog = yield* startStubCatalog(yield* Scope.Scope, stub.service);
+ // Management steps queue behind startup, so this returns after it.
+ const { installations } = yield* catalog.refresh({});
+ return { stub, installations };
+ });
+
+ const storedRows = Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const rows = yield* sql<{ readonly record_json: string }>`
+ SELECT record_json FROM plugin_installations
+ `;
+ return rows.map((row) => JSON.parse(row.record_json) as { readonly enabled: boolean });
+ });
+
+ it.effect("keeps a disable whose caller left while the process stopped", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const { stub, before, catalog, installationId } = yield* enabledInStub();
+ const stopping = yield* makeHold;
+ stub.holds.disable = stopping;
+ const disabling = yield* catalog
+ .disable({ installationId })
+ .pipe(Effect.forkChild({ startImmediately: true }));
+ yield* Deferred.await(stopping.reached);
+ yield* Fiber.interrupt(disabling);
+
+ const [row] = (yield* catalog.list).installations;
+ expect(row).toMatchObject({ enabled: false });
+ expect(row!.hostState).toBeUndefined();
+ expect(yield* storedRows).toMatchObject([{ enabled: false }]);
+ expect(stub.registrations.size).toBe(0);
+ yield* Deferred.succeed(stopping.release, undefined);
+ yield* Scope.close(before, Exit.void);
+
+ const after = yield* restart();
+ expect(after.installations).toMatchObject([{ installationId, enabled: false }]);
+ expect(after.stub.registrations.size).toBe(0);
+ }),
+ ),
+ );
+
+ it.effect("makes a repeated disable wait for the interrupted stop", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const { stub, catalog, installationId } = yield* enabledInStub();
+ const stopping = yield* makeHold;
+ stub.holds.disable = stopping;
+ const disabling = yield* catalog
+ .disable({ installationId })
+ .pipe(Effect.forkChild({ startImmediately: true }));
+ yield* Deferred.await(stopping.reached);
+ yield* Fiber.interrupt(disabling);
+
+ const retry = yield* catalog
+ .disable({ installationId })
+ .pipe(Effect.forkChild({ startImmediately: true }));
+ yield* Effect.yieldNow;
+ expect(retry.pollUnsafe()).toBeUndefined();
+ expect(stub.registrations.size).toBe(0);
+
+ yield* Deferred.succeed(stopping.release, undefined);
+ const { installation } = yield* Fiber.join(retry);
+ expect(installation).toMatchObject({ enabled: false });
+ expect(installation.hostState).toBeUndefined();
+ expect(stub.registrations.size).toBe(0);
+ }),
+ ),
+ );
+
+ it.effect("keeps a remove whose caller left while the process stopped", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const { stub, before, catalog, installationId } = yield* enabledInStub();
+ const stopping = yield* makeHold;
+ stub.holds.disable = stopping;
+ const removing = yield* catalog
+ .remove({ installationId })
+ .pipe(Effect.forkChild({ startImmediately: true }));
+ yield* Deferred.await(stopping.reached);
+ yield* Fiber.interrupt(removing);
+
+ expect((yield* catalog.list).installations).toEqual([]);
+ expect(yield* storedRows).toEqual([]);
+ expect(stub.registrations.size).toBe(0);
+ yield* Deferred.succeed(stopping.release, undefined);
+ yield* Scope.close(before, Exit.void);
+
+ const after = yield* restart();
+ expect(after.installations).toEqual([]);
+ expect(after.stub.registrations.size).toBe(0);
+ }),
+ ),
+ );
+
+ it.effect("finishes an enable whose caller left during registration", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const { stub, before, catalog, installationId } = yield* enabledInStub();
+ yield* catalog.disable({ installationId });
+ const registering = yield* makeHold;
+ stub.holds.enable = registering;
+ const enabling = yield* catalog
+ .enable({ installationId })
+ .pipe(Effect.forkChild({ startImmediately: true }));
+ yield* Deferred.await(registering.reached);
+ const interrupting = yield* Fiber.interrupt(enabling).pipe(
+ Effect.forkChild({ startImmediately: true }),
+ );
+ yield* Deferred.succeed(registering.release, undefined);
+ yield* Fiber.join(interrupting);
+
+ // Registered, saved, and shown together: never one without the others.
+ const [row] = (yield* catalog.list).installations;
+ expect(row).toMatchObject({ enabled: true, generation: 2 });
+ expect(yield* storedRows).toMatchObject([{ enabled: true }]);
+ expect(stub.registrations.size).toBe(1);
+ yield* Scope.close(before, Exit.void);
+ }),
+ ),
+ );
+
+ it.effect("holds a call made during startup until the plugin is registered again", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const { before, installationId } = yield* enabledInStub();
+ yield* Scope.close(before, Exit.void);
+
+ const stub = yield* makeStubSupervisor;
+ const registering = yield* makeHold;
+ stub.holds.enable = registering;
+ const after = yield* startStubCatalog(yield* Scope.Scope, stub.service);
+ // Runs before the catalogue's finalizers, so a failing test can still close its scope.
+ yield* Effect.addFinalizer(() => Deferred.succeed(registering.release, undefined));
+ yield* Deferred.await(registering.reached);
+ const calling = yield* after
+ .invoke(installationId, "ping", null)
+ .pipe(Effect.forkChild({ startImmediately: true }));
+ yield* Effect.yieldNow;
+ expect(calling.pollUnsafe()).toBeUndefined();
+
+ yield* Deferred.succeed(registering.release, undefined);
+ const [directory] = [...stub.registrations.values()].map((r) => r.directory);
+ expect(yield* Fiber.join(calling)).toBe(directory);
+ expect(stub.invoked).toEqual([directory]);
+ }),
+ ),
+ );
+
+ it.effect("keeps a remove made as the catalogue starts", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const { before, installationId } = yield* enabledInStub();
+ yield* Scope.close(before, Exit.void);
+
+ const stub = yield* makeStubSupervisor;
+ const after = yield* startStubCatalog(yield* Scope.Scope, stub.service);
+ yield* after.remove({ installationId });
+ // A call waits for startup to finish restoring.
+ yield* after.invoke(installationId, "ping", null).pipe(Effect.flip);
+
+ expect((yield* after.list).installations).toEqual([]);
+ expect(yield* storedRows).toEqual([]);
+ expect(stub.registrations.size).toBe(0);
+ }),
+ ),
+ );
+
+ it.effect("keeps an enable made as the catalogue starts", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const { before, installationId } = yield* enabledInStub();
+ yield* Scope.close(before, Exit.void);
+
+ const stub = yield* makeStubSupervisor;
+ const after = yield* startStubCatalog(yield* Scope.Scope, stub.service);
+ yield* after.enable({ installationId });
+ const [directory] = [...stub.registrations.values()].map((r) => r.directory);
+ expect(yield* after.invoke(installationId, "ping", null)).toBe(directory);
+
+ const [row] = (yield* after.list).installations;
+ expect(row).toMatchObject({ enabled: true, generation: 2 });
+ expect(stub.registrations.size).toBe(1);
+ }),
+ ),
+ );
+ });
+
+ describe("server restart", () => {
+ it.effect("re-enables approved plugins without starting them and drops changed ones", () =>
+ withDatabase(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const kept = yield* preparePlugin("test.kept");
+ const changed = yield* preparePlugin("test.restart-changed");
+
+ const before = yield* Scope.make();
+ const first = yield* startCatalog(before);
+ const ids = yield* Effect.forEach([kept, changed], (plugin) =>
+ Effect.gen(function* () {
+ const { installation } = yield* first.add({ directory: plugin.directory });
+ const installationId = installation.installationId;
+ yield* first.consent({ installationId, digest: installation.source!.digest });
+ yield* first.enable({ installationId });
+ return installationId;
+ }),
+ );
+ const pid = pidOf(yield* first.invoke(ids[0]!, "ping", null));
+ yield* Scope.close(before, Exit.void);
+ expect(isProcessAlive(pid)).toBe(false);
+
+ yield* fs.remove(kept.marker);
+ yield* changed.edit("changed while the server was down");
+ const after = yield* startCatalog(yield* Scope.Scope);
+ const restarted = yield* awaitSnapshot(after, (rows) =>
+ rows.every((row) => row.hostState?._tag === "idle" || !row.enabled),
+ );
+ const keptRow = restarted.find((row) => row.installationId === ids[0]);
+ const changedRow = restarted.find((row) => row.installationId === ids[1]);
+ expect(keptRow).toMatchObject({ enabled: true, generation: 2 });
+ expect(pluginInstallationStatus(changedRow!)).toBe("needs-consent");
+ expect(changedRow!.enabled).toBe(false);
+ expect(yield* fs.exists(kept.marker)).toBe(false);
+
+ const restartedPid = pidOf(yield* after.invoke(ids[0]!, "ping", null));
+ expect(restartedPid).not.toBe(pid);
+ expect(yield* fs.exists(kept.marker)).toBe(true);
+ }),
+ ),
+ );
+ });
+});
diff --git a/apps/server/src/plugins/PluginCatalog.ts b/apps/server/src/plugins/PluginCatalog.ts
new file mode 100644
index 000000000000..c05b29dc975a
--- /dev/null
+++ b/apps/server/src/plugins/PluginCatalog.ts
@@ -0,0 +1,912 @@
+/**
+ * The environment's trusted local plugins: which directories were added, what
+ * their exact bytes were, who consented to which bytes, and which are enabled.
+ *
+ * Nothing in a plugin directory runs until its current digest has consent and
+ * the installation is enabled. Enabling registers it with the supervisor,
+ * which still starts no process until the first invoke. The bytes are checked
+ * again whenever they are about to matter: on add, refresh, consent, enable,
+ * server start, and before an invoke that would start a fresh process. A
+ * change found at any of those points disables the installation and leaves it
+ * needing consent; it is never re-enabled automatically.
+ *
+ * Plugins are trusted OS-user code. The digest pins what the user agreed to
+ * run, not what the directory's owner can do between checks.
+ */
+import {
+ PluginCatalogError,
+ PluginInstallation,
+ pluginInstallationStatus,
+ type PluginAddInput,
+ type PluginCatalogSnapshot,
+ type PluginConsentInput,
+ type PluginId,
+ type PluginInstallationInput,
+ type PluginInstallationManifest,
+ type PluginInstallationResult,
+ type PluginManifest,
+ type PluginRefreshInput,
+ type PluginRemoveResult,
+ type PluginSource,
+ PluginInstallationId,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Crypto from "effect/Crypto";
+import * as DateTime from "effect/DateTime";
+import * as Equal from "effect/Equal";
+import * as Deferred from "effect/Deferred";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+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 PubSub from "effect/PubSub";
+import * as Schema from "effect/Schema";
+import * as Semaphore from "effect/Semaphore";
+import * as Stream from "effect/Stream";
+import * as SqlClient from "effect/sql/SqlClient";
+
+import { PluginEventDelivery } from "./PluginEventDelivery.ts";
+import { loadPluginDirectory, type PluginRegistration } from "./PluginManifestLoader.ts";
+import {
+ defaultPluginSourceLimits,
+ digestPluginSource,
+ type PluginSourceLimits,
+} from "./pluginSource.ts";
+import { PluginSupervisor, type PluginInvokeError } from "./PluginSupervisor.ts";
+
+/** What is persisted: the wire record without the live process and delivery states. */
+const PluginInstallationRecord = PluginInstallation.mapFields(
+ ({ hostState: _hostState, eventDelivery: _eventDelivery, ...fields }) => fields,
+);
+type PluginInstallationRecord = typeof PluginInstallationRecord.Type;
+
+const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginInstallationRecord));
+const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(PluginInstallationRecord));
+
+/** One registration with the supervisor. A re-enable makes a new one; it is never mutated. */
+interface Registration {
+ readonly pluginId: PluginId;
+ readonly generation: number;
+}
+
+interface Installation {
+ record: PluginInstallationRecord;
+ /** The registration this installation runs under, while enabled. */
+ registered: Registration | undefined;
+ /** Waits for the process of the last registration revoked here to exit. */
+ stopping: Fiber.Fiber | undefined;
+}
+
+type Inspection =
+ | {
+ readonly _tag: "ok";
+ readonly registration: PluginRegistration;
+ readonly source: PluginSource;
+ }
+ | { readonly _tag: "failed"; readonly reason: string };
+
+/**
+ * How long a call waits for startup to re-register enabled plugins before it
+ * proceeds without them, matching the supervisor's default activation timeout.
+ */
+const STARTUP_RESTORE_WAIT = Duration.seconds(10);
+
+/** What one admission attempt of `invoke` found. */
+type Admission =
+ | { readonly _tag: "called"; readonly value: Schema.Json }
+ | { readonly _tag: "revoked" }
+ /** A fresh process would start: the bytes must be checked against `consented` first. */
+ | { readonly _tag: "check"; readonly consented: string };
+
+/** What clients see of a manifest; also the summary of a downloaded npm update under review. */
+export const summarizePluginManifest = (manifest: PluginManifest): PluginInstallationManifest => ({
+ id: manifest.id,
+ name: manifest.name,
+ version: manifest.version,
+ ...(manifest.description === undefined ? {} : { description: manifest.description }),
+ capabilities: manifest.capabilities,
+ proposedApi: manifest.proposedApi,
+ ...(manifest.tools === undefined || manifest.tools.length === 0 ? {} : { tools: manifest.tools }),
+ ...(manifest.settings === undefined ? {} : { settings: manifest.settings }),
+ ...(manifest.actions === undefined || manifest.actions.length === 0
+ ? {}
+ : { actions: manifest.actions }),
+});
+
+const catalogError = (
+ reason: string,
+ message: string,
+ installationId?: PluginInstallationId,
+): PluginCatalogError =>
+ new PluginCatalogError({
+ reason,
+ message,
+ ...(installationId === undefined ? {} : { installationId }),
+ });
+
+const storageError = (cause: unknown) =>
+ Effect.logWarning("Plugin catalogue storage failed", { cause }).pipe(
+ Effect.andThen(Effect.fail(catalogError("storage", "Could not save the plugin catalogue."))),
+ );
+
+export class PluginCatalog extends Context.Service<
+ PluginCatalog,
+ {
+ readonly list: Effect.Effect;
+ /** Changes whenever `list` could show different installation records, so a reader can cache what it derives from them. */
+ readonly revision: Effect.Effect;
+ /** One snapshot now, then a fresh one after every catalogue or plugin state change. */
+ readonly subscribe: Stream.Stream;
+ readonly add: (
+ input: PluginAddInput,
+ ) => Effect.Effect;
+ readonly refresh: (
+ input: PluginRefreshInput,
+ ) => Effect.Effect;
+ readonly consent: (
+ input: PluginConsentInput,
+ ) => Effect.Effect;
+ readonly enable: (
+ input: PluginInstallationInput,
+ ) => Effect.Effect;
+ readonly disable: (
+ input: PluginInstallationInput,
+ ) => Effect.Effect;
+ readonly remove: (
+ input: PluginInstallationInput,
+ ) => Effect.Effect;
+ /** Clears the process's backoff, quarantine, or incompatibility, and restarts stopped event delivery. */
+ readonly resume: (
+ input: PluginInstallationInput,
+ ) => Effect.Effect;
+ /**
+ * Replaces the files in an installation's directory and consents to
+ * `digest`, as one management step: no other step, such as a disable from
+ * another client, runs in between, and the installation stays revoked
+ * throughout. The files are claimed first (see `changeFiles`), with
+ * `paths` naming everywhere `swap` and `restore` move files. `swap` puts
+ * the new files in place. If it fails, or the directory then does not hold
+ * `digest`, `restore` puts the old files back. Afterwards the installation
+ * is enabled again only if it was enabled and the bytes on disk have
+ * consent: the new ones, or the old ones still.
+ */
+ readonly replace: (
+ input: PluginConsentInput,
+ files: {
+ readonly paths: ReadonlyArray;
+ readonly swap: Effect.Effect;
+ readonly restore: Effect.Effect;
+ },
+ ) => Effect.Effect;
+ /**
+ * Settles a `replace` that was cut short, by a crash or a failed `restore`,
+ * as one management step. If the installation has consent to `digest`, the
+ * replacement committed and nothing changes. Otherwise `files.restore`,
+ * when given, puts the old files back after claiming `files.paths` (see
+ * `changeFiles`), and the files are inspected again. The installation
+ * stays disabled, and consent is never granted.
+ */
+ readonly settleReplace: (
+ input: PluginConsentInput,
+ files?: {
+ readonly paths: ReadonlyArray;
+ readonly restore: Effect.Effect;
+ },
+ ) => Effect.Effect<"committed" | "rolled-back", PluginCatalogError>;
+ /**
+ * Runs `effect`, which moves or deletes files under `paths`, as one
+ * management step. It fails `already-added`, and touches nothing, if any
+ * installation is rooted in or around one of the paths. Otherwise every
+ * process that ran from files there has exited before `effect` starts,
+ * including one whose disable or remove was cut short.
+ */
+ readonly changeFiles: (
+ paths: ReadonlyArray,
+ effect: Effect.Effect,
+ ) => Effect.Effect;
+ /**
+ * Calls a handler of an enabled installation. A call that would start a
+ * fresh process first checks the bytes still match the consent. Pass the
+ * `generation` the caller saw to refuse a call that would reach a later
+ * registration (`generation-changed`). A call whose installation is
+ * disabled, removed, or re-registered before the supervisor takes it
+ * fails; it never follows the plugin id to a replacement.
+ */
+ readonly invoke: (
+ installationId: PluginInstallationId,
+ handler: string,
+ input: Schema.Json,
+ options?: { readonly timeout?: Duration.Input; readonly generation?: number },
+ ) => Effect.Effect;
+ }
+>()("t3/plugins/PluginCatalog") {}
+
+export const make = Effect.fn("PluginCatalog.make")(function* (
+ sourceLimits: PluginSourceLimits = defaultPluginSourceLimits,
+) {
+ const sql = yield* SqlClient.SqlClient;
+ const supervisor = yield* PluginSupervisor;
+ const crypto = yield* Crypto.Crypto;
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const scope = yield* Effect.scope;
+ const eventDeliveries = yield* Effect.serviceOption(PluginEventDelivery);
+
+ const installations = new Map();
+ // Management is rare and each step may wait for a process to exit; one at a time keeps the
+ // catalogue, the table, and the supervisor in step.
+ const lock = yield* Semaphore.make(1);
+ /**
+ * Processes still exiting after their registration was revoked, by the
+ * directory they ran from. Kept apart from `registered`, since a disable
+ * or remove whose caller went away no longer waits for its process.
+ */
+ const exiting = new Map, string>();
+ const changes = yield* PubSub.sliding(1);
+ const notify = PubSub.publish(changes, undefined).pipe(Effect.asVoid);
+ // Counts changes a snapshot can show, so a step that changed nothing tells no one.
+ let revision = 0;
+ // Completes when startup has tried to re-register every enabled installation.
+ const restored = yield* Deferred.make();
+
+ const now = DateTime.now.pipe(Effect.map(DateTime.formatIso));
+
+ const save = (record: PluginInstallationRecord) =>
+ encodeRecord(record).pipe(
+ Effect.flatMap(
+ (json) => sql`
+ INSERT INTO plugin_installations (installation_id, directory, record_json)
+ VALUES (${record.installationId}, ${record.directory}, ${json})
+ ON CONFLICT (installation_id) DO UPDATE SET
+ directory = excluded.directory,
+ record_json = excluded.record_json
+ `,
+ ),
+ Effect.asVoid,
+ Effect.catch(storageError),
+ );
+
+ /** Saves `record` as enabled, with the event cursor its capabilities need, or neither. */
+ const saveEnabled = (record: PluginInstallationRecord, capabilities: ReadonlyArray) =>
+ Option.match(eventDeliveries, {
+ onNone: () => save(record),
+ onSome: (delivery) =>
+ sql
+ .withTransaction(
+ delivery
+ .begin(record.installationId, capabilities)
+ .pipe(Effect.catch(storageError), Effect.andThen(save(record))),
+ )
+ .pipe(Effect.catchTags({ SqlError: storageError })),
+ });
+
+ /** Saves `record` and then shows it, or neither. */
+ const commit = (installation: Installation, record: PluginInstallationRecord) =>
+ save(record).pipe(
+ Effect.andThen(
+ Effect.sync(() => {
+ installation.record = record;
+ revision++;
+ }),
+ ),
+ Effect.uninterruptible,
+ );
+
+ const inspect = (directory: string): Effect.Effect =>
+ loadPluginDirectory(directory).pipe(
+ Effect.flatMap((registration) =>
+ digestPluginSource(registration.directory, sourceLimits).pipe(
+ Effect.map((source) => ({ _tag: "ok" as const, registration, source })),
+ ),
+ ),
+ Effect.catch((error) => Effect.succeed({ _tag: "failed" as const, reason: error.reason })),
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
+ Effect.provideService(Path.Path, path),
+ );
+
+ const isReady = (record: PluginInstallationRecord) =>
+ pluginInstallationStatus({ ...record, enabled: true }) === "enabled";
+
+ /**
+ * Revokes the installation's registration and returns the fiber that waits
+ * for its process to exit. The supervisor revokes as soon as `disable`
+ * starts, so this starts it at once; only the wait may be cut short. An
+ * installation already revoked returns its earlier stop, so a retry still
+ * waits for that process and never touches a replacement under the same id.
+ */
+ const unregister = Effect.fnUntraced(function* (installation: Installation) {
+ const registered = installation.registered;
+ if (registered === undefined) return installation.stopping;
+ installation.registered = undefined;
+ revision++;
+ const stopping = yield* supervisor
+ .disable(registered.pluginId)
+ .pipe(Effect.forkIn(scope, { startImmediately: true }));
+ installation.stopping = stopping;
+ exiting.set(stopping, installation.record.directory);
+ stopping.addObserver(() => exiting.delete(stopping));
+ return stopping;
+ });
+
+ /**
+ * Saves `record` (normally with `enabled: false`), revokes the registration,
+ * and waits for the process to exit. The row is written first and
+ * everything but the wait finishes even if the caller goes away, so a
+ * disable that was cut short never comes back enabled at the next start. A
+ * failed save changes nothing.
+ */
+ const revoke = (installation: Installation, record: PluginInstallationRecord) =>
+ Effect.uninterruptibleMask((restore) =>
+ Effect.gen(function* () {
+ if (record !== installation.record) yield* commit(installation, record);
+ const stopping = yield* unregister(installation);
+ if (stopping) yield* restore(Fiber.join(stopping));
+ }),
+ );
+
+ /**
+ * Inspects the directory again and records the result. An enabled
+ * installation whose bytes no longer match its consent is stopped and
+ * disabled.
+ */
+ const reinspect = Effect.fnUntraced(function* (installation: Installation) {
+ const inspection = yield* inspect(installation.record.directory);
+ const current = installation.record;
+ const found =
+ inspection._tag === "ok"
+ ? {
+ manifest: summarizePluginManifest(inspection.registration.manifest),
+ source: inspection.source,
+ problem: null,
+ }
+ : { manifest: current.manifest, source: null, problem: inspection.reason };
+ // The same result keeps the record as it was, `inspectedAt` included.
+ if (
+ Equal.equals(found, {
+ manifest: current.manifest,
+ source: current.source,
+ problem: current.problem,
+ })
+ )
+ return inspection;
+ const record: PluginInstallationRecord = { ...current, ...found, inspectedAt: yield* now };
+ if (record.enabled && !isReady(record)) {
+ yield* Effect.logWarning("Plugin source changed; disabling until consent is renewed", {
+ installationId: record.installationId,
+ directory: record.directory,
+ });
+ yield* revoke(installation, { ...record, enabled: false });
+ } else {
+ yield* commit(installation, record);
+ }
+ return inspection;
+ });
+
+ /**
+ * Registers a ready installation with the supervisor under a new generation
+ * and saves it as enabled. Both happen or neither, even if the caller is
+ * interrupted.
+ */
+ const register = Effect.fnUntraced(function* (
+ installation: Installation,
+ registration: PluginRegistration,
+ ) {
+ const pluginId = registration.manifest.id;
+ const holder = [...installations.values()].find(
+ (other) => other !== installation && other.registered?.pluginId === pluginId,
+ );
+ if (holder)
+ return yield* catalogError(
+ "plugin-id-conflict",
+ `Another enabled plugin already uses the id ${pluginId} (${holder.record.directory}). Disable it first.`,
+ installation.record.installationId,
+ );
+ yield* supervisor
+ .enable({ ...registration, installationId: installation.record.installationId })
+ .pipe(
+ Effect.mapError(() =>
+ catalogError(
+ "plugin-id-conflict",
+ `A plugin with the id ${pluginId} is already running.`,
+ installation.record.installationId,
+ ),
+ ),
+ );
+ const record = {
+ ...installation.record,
+ enabled: true,
+ generation: installation.record.generation + 1,
+ };
+ yield* saveEnabled(record, registration.manifest.capabilities).pipe(
+ Effect.tapError(() => supervisor.disable(pluginId)),
+ );
+ // Together, so an invoke never sees the new registration with the old generation.
+ installation.record = record;
+ installation.registered = { pluginId, generation: record.generation };
+ revision++;
+ }, Effect.uninterruptible);
+
+ const find = (installationId: PluginInstallationId) =>
+ Effect.suspend(() => {
+ const installation = installations.get(installationId);
+ return installation
+ ? Effect.succeed(installation)
+ : Effect.fail(
+ catalogError("not-found", "That plugin is not installed here.", installationId),
+ );
+ });
+
+ const toWire = Effect.fnUntraced(function* (installation: Installation) {
+ const registered = installation.registered;
+ if (registered === undefined) return installation.record;
+ const hostState = yield* supervisor.state(registered.pluginId);
+ const eventDelivery = Option.isSome(eventDeliveries)
+ ? yield* eventDeliveries.value.state(
+ installation.record.installationId,
+ registered.generation,
+ )
+ : Option.none();
+ const wire: PluginInstallation = {
+ ...installation.record,
+ ...(Option.isSome(hostState) ? { hostState: hostState.value } : {}),
+ ...(Option.isSome(eventDelivery) ? { eventDelivery: eventDelivery.value } : {}),
+ };
+ return wire;
+ });
+
+ const list = Effect.suspend(() =>
+ Effect.forEach(
+ [...installations.values()].sort(
+ (a, b) =>
+ a.record.addedAt.localeCompare(b.record.addedAt) ||
+ a.record.installationId.localeCompare(b.record.installationId),
+ ),
+ toWire,
+ ),
+ ).pipe(Effect.map((installations) => ({ installations })));
+
+ const result = (installation: Installation) =>
+ toWire(installation).pipe(Effect.map((wire) => ({ installation: wire })));
+
+ /** Runs a management step under the lock and tells subscribers if it changed anything, even when it then failed. */
+ const managed = (effect: Effect.Effect) =>
+ lock.withPermit(
+ Effect.suspend(() => {
+ const before = revision;
+ return effect.pipe(
+ Effect.ensuring(Effect.suspend(() => (revision === before ? Effect.void : notify))),
+ );
+ }),
+ );
+
+ const add = Effect.fn("PluginCatalog.add")(function* (input: PluginAddInput) {
+ if (!path.isAbsolute(input.directory))
+ return yield* catalogError(
+ "invalid-directory",
+ "Enter the plugin directory's absolute path on the server's machine.",
+ );
+ const inspection = yield* inspect(input.directory);
+ if (inspection._tag === "failed")
+ return yield* catalogError("invalid-directory", inspection.reason);
+ const directory = inspection.registration.directory;
+ const existing = [...installations.values()].find(
+ (installation) => installation.record.directory === directory,
+ );
+ if (existing)
+ return yield* catalogError(
+ "already-added",
+ `${directory} is already installed.`,
+ existing.record.installationId,
+ );
+ const installationId = PluginInstallationId.make(yield* crypto.randomUUIDv4.pipe(Effect.orDie));
+ const at = yield* now;
+ const record: PluginInstallationRecord = {
+ installationId,
+ generation: 0,
+ directory,
+ manifest: summarizePluginManifest(inspection.registration.manifest),
+ source: inspection.source,
+ problem: null,
+ inspectedAt: at,
+ consent: null,
+ enabled: false,
+ addedAt: at,
+ };
+ const installation: Installation = { record, registered: undefined, stopping: undefined };
+ yield* save(record).pipe(
+ Effect.andThen(
+ Effect.sync(() => {
+ installations.set(installationId, installation);
+ revision++;
+ }),
+ ),
+ Effect.uninterruptible,
+ );
+ return yield* result(installation);
+ });
+
+ const refresh = Effect.fn("PluginCatalog.refresh")(function* (input: PluginRefreshInput) {
+ const targets =
+ input.installationId === undefined
+ ? [...installations.values()]
+ : [yield* find(input.installationId)];
+ yield* Effect.forEach(targets, reinspect, { discard: true });
+ return yield* list;
+ });
+
+ const consent = Effect.fn("PluginCatalog.consent")(function* (input: PluginConsentInput) {
+ const installation = yield* find(input.installationId);
+ const inspection = yield* reinspect(installation);
+ if (inspection._tag === "failed")
+ return yield* catalogError("unavailable", inspection.reason, input.installationId);
+ if (inspection.source.digest !== input.digest)
+ return yield* catalogError(
+ "source-changed",
+ "The plugin's files changed after they were reviewed. Review the current version.",
+ input.installationId,
+ );
+ yield* commit(installation, {
+ ...installation.record,
+ consent: {
+ digest: inspection.source.digest,
+ capabilities: inspection.registration.manifest.capabilities,
+ grantedAt: yield* now,
+ },
+ });
+ return yield* result(installation);
+ });
+
+ const enable = Effect.fn("PluginCatalog.enable")(function* (input: PluginInstallationInput) {
+ const installation = yield* find(input.installationId);
+ // Checked even when already enabled: changed bytes stop it and need consent again.
+ const inspection = yield* reinspect(installation);
+ if (inspection._tag === "failed")
+ return yield* catalogError("unavailable", inspection.reason, input.installationId);
+ if (!isReady(installation.record))
+ return yield* catalogError(
+ "consent-required",
+ installation.record.consent === null
+ ? "Review and approve the plugin before enabling it."
+ : "The plugin's files changed since they were approved. Review the current version.",
+ input.installationId,
+ );
+ // Already enabled with these bytes: keep the registration and its generation.
+ if (installation.registered !== undefined) return yield* result(installation);
+ yield* register(installation, inspection.registration);
+ return yield* result(installation);
+ });
+
+ const disableInstallation = (installation: Installation) =>
+ revoke(
+ installation,
+ installation.record.enabled
+ ? { ...installation.record, enabled: false }
+ : installation.record,
+ );
+
+ const overlaps = (a: string, b: string) =>
+ a === b || a.startsWith(`${b}${path.sep}`) || b.startsWith(`${a}${path.sep}`);
+
+ /**
+ * The one rule for moving or deleting files under `paths`, run inside a
+ * management step: no installation but `owner` may be rooted in or around
+ * them, `owner` is disabled, and every process that ran from files there
+ * has exited before this returns.
+ */
+ const claimFiles = Effect.fnUntraced(function* (
+ paths: ReadonlyArray,
+ owner?: Installation,
+ ) {
+ const other = [...installations.values()].find(
+ (installation) =>
+ installation !== owner &&
+ paths.some((target) => overlaps(target, installation.record.directory)),
+ );
+ if (other)
+ return yield* catalogError(
+ "already-added",
+ `${other.record.directory} is installed as a plugin, so its files were left in place.`,
+ other.record.installationId,
+ );
+ if (owner) yield* disableInstallation(owner);
+ yield* Effect.forEach(
+ [...exiting].filter(([, directory]) => paths.some((target) => overlaps(target, directory))),
+ ([stopping]) => Fiber.await(stopping),
+ { discard: true },
+ );
+ });
+
+ const disable = Effect.fn("PluginCatalog.disable")(function* (input: PluginInstallationInput) {
+ const installation = yield* find(input.installationId);
+ yield* disableInstallation(installation);
+ return yield* result(installation);
+ });
+
+ const remove = Effect.fn("PluginCatalog.remove")(function* (input: PluginInstallationInput) {
+ const installation = yield* find(input.installationId);
+ // Forgotten durably first, like disable, so an interrupted remove does not come back.
+ yield* Effect.uninterruptibleMask((restore) =>
+ Effect.gen(function* () {
+ yield* sql`DELETE FROM plugin_installations WHERE installation_id = ${input.installationId}`.pipe(
+ Effect.catch(storageError),
+ );
+ installations.delete(input.installationId);
+ revision++;
+ const stopping = yield* unregister(installation);
+ if (stopping) yield* restore(Fiber.join(stopping));
+ }),
+ );
+ return { installationId: input.installationId };
+ });
+
+ const replace = Effect.fn("PluginCatalog.replace")(function* (
+ input: PluginConsentInput,
+ files: Parameters[1],
+ ) {
+ const installation = yield* find(input.installationId);
+ const wasEnabled = installation.record.enabled;
+ yield* claimFiles([installation.record.directory, ...files.paths], installation);
+ const enableAgain = (inspection: Inspection) =>
+ wasEnabled && inspection._tag === "ok" && isReady(installation.record)
+ ? register(installation, inspection.registration)
+ : Effect.void;
+ const inspection = yield* files.swap.pipe(
+ Effect.andThen(reinspect(installation)),
+ Effect.filterOrFail(
+ (inspection): inspection is Extract =>
+ inspection._tag === "ok" && inspection.source.digest === input.digest,
+ () =>
+ catalogError(
+ "source-changed",
+ "The new files changed after they were reviewed, so the old ones were put back.",
+ input.installationId,
+ ),
+ ),
+ Effect.tap((inspection) =>
+ Effect.gen(function* () {
+ yield* commit(installation, {
+ ...installation.record,
+ consent: {
+ digest: inspection.source.digest,
+ capabilities: inspection.registration.manifest.capabilities,
+ grantedAt: yield* now,
+ },
+ });
+ }),
+ ),
+ Effect.tapError(() =>
+ files.restore.pipe(
+ Effect.andThen(reinspect(installation)),
+ Effect.flatMap(enableAgain),
+ Effect.catch((error) =>
+ Effect.logWarning("Could not put a plugin's old files back", {
+ installationId: input.installationId,
+ detail: error.message,
+ }),
+ ),
+ ),
+ ),
+ );
+ yield* enableAgain(inspection);
+ return yield* result(installation);
+ }, Effect.uninterruptible);
+
+ const settleReplace = Effect.fn("PluginCatalog.settleReplace")(function* (
+ input: PluginConsentInput,
+ files: Parameters[1],
+ ) {
+ const installation = yield* find(input.installationId);
+ if (installation.record.consent?.digest === input.digest) return "committed" as const;
+ if (files !== undefined) {
+ yield* claimFiles([installation.record.directory, ...files.paths], installation);
+ yield* files.restore;
+ yield* reinspect(installation);
+ }
+ return "rolled-back" as const;
+ }, Effect.uninterruptible);
+
+ const changeFiles = (paths: ReadonlyArray, effect: Effect.Effect) =>
+ claimFiles(paths).pipe(Effect.andThen(effect), Effect.uninterruptible);
+
+ const resume = Effect.fn("PluginCatalog.resume")(function* (input: PluginInstallationInput) {
+ const installation = yield* find(input.installationId);
+ if (installation.registered === undefined)
+ return yield* catalogError("unavailable", "The plugin is not enabled.", input.installationId);
+ yield* supervisor.resume(installation.registered.pluginId).pipe(Effect.ignore);
+ if (Option.isSome(eventDeliveries)) yield* eventDeliveries.value.resume(input.installationId);
+ return yield* result(installation);
+ });
+
+ /**
+ * Re-checks that `registered` is still this installation's registration and, unless the call
+ * would start a fresh process with unchecked bytes, hands the call to the supervisor. It runs
+ * in a fiber that starts synchronously, so no management step can revoke or replace the
+ * registration between the check and the supervisor admitting the call.
+ */
+ const admit = (
+ installation: Installation,
+ registered: Registration,
+ verified: string | undefined,
+ call: (pluginId: PluginId) => Effect.Effect,
+ ) =>
+ Effect.uninterruptibleMask((restore) =>
+ Effect.forkChild(
+ Effect.suspend((): Effect.Effect => {
+ if (
+ installations.get(installation.record.installationId) !== installation ||
+ installation.registered !== registered ||
+ installation.record.consent === null
+ )
+ return Effect.succeed({ _tag: "revoked" });
+ const consented = installation.record.consent.digest;
+ return supervisor
+ .state(registered.pluginId)
+ .pipe(
+ Effect.flatMap((state): Effect.Effect =>
+ Option.isSome(state) && state.value._tag === "idle" && verified !== consented
+ ? Effect.succeed({ _tag: "check", consented })
+ : call(registered.pluginId).pipe(
+ Effect.map((value) => ({ _tag: "called", value })),
+ ),
+ ),
+ );
+ }),
+ { startImmediately: true },
+ ).pipe(
+ Effect.flatMap((fiber) =>
+ restore(Fiber.join(fiber)).pipe(Effect.onInterrupt(() => Fiber.interrupt(fiber))),
+ ),
+ ),
+ );
+
+ const invoke: PluginCatalog["Service"]["invoke"] = Effect.fn("PluginCatalog.invoke")(
+ function* (installationId, handler, input, options) {
+ // A call right after a restart waits for its installation to be registered again.
+ yield* Deferred.await(restored).pipe(Effect.timeoutOption(STARTUP_RESTORE_WAIT));
+ const installation = yield* find(installationId);
+ const registered = installation.registered;
+ if (registered === undefined)
+ return yield* catalogError("unavailable", "The plugin is not enabled.", installationId);
+ if (options?.generation !== undefined && options.generation !== registered.generation)
+ return yield* catalogError(
+ "generation-changed",
+ "The plugin was enabled again since this call was prepared.",
+ installationId,
+ );
+ const call = (pluginId: PluginId) =>
+ supervisor.invoke(
+ pluginId,
+ handler,
+ input,
+ options?.timeout === undefined ? undefined : { timeout: options.timeout },
+ );
+ let verified: string | undefined;
+ while (true) {
+ const admission = yield* admit(installation, registered, verified, call);
+ if (admission._tag === "called") return admission.value;
+ if (admission._tag === "revoked")
+ return yield* catalogError(
+ "unavailable",
+ "The plugin was disabled or replaced before the call started.",
+ installationId,
+ );
+ const inspection = yield* inspect(installation.record.directory);
+ if (inspection._tag === "ok" && inspection.source.digest === admission.consented) {
+ verified = admission.consented;
+ continue;
+ }
+ // Skip the record if it was disabled or removed meanwhile, so it is not written back.
+ yield* managed(
+ Effect.suspend(() =>
+ installations.get(installationId) === installation &&
+ installation.registered === registered
+ ? reinspect(installation)
+ : Effect.void,
+ ),
+ );
+ return yield* catalogError(
+ "source-changed",
+ "The plugin's files changed since they were approved, so it was disabled.",
+ installationId,
+ );
+ }
+ },
+ );
+
+ // Load what was installed before this start.
+ const rows = yield* sql<{ readonly record_json: string }>`
+ SELECT record_json FROM plugin_installations
+ `.pipe(Effect.orDie);
+ for (const row of rows) {
+ const decoded = yield* decodeRecord(row.record_json).pipe(Effect.option);
+ if (Option.isNone(decoded)) {
+ yield* Effect.logWarning("Skipping an unreadable plugin installation row");
+ continue;
+ }
+ installations.set(decoded.value.installationId, {
+ record: decoded.value,
+ registered: undefined,
+ stopping: undefined,
+ });
+ }
+
+ // Plugin state changes reach subscribers as fresh snapshots.
+ const supervisorEvents = yield* supervisor.subscribe;
+ yield* Stream.fromSubscription(supervisorEvents).pipe(
+ Stream.filter((event) => event._tag === "StateChanged"),
+ Stream.runForEach(() => notify),
+ Effect.forkScoped,
+ );
+
+ // So do changes of event delivery state.
+ if (Option.isSome(eventDeliveries)) {
+ const deliveryChanges = yield* eventDeliveries.value.changes;
+ yield* Stream.fromSubscription(deliveryChanges).pipe(
+ Stream.runForEach(() => notify),
+ Effect.forkScoped,
+ );
+ }
+
+ // Re-register what was enabled, off the startup path. Changed bytes are disabled here;
+ // nothing starts a process until it is used. Starting at once takes the lock before this
+ // returns, so no management step can act on an installation still waiting to be restored.
+ yield* managed(
+ Effect.forEach(
+ [...installations.values()].filter((installation) => installation.record.enabled),
+ (installation) =>
+ reinspect(installation).pipe(
+ Effect.flatMap((inspection) =>
+ inspection._tag === "ok" && installation.record.enabled
+ ? register(installation, inspection.registration)
+ : Effect.void,
+ ),
+ Effect.catch((error) =>
+ Effect.logWarning("Could not re-enable a plugin at startup", {
+ installationId: installation.record.installationId,
+ detail: error.message,
+ }).pipe(Effect.andThen(disableInstallation(installation).pipe(Effect.ignore))),
+ ),
+ ),
+ { discard: true },
+ ),
+ ).pipe(
+ Effect.ensuring(Deferred.succeed(restored, undefined)),
+ Effect.forkIn(scope, { startImmediately: true }),
+ );
+
+ return PluginCatalog.of({
+ list,
+ revision: Effect.sync(() => revision),
+ subscribe: Stream.unwrap(
+ // Subscribe before the first snapshot so a change in between is not lost.
+ PubSub.subscribe(changes).pipe(
+ Effect.map((subscription) =>
+ Stream.concat(
+ Stream.fromEffect(list),
+ Stream.fromSubscription(subscription).pipe(Stream.mapEffect(() => list)),
+ ).pipe(
+ // A plugin state event and the step that caused it can describe the same snapshot.
+ Stream.changes,
+ ),
+ ),
+ ),
+ ),
+ add: (input) => managed(add(input)),
+ refresh: (input) => managed(refresh(input)),
+ consent: (input) => managed(consent(input)),
+ enable: (input) => managed(enable(input)),
+ disable: (input) => managed(disable(input)),
+ remove: (input) => managed(remove(input)),
+ resume: (input) => managed(resume(input)),
+ replace: (input, files) => managed(replace(input, files)),
+ settleReplace: (input, files) => managed(settleReplace(input, files)),
+ changeFiles: (paths, effect) => managed(changeFiles(paths, effect)),
+ invoke,
+ });
+});
+
+export const layer = (sourceLimits?: PluginSourceLimits) =>
+ Layer.effect(PluginCatalog, make(sourceLimits));
diff --git a/apps/server/src/plugins/PluginCatalogRpc.test.ts b/apps/server/src/plugins/PluginCatalogRpc.test.ts
new file mode 100644
index 000000000000..bd46da4c1881
--- /dev/null
+++ b/apps/server/src/plugins/PluginCatalogRpc.test.ts
@@ -0,0 +1,148 @@
+import {
+ AuthAccessWriteScope,
+ AuthAdministrativeScopes,
+ type AuthEnvironmentScope,
+ AuthOrchestrationReadScope,
+ AuthRelayReadScope,
+ AuthStandardClientScopes,
+ PluginCatalogError,
+ PluginInstallationId,
+ WS_METHODS,
+ WsRpcGroup,
+} from "@t3tools/contracts";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Stream from "effect/Stream";
+import * as RpcTest from "effect/rpc/RpcTest";
+
+import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts";
+import * as RpcAuthorization from "../auth/RpcAuthorization.ts";
+
+const reads = [WS_METHODS.pluginsList, WS_METHODS.pluginsSubscribe] as const;
+const writes = [
+ WS_METHODS.pluginsAdd,
+ WS_METHODS.pluginsRefresh,
+ WS_METHODS.pluginsConsent,
+ WS_METHODS.pluginsEnable,
+ WS_METHODS.pluginsDisable,
+ WS_METHODS.pluginsRemove,
+ WS_METHODS.pluginsResume,
+] as const;
+type PluginMethod = (typeof reads)[number] | (typeof writes)[number];
+const pluginMethods: ReadonlySet = new Set([...reads, ...writes]);
+
+const group = WsRpcGroup.omit(
+ ...[...WsRpcGroup.requests.keys()].filter(
+ (tag): tag is Exclude =>
+ !pluginMethods.has(tag),
+ ),
+);
+
+const installationId = PluginInstallationId.make("fixture");
+const digest = `sha256:${"0".repeat(64)}`;
+
+/** Serves the plugin RPCs through the real scope middleware; handlers record that they ran. */
+const makeClient = (scopes: ReadonlyArray, handled: Array) => {
+ // Mutations answer with a catalogue error: reaching it proves the middleware let the call in.
+ const mutation = (method: string) => () =>
+ Effect.sync(() => handled.push(method)).pipe(
+ Effect.andThen(
+ Effect.fail(new PluginCatalogError({ reason: "not-found", message: "fixture" })),
+ ),
+ );
+ return RpcTest.makeClient(group).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ group.toLayerHandler(WS_METHODS.pluginsList, () =>
+ Effect.sync(() => handled.push(WS_METHODS.pluginsList)).pipe(
+ Effect.as({ installations: [] }),
+ ),
+ ),
+ group.toLayerHandler(WS_METHODS.pluginsSubscribe, () =>
+ Stream.fromEffect(
+ Effect.sync(() => handled.push(WS_METHODS.pluginsSubscribe)).pipe(
+ Effect.as({ installations: [] }),
+ ),
+ ),
+ ),
+ group.toLayerHandler(WS_METHODS.pluginsAdd, mutation(WS_METHODS.pluginsAdd)),
+ group.toLayerHandler(WS_METHODS.pluginsRefresh, mutation(WS_METHODS.pluginsRefresh)),
+ group.toLayerHandler(WS_METHODS.pluginsConsent, mutation(WS_METHODS.pluginsConsent)),
+ group.toLayerHandler(WS_METHODS.pluginsEnable, mutation(WS_METHODS.pluginsEnable)),
+ group.toLayerHandler(WS_METHODS.pluginsDisable, mutation(WS_METHODS.pluginsDisable)),
+ group.toLayerHandler(WS_METHODS.pluginsRemove, mutation(WS_METHODS.pluginsRemove)),
+ group.toLayerHandler(WS_METHODS.pluginsResume, mutation(WS_METHODS.pluginsResume)),
+ RpcAuthorization.layer(scopes),
+ ),
+ ),
+ );
+};
+
+/** Calls every management RPC and returns the tag each one failed with. */
+const callWrites = (client: Effect.Success>) =>
+ Effect.all([
+ client[WS_METHODS.pluginsAdd]({ directory: "/plugins/fixture" }).pipe(Effect.flip),
+ client[WS_METHODS.pluginsRefresh]({}).pipe(Effect.flip),
+ client[WS_METHODS.pluginsConsent]({ installationId, digest }).pipe(Effect.flip),
+ client[WS_METHODS.pluginsEnable]({ installationId }).pipe(Effect.flip),
+ client[WS_METHODS.pluginsDisable]({ installationId }).pipe(Effect.flip),
+ client[WS_METHODS.pluginsRemove]({ installationId }).pipe(Effect.flip),
+ client[WS_METHODS.pluginsResume]({ installationId }).pipe(Effect.flip),
+ ]);
+
+describe("plugin RPC scopes", () => {
+ it.effect("lets a standard pairing read the catalogue but not change what runs", () =>
+ Effect.gen(function* () {
+ const handled: Array = [];
+ const client = yield* makeClient(AuthStandardClientScopes, handled);
+
+ expect(yield* client[WS_METHODS.pluginsList]({})).toEqual({ installations: [] });
+ expect(
+ yield* client[WS_METHODS.pluginsSubscribe]({}).pipe(Stream.take(1), Stream.runCollect),
+ ).toEqual([{ installations: [] }]);
+
+ const failures = yield* callWrites(client);
+ for (const failure of failures) {
+ expect(failure).toMatchObject({
+ _tag: "EnvironmentAuthorizationError",
+ requiredScope: AuthAccessWriteScope,
+ requiredPermission: AuthAccessWriteScope,
+ });
+ }
+ expect(handled).toEqual([WS_METHODS.pluginsList, WS_METHODS.pluginsSubscribe]);
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("lets an administrative pairing manage plugins", () =>
+ Effect.gen(function* () {
+ const handled: Array = [];
+ const client = yield* makeClient(AuthAdministrativeScopes, handled);
+
+ const failures = yield* callWrites(client);
+ for (const failure of failures) expect(failure._tag).toBe("PluginCatalogError");
+ expect(handled).toEqual([...writes]);
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("refuses catalogue reads without the orchestration read scope", () =>
+ Effect.gen(function* () {
+ const handled: Array = [];
+ const client = yield* makeClient([AuthRelayReadScope], handled);
+
+ expect(yield* client[WS_METHODS.pluginsList]({}).pipe(Effect.flip)).toMatchObject({
+ _tag: "EnvironmentAuthorizationError",
+ requiredScope: AuthOrchestrationReadScope,
+ requiredPermission: AuthOrchestrationReadScope,
+ });
+ expect(
+ yield* client[WS_METHODS.pluginsSubscribe]({}).pipe(Stream.runCollect, Effect.flip),
+ ).toMatchObject({
+ _tag: "EnvironmentAuthorizationError",
+ requiredScope: AuthOrchestrationReadScope,
+ requiredPermission: AuthOrchestrationReadScope,
+ });
+ expect(handled).toEqual([]);
+ }).pipe(Effect.scoped),
+ );
+});
diff --git a/apps/server/src/plugins/PluginEventDelivery.ts b/apps/server/src/plugins/PluginEventDelivery.ts
new file mode 100644
index 000000000000..ed06a5de5c62
--- /dev/null
+++ b/apps/server/src/plugins/PluginEventDelivery.ts
@@ -0,0 +1,116 @@
+/**
+ * The part of plugin event delivery that the plugin catalogue calls into. It
+ * lets the catalogue start a plugin's event cursor, show how delivery is
+ * going, and resume it, without depending on the event feed, which itself
+ * depends on the catalogue.
+ */
+import {
+ PLUGIN_EVENTS_CAPABILITY,
+ type PluginEventDeliveryState,
+ type PluginInstallationId,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Equal from "effect/Equal";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as PubSub from "effect/PubSub";
+import type * as Scope from "effect/Scope";
+import * as SqlClient from "effect/sql/SqlClient";
+import type * as SqlError from "effect/sql/SqlError";
+
+export class PluginEventDelivery extends Context.Service<
+ PluginEventDelivery,
+ {
+ /**
+ * Gives an installation that declares `events` its starting cursor, the
+ * current end of the event log, unless it already has one. The catalogue
+ * runs this in the transaction that saves the installation as enabled, so
+ * every event committed after the enable is delivered and none from
+ * before it. Re-enables and restarts keep the stored cursor.
+ */
+ readonly begin: (
+ installationId: PluginInstallationId,
+ capabilities: ReadonlyArray,
+ ) => Effect.Effect;
+ /** The delivery state of one registration, if the feed reported one. */
+ readonly state: (
+ installationId: PluginInstallationId,
+ generation: number,
+ ) => Effect.Effect>;
+ /** Reports a registration's state; `undefined` when it no longer delivers. */
+ readonly report: (
+ installationId: PluginInstallationId,
+ generation: number,
+ state: PluginEventDeliveryState | undefined,
+ ) => Effect.Effect;
+ /** Signals each reported change of a state (never a cursor move). Sliding, 1. */
+ readonly changes: Effect.Effect, never, Scope.Scope>;
+ /** Restarts quarantined or retrying delivery from its cursor. Nothing else changes. */
+ readonly resume: (installationId: PluginInstallationId) => Effect.Effect;
+ /** Installs what `resume` runs, for as long as the scope is open. */
+ readonly handleResume: (
+ resume: (installationId: PluginInstallationId) => Effect.Effect,
+ ) => Effect.Effect;
+ }
+>()("t3/plugins/PluginEventDelivery") {}
+
+export const make = Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const states = new Map<
+ PluginInstallationId,
+ { readonly generation: number; readonly state: PluginEventDeliveryState }
+ >();
+ const changes = yield* PubSub.sliding(1);
+ let resume: ((installationId: PluginInstallationId) => Effect.Effect) | undefined;
+
+ return PluginEventDelivery.of({
+ begin: (installationId, capabilities) =>
+ capabilities.includes(PLUGIN_EVENTS_CAPABILITY)
+ ? Effect.gen(function* () {
+ // One statement, so the end it reads is the end when the row is written.
+ yield* sql`
+ INSERT INTO plugin_event_cursors (installation_id, acknowledged_sequence, updated_at)
+ SELECT ${installationId}, COALESCE(MAX(sequence), 0), ${DateTime.formatIso(yield* DateTime.now)}
+ FROM orchestration_events
+ WHERE application_event_version = 2
+ AND aggregate_kind = 'thread'
+ ON CONFLICT (installation_id) DO NOTHING
+ `;
+ })
+ : Effect.void,
+ state: (installationId, generation) =>
+ Effect.sync(() => {
+ const current = states.get(installationId);
+ return current?.generation === generation ? Option.some(current.state) : Option.none();
+ }),
+ report: (installationId, generation, state) =>
+ Effect.suspend(() => {
+ const current = states.get(installationId);
+ if (state === undefined) {
+ if (current?.generation !== generation) return Effect.void;
+ states.delete(installationId);
+ } else {
+ if (current?.generation === generation && Equal.equals(current.state, state))
+ return Effect.void;
+ states.set(installationId, { generation, state });
+ }
+ return PubSub.publish(changes, undefined).pipe(Effect.asVoid);
+ }),
+ changes: PubSub.subscribe(changes),
+ resume: (installationId) => Effect.suspend(() => resume?.(installationId) ?? Effect.void),
+ handleResume: (handler) =>
+ Effect.acquireRelease(
+ Effect.sync(() => {
+ resume = handler;
+ }),
+ () =>
+ Effect.sync(() => {
+ if (resume === handler) resume = undefined;
+ }),
+ ),
+ });
+});
+
+export const layer = Layer.effect(PluginEventDelivery, make);
diff --git a/apps/server/src/plugins/PluginEventFeed.test.ts b/apps/server/src/plugins/PluginEventFeed.test.ts
new file mode 100644
index 000000000000..b97e0f81000c
--- /dev/null
+++ b/apps/server/src/plugins/PluginEventFeed.test.ts
@@ -0,0 +1,857 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import {
+ EnvironmentId,
+ EventId,
+ PluginCatalogError,
+ type PluginCatalogSnapshot,
+ type PluginEventDeliveryState,
+ type PluginInstallationId,
+ ProjectId,
+ ProviderInstanceId,
+ RunId,
+ ThreadId,
+} from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as DateTime from "effect/DateTime";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+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 PubSub from "effect/PubSub";
+import * as Queue from "effect/Queue";
+import * as Schema from "effect/Schema";
+import * as Scope from "effect/Scope";
+import * as Stream from "effect/Stream";
+import * as SqlClient from "effect/sql/SqlClient";
+import * as TestClock from "effect/testing/TestClock";
+
+import { ServerEnvironment } from "../environment/ServerEnvironment.ts";
+import * as EventSink from "../orchestration-v2/EventSink.ts";
+import * as EventStore from "../orchestration-v2/EventStore.ts";
+import { LiveStreamBufferError } from "../orchestration-v2/LiveStreamBudget.ts";
+import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
+import * as SqlitePersistence from "../persistence/Sqlite.ts";
+import * as PluginCatalog from "./PluginCatalog.ts";
+import * as PluginEventDelivery from "./PluginEventDelivery.ts";
+import * as PluginEventFeed from "./PluginEventFeed.ts";
+import * as PluginManifestLoader from "./PluginManifestLoader.ts";
+import * as PluginSupervisor from "./PluginSupervisor.ts";
+
+// Children run the real CLI entry, which routes `__plugin-host` to the child runtime.
+const BIN_PATH = `${import.meta.dirname}/../bin.ts`;
+
+const environmentId = EnvironmentId.make("environment:plugin-events");
+const threadId = ThreadId.make("thread:plugin-events");
+const projectId = ProjectId.make("project:plugin-events");
+const providerInstanceId = ProviderInstanceId.make("codex");
+
+const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
+
+type Receipts = PubSub.Subscription;
+
+type Catalog = PluginCatalog.PluginCatalog["Service"];
+
+/** A catalogue whose snapshots the feed sees only once `observed` completes. */
+const observedAfter =
+ (observed: Deferred.Deferred) =>
+ (catalog: Catalog): Catalog => ({
+ ...catalog,
+ subscribe: Stream.unwrap(Deferred.await(observed).pipe(Effect.as(catalog.subscribe))),
+ });
+
+type Sink = EventSink.EventSinkV2["Service"];
+
+/**
+ * Starts a supervisor, catalogue and event feed in `scope`, as one server start
+ * would. `feedSees` changes the catalogue or event sink the feed sees.
+ */
+const startServer = Effect.fn("startServer")(function* (
+ scope: Scope.Scope,
+ options: Partial = {},
+ feedSees: {
+ readonly catalog?: (catalog: Catalog) => Catalog;
+ readonly eventSink?: (eventSink: Sink) => Sink;
+ } = {},
+) {
+ const delivery = yield* PluginEventDelivery.make;
+ const supervisor = yield* PluginSupervisor.make({
+ heapLimitMb: 64,
+ activationTimeout: "10 seconds",
+ stopGrace: "1 second",
+ }).pipe(
+ Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]),
+ Effect.provideService(Scope.Scope, scope),
+ );
+ const catalog = yield* PluginCatalog.make().pipe(
+ Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor),
+ Effect.provideService(PluginEventDelivery.PluginEventDelivery, delivery),
+ Effect.provideService(Scope.Scope, scope),
+ );
+ const feed = yield* PluginEventFeed.make(options).pipe(
+ Effect.provideService(PluginCatalog.PluginCatalog, feedSees.catalog?.(catalog) ?? catalog),
+ Effect.provideService(
+ EventSink.EventSinkV2,
+ feedSees.eventSink?.(yield* EventSink.EventSinkV2) ?? (yield* EventSink.EventSinkV2),
+ ),
+ Effect.provideService(PluginEventDelivery.PluginEventDelivery, delivery),
+ Effect.provideService(
+ ServerEnvironment,
+ ServerEnvironment.of({
+ getEnvironmentId: Effect.succeed(environmentId),
+ getDescriptor: Effect.die("unused"),
+ }),
+ ),
+ Effect.provideService(Scope.Scope, scope),
+ );
+ const receipts: Receipts = yield* feed.subscribe.pipe(Effect.provideService(Scope.Scope, scope));
+ return { catalog, feed, receipts };
+});
+
+/**
+ * Writes a plugin that appends each event it handles to `events.log` next to
+ * its directory, and fails every event while a `fail` file exists there.
+ */
+const preparePlugin = Effect.fn("preparePlugin")(function* (id: string, onEvent = true) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-events-" });
+ const directory = path.join(root, "plugin");
+ yield* fs.makeDirectory(directory);
+ const log = path.join(root, "events.log");
+ const fail = path.join(root, "fail");
+ yield* fs.writeFileString(
+ path.join(directory, "main.mjs"),
+ [
+ `import * as NodeFS from "node:fs";`,
+ `export function activate(context) {`,
+ ` if (!${onEvent}) return;`,
+ ` context.proposed.onEvent((event) => {`,
+ ` if (NodeFS.existsSync(${toJson(fail)})) throw new Error("told to fail");`,
+ ` NodeFS.appendFileSync(${toJson(log)}, JSON.stringify(event) + "\\n");`,
+ ` });`,
+ `}`,
+ ``,
+ ].join("\n"),
+ );
+ yield* fs.writeFileString(
+ path.join(directory, "t3-plugin.json"),
+ toJson({
+ id,
+ name: id,
+ version: "1.0.0",
+ apiVersion: 1,
+ entry: "main.mjs",
+ capabilities: ["events"],
+ proposedApi: true,
+ }),
+ );
+ const handled = fs.exists(log).pipe(
+ Effect.flatMap((exists) => (exists ? fs.readFileString(log) : Effect.succeed(""))),
+ Effect.map((content) =>
+ content
+ .split("\n")
+ .filter((line) => line !== "")
+ .map((line) => JSON.parse(line) as Record),
+ ),
+ );
+ return {
+ directory,
+ handled,
+ failing: (on: boolean) => (on ? fs.writeFileString(fail, "") : fs.remove(fail)),
+ };
+});
+
+const install = Effect.fn("install")(function* (catalog: Catalog, directory: string) {
+ const { installation } = yield* catalog.add({ directory });
+ const installationId = installation.installationId;
+ yield* catalog.consent({ installationId, digest: installation.source!.digest });
+ yield* catalog.enable({ installationId });
+ return installationId;
+});
+
+/** Follows the event delivery states that catalogue subscribers see. */
+const deliveryStates = Effect.fn("deliveryStates")(function* (catalog: Catalog) {
+ const snapshots = yield* Queue.unbounded();
+ yield* catalog.subscribe.pipe(
+ Stream.runForEach((snapshot) => Queue.offer(snapshots, snapshot)),
+ Effect.forkScoped,
+ );
+ const stateOf = (snapshot: PluginCatalogSnapshot, installationId: PluginInstallationId) =>
+ snapshot.installations.find((installation) => installation.installationId === installationId)
+ ?.eventDelivery;
+ return {
+ /** Takes snapshots through the first that shows `tag`; returns each state that changed on the way. */
+ untilTag: (installationId: PluginInstallationId, tag: PluginEventDeliveryState["_tag"]) =>
+ Effect.gen(function* () {
+ const states: Array = [];
+ while (true) {
+ const state = stateOf(yield* Queue.take(snapshots), installationId);
+ if (state?._tag !== states.at(-1)?._tag) states.push(state);
+ if (state?._tag === tag) return states;
+ }
+ }),
+ /** Takes snapshots until one shows `tag` for the installation; earlier ones are dropped. */
+ next: (installationId: PluginInstallationId, tag: PluginEventDeliveryState["_tag"]) =>
+ Effect.gen(function* () {
+ while (true) {
+ const snapshot = yield* Queue.take(snapshots);
+ const state = snapshot.installations.find(
+ (installation) => installation.installationId === installationId,
+ )?.eventDelivery;
+ if (state?._tag === tag) return state;
+ }
+ }),
+ };
+});
+
+/** Takes receipts until one matches; earlier ones are dropped. */
+const next = (
+ receipts: Receipts,
+ tag: Tag,
+ installationId: PluginInstallationId,
+) =>
+ Effect.gen(function* () {
+ while (true) {
+ const receipt = yield* PubSub.take(receipts);
+ if (receipt._tag === tag && receipt.installationId === installationId)
+ return receipt as Extract;
+ }
+ });
+
+const seedThread = Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const now = yield* DateTime.now;
+ yield* eventSink.write({
+ events: [
+ {
+ id: EventId.make("event:plugin-events:thread"),
+ type: "thread.created",
+ threadId,
+ providerInstanceId,
+ occurredAt: now,
+ payload: {
+ createdBy: "user",
+ creationSource: "web",
+ id: threadId,
+ projectId,
+ title: "Fix the login bug",
+ providerInstanceId,
+ modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ activeProviderThreadId: null,
+ lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
+ forkedFrom: null,
+ createdAt: now,
+ updatedAt: now,
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ lastVisitedAt: null,
+ deletedAt: null,
+ },
+ },
+ ],
+ });
+});
+
+/** Records a run's finalization the way RunFinalizationService does; returns its sequence. */
+const finalizeRun = (name: string, failedAt?: "refresh-workspace") =>
+ Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const runId = RunId.make(`run:${name}`);
+ const envelope = {
+ id: EventId.make(`event:run-finalized:${runId}`),
+ threadId,
+ runId,
+ providerInstanceId,
+ occurredAt: yield* DateTime.now,
+ };
+ const [stored] = yield* eventSink.write({
+ events: [
+ failedAt === undefined
+ ? {
+ ...envelope,
+ type: "run.finalized",
+ payload: { runId, outcome: "completed", checkpointId: null },
+ }
+ : {
+ ...envelope,
+ type: "run.finalization-failed",
+ payload: { runId, operation: failedAt },
+ },
+ ],
+ });
+ return stored!.sequence;
+ });
+
+const storedCursor = (installationId: PluginInstallationId) =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const rows = yield* sql<{ readonly acknowledged_sequence: number }>`
+ SELECT acknowledged_sequence FROM plugin_event_cursors
+ WHERE installation_id = ${installationId}
+ `;
+ return rows[0]?.acknowledged_sequence;
+ });
+
+// One database, event store and projection per test; "restarts" replace the plugin side.
+const withStores = (effect: Effect.Effect) => {
+ const stores = Layer.mergeAll(EventStore.layer, ProjectionStore.layer).pipe(
+ Layer.provideMerge(SqlitePersistence.layerMemory),
+ );
+ return effect.pipe(Effect.provide(EventSink.layer.pipe(Layer.provideMerge(stores))));
+};
+
+it.layer(NodeServices.layer)("PluginEventFeed", (it) => {
+ describe("delivery", () => {
+ it.effect("delivers finished turns once, in order, and keeps the cursor across a restart", () =>
+ withStores(
+ Effect.gen(function* () {
+ yield* seedThread;
+ const plugin = yield* preparePlugin("test.turn-notifier");
+ const first = yield* Scope.make();
+ const server = yield* startServer(first, { pageSize: 2 });
+ const installationId = yield* install(server.catalog, plugin.directory);
+ // The first start begins at the end of the log: the thread's creation is not delivered.
+ const started = yield* next(server.receipts, "Started", installationId);
+
+ const sequences = yield* Effect.forEach(["a", "b", "c"], (name) => finalizeRun(name));
+ const failedSequence = yield* finalizeRun("d", "refresh-workspace");
+ let acknowledged = started.cursor;
+ while (acknowledged < failedSequence) {
+ const receipt = yield* next(server.receipts, "Acknowledged", installationId);
+ // Pages hold at most two events.
+ expect(receipt.delivered).toBeLessThanOrEqual(2);
+ acknowledged = receipt.throughSequence;
+ }
+ const handled = yield* plugin.handled;
+ expect(handled.map((event) => event.sequence)).toEqual([...sequences, failedSequence]);
+ expect(handled[0]).toEqual({
+ deliveryId: "event:run-finalized:run:a",
+ sequence: sequences[0],
+ occurredAt: expect.any(String),
+ environmentId,
+ threadId,
+ runId: "run:a",
+ thread: { projectId, title: "Fix the login bug" },
+ type: "run.finalized",
+ outcome: "completed",
+ });
+ expect(handled[3]).toMatchObject({
+ deliveryId: "event:run-finalized:run:d",
+ type: "run.finalization-failed",
+ operation: "refresh-workspace",
+ });
+ expect(yield* storedCursor(installationId)).toBe(failedSequence);
+
+ yield* Scope.close(first, Exit.void);
+ const later = yield* finalizeRun("e");
+ const restarted = yield* startServer(yield* Scope.Scope);
+ const receipt = yield* next(restarted.receipts, "Acknowledged", installationId);
+ expect(receipt).toMatchObject({ delivered: 1, throughSequence: later });
+ // Nothing acknowledged before the restart arrives again.
+ expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([
+ ...sequences,
+ failedSequence,
+ later,
+ ]);
+ }),
+ ),
+ );
+ });
+
+ describe("wakeups", () => {
+ it.effect("replaces a wakeup subscription that fell behind and delivers what it missed", () =>
+ withStores(
+ Effect.gen(function* () {
+ yield* seedThread;
+ const plugin = yield* preparePlugin("test.overflow");
+ const overflow = yield* Deferred.make();
+ const resubscribed = yield* Deferred.make();
+ const reopen = yield* Deferred.make();
+ const requests: Array[0]> = [];
+ const { catalog, receipts } = yield* startServer(
+ yield* Scope.Scope,
+ {},
+ {
+ eventSink: (eventSink) => ({
+ ...eventSink,
+ stream: (input) => {
+ requests.push(input);
+ // The first subscription per type overflows on demand; the next ones wait
+ // for `reopen`, so commits in between reach no live subscription.
+ return requests.length <= 2
+ ? Stream.merge(
+ eventSink.stream(input),
+ Stream.fromEffect(Deferred.await(overflow)).pipe(
+ Stream.flatMap(() =>
+ Stream.fail(
+ new EventSink.EventSinkStreamError({
+ cause: new LiveStreamBufferError({ message: "full" }),
+ }),
+ ),
+ ),
+ ),
+ )
+ : Stream.unwrap(
+ Deferred.succeed(resubscribed, undefined).pipe(
+ Effect.andThen(Deferred.await(reopen)),
+ Effect.as(eventSink.stream(input)),
+ ),
+ );
+ },
+ }),
+ },
+ );
+ const installationId = yield* install(catalog, plugin.directory);
+ yield* next(receipts, "Started", installationId);
+ const before = yield* finalizeRun("before-overflow");
+ yield* next(receipts, "Acknowledged", installationId);
+
+ yield* Deferred.succeed(overflow, undefined);
+ yield* Deferred.await(resubscribed);
+ const missed = yield* finalizeRun("while-resubscribing");
+ yield* Deferred.succeed(reopen, undefined);
+ expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({
+ delivered: 1,
+ throughSequence: missed,
+ });
+ expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([before, missed]);
+ // Every subscription is bounded, and a new one starts at the log's end, not at startup.
+ expect(requests.every((input) => input?.bounded === true)).toBe(true);
+ expect(requests.slice(2).map((input) => input?.afterSequence)).toEqual([before, before]);
+ }),
+ ),
+ );
+ });
+
+ describe("starting point", () => {
+ it.effect(
+ "delivers events committed after enable even when the feed sees the enable late",
+ () =>
+ withStores(
+ Effect.gen(function* () {
+ yield* seedThread;
+ const plugin = yield* preparePlugin("test.late-observer");
+ const observed = yield* Deferred.make();
+ const server = yield* startServer(
+ yield* Scope.Scope,
+ {},
+ { catalog: observedAfter(observed) },
+ );
+ const installationId = yield* install(server.catalog, plugin.directory);
+ const enabledAt = yield* storedCursor(installationId);
+ const sequence = yield* finalizeRun("right-after-enable");
+ expect(enabledAt).toBeLessThan(sequence);
+
+ yield* Deferred.succeed(observed, undefined);
+ expect(yield* next(server.receipts, "Started", installationId)).toMatchObject({
+ cursor: enabledAt,
+ });
+ expect(yield* next(server.receipts, "Acknowledged", installationId)).toMatchObject({
+ delivered: 1,
+ throughSequence: sequence,
+ });
+ expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([sequence]);
+ }),
+ ),
+ );
+
+ it.effect(
+ "keeps the enable's starting point when the server stops before the feed saw it",
+ () =>
+ withStores(
+ Effect.gen(function* () {
+ yield* seedThread;
+ const plugin = yield* preparePlugin("test.early-restart");
+ const first = yield* Scope.make();
+ const server = yield* startServer(
+ first,
+ {},
+ { catalog: observedAfter(yield* Deferred.make()) },
+ );
+ const installationId = yield* install(server.catalog, plugin.directory);
+ const sequence = yield* finalizeRun("before-restart");
+ yield* Scope.close(first, Exit.void);
+
+ const restarted = yield* startServer(yield* Scope.Scope);
+ expect(yield* next(restarted.receipts, "Acknowledged", installationId)).toMatchObject({
+ delivered: 1,
+ throughSequence: sequence,
+ });
+ expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([sequence]);
+ }),
+ ),
+ );
+ });
+
+ describe("handler failures", () => {
+ it.effect("retries a failing page, quarantines it without moving the cursor, and resumes", () =>
+ withStores(
+ Effect.gen(function* () {
+ yield* seedThread;
+ const plugin = yield* preparePlugin("test.failing");
+ const { catalog, feed, receipts } = yield* startServer(yield* Scope.Scope, {
+ maxFailures: 2,
+ retryBackoff: "1 second",
+ });
+ const shown = yield* deliveryStates(catalog);
+ const installationId = yield* install(catalog, plugin.directory);
+ const { cursor } = yield* next(receipts, "Started", installationId);
+ expect(yield* shown.next(installationId, "active")).toEqual({ _tag: "active" });
+
+ yield* plugin.failing(true);
+ const sequence = yield* finalizeRun("failing");
+ const failed = yield* next(receipts, "Failed", installationId);
+ expect(failed).toMatchObject({ cursor, failures: 1 });
+ expect(failed.reason).toContain("told to fail");
+ expect(yield* shown.next(installationId, "retrying")).toMatchObject({ failures: 1 });
+ yield* TestClock.adjust("1 second");
+ // The retry itself is not shown as active again.
+ const states = yield* shown.untilTag(installationId, "quarantined");
+ expect(states.map((state) => state?._tag)).not.toContain("active");
+ const quarantined = yield* next(receipts, "Quarantined", installationId);
+ expect(quarantined).toMatchObject({ cursor, failures: 2 });
+ expect(yield* storedCursor(installationId)).toBe(cursor);
+ const status = Option.getOrThrow(yield* feed.status(installationId));
+ expect(status).toMatchObject({ cursor, state: { _tag: "quarantined", failures: 2 } });
+ // Management sees the quarantine and why, beside a plugin process that still runs.
+ const visible = states.at(-1)!;
+ expect(visible).toMatchObject({ failures: 2 });
+ expect(visible._tag === "quarantined" && visible.reason).toContain("told to fail");
+ const [listed] = (yield* catalog.list).installations;
+ expect(listed?.hostState?._tag).toBe("running");
+ expect(listed?.eventDelivery?._tag).toBe("quarantined");
+
+ // Quarantine waits for a person, even when the handler would succeed now.
+ yield* plugin.failing(false);
+ yield* TestClock.adjust("1 minute");
+ expect((yield* feed.status(installationId)).pipe(Option.getOrThrow).state._tag).toBe(
+ "quarantined",
+ );
+ expect(yield* plugin.handled).toEqual([]);
+
+ // The one management resume clears it, and its answer already shows delivery active.
+ const resumed = yield* catalog.resume({ installationId });
+ expect(resumed.installation.eventDelivery).toEqual({ _tag: "active" });
+ expect(yield* next(receipts, "Started", installationId)).toMatchObject({ cursor });
+ expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({
+ delivered: 1,
+ throughSequence: sequence,
+ });
+ expect(yield* shown.next(installationId, "active")).toEqual({ _tag: "active" });
+ expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([sequence]);
+
+ // Disable removes the state with the registration.
+ const disabled = yield* catalog.disable({ installationId });
+ expect(disabled.installation.eventDelivery).toBeUndefined();
+ }),
+ ),
+ );
+ it.effect("stops in front of an unreadable event and delivers it once repaired", () =>
+ withStores(
+ Effect.gen(function* () {
+ yield* seedThread;
+ const sql = yield* SqlClient.SqlClient;
+ const plugin = yield* preparePlugin("test.unreadable");
+ const observed = yield* Deferred.make();
+ const { catalog, receipts } = yield* startServer(
+ yield* Scope.Scope,
+ {},
+ { catalog: observedAfter(observed) },
+ );
+ const installationId = yield* install(catalog, plugin.directory);
+ const [first, broken, last] = yield* Effect.forEach(["a", "b", "c"], (name) =>
+ finalizeRun(name),
+ );
+ const [stored] = yield* sql<{ readonly payload_json: string }>`
+ SELECT payload_json FROM orchestration_events WHERE sequence = ${broken}
+ `;
+ yield* sql`UPDATE orchestration_events SET payload_json = '{}' WHERE sequence = ${broken}`;
+ yield* Deferred.succeed(observed, undefined);
+
+ expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({
+ delivered: 1,
+ throughSequence: broken! - 1,
+ });
+ const quarantined = yield* next(receipts, "Quarantined", installationId);
+ expect(quarantined).toMatchObject({ cursor: broken! - 1, failures: 0 });
+ expect(quarantined.reason).toContain(`sequence ${broken}`);
+ expect(yield* storedCursor(installationId)).toBe(broken! - 1);
+ expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([first]);
+
+ yield* sql`
+ UPDATE orchestration_events SET payload_json = ${stored!.payload_json}
+ WHERE sequence = ${broken}
+ `;
+ yield* catalog.resume({ installationId });
+ expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({
+ delivered: 2,
+ throughSequence: last,
+ });
+ expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([
+ first,
+ broken,
+ last,
+ ]);
+ }),
+ ),
+ );
+ it.effect(
+ "stops in front of an event with unreadable identifiers and delivers it once repaired",
+ () =>
+ withStores(
+ Effect.gen(function* () {
+ yield* seedThread;
+ const sql = yield* SqlClient.SqlClient;
+ const plugin = yield* preparePlugin("test.unreadable-ids");
+ const observed = yield* Deferred.make();
+ const { catalog, receipts } = yield* startServer(
+ yield* Scope.Scope,
+ {},
+ { catalog: observedAfter(observed) },
+ );
+ const installationId = yield* install(catalog, plugin.directory);
+ const [first, broken, last] = yield* Effect.forEach(["a", "b", "c"], (name) =>
+ finalizeRun(name),
+ );
+ const [stored] = yield* sql<{ readonly event_id: string; readonly stream_id: string }>`
+ SELECT event_id, stream_id FROM orchestration_events WHERE sequence = ${broken}
+ `;
+ yield* sql`
+ UPDATE orchestration_events SET event_id = '', stream_id = '' WHERE sequence = ${broken}
+ `;
+ yield* Deferred.succeed(observed, undefined);
+
+ expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({
+ delivered: 1,
+ throughSequence: broken! - 1,
+ });
+ const quarantined = yield* next(receipts, "Quarantined", installationId);
+ expect(quarantined).toMatchObject({ cursor: broken! - 1, failures: 0 });
+ expect(quarantined.reason).toContain(`sequence ${broken}`);
+ expect(yield* storedCursor(installationId)).toBe(broken! - 1);
+ expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([first]);
+
+ yield* sql`
+ UPDATE orchestration_events
+ SET event_id = ${stored!.event_id}, stream_id = ${stored!.stream_id}
+ WHERE sequence = ${broken}
+ `;
+ yield* catalog.resume({ installationId });
+ expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({
+ delivered: 2,
+ throughSequence: last,
+ });
+ expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([
+ first,
+ broken,
+ last,
+ ]);
+ }),
+ ),
+ );
+ it.effect("retries after catalogue errors while still registered, without quarantine", () =>
+ withStores(
+ Effect.gen(function* () {
+ yield* seedThread;
+ const plugin = yield* preparePlugin("test.storage");
+ // The first two calls fail outside the plugin, the way a failed catalogue save does.
+ const refusals = ["storage", "unavailable"];
+ const { catalog, feed, receipts } = yield* startServer(
+ yield* Scope.Scope,
+ { maxFailures: 1, retryBackoff: "1 second" },
+ {
+ catalog: (catalog) => ({
+ ...catalog,
+ invoke: (installationId, handler, input, options) => {
+ const reason = refusals.shift();
+ return reason === undefined
+ ? catalog.invoke(installationId, handler, input, options)
+ : Effect.fail(
+ new PluginCatalogError({ reason, message: `Refused: ${reason}.` }),
+ );
+ },
+ }),
+ },
+ );
+ const installationId = yield* install(catalog, plugin.directory);
+ const { cursor, generation } = yield* next(receipts, "Started", installationId);
+ const sequence = yield* finalizeRun("after-storage-trouble");
+
+ expect(yield* next(receipts, "Retrying", installationId)).toMatchObject({
+ cursor,
+ generation,
+ reason: "Refused: storage.",
+ });
+ expect(Option.getOrThrow(yield* feed.status(installationId)).state).toMatchObject({
+ _tag: "retrying",
+ failures: 1,
+ });
+ yield* TestClock.adjust("1 second");
+ expect(yield* next(receipts, "Retrying", installationId)).toMatchObject({
+ reason: "Refused: unavailable.",
+ });
+ yield* TestClock.adjust("2 seconds");
+ expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({
+ generation,
+ delivered: 1,
+ throughSequence: sequence,
+ });
+ expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([sequence]);
+ }),
+ ),
+ );
+ it.effect("keeps showing retrying after a cursor save fails until the page is saved", () =>
+ withStores(
+ Effect.gen(function* () {
+ yield* seedThread;
+ const sql = yield* SqlClient.SqlClient;
+ const plugin = yield* preparePlugin("test.cursor-save");
+ // The second call, the retry after the failed save, waits for `release`.
+ const retried = yield* Deferred.make();
+ const release = yield* Deferred.make();
+ let calls = 0;
+ const { catalog, feed, receipts } = yield* startServer(
+ yield* Scope.Scope,
+ {},
+ {
+ catalog: (catalog) => ({
+ ...catalog,
+ invoke: (installationId, handler, input, options) => {
+ const held =
+ ++calls === 2
+ ? Deferred.succeed(retried, undefined).pipe(
+ Effect.andThen(Deferred.await(release)),
+ )
+ : Effect.void;
+ return held.pipe(
+ Effect.andThen(catalog.invoke(installationId, handler, input, options)),
+ );
+ },
+ }),
+ },
+ );
+ const shown = yield* deliveryStates(catalog);
+ const installationId = yield* install(catalog, plugin.directory);
+ const { cursor } = yield* next(receipts, "Started", installationId);
+ expect(yield* shown.next(installationId, "active")).toEqual({ _tag: "active" });
+
+ yield* sql`
+ CREATE TRIGGER refuse_cursor_save BEFORE UPDATE ON plugin_event_cursors
+ BEGIN SELECT RAISE(ABORT, 'disk trouble'); END
+ `;
+ const sequence = yield* finalizeRun("cursor-save-fails");
+ expect(yield* shown.next(installationId, "retrying")).toMatchObject({
+ failures: 1,
+ reason: "Could not read or save event delivery progress.",
+ });
+ yield* sql`DROP TRIGGER refuse_cursor_save`;
+ yield* TestClock.adjust("1 minute");
+
+ // The page is being delivered again but has not succeeded: still retrying, not active.
+ yield* Deferred.await(retried);
+ expect(Option.getOrThrow(yield* feed.status(installationId)).state._tag).toBe("retrying");
+ const [listed] = (yield* catalog.list).installations;
+ expect(listed?.eventDelivery?._tag).toBe("retrying");
+ expect(yield* storedCursor(installationId)).toBe(cursor);
+
+ yield* Deferred.succeed(release, undefined);
+ expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({
+ delivered: 1,
+ throughSequence: sequence,
+ });
+ expect(yield* shown.next(installationId, "active")).toEqual({ _tag: "active" });
+ // At least once: the page answered before the failed save is delivered again.
+ expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([
+ sequence,
+ sequence,
+ ]);
+ }),
+ ),
+ );
+ });
+
+ describe("plugin contract", () => {
+ it.effect("refuses the events capability without the proposed API opt-in", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-events-" });
+ yield* fs.writeFileString(
+ path.join(directory, "main.mjs"),
+ "export function activate() {}\n",
+ );
+ yield* fs.writeFileString(
+ path.join(directory, "t3-plugin.json"),
+ toJson({
+ id: "test.no-opt-in",
+ name: "test.no-opt-in",
+ version: "1.0.0",
+ apiVersion: 1,
+ entry: "main.mjs",
+ capabilities: ["events"],
+ }),
+ );
+ const error = yield* PluginManifestLoader.loadPluginDirectory(directory).pipe(Effect.flip);
+ expect(error.reason).toContain('"events" capability needs "proposedApi": true');
+ }).pipe(Effect.scoped),
+ );
+ it.effect("fails delivery to a plugin that registered no onEvent handler", () =>
+ withStores(
+ Effect.gen(function* () {
+ yield* seedThread;
+ const plugin = yield* preparePlugin("test.no-handler", false);
+ const { catalog, receipts } = yield* startServer(yield* Scope.Scope);
+ const installationId = yield* install(catalog, plugin.directory);
+ const { cursor } = yield* next(receipts, "Started", installationId);
+ yield* finalizeRun("unhandled");
+ const failed = yield* next(receipts, "Failed", installationId);
+ expect(failed).toMatchObject({ cursor, failures: 1 });
+ expect(failed.reason).toContain("registered no onEvent handler");
+ }),
+ ),
+ );
+ });
+
+ describe("reverse states", () => {
+ it.effect("stops on disable, resumes from the cursor on enable, and forgets it on remove", () =>
+ withStores(
+ Effect.gen(function* () {
+ yield* seedThread;
+ const plugin = yield* preparePlugin("test.reverse");
+ const { catalog, feed, receipts } = yield* startServer(yield* Scope.Scope);
+ const installationId = yield* install(catalog, plugin.directory);
+ const { cursor } = yield* next(receipts, "Started", installationId);
+
+ yield* catalog.disable({ installationId });
+ const missed = yield* finalizeRun("while-disabled");
+ expect(yield* feed.status(installationId)).toEqual(Option.none());
+ expect(yield* plugin.handled).toEqual([]);
+
+ yield* catalog.enable({ installationId });
+ expect(yield* next(receipts, "Started", installationId)).toMatchObject({
+ cursor,
+ generation: 2,
+ });
+ expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({
+ delivered: 1,
+ throughSequence: missed,
+ });
+
+ yield* catalog.remove({ installationId });
+ yield* next(receipts, "Forgotten", installationId);
+ expect(yield* storedCursor(installationId)).toBeUndefined();
+ expect(yield* feed.status(installationId)).toEqual(Option.none());
+ }),
+ ),
+ );
+ });
+});
diff --git a/apps/server/src/plugins/PluginEventFeed.ts b/apps/server/src/plugins/PluginEventFeed.ts
new file mode 100644
index 000000000000..3c56caba7f8a
--- /dev/null
+++ b/apps/server/src/plugins/PluginEventFeed.ts
@@ -0,0 +1,635 @@
+/**
+ * Delivers the curated event projection (contracts `PluginEvent`) to every
+ * enabled plugin that declares the `events` capability.
+ *
+ * Each such installation has a durable cursor: the event log sequence it has
+ * acknowledged through. Its worker reads the log after the cursor in bounded
+ * windows and pages, calls the plugin's `t3.events` handler through the
+ * catalogue, and moves the cursor past a page only after the plugin answered
+ * it. A failed page is retried with backoff and, after `maxFailures`
+ * consecutive failures, the worker is quarantined with the cursor unchanged
+ * until `plugins.resume`, a re-enable, or a server restart. Delivery is
+ * at-least-once. Each worker reports its state through `PluginEventDelivery`,
+ * which the catalogue shows as the installation's `eventDelivery`.
+ *
+ * The cursor starts at the end of the log when an installation is first
+ * enabled (the catalogue records it with the enable, through
+ * `PluginEventDelivery`), so a new plugin sees exactly the later events. It
+ * belongs to the installation: disable and re-enable, consent to changed
+ * bytes, and restarts continue from it; remove forgets it. Workers never
+ * subscribe to raw events: a commit of a projected event type only wakes
+ * them, and they read the store.
+ */
+import {
+ OrchestrationV2RunFinalizationFailed,
+ OrchestrationV2RunFinalized,
+ PLUGIN_EVENT_THREAD_TITLE_MAX_LENGTH,
+ PLUGIN_EVENT_TYPES,
+ PLUGIN_EVENTS_CAPABILITY,
+ PluginEvent,
+ PluginEventPage,
+ type EnvironmentId,
+ type PluginInstallation,
+ type PluginRunFinalizationFailedEvent,
+ type PluginRunFinalizedEvent,
+ type PluginInstallationId,
+ EventId,
+ ThreadId,
+} from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
+import * as Context from "effect/Context";
+import * as DateTime from "effect/DateTime";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as PubSub from "effect/PubSub";
+import * as Result from "effect/Result";
+import * as Schedule from "effect/Schedule";
+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 * as SqlClient from "effect/sql/SqlClient";
+
+import { ServerEnvironment } from "../environment/ServerEnvironment.ts";
+import { EventSinkV2, type EventSinkV2Error } from "../orchestration-v2/EventSink.ts";
+import { LiveStreamBufferError } from "../orchestration-v2/LiveStreamBudget.ts";
+import { ProjectionStoreV2 } from "../orchestration-v2/ProjectionStore.ts";
+import { PluginCatalog } from "./PluginCatalog.ts";
+import { PluginEventDelivery } from "./PluginEventDelivery.ts";
+import { PLUGIN_EVENTS_HANDLER } from "./pluginIpcFraming.ts";
+
+export interface PluginEventFeedOptions {
+ /** Most events in one page; at most what `PluginEventPage` accepts. */
+ readonly pageSize: number;
+ /** Most log sequences one read scans, so a far-behind cursor never scans the whole log at once. */
+ readonly scanWindow: number;
+ /** How long the plugin may take to answer one page. */
+ readonly deliveryTimeout: Duration.Input;
+ readonly retryBackoff: Duration.Input;
+ readonly maxRetryBackoff: Duration.Input;
+ /** Consecutive failed attempts at one page before the worker is quarantined. */
+ readonly maxFailures: number;
+}
+
+const defaultOptions: PluginEventFeedOptions = {
+ pageSize: 32,
+ scanWindow: 4096,
+ deliveryTimeout: Duration.seconds(30),
+ retryBackoff: Duration.seconds(2),
+ maxRetryBackoff: Duration.minutes(1),
+ maxFailures: 5,
+};
+
+/** What one installation's worker is doing. Kept in memory; only the cursor is durable. */
+type PluginEventFeedState =
+ | { readonly _tag: "waiting" }
+ | { readonly _tag: "delivering" }
+ | {
+ readonly _tag: "retrying";
+ readonly failures: number;
+ readonly reason: string;
+ readonly retryAt: string;
+ }
+ /** `failures` is 0 when delivery stopped in front of an event it cannot read. */
+ | { readonly _tag: "quarantined"; readonly failures: number; readonly reason: string }
+ /** The registration it delivered to is gone; the next catalogue change replaces it. */
+ | { readonly _tag: "stopped" };
+
+interface PluginEventFeedStatus {
+ readonly generation: number;
+ /** The log sequence acknowledged through, or undefined before it was loaded. */
+ readonly cursor: number | undefined;
+ readonly state: PluginEventFeedState;
+}
+
+/** Milestones a worker reaches, for logs and tests. */
+export type PluginEventFeedReceipt =
+ | {
+ /** A worker loaded its cursor and delivers everything after it. */
+ readonly _tag: "Started";
+ readonly installationId: PluginInstallationId;
+ readonly generation: number;
+ readonly cursor: number;
+ }
+ | {
+ readonly _tag: "Acknowledged";
+ readonly installationId: PluginInstallationId;
+ readonly generation: number;
+ /** The new cursor. */
+ readonly throughSequence: number;
+ /** Events the plugin answered for; 0 when the window held none. */
+ readonly delivered: number;
+ }
+ | {
+ readonly _tag: "Failed";
+ readonly installationId: PluginInstallationId;
+ readonly generation: number;
+ readonly cursor: number;
+ readonly failures: number;
+ readonly reason: string;
+ }
+ | {
+ /** A step outside the plugin failed, such as catalogue storage; retried without counting. */
+ readonly _tag: "Retrying";
+ readonly installationId: PluginInstallationId;
+ readonly generation: number;
+ readonly cursor: number;
+ readonly reason: string;
+ }
+ | {
+ readonly _tag: "Quarantined";
+ readonly installationId: PluginInstallationId;
+ readonly generation: number;
+ readonly cursor: number;
+ readonly failures: number;
+ readonly reason: string;
+ }
+ /** A removed installation's cursor was deleted. */
+ | { readonly _tag: "Forgotten"; readonly installationId: PluginInstallationId };
+
+export class PluginEventFeed extends Context.Service<
+ PluginEventFeed,
+ {
+ readonly status: (
+ installationId: PluginInstallationId,
+ ) => Effect.Effect>;
+ /** Subscribes before returning, so no receipt after this point is missed (sliding, 1024). */
+ readonly subscribe: Effect.Effect<
+ PubSub.Subscription,
+ never,
+ Scope.Scope
+ >;
+ }
+>()("t3/plugins/PluginEventFeed") {}
+
+interface EventRow {
+ readonly sequence: number;
+ readonly event_id: string;
+ readonly event_type: string;
+ readonly stream_id: string;
+ readonly occurred_at: string;
+ readonly payload_json: string;
+}
+
+interface Worker {
+ readonly generation: number;
+ cursor: number | undefined;
+ state: PluginEventFeedState;
+ fiber: Fiber.Fiber | undefined;
+}
+
+const decodeFinalized = Schema.decodeUnknownEffect(
+ Schema.fromJsonString(OrchestrationV2RunFinalized),
+);
+const decodeFinalizationFailed = Schema.decodeUnknownEffect(
+ Schema.fromJsonString(OrchestrationV2RunFinalizationFailed),
+);
+const decodeEnvelope = Schema.decodeUnknownEffect(
+ Schema.Struct({ event_id: EventId, stream_id: ThreadId }),
+);
+const checkEvent = Schema.encodeEffect(PluginEvent);
+const encodePage = Schema.encodeEffect(PluginEventPage);
+const isLiveStreamBufferError = Schema.is(LiveStreamBufferError);
+
+/** Cuts `text` to `max` UTF-16 units without splitting a surrogate pair. */
+const truncate = (text: string, max: number) => {
+ if (text.length <= max) return text;
+ const end = /[\uD800-\uDBFF]/.test(text.charAt(max - 1)) ? max - 1 : max;
+ return text.slice(0, end);
+};
+
+/** True for an installation the feed delivers to: enabled, registered, and asking for events. */
+const receivesEvents = (installation: PluginInstallation) =>
+ installation.enabled &&
+ installation.hostState !== undefined &&
+ installation.manifest?.capabilities.includes(PLUGIN_EVENTS_CAPABILITY) === true;
+
+export const make = Effect.fn("PluginEventFeed.make")(function* (
+ overrides: Partial = {},
+) {
+ const options = { ...defaultOptions, ...overrides };
+ const retryBackoff = Duration.fromInputUnsafe(options.retryBackoff);
+ const maxRetryBackoff = Duration.fromInputUnsafe(options.maxRetryBackoff);
+ const sql = yield* SqlClient.SqlClient;
+ const catalog = yield* PluginCatalog;
+ const eventSink = yield* EventSinkV2;
+ const projections = yield* ProjectionStoreV2;
+ const delivery = yield* PluginEventDelivery;
+ const environmentId: EnvironmentId = yield* (yield* ServerEnvironment).getEnvironmentId;
+ const scope = yield* Effect.scope;
+
+ const workers = new Map();
+ // Reconciling and resuming both replace workers; one at a time.
+ const lock = yield* Semaphore.make(1);
+ // A pending wake per worker is enough: the worker reads everything after its cursor.
+ const wakes = yield* PubSub.sliding(1);
+ const receipts = yield* PubSub.sliding(1024);
+ const publish = (receipt: PluginEventFeedReceipt) =>
+ PubSub.publish(receipts, receipt).pipe(Effect.asVoid);
+
+ const loadCursor = Effect.fnUntraced(function* (installationId: PluginInstallationId) {
+ // The catalogue started the cursor when the plugin was enabled; this covers a registration
+ // that skipped it.
+ yield* delivery.begin(installationId, [PLUGIN_EVENTS_CAPABILITY]);
+ const rows = yield* sql<{ readonly acknowledged_sequence: number }>`
+ SELECT acknowledged_sequence FROM plugin_event_cursors
+ WHERE installation_id = ${installationId}
+ `;
+ return rows[0]?.acknowledged_sequence ?? (yield* eventSink.latestSequence());
+ });
+
+ const saveCursor = Effect.fnUntraced(function* (
+ installationId: PluginInstallationId,
+ sequence: number,
+ ) {
+ yield* sql`
+ UPDATE plugin_event_cursors
+ SET acknowledged_sequence = ${sequence},
+ updated_at = ${DateTime.formatIso(yield* DateTime.now)}
+ WHERE installation_id = ${installationId}
+ `;
+ });
+
+ const readRows = (afterSequence: number, throughSequence: number) =>
+ sql`
+ SELECT sequence, event_id, event_type, stream_id, occurred_at, payload_json
+ FROM orchestration_events
+ WHERE sequence > ${afterSequence}
+ AND sequence <= ${throughSequence}
+ AND application_event_version = 2
+ AND aggregate_kind = 'thread'
+ AND event_type IN ${sql.in(PLUGIN_EVENT_TYPES)}
+ ORDER BY sequence ASC
+ LIMIT ${options.pageSize}
+ `;
+
+ /**
+ * Projects one stored row, or returns why it cannot be read. Such a row is
+ * never skipped: delivery stops in front of it until someone resumes.
+ */
+ const project = Effect.fnUntraced(function* (row: EventRow) {
+ const decoding: Effect.Effect<
+ | Pick
+ | Pick,
+ Schema.SchemaError
+ > =
+ row.event_type === "run.finalized"
+ ? decodeFinalized(row.payload_json).pipe(
+ Effect.map((payload) => ({
+ type: "run.finalized" as const,
+ runId: payload.runId,
+ outcome: payload.outcome,
+ })),
+ )
+ : decodeFinalizationFailed(row.payload_json).pipe(
+ Effect.map((payload) => ({
+ type: "run.finalization-failed" as const,
+ runId: payload.runId,
+ operation: payload.operation,
+ })),
+ );
+ const unreadable = {
+ unreadable: `The stored event at sequence ${row.sequence} cannot be read.`,
+ };
+ const decoded = yield* Effect.result(Effect.all([decodeEnvelope(row), decoding]));
+ if (Result.isFailure(decoded)) return unreadable;
+ const [{ event_id, stream_id }, payload] = decoded.success;
+ const shell = yield* projections.getThreadShell(stream_id);
+ const event: PluginEvent = {
+ deliveryId: event_id,
+ sequence: row.sequence,
+ occurredAt: row.occurred_at,
+ environmentId,
+ threadId: stream_id,
+ thread:
+ shell === null
+ ? null
+ : {
+ projectId: shell.projectId,
+ title: truncate(shell.title, PLUGIN_EVENT_THREAD_TITLE_MAX_LENGTH),
+ },
+ ...payload,
+ };
+ // Checked here so one bad row quarantines in front of itself instead of failing its page.
+ if (Result.isFailure(yield* Effect.result(checkEvent(event)))) return unreadable;
+ return { event };
+ });
+
+ const backoff = (failures: number) =>
+ Duration.min(Duration.times(retryBackoff, 2 ** (failures - 1)), maxRetryBackoff);
+
+ /** Records a worker's state and shows it on its installation; cursor moves change nothing shown. */
+ const setState = (
+ installationId: PluginInstallationId,
+ worker: Worker,
+ state: PluginEventFeedState,
+ ) =>
+ Effect.suspend(() => {
+ worker.state = state;
+ return delivery.report(
+ installationId,
+ worker.generation,
+ state._tag === "waiting" || state._tag === "delivering"
+ ? { _tag: "active" }
+ : state._tag === "stopped"
+ ? undefined
+ : state,
+ );
+ });
+
+ /** True while the catalogue still shows this registration enabled. */
+ const isRegistered = (installationId: PluginInstallationId, generation: number) =>
+ catalog.list.pipe(
+ Effect.map(({ installations }) =>
+ installations.some(
+ (installation) =>
+ installation.installationId === installationId &&
+ installation.generation === generation &&
+ receivesEvents(installation),
+ ),
+ ),
+ );
+
+ /** Delivers to one registration until interrupted. */
+ const run = (installationId: PluginInstallationId, worker: Worker) => {
+ const generation = worker.generation;
+ // Consecutive failures at the pending page: the plugin's, and those outside it. They outlive
+ // a storage retry, which starts the loop over from the stored cursor.
+ let failures = 0;
+ let transient = 0;
+ /**
+ * Every retry path shows `retrying` through here. It stays shown until the pending page is
+ * acknowledged, there is nothing left to deliver, or the worker is quarantined.
+ */
+ const retrying = (attempts: number, reason: string, delay: Duration.Duration) =>
+ Effect.flatMap(DateTime.now, (now) =>
+ setState(installationId, worker, {
+ _tag: "retrying",
+ failures: attempts,
+ reason,
+ retryAt: DateTime.formatIso(DateTime.addDuration(now, delay)),
+ }),
+ );
+ return Effect.gen(function* () {
+ const wake = yield* PubSub.subscribe(wakes);
+ let cursor = yield* loadCursor(installationId);
+ worker.cursor = cursor;
+ yield* publish({ _tag: "Started", installationId, generation, cursor });
+ /** Stops at the cursor until `resume`, a re-enable, or a restart. */
+ const quarantine = (attempts: number, reason: string) =>
+ Effect.gen(function* () {
+ yield* setState(installationId, worker, {
+ _tag: "quarantined",
+ failures: attempts,
+ reason,
+ });
+ yield* publish({
+ _tag: "Quarantined",
+ installationId,
+ generation,
+ cursor,
+ failures: attempts,
+ reason,
+ });
+ return yield* Effect.never;
+ });
+ while (true) {
+ const head = yield* eventSink.latestSequence();
+ if (cursor >= head) {
+ transient = 0;
+ yield* setState(installationId, worker, { _tag: "waiting" });
+ yield* PubSub.take(wake);
+ continue;
+ }
+ const through = Math.min(head, cursor + options.scanWindow);
+ const rows = yield* readRows(cursor, through);
+ const events: Array