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/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..89fc0a246a16 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx @@ -0,0 +1,120 @@ +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, 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 ? : 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..3f09c12308fb --- /dev/null +++ b/apps/mobile/src/features/threads/thread-contribution-status-presentation.test.ts @@ -0,0 +1,121 @@ +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("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..647edaff8371 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-contribution-status-presentation.ts @@ -0,0 +1,85 @@ +import { + type ContributionStatusEntry, + 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; + readonly driver: ProviderDriverKind; + /** The first chip of each source carries that source's provider 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 statusHelp(driver: ProviderDriverKind): string { + const origin = driver === "pi" ? "a Pi extension" : providerLabel(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 sourceKey = contributionStatusSourceKey(entry.source); + const { driver } = entry.source; + return entry.items.map((item, index) => { + const help = statusHelp(driver); + return { + id: JSON.stringify([sourceKey, item.key]), + driver, + leadsSource: index === 0, + text: item.text, + tone: item.tone ?? "neutral", + tooltip: item.tooltip ?? null, + accessibilityLabel: `${providerLabel(driver)} status: ${item.text}`, + help, + details: { + title: `${providerLabel(driver)} 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..291c8d5f60b0 --- /dev/null +++ b/apps/mobile/src/state/plugin-actions.ts @@ -0,0 +1,79 @@ +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 + ); +} + +/** 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/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 511fd77cd147..bba6807d214c 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,25 @@ 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, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, @@ -167,6 +187,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope, + [WS_METHODS.subscribeContributionStatus]: 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..76bfa92c9e76 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -251,6 +251,10 @@ export const make = Effect.gen(function* () { serverResolvedCommandContext: true, environmentIcon: true, projectCloneTracking: true, + contributionStatus: true, + plugins: true, + pluginSettings: true, + pluginActions: 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..90825296d4a1 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -88,6 +88,19 @@ 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.scheduledTasksRotateWebhookToken]: "scheduledTasks", [WS_METHODS.scheduledTasksListWebhookDeliveries]: "scheduledTasks", [WS_METHODS.scheduledTasksGetWebhookDelivery]: "scheduledTasks", @@ -154,6 +167,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..46269f48d6e4 --- /dev/null +++ b/apps/server/src/plugins/PluginCatalog.test.ts @@ -0,0 +1,784 @@ +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([]); + 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..b54e884c6c80 --- /dev/null +++ b/apps/server/src/plugins/PluginCatalog.ts @@ -0,0 +1,753 @@ +/** + * 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 }; + +const summarize = (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; + /** + * 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); + 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++; + installation.stopping = yield* supervisor + .disable(registered.pluginId) + .pipe(Effect.forkIn(scope, { startImmediately: true })); + return installation.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: summarize(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: summarize(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 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 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)), + 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 = []; + let unreadable: { readonly sequence: number; readonly reason: string } | undefined; + for (const row of rows) { + const projected = yield* project(row); + if ("unreadable" in projected) { + unreadable = { sequence: row.sequence, reason: projected.unreadable }; + break; + } + events.push(projected.event); + } + // A full page covers the log only up to its last event, and an unreadable one up to + // just before it. + const covered = + unreadable !== undefined + ? unreadable.sequence - 1 + : rows.length === options.pageSize + ? (rows.at(-1)?.sequence ?? through) + : through; + if (unreadable !== undefined && covered === cursor) { + yield* Effect.logWarning("Stopped a plugin's event delivery at an unreadable event", { + installationId, + sequence: unreadable.sequence, + }); + return yield* quarantine(0, unreadable.reason); + } + if (events.length > 0) { + if (worker.state._tag !== "retrying") + yield* setState(installationId, worker, { _tag: "delivering" }); + const input = yield* encodePage({ events }).pipe(Effect.orDie); + const exit = yield* catalog + .invoke(installationId, PLUGIN_EVENTS_HANDLER, input, { + timeout: options.deliveryTimeout, + generation, + }) + .pipe(Effect.exit); + if (exit._tag === "Failure") { + const error = exit.cause.reasons.find((reason) => reason._tag === "Fail")?.error; + if (error?._tag === "PluginCatalogError" || error?._tag === "PluginStoppedError") { + // Revoked or replaced: this registration is over, and a catalogue change follows. + if (!(yield* isRegistered(installationId, generation))) { + yield* setState(installationId, worker, { _tag: "stopped" }); + return yield* Effect.never; + } + // Still registered (a storage failure, or a call that raced a management step + // that changed nothing): not the plugin's failure, so retry without counting it. + transient++; + const delay = backoff(transient); + const reason = error.message.slice(0, 1000); + yield* retrying(transient, reason, delay); + yield* publish({ _tag: "Retrying", installationId, generation, cursor, reason }); + yield* Effect.sleep(delay); + continue; + } + if (error === undefined && Cause.hasInterrupts(exit.cause)) + return yield* Effect.failCause(exit.cause); + // A defect counts like a failed page, so a bug never silently ends delivery. + failures++; + const reason = (error?.message ?? Cause.pretty(exit.cause)).slice(0, 1000); + if (failures >= options.maxFailures) { + yield* Effect.logWarning("Quarantined a plugin's event delivery", { + installationId, + cursor, + failures, + reason, + }); + return yield* quarantine(failures, reason); + } + const delay = backoff(failures); + yield* retrying(failures, reason, delay); + yield* publish({ + _tag: "Failed", + installationId, + generation, + cursor, + failures, + reason, + }); + yield* Effect.sleep(delay); + continue; + } + failures = 0; + } + yield* saveCursor(installationId, covered); + cursor = covered; + worker.cursor = cursor; + transient = 0; + if (worker.state._tag === "retrying") + yield* setState(installationId, worker, { _tag: "delivering" }); + yield* publish({ + _tag: "Acknowledged", + installationId, + generation, + throughSequence: covered, + delivered: events.length, + }); + } + }).pipe( + Effect.scoped, + // Storage trouble is not the plugin's failure: wait and start over from the stored cursor. + Effect.tapError((error) => + Effect.gen(function* () { + transient++; + yield* retrying( + transient, + "Could not read or save event delivery progress.", + maxRetryBackoff, + ); + yield* Effect.logWarning("Plugin event delivery could not read or save its cursor", { + installationId, + error, + }); + }), + ), + Effect.retry(Schedule.spaced(maxRetryBackoff)), + Effect.asVoid, + ); + }; + + const start = Effect.fnUntraced(function* ( + installationId: PluginInstallationId, + generation: number, + cursor: number | undefined, + ) { + const worker: Worker = { generation, cursor, state: { _tag: "waiting" }, fiber: undefined }; + workers.set(installationId, worker); + yield* delivery.report(installationId, generation, { _tag: "active" }); + worker.fiber = yield* run(installationId, worker).pipe( + Effect.forkIn(scope, { startImmediately: true }), + ); + }); + + const stop = Effect.fnUntraced(function* (installationId: PluginInstallationId) { + const worker = workers.get(installationId); + if (worker === undefined) return; + workers.delete(installationId); + if (worker.fiber) yield* Fiber.interrupt(worker.fiber); + yield* delivery.report(installationId, worker.generation, undefined); + }); + + let known: ReadonlySet | undefined; + + const reconcile = (installations: ReadonlyArray) => + lock.withPermit( + Effect.gen(function* () { + const wanted = new Map( + installations + .filter(receivesEvents) + .map((installation) => [installation.installationId, installation.generation]), + ); + for (const [installationId, worker] of workers) { + if (wanted.get(installationId) !== worker.generation) yield* stop(installationId); + } + for (const [installationId, generation] of wanted) { + if (!workers.has(installationId)) yield* start(installationId, generation, undefined); + } + // Removed installations forget their cursor; a later add is a new installation. + const present = new Set(installations.map((installation) => installation.installationId)); + for (const installationId of known ?? []) { + if (present.has(installationId)) continue; + yield* sql`DELETE FROM plugin_event_cursors WHERE installation_id = ${installationId}`.pipe( + Effect.andThen(publish({ _tag: "Forgotten", installationId })), + Effect.catch((error) => + Effect.logWarning("Could not forget a removed plugin's event cursor", { + installationId, + error, + }), + ), + ); + } + known = present; + }), + ); + + yield* catalog.subscribe.pipe( + Stream.runForEach((snapshot) => reconcile(snapshot.installations)), + Effect.forkScoped, + ); + + // Only commits of projected types wake workers; the events themselves are read from the store. + // The subscriptions are bounded. One that falls behind fails and is replaced at once: the wake + // sent before resubscribing makes every worker read what was committed meanwhile. + const wakeOnCommits = Effect.gen(function* () { + const head = yield* eventSink.latestSequence(); + yield* PubSub.publish(wakes, undefined); + yield* Stream.mergeAll( + PLUGIN_EVENT_TYPES.map((eventType) => + eventSink.stream({ eventType, afterSequence: head, bounded: true }), + ), + { concurrency: "unbounded" }, + ).pipe(Stream.runForEach(() => PubSub.publish(wakes, undefined))); + }); + const fellBehind = (error: EventSinkV2Error) => + error._tag === "EventSinkStreamError" && isLiveStreamBufferError(error.cause); + yield* wakeOnCommits.pipe( + Effect.retry({ while: fellBehind }), + Effect.tapError((error) => Effect.logWarning("Plugin event wakeups stopped", { error })), + Effect.retry(Schedule.spaced(maxRetryBackoff)), + Effect.forkScoped, + ); + + // `plugins.resume` reaches this through the catalogue. + yield* delivery.handleResume((installationId) => + lock.withPermit( + Effect.suspend(() => { + const worker = workers.get(installationId); + if (worker?.state._tag !== "quarantined" && worker?.state._tag !== "retrying") + return Effect.void; + return stop(installationId).pipe( + Effect.andThen(start(installationId, worker.generation, worker.cursor)), + ); + }), + ), + ); + + return PluginEventFeed.of({ + status: (installationId) => + Effect.sync(() => + Option.fromNullishOr(workers.get(installationId)).pipe( + Option.map(({ generation, cursor, state }) => ({ generation, cursor, state })), + ), + ), + subscribe: PubSub.subscribe(receipts), + }); +}); + +export const layer = (overrides?: Partial) => + Layer.effect(PluginEventFeed, make(overrides)); diff --git a/apps/server/src/plugins/PluginIpc.ts b/apps/server/src/plugins/PluginIpc.ts new file mode 100644 index 000000000000..16adf51395d0 --- /dev/null +++ b/apps/server/src/plugins/PluginIpc.ts @@ -0,0 +1,76 @@ +/** + * Messages between the server and one plugin child, carried as + * newline-delimited JSON on the child's fd 3 (see pluginIpcFraming.ts). + * + * Both ends ship in the same server build, so this protocol carries no + * version: only the plugin API (PLUGIN_API_VERSION) is versioned. The server + * decodes every child message with these schemas; a message that fails to + * decode or exceeds the byte bound gets the child killed. + * + * Every `Invoke` is answered by exactly one `Succeeded` or `Failed` with the + * same `requestId`, including after `Cancel`; that answer is how the server + * learns a cancelled call has settled. + * + * The other direction is a `HostCall`: the plugin asks the server for + * something a capability provides (such as a setting value), and the server + * answers with one `HostCallSucceeded` or `HostCallFailed`. Host call ids are + * the child's own sequence, separate from `Invoke` ids. + */ +import { PluginId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const RequestId = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); + +/** Name a plugin registers a handler under, and the server invokes it by. */ +export const PluginHandlerName = Schema.String.check( + Schema.isMaxLength(128), + Schema.isPattern(/^[A-Za-z][A-Za-z0-9_.:-]*$/), +); + +const PluginErrorMessage = Schema.String.check(Schema.isMaxLength(2000)); + +/** A server method a capability serves to plugins, such as `settings.get`. */ +const PluginHostMethodName = Schema.String.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$/), +); + +const PluginLogLevel = Schema.Literals(["debug", "info", "warn", "error"]); +export type PluginLogLevel = typeof PluginLogLevel.Type; + +const PluginHostMessage = Schema.TaggedUnion({ + Activate: { + pluginId: PluginId, + version: Schema.String, + apiVersion: Schema.Int, + entryPath: Schema.String, + proposedApi: Schema.Boolean, + /** The manifest's capabilities, so the child offers only the APIs they grant. */ + capabilities: Schema.Array(Schema.String), + maxMessageBytes: Schema.Int, + }, + Invoke: { requestId: RequestId, handler: PluginHandlerName, input: Schema.Json }, + Cancel: { requestId: RequestId }, + Deactivate: {}, + HostCallSucceeded: { requestId: RequestId, value: Schema.Json }, + HostCallFailed: { requestId: RequestId, message: PluginErrorMessage }, +}); +export type PluginHostMessage = typeof PluginHostMessage.Type; + +const PluginChildMessage = Schema.TaggedUnion({ + Ready: {}, + ActivationFailed: { message: PluginErrorMessage }, + /** The entry cannot load on any runtime this server ships on; retrying cannot help. */ + Incompatible: { message: PluginErrorMessage }, + Succeeded: { requestId: RequestId, value: Schema.Json }, + Failed: { requestId: RequestId, message: PluginErrorMessage }, + Log: { level: PluginLogLevel, message: Schema.String.check(Schema.isMaxLength(4000)) }, + Deactivated: {}, + HostCall: { requestId: RequestId, method: PluginHostMethodName, input: Schema.Json }, +}); +export type PluginChildMessage = typeof PluginChildMessage.Type; + +export const decodePluginChildMessage = Schema.decodeUnknownExit( + Schema.fromJsonString(PluginChildMessage), +); +export const encodePluginHostMessage = Schema.encodeExit(Schema.fromJsonString(PluginHostMessage)); diff --git a/apps/server/src/plugins/PluginManifestLoader.ts b/apps/server/src/plugins/PluginManifestLoader.ts new file mode 100644 index 000000000000..a5ce29cacd5c --- /dev/null +++ b/apps/server/src/plugins/PluginManifestLoader.ts @@ -0,0 +1,150 @@ +import { + PLUGIN_API_VERSION, + PLUGIN_EVENTS_CAPABILITY, + PLUGIN_MANIFEST_FILE, + PLUGIN_SETTINGS_CAPABILITY, + PLUGIN_TOOLS_CAPABILITY, + PluginManifest, + type PluginCapabilityName, + type PluginInstallationId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { preparePluginTools } from "./pluginToolDeclarations.ts"; + +/** Capabilities this server implements. A plugin declaring any other is not loaded. */ +const SUPPORTED_PLUGIN_CAPABILITIES: ReadonlySet = new Set([ + PLUGIN_EVENTS_CAPABILITY, + PLUGIN_SETTINGS_CAPABILITY, + PLUGIN_TOOLS_CAPABILITY, + "actions", +]); + +const MAX_MANIFEST_BYTES = 64 * 1024; + +const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); + +class PluginManifestError extends Schema.TaggedError()("PluginManifestError", { + directory: Schema.String, + reason: Schema.String, +}) { + override get message(): string { + return `Cannot load the plugin in ${this.directory}: ${this.reason}`; + } +} + +/** A validated plugin directory: what the supervisor needs to run it. */ +export interface PluginRegistration { + readonly manifest: PluginManifest; + /** Real path of the plugin directory, used as the child's working directory. */ + readonly directory: string; + /** Real path of the entry module, inside `directory`. */ + readonly entryPath: string; + /** The catalogue installation this registration runs, set when the catalogue enables it. */ + readonly installationId?: PluginInstallationId; +} + +/** Declared tools need the capability and the proposed `handle` API, and must compile. */ +const checkTools = (manifest: PluginManifest): string | undefined => { + const tools = manifest.tools ?? []; + if (tools.length === 0) return undefined; + if (!manifest.capabilities.includes(PLUGIN_TOOLS_CAPABILITY)) + return "it declares tools without the tools capability."; + if (!manifest.proposedApi) return "it declares tools, which need proposedApi: true."; + const prepared = preparePluginTools(manifest, tools); + return "problem" in prepared ? prepared.problem : undefined; +}; + +/** Declared actions need the capability and the proposed `handle` API, and unique names. */ +const checkActions = (manifest: PluginManifest): string | undefined => { + const actions = manifest.actions ?? []; + if (actions.length === 0) return undefined; + if (!manifest.capabilities.includes("actions")) + return "it declares actions without the actions capability."; + if (!manifest.proposedApi) return "it declares actions, which need proposedApi: true."; + const names = new Set(); + for (const action of actions) { + if (names.has(action.name)) return `it declares the action ${action.name} twice.`; + names.add(action.name); + if (new Set(action.placements).size !== action.placements.length) + return `the action ${action.name} repeats a placement.`; + } + return undefined; +}; + +/** + * Reads and validates `t3-plugin.json` in `directory`. The entry must resolve, + * after symlinks, to a file inside the directory, and the manifest must target + * this server's plugin API version with only supported capabilities. + */ +export const loadPluginDirectory = Effect.fn("PluginManifestLoader.loadPluginDirectory")(function* ( + directory: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fail = (reason: string) => new PluginManifestError({ directory, reason }); + + const realDirectory = yield* fs + .realPath(directory) + .pipe(Effect.mapError(() => fail("the directory does not exist."))); + const manifestPath = path.join(realDirectory, PLUGIN_MANIFEST_FILE); + const info = yield* fs + .stat(manifestPath) + .pipe(Effect.mapError(() => fail(`${PLUGIN_MANIFEST_FILE} is missing.`))); + if (info.type !== "File" || Number(info.size) > MAX_MANIFEST_BYTES) + return yield* fail(`${PLUGIN_MANIFEST_FILE} must be a file of at most 64 KiB.`); + const raw = yield* fs + .readFileString(manifestPath) + .pipe(Effect.mapError(() => fail(`${PLUGIN_MANIFEST_FILE} is not readable.`))); + const manifest = yield* decodeManifest(raw).pipe( + Effect.mapError((error) => fail(`${PLUGIN_MANIFEST_FILE} is invalid: ${error.message}`)), + ); + + if (manifest.apiVersion !== PLUGIN_API_VERSION) + return yield* fail( + `it targets plugin API version ${manifest.apiVersion}; this server implements version ${PLUGIN_API_VERSION}.`, + ); + const unsupported = manifest.capabilities.filter( + (capability) => !SUPPORTED_PLUGIN_CAPABILITIES.has(capability), + ); + if (unsupported.length > 0) + return yield* fail(`this server does not support ${unsupported.join(", ")}.`); + // Events arrive through `context.proposed.onEvent`, which only exists with the opt-in. + if (manifest.capabilities.includes(PLUGIN_EVENTS_CAPABILITY) && !manifest.proposedApi) + return yield* fail(`the "${PLUGIN_EVENTS_CAPABILITY}" capability needs "proposedApi": true.`); + const toolProblem = checkTools(manifest); + if (toolProblem !== undefined) return yield* fail(toolProblem); + const hasSettings = manifest.capabilities.includes(PLUGIN_SETTINGS_CAPABILITY); + if (manifest.settings !== undefined && !hasSettings) + return yield* fail( + `it declares settings without the "${PLUGIN_SETTINGS_CAPABILITY}" capability.`, + ); + // The settings API is still proposed, so it only exists with the opt-in. + if (hasSettings && !manifest.proposedApi) + return yield* fail(`the "${PLUGIN_SETTINGS_CAPABILITY}" capability needs "proposedApi": true.`); + const actionProblem = checkActions(manifest); + if (actionProblem !== undefined) return yield* fail(actionProblem); + + const entryPath = yield* fs + .realPath(path.resolve(realDirectory, manifest.entry)) + .pipe(Effect.mapError(() => fail(`the entry ${manifest.entry} does not exist.`))); + const relative = path.relative(realDirectory, entryPath); + if ( + relative === "" || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) + return yield* fail(`the entry ${manifest.entry} resolves outside the plugin directory.`); + if (!/\.m?js$/.test(entryPath)) + return yield* fail(`the entry ${manifest.entry} must be a .js or .mjs file.`); + const entryInfo = yield* fs + .stat(entryPath) + .pipe(Effect.mapError(() => fail(`the entry ${manifest.entry} is not readable.`))); + if (entryInfo.type !== "File") return yield* fail(`the entry ${manifest.entry} is not a file.`); + + return { manifest, directory: realDirectory, entryPath } satisfies PluginRegistration; +}); diff --git a/apps/server/src/plugins/PluginSettings.test.ts b/apps/server/src/plugins/PluginSettings.test.ts new file mode 100644 index 000000000000..cc6556a570d8 --- /dev/null +++ b/apps/server/src/plugins/PluginSettings.test.ts @@ -0,0 +1,1246 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import type { PluginInstallationId, PluginSettingsValues } 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 Logger from "effect/Logger"; +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 * as SqlClient from "effect/sql/SqlClient"; + +import { + SecretStorePersistError, + SecretStoreRemoveError, + ServerSecretStore, +} from "../auth/ServerSecretStore.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as PluginCatalog from "./PluginCatalog.ts"; +import { loadPluginDirectory } from "./PluginManifestLoader.ts"; +import * as PluginSettings from "./PluginSettings.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; + +const FIXTURE_DIR = `${import.meta.dirname}/testFixtures/settingsPlugin`; +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; +// Or a stand-in that speaks the IPC directly, as a plugin writing raw lines to fd 3 could. +const RAW_CHILD_PATH = `${import.meta.dirname}/testFixtures/rawHostCallChild.mjs`; +const SECRET = "s3cret-token-value"; + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const parseManifest = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +/** + * A secret store in memory, so a test can see exactly what was saved and deleted. `faults` + * makes the next writes fail after saving (as an interrupted save would) or deletes fail, and + * `beforeSet` and `beforeGet` hold writes and reads. + */ +const makeSecretStore = () => { + const entries = new Map(); + const faults = { set: false, remove: false }; + const hooks: { beforeSet: Effect.Effect; beforeGet: Effect.Effect } = { + beforeSet: Effect.void, + beforeGet: Effect.void, + }; + const service = ServerSecretStore.of({ + get: (name) => + Effect.suspend(() => hooks.beforeGet).pipe( + Effect.andThen(Effect.sync(() => Option.fromUndefinedOr(entries.get(name)))), + ), + set: (name, value) => + Effect.suspend(() => hooks.beforeSet).pipe( + Effect.andThen( + Effect.suspend(() => { + entries.set(name, value); + return faults.set + ? Effect.fail(new SecretStorePersistError({ resource: name, cause: "fault" })) + : Effect.void; + }), + ), + ), + create: (name, value) => Effect.sync(() => void entries.set(name, value)), + getOrCreateRandom: (name, bytes) => + Effect.sync(() => { + const value = entries.get(name) ?? new Uint8Array(bytes); + entries.set(name, value); + return value; + }), + remove: (name) => + Effect.suspend(() => + faults.remove + ? Effect.fail(new SecretStoreRemoveError({ resource: name, cause: "fault" })) + : Effect.sync(() => void entries.delete(name)), + ), + }); + return { entries, faults, hooks, service }; +}; + +/** Starts a supervisor, catalogue and settings in `scope`, as one server start would. */ +const startPlugins = Effect.fn("startPlugins")(function* ( + scope: Scope.Scope, + secretStore: ServerSecretStore["Service"], + limits?: PluginSettings.PluginStorageLimits, + /** Runs as each settings host call starts, so a test can see that one arrived. */ + onHostCall: (method: string) => Effect.Effect = () => Effect.void, +) { + 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 settings = yield* PluginSettings.make(limits).pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, { + ...supervisor, + serveHostMethod: (method, handler) => + supervisor.serveHostMethod(method, (call) => + onHostCall(method).pipe(Effect.andThen(handler(call))), + ), + }), + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + Effect.provideService(ServerSecretStore, secretStore), + Effect.provideService(Scope.Scope, scope), + ); + return { supervisor, catalog, settings }; +}); + +/** Writes the fixture's manifest into `directory`, with `manifest` overriding its keys. */ +const writeManifest = Effect.fn("writeManifest")(function* ( + directory: string, + manifest: Record = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = parseManifest(yield* fs.readFileString(path.join(FIXTURE_DIR, "t3-plugin.json"))); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ ...fixture, ...manifest }), + ); +}); + +/** Copies the fixture into a scoped temp directory, optionally under a changed manifest. */ +const preparePlugin = Effect.fn("preparePlugin")(function* ( + manifest: Record = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-settings-" }); + yield* fs.copyFile(path.join(FIXTURE_DIR, "main.mjs"), path.join(directory, "main.mjs")); + yield* writeManifest(directory, manifest); + return directory; +}); + +/** A plugin directory for the raw IPC child, which reads `config` from `raw-child.json`. */ +const prepareRawPlugin = Effect.fn("prepareRawPlugin")(function* ( + id: string, + config: Record, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* preparePlugin({ id }); + yield* fs.writeFileString(path.join(directory, "raw-child.json"), toJson(config)); + return { directory, registration: yield* loadPluginDirectory(directory) }; +}); + +/** A supervisor whose children run `childPath`. */ +const makeSupervisor = ( + scope: Scope.Scope, + childPath: string, + options: Partial = {}, +) => + PluginSupervisor.make({ heapLimitMb: 64, stopGrace: "1 second", ...options }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, childPath]), + Effect.provideService(Scope.Scope, scope), + ); + +/** Starts waiting for the first log line of `pluginId` that satisfies `predicate`. */ +const awaitLog = ( + supervisor: PluginSupervisor.PluginSupervisor["Service"], + pluginId: string, + predicate: (message: string) => boolean, +) => + supervisor.subscribe.pipe( + Effect.flatMap((subscription) => + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event._tag === "Log" && event.pluginId === pluginId), + Stream.map((event) => (event._tag === "Log" ? event.message : "")), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild({ startImmediately: true }), + ), + ), + ); + +/** Closes `scope` and starts the plugin services again on the same database, as a restart would. */ +const restart = Effect.fn("restart")(function* ( + scope: Scope.Closeable, + secretStore: ServerSecretStore["Service"], +) { + yield* Scope.close(scope, Exit.void); + const next = yield* Scope.make(); + return { scope: next, ...(yield* startPlugins(next, secretStore)) }; +}); + +/** Adds, approves and enables the plugin in `directory`. */ +const install = Effect.fn("install")(function* ( + catalog: PluginCatalog.PluginCatalog["Service"], + 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; +}); + +/** Waits, through the subscription, for values that satisfy `predicate`. */ +const awaitValues = ( + settings: PluginSettings.PluginSettings["Service"], + installationId: PluginInstallationId, + predicate: (values: PluginSettingsValues) => boolean, +) => + settings + .subscribe(installationId) + .pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)); + +const parseRefusals = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +const valueOf = (values: PluginSettingsValues, key: string) => + values.values.find((entry) => entry.key === key)?.value; + +const countRows = Effect.fn("countRows")(function* (installationId: PluginInstallationId) { + const sql = yield* SqlClient.SqlClient; + const settings = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM plugin_settings WHERE installation_id = ${installationId} + `; + const secrets = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM plugin_setting_secrets WHERE installation_id = ${installationId} + `; + const storage = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM plugin_storage WHERE installation_id = ${installationId} + `; + return { settings: settings[0]!.count, secrets: secrets[0]!.count, storage: storage[0]!.count }; +}); + +// Each test gets its own database. +const withDatabase = (effect: Effect.Effect) => + effect.pipe(Effect.provide(SqlitePersistence.layerMemory)); + +it.layer(NodeServices.layer)("PluginSettings", (it) => { + describe("values", () => { + it.effect("saves what the plugin reads and never sends a secret back", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const installationId = yield* install(catalog, yield* preparePlugin()); + + // The catalogue carries the declared fields for clients to render. + const listed = (yield* catalog.list).installations[0]!; + expect(listed.manifest?.settings?.map((field) => field.key)).toEqual([ + "apiUrl", + "token", + "verbose", + "retries", + "mode", + ]); + + const initial = yield* awaitValues(settings, installationId, () => true); + expect(initial).toEqual({ installationId, values: [], secrets: [] }); + // Unsaved fields read as their defaults; a secret without a value reads as unset. + expect(yield* catalog.invoke(installationId, "activationMode", null)).toBe("safe"); + expect(yield* catalog.invoke(installationId, "read", { key: "retries" })).toEqual({ + value: 2, + }); + expect(yield* catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + unset: true, + }); + + const watching = yield* awaitValues( + settings, + installationId, + (values) => values.secrets.length > 0, + ).pipe(Effect.forkChild({ startImmediately: true })); + const saved = yield* settings.update({ + installationId, + changes: [ + { key: "apiUrl", value: "https://other.example.com" }, + { key: "token", value: SECRET }, + { key: "verbose", value: true }, + { key: "retries", value: 4 }, + { key: "mode", value: "fast" }, + ], + }); + expect(saved.secrets).toEqual(["token"]); + expect(valueOf(saved, "retries")).toBe(4); + expect(valueOf(saved, "token")).toBeUndefined(); + expect(toJson(saved)).not.toContain(SECRET); + // Every subscriber hears about the change, still without the secret. + const heard = yield* Fiber.join(watching); + expect(heard).toEqual(saved); + + for (const [key, value] of [ + ["apiUrl", "https://other.example.com"], + ["token", SECRET], + ["verbose", true], + ["retries", 4], + ["mode", "fast"], + ] as const) + expect(yield* catalog.invoke(installationId, "read", { key })).toEqual({ value }); + const undeclared = yield* catalog.invoke(installationId, "attempt", { + method: "get", + key: "missing", + }); + expect(undeclared).toEqual({ + ok: false, + message: '"missing" is not a declared setting.', + }); + + // Clearing returns a field to its default and deletes a secret. + const cleared = yield* settings.update({ + installationId, + changes: [ + { key: "token", value: null }, + { key: "retries", value: null }, + { key: "verbose", value: null }, + ], + }); + expect(cleared.secrets).toEqual([]); + expect(valueOf(cleared, "retries")).toBeUndefined(); + // A reset boolean keeps no override, so the plugin reads whatever its default is. + expect(valueOf(cleared, "verbose")).toBeUndefined(); + expect(yield* catalog.invoke(installationId, "read", { key: "verbose" })).toEqual({ + value: false, + }); + expect(secrets.entries.size).toBe(0); + expect(yield* catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + unset: true, + }); + expect(yield* catalog.invoke(installationId, "read", { key: "retries" })).toEqual({ + value: 2, + }); + }), + ), + ); + + it.effect("checks every change before saving any, without repeating the value", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const installationId = yield* install(catalog, yield* preparePlugin()); + const reject = (changes: Parameters[0]["changes"]) => + settings.update({ installationId, changes }).pipe(Effect.flip); + + for (const [changes, message] of [ + [[{ key: "nope", value: "x" }], 'This plugin has no setting "nope".'], + [[{ key: "verbose", value: "yes" }], "Verbose logging must be on or off."], + [[{ key: "retries", value: 9 }], "Retries must be at most 5."], + [[{ key: "retries", value: 1.5 }], "Retries must be a whole number."], + [[{ key: "mode", value: "turbo" }], "Mode must be one of its options."], + [[{ key: "token", value: "" }], "API token must be non-empty text."], + [ + [ + { key: "mode", value: "fast" }, + { key: "mode", value: "safe" }, + ], + '"mode" is changed twice.', + ], + // A valid change next to an invalid one is not saved either. + [ + [ + { key: "token", value: SECRET }, + { key: "retries", value: -1 }, + ], + "Retries must be at least 0.", + ], + ] as const) { + const error = yield* reject(changes); + expect(error).toMatchObject({ reason: "invalid-setting", message }); + } + const tooLong = yield* reject([{ key: "token", value: SECRET.repeat(1000) }]); + expect(tooLong.message).not.toContain(SECRET); + expect(yield* countRows(installationId)).toEqual({ settings: 0, secrets: 0, storage: 0 }); + expect(secrets.entries.size).toBe(0); + + const unknown = yield* settings + .update({ + installationId: "missing" as PluginInstallationId, + changes: [{ key: "mode", value: "fast" }], + }) + .pipe(Effect.flip); + expect(unknown.reason).toBe("not-found"); + }), + ), + ); + + it.effect("refuses manifests whose settings it cannot honor", () => + withDatabase( + Effect.gen(function* () { + const { catalog } = yield* startPlugins(yield* Scope.Scope, makeSecretStore().service); + const field = { type: "text", key: "a", label: "A" }; + for (const [manifest, problem] of [ + [{ capabilities: [] }, 'declares settings without the "settings" capability'], + [{ proposedApi: false }, 'needs "proposedApi": true'], + [{ settings: [field, field] }, "settings: keys repeat."], + [ + { + settings: [ + { ...field, type: "select", options: [{ value: "x", label: "X" }], default: "y" }, + ], + }, + "a: the default is invalid.", + ], + [{ settings: [{ ...field, type: "number", min: 2, max: 1 }] }, "min is greater"], + ] as const) { + const error = yield* catalog + .add({ directory: yield* preparePlugin(manifest) }) + .pipe(Effect.flip); + expect(error.reason).toBe("invalid-directory"); + expect(error.message).toContain(problem); + } + }), + ), + ); + }); + + describe("storage", () => { + it.effect("keeps a bounded private store per installation", () => + withDatabase( + Effect.gen(function* () { + const { catalog } = yield* startPlugins(yield* Scope.Scope, makeSecretStore().service, { + maxKeyLength: 8, + maxValueBytes: 100, + maxKeys: 2, + maxTotalBytes: 150, + }); + const first = yield* install(catalog, yield* preparePlugin()); + const second = yield* install( + catalog, + yield* preparePlugin({ id: "test.settings-other" }), + ); + const attempt = (installationId: PluginInstallationId, key: string, value: unknown) => + catalog.invoke(installationId, "attempt", { + method: "set", + key, + value: value as Schema.Json, + }); + + expect(yield* attempt(first, "a", { n: 1 })).toEqual({ ok: true, result: null }); + expect(yield* catalog.invoke(first, "load", { key: "a" })).toEqual({ + value: { n: 1 }, + }); + // Another installation sees nothing of it. + expect(yield* catalog.invoke(second, "load", { key: "a" })).toEqual({ missing: true }); + + expect(yield* attempt(first, "big", "x".repeat(200))).toMatchObject({ + ok: false, + message: "The value is 202 bytes; the limit is 100.", + }); + expect(yield* attempt(first, "much-too-long", 1)).toMatchObject({ ok: false }); + expect(yield* attempt(first, "bad\nkey", 1)).toMatchObject({ ok: false }); + expect(yield* attempt(first, "b", "y".repeat(60))).toEqual({ ok: true, result: null }); + expect(yield* attempt(first, "c", 1)).toEqual({ + ok: false, + message: "The plugin already stores 2 keys.", + }); + // Replacing a key counts its new size, not both. + expect(yield* attempt(first, "a", "z".repeat(90))).toMatchObject({ + ok: false, + message: "Saving this would store more than 150 bytes for the plugin.", + }); + expect(yield* attempt(first, "a", "z".repeat(40))).toEqual({ ok: true, result: null }); + expect(yield* catalog.invoke(first, "keys", null)).toEqual(["a", "b"]); + + yield* catalog.invoke(first, "drop", { key: "a" }); + expect(yield* catalog.invoke(first, "load", { key: "a" })).toEqual({ missing: true }); + expect(yield* catalog.invoke(first, "keys", null)).toEqual(["b"]); + }), + ), + ); + + it.effect("lets a plugin wait for at most 16 host calls at a time", () => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const supervisor = yield* PluginSupervisor.make({ heapLimitMb: 64 }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + const release = yield* Deferred.make(); + let inFlight = 0; + yield* supervisor + .serveHostMethod("storage.get", () => + Effect.sync(() => inFlight++).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ found: false, value: null }), + ), + ) + .pipe(Effect.provideService(Scope.Scope, scope)); + yield* supervisor + .serveHostMethod("settings.get", () => Effect.succeed({ value: null })) + .pipe(Effect.provideService(Scope.Scope, scope)); + const registration = yield* loadPluginDirectory(yield* preparePlugin()); + yield* supervisor.enable(registration); + const refused = yield* supervisor.subscribe.pipe( + Effect.map((subscription) => + Stream.fromSubscription(subscription).pipe( + Stream.filter( + (event) => event._tag === "Log" && event.message === "host-call-refused", + ), + Stream.runHead, + ), + ), + ); + const waitingForRefusal = yield* refused.pipe(Effect.forkChild({ startImmediately: true })); + + const burst = yield* supervisor + .invoke(registration.manifest.id, "burst", { count: 17 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.join(waitingForRefusal); + yield* Deferred.succeed(release, undefined); + const results = (yield* Fiber.join(burst)) as ReadonlyArray; + expect(results.filter((result) => result === "ok")).toHaveLength(16); + expect(results.filter((result) => result !== "ok")).toEqual([ + "16 calls to the server are already in flight.", + ]); + expect(inFlight).toBe(16); + }), + ); + }); + + describe("lifetime", () => { + it.effect("keeps values across disable and re-enable, and deletes them on remove", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const installationId = yield* install(catalog, yield* preparePlugin()); + yield* settings.update({ + installationId, + changes: [ + { key: "token", value: SECRET }, + { key: "mode", value: "fast" }, + ], + }); + yield* catalog.invoke(installationId, "store", { key: "cursor", value: 42 }); + + yield* catalog.disable({ installationId }); + // Disabled plugins keep their values and can still be configured. + yield* settings.update({ installationId, changes: [{ key: "verbose", value: true }] }); + yield* catalog.enable({ installationId }); + expect(yield* catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + value: SECRET, + }); + expect(yield* catalog.invoke(installationId, "load", { key: "cursor" })).toEqual({ + value: 42, + }); + expect(yield* countRows(installationId)).toEqual({ settings: 2, secrets: 1, storage: 1 }); + expect(secrets.entries.size).toBe(1); + + // The settings stream ends once the cleanup after the removal has run. + const ended = yield* settings + .subscribe(installationId) + .pipe(Stream.runDrain, Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* catalog.remove({ installationId }); + expect((yield* Fiber.join(ended)).reason).toBe("not-found"); + expect(yield* countRows(installationId)).toEqual({ settings: 0, secrets: 0, storage: 0 }); + expect(secrets.entries.size).toBe(0); + const late = yield* settings + .update({ installationId, changes: [{ key: "mode", value: "safe" }] }) + .pipe(Effect.flip); + expect(late.reason).toBe("not-found"); + expect(yield* countRows(installationId)).toEqual({ settings: 0, secrets: 0, storage: 0 }); + }), + ), + ); + + it.effect("deletes what an earlier run left for removed installations at start", () => + withDatabase( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const secrets = makeSecretStore(); + const gone = "gone-installation" as PluginInstallationId; + yield* sql` + INSERT INTO plugin_settings (installation_id, key, value_json) + VALUES (${gone}, 'mode', '"fast"') + `; + yield* sql` + INSERT INTO plugin_setting_secrets (installation_id, key, saved) + VALUES (${gone}, 'token', 1) + `; + yield* sql` + INSERT INTO plugin_storage (installation_id, key, value_json, bytes) + VALUES (${gone}, 'cursor', '1', 1) + `; + const secretName = `plugin-setting-${Buffer.from(gone).toString("base64url")}-${Buffer.from("token").toString("base64url")}`; + secrets.entries.set(secretName, new TextEncoder().encode(SECRET)); + + yield* startPlugins(yield* Scope.Scope, secrets.service); + expect(yield* countRows(gone)).toEqual({ settings: 0, secrets: 0, storage: 0 }); + expect(secrets.entries.size).toBe(0); + }), + ), + ); + + it.effect("refuses settings and storage to a registration without the capability", () => + Effect.gen(function* () { + const methods = new Map(); + const supervisor = PluginSupervisor.PluginSupervisor.of({ + enable: () => Effect.void, + disable: () => Effect.void, + resume: () => Effect.void, + invoke: () => Effect.succeed(null), + state: () => Effect.succeedNone, + subscribe: Effect.die("unused"), + serveHostMethod: (method, handler) => + Effect.sync(() => void methods.set(method, handler)), + }); + const catalog = PluginCatalog.PluginCatalog.of({ + list: Effect.succeed({ installations: [] }), + revision: Effect.succeed(0), + subscribe: Stream.empty, + add: () => Effect.die("unused"), + refresh: () => Effect.die("unused"), + consent: () => Effect.die("unused"), + enable: () => Effect.die("unused"), + disable: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + resume: () => Effect.die("unused"), + invoke: () => Effect.die("unused"), + }); + yield* PluginSettings.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + Effect.provideService(ServerSecretStore, makeSecretStore().service), + Effect.provide(SqlitePersistence.layerMemory), + ); + const registration = yield* loadPluginDirectory( + yield* preparePlugin({ capabilities: [], settings: undefined }), + ); + for (const method of ["settings.get", "storage.get", "storage.set", "storage.keys"]) { + const error = yield* methods.get(method)!({ + registration: { ...registration, installationId: "x" as PluginInstallationId }, + input: { key: "a", value: 1 }, + admitted: Effect.void, + }).pipe(Effect.flip); + expect(error.message).toBe('The plugin did not declare the "settings" capability.'); + } + }), + ); + }); + + describe("host calls", () => { + /** Serves `storage.get` with a call held until `release`, recording how it ended. */ + const holdStorageGet = Effect.fn("holdStorageGet")(function* ( + supervisor: PluginSupervisor.PluginSupervisor["Service"], + ) { + const scope = yield* Scope.Scope; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const ended = yield* Deferred.make>(); + const counts = { reads: 0, writes: 0 }; + yield* supervisor + .serveHostMethod("settings.get", () => + Effect.sync(() => { + counts.reads++; + return { value: null }; + }), + ) + .pipe(Effect.provideService(Scope.Scope, scope)); + yield* supervisor + .serveHostMethod("storage.get", () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + // Stands for a side effect after an awaited host operation. + Effect.andThen(Effect.sync(() => counts.writes++)), + Effect.as({ found: false, value: null }), + Effect.onExit((exit) => Deferred.succeed(ended, exit)), + ), + ) + .pipe(Effect.provideService(Scope.Scope, scope)); + return { started, release, ended, counts }; + }); + + it.effect("ends a disabled generation's host work and refuses its later calls", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(yield* Scope.Scope, BIN_PATH); + const held = yield* holdStorageGet(supervisor); + const registration = yield* loadPluginDirectory(yield* preparePlugin()); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const load = yield* supervisor + .invoke(pluginId, "load", { key: "a" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(held.started); + const deactivated = yield* awaitLog(supervisor, pluginId, (message) => + message.startsWith("deactivate:"), + ); + const readsBefore = held.counts.reads; + + yield* supervisor.disable(pluginId); + // The held call ended with its generation, before disable returned. + expect(yield* Deferred.isDone(held.ended)).toBe(true); + expect(Exit.hasInterrupts(yield* Deferred.await(held.ended))).toBe(true); + expect((yield* Fiber.join(load))._tag).toBe("PluginStoppedError"); + // deactivate() asked for a setting after the revocation: refused, never served. + expect(yield* Fiber.join(deactivated)).toBe("deactivate: The plugin was stopped."); + expect(held.counts.reads).toBe(readsBefore); + yield* Deferred.succeed(held.release, undefined); + expect(held.counts.writes).toBe(0); + }), + ); + + it.effect("ends host work of a process that exits", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(yield* Scope.Scope, BIN_PATH); + const held = yield* holdStorageGet(supervisor); + const registration = yield* loadPluginDirectory(yield* preparePlugin()); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const load = yield* supervisor + .invoke(pluginId, "load", { key: "a" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(held.started); + + const crashed = yield* supervisor.invoke(pluginId, "exit", null).pipe(Effect.flip); + expect(crashed._tag).toBe("PluginCrashedError"); + expect(Exit.hasInterrupts(yield* Deferred.await(held.ended))).toBe(true); + expect((yield* Fiber.join(load))._tag).toBe("PluginCrashedError"); + yield* Deferred.succeed(held.release, undefined); + expect(held.counts.writes).toBe(0); + }), + ); + + it.effect("waits for host work still ending when disabling a process that exited", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(yield* Scope.Scope, BIN_PATH); + const started = yield* Deferred.make(); + const ending = yield* Deferred.make(); + const release = yield* Deferred.make(); + const order: Array = []; + yield* supervisor.serveHostMethod("settings.get", () => Effect.succeed({ value: null })); + yield* supervisor.serveHostMethod("storage.get", () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + // Stands for cleanup that outlasts the process, such as releasing a lock. + Effect.onInterrupt(() => + Deferred.succeed(ending, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.sync(() => order.push("host work ended"))), + ), + ), + ), + ); + const registration = yield* loadPluginDirectory(yield* preparePlugin()); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + yield* supervisor + .invoke(pluginId, "load", { key: "a" }) + .pipe(Effect.ignore, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + yield* supervisor + .invoke(pluginId, "exit", null) + .pipe(Effect.ignore, Effect.forkChild({ startImmediately: true })); + // The process has exited and its host work is ending. + yield* Deferred.await(ending); + + const disabling = yield* supervisor + .disable(pluginId) + .pipe( + Effect.andThen(Effect.sync(() => order.push("disabled"))), + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(disabling); + expect(order).toEqual(["host work ended", "disabled"]); + }), + ); + + it.effect("drops a write that waited for the settings lock past its generation", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const storeArrived = yield* Deferred.make(); + const readArrived = yield* Deferred.make(); + const { catalog, settings } = yield* startPlugins( + yield* Scope.Scope, + secrets.service, + undefined, + (method) => + method === "storage.set" + ? Deferred.succeed(storeArrived, undefined) + : Deferred.isDone(storeArrived).pipe( + Effect.flatMap((stored) => + stored ? Deferred.succeed(readArrived, undefined) : Effect.void, + ), + ), + ); + const installationId = yield* install(catalog, yield* preparePlugin()); + expect(yield* catalog.invoke(installationId, "read", { key: "mode" })).toEqual({ + value: "safe", + }); + + // A client save holds the settings lock while its secret is written. + const writing = yield* Deferred.make(); + const finishWrite = yield* Deferred.make(); + secrets.hooks.beforeSet = Deferred.succeed(writing, undefined).pipe( + Effect.andThen(Deferred.await(finishWrite)), + ); + const saving = yield* settings + .update({ installationId, changes: [{ key: "token", value: SECRET }] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(writing); + const storing = yield* catalog + .invoke(installationId, "store", { key: "cursor", value: 1 }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(storeArrived); + // So does its read of the secret being saved. + const reading = yield* catalog + .invoke(installationId, "read", { key: "token" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(readArrived); + + // The plugin's write waits for the lock while the plugin is disabled and enabled again. + yield* catalog.disable({ installationId }); + // Disabling ended the waiting read instead of waiting for the lock itself. + expect((yield* Fiber.join(reading))._tag).toBe("PluginStoppedError"); + yield* catalog.enable({ installationId }); + yield* Deferred.succeed(finishWrite, undefined); + expect((yield* Fiber.join(saving)).secrets).toEqual(["token"]); + yield* Fiber.join(storing); + expect((yield* countRows(installationId)).storage).toBe(0); + + // The new generation saves as usual. + yield* catalog.invoke(installationId, "store", { key: "cursor", value: 2 }); + expect(yield* catalog.invoke(installationId, "load", { key: "cursor" })).toEqual({ + value: 2, + }); + }), + ), + ); + + // Below the channel's 64 KiB buffer a full bound can come without a write that waits for drain. + it.effect.each([ + { maxMessageBytes: 128 * 1024, answerBytes: 60_000 }, + { maxMessageBytes: 32 * 1024, answerBytes: 20_000 }, + ])( + "stops reading a plugin that does not read its answers, and loses none ($maxMessageBytes bytes)", + ({ maxMessageBytes, answerBytes }) => { + const backedUp = Deferred.makeUnsafe(); + const logger = Logger.make(({ message }) => { + if (String(message).includes("not reading the server's answers")) + Deferred.doneUnsafe(backedUp, Exit.void); + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supervisor = yield* makeSupervisor(yield* Scope.Scope, RAW_CHILD_PATH, { + maxMessageBytes, + }); + let served = 0; + yield* supervisor + .serveHostMethod("flood.get", () => + Effect.sync(() => { + served++; + return { data: "x".repeat(answerBytes) }; + }), + ) + .pipe(Effect.provideService(Scope.Scope, yield* Scope.Scope)); + const requests = 400; + const flood = yield* prepareRawPlugin("test.flood", { + mode: "flood", + requests, + method: "flood.get", + }); + const echo = yield* prepareRawPlugin("test.echo", { mode: "echo" }); + yield* supervisor.enable(flood.registration); + yield* supervisor.enable(echo.registration); + const summary = yield* awaitLog(supervisor, "test.flood", (message) => + message.startsWith("answered"), + ); + const first = yield* supervisor + .invoke(flood.registration.manifest.id, "ping", 1) + .pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(backedUp); + // Other plugins keep working while this one is not read. + expect(yield* supervisor.invoke(echo.registration.manifest.id, "ping", 2)).toBe(2); + // Without backpressure all 400 answers would sit in the server's write buffer. + expect(served).toBeGreaterThan(0); + expect(served).toBeLessThan(40); + + // Once the plugin reads again, every request gets exactly one answer. + const pid = Number(yield* fs.readFileString(path.join(flood.directory, "raw-child.pid"))); + process.kill(pid, "SIGUSR2"); + const answered = yield* Fiber.join(summary); + const [, total, refused, messages] = /^answered (\d+), refused (\d+): (.*)$/.exec( + answered, + )!; + expect(Number(total)).toBe(requests); + expect(served + Number(refused)).toBe(requests); + // A refusal can only be the cap, while answers wait for the plugin to read. + for (const message of parseRefusals(messages!)) + expect(message).toBe("16 calls to the server are already in flight."); + expect(yield* Fiber.join(first)).toBe(1); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }, + ); + + it.effect("refuses past 16 host calls from a plugin that bypasses the API", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(yield* Scope.Scope, RAW_CHILD_PATH); + const release = yield* Deferred.make(); + let inFlight = 0; + yield* supervisor + .serveHostMethod("burst.get", () => + Effect.sync(() => inFlight++).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(null), + ), + ) + .pipe(Effect.provideService(Scope.Scope, yield* Scope.Scope)); + const burst = yield* prepareRawPlugin("test.burst", { + mode: "burst", + requests: 17, + method: "burst.get", + }); + const pluginId = burst.registration.manifest.id; + yield* supervisor.enable(burst.registration); + const refused = yield* awaitLog(supervisor, pluginId, (message) => + message.startsWith("refused"), + ); + const summary = yield* awaitLog(supervisor, pluginId, (message) => + message.startsWith("answered"), + ); + expect(yield* supervisor.invoke(pluginId, "ping", 1)).toBe(1); + expect(yield* Fiber.join(refused)).toBe( + "refused: 16 calls to the server are already in flight.", + ); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(summary)).toBe( + 'answered 17, refused 1: ["16 calls to the server are already in flight."]', + ); + expect(inFlight).toBe(16); + }), + ); + }); + + describe("secrets", () => { + it.effect("keeps a secret's row until its file is deleted, so cleanup always finishes", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + let run = yield* restart(yield* Scope.make(), secrets.service); + const installationId = yield* install(run.catalog, yield* preparePlugin()); + const saveToken = run.settings.update({ + installationId, + changes: [{ key: "token", value: SECRET }], + }); + yield* saveToken; + + // A clear whose file deletion fails reads as cleared, and keeps the row that finds it. + secrets.faults.remove = true; + const clearing = yield* run.settings + .update({ installationId, changes: [{ key: "token", value: null }] }) + .pipe(Effect.flip); + expect(clearing.reason).toBe("storage"); + expect((yield* awaitValues(run.settings, installationId, () => true)).secrets).toEqual( + [], + ); + expect(yield* run.catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + unset: true, + }); + expect(secrets.entries.size).toBe(1); + secrets.faults.remove = false; + run = yield* restart(run.scope, secrets.service); + expect(secrets.entries.size).toBe(0); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 0, + storage: 0, + }); + + // A save interrupted after its file was written is found the same way. + secrets.faults.set = true; + const saving = yield* run.settings + .update({ installationId, changes: [{ key: "token", value: SECRET }] }) + .pipe(Effect.flip); + expect(saving.reason).toBe("storage"); + expect(secrets.entries.size).toBe(1); + expect((yield* awaitValues(run.settings, installationId, () => true)).secrets).toEqual( + [], + ); + secrets.faults.set = false; + run = yield* restart(run.scope, secrets.service); + expect(secrets.entries.size).toBe(0); + expect((yield* countRows(installationId)).secrets).toBe(0); + + // A removal whose file deletion fails finishes at the next start. + yield* run.settings.update({ + installationId, + changes: [{ key: "token", value: SECRET }], + }); + secrets.faults.remove = true; + const ended = yield* run.settings + .subscribe(installationId) + .pipe(Stream.runDrain, Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* run.catalog.remove({ installationId }); + expect((yield* Fiber.join(ended)).reason).toBe("not-found"); + expect(secrets.entries.size).toBe(1); + expect((yield* countRows(installationId)).secrets).toBe(1); + secrets.faults.remove = false; + run = yield* restart(run.scope, secrets.service); + expect(secrets.entries.size).toBe(0); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 0, + storage: 0, + }); + yield* Scope.close(run.scope, Exit.void); + }), + ), + ); + + it.effect("never lets a plugin read a secret whose save did not finish", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const installationId = yield* install(catalog, yield* preparePlugin()); + yield* settings.update({ + installationId, + changes: [{ key: "token", value: "old-valid" }], + }); + + // The plugin's read has found the saved secret and is about to read its file. + const reading = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + secrets.hooks.beforeGet = Deferred.succeed(reading, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + ); + const read = yield* catalog + .invoke(installationId, "read", { key: "token" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(reading); + + // Meanwhile a client clears it, then a new save fails after writing its file. + const changing = yield* Effect.gen(function* () { + yield* settings.update({ installationId, changes: [{ key: "token", value: null }] }); + secrets.faults.set = true; + return yield* settings + .update({ installationId, changes: [{ key: "token", value: "new-failed" }] }) + .pipe(Effect.flip); + }).pipe(Effect.forkChild({ startImmediately: true })); + + secrets.hooks.beforeGet = Effect.void; + yield* Deferred.succeed(finishRead, undefined); + // The read sees the secret saved when it started, never the unfinished one. + expect(yield* Fiber.join(read)).toEqual({ value: "old-valid" }); + expect((yield* Fiber.join(changing)).reason).toBe("storage"); + expect(yield* catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + unset: true, + }); + }), + ), + ); + + it.effect("keeps only the fields the manifest declares now", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const directory = yield* preparePlugin(); + const installationId = yield* install(catalog, directory); + const redeclare = (fields: ReadonlyArray>) => + writeManifest(directory, { settings: fields }).pipe( + Effect.andThen(catalog.refresh({ installationId })), + ); + yield* settings.update({ + installationId, + changes: [ + { key: "token", value: SECRET }, + { key: "mode", value: "fast" }, + ], + }); + + // A secret field that becomes text loses its secret before the text is saved. + yield* redeclare([{ type: "text", key: "token", label: "API token" }]); + const retyped = yield* settings.update({ + installationId, + changes: [{ key: "token", value: "plain" }], + }); + expect(retyped).toEqual({ + installationId, + values: [{ key: "token", value: "plain" }], + secrets: [], + }); + expect(secrets.entries.size).toBe(0); + expect(yield* countRows(installationId)).toEqual({ + settings: 1, + secrets: 0, + storage: 0, + }); + + // Manifests that keep renaming 32 fields keep 32 values, and frames stay one size. + const sizes = []; + for (const generation of [0, 1, 2]) { + const keys = Array.from( + { length: 32 }, + (_, index) => `g${generation}k${String(index).padStart(2, "0")}`, + ); + yield* redeclare(keys.map((key) => ({ type: "text", key, label: key }))); + const saved = yield* settings.update({ + installationId, + changes: keys.map((key) => ({ key, value: "v".repeat(2000) })), + }); + expect(saved.values.map((entry) => entry.key)).toEqual(keys); + expect(yield* countRows(installationId)).toEqual({ + settings: 32, + secrets: 0, + storage: 0, + }); + sizes.push(toJson(saved).length); + } + expect(new Set(sizes).size).toBe(1); + + // Values of fields no longer declared are not sent, even before the next save. + yield* redeclare([{ type: "boolean", key: "verbose", label: "Verbose" }]); + expect(yield* awaitValues(settings, installationId, () => true)).toEqual({ + installationId, + values: [], + secrets: [], + }); + }), + ), + ); + + it.effect("sends open subscriptions the new fields when the declaration changes", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const directory = yield* preparePlugin(); + const installationId = yield* install(catalog, directory); + yield* settings.update({ + installationId, + changes: [ + { key: "token", value: SECRET }, + { key: "mode", value: "fast" }, + ], + }); + const first = yield* Deferred.make(); + const snapshots = yield* settings.subscribe(installationId).pipe( + Stream.tap(() => Deferred.succeed(first, undefined)), + Stream.take(2), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(first); + + yield* writeManifest(directory, { + settings: [{ type: "boolean", key: "verbose", label: "Verbose" }], + }); + yield* catalog.refresh({ installationId }); + expect(yield* Fiber.join(snapshots)).toEqual([ + { installationId, values: [{ key: "mode", value: "fast" }], secrets: ["token"] }, + { installationId, values: [], secrets: [] }, + ]); + }), + ), + ); + + it.effect("saves nothing new while a retired secret cannot be deleted", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const directory = yield* preparePlugin(); + const installationId = yield* install(catalog, directory); + const declare = (generation: number) => + Effect.gen(function* () { + const keys = Array.from( + { length: 32 }, + (_, index) => `g${generation}k${String(index).padStart(2, "0")}`, + ); + yield* writeManifest(directory, { + settings: keys.map((key) => ({ type: "secret", key, label: key })), + }); + yield* catalog.refresh({ installationId }); + return keys.map((key) => ({ key, value: SECRET })); + }); + yield* settings.update({ installationId, changes: yield* declare(0) }); + expect(secrets.entries.size).toBe(32); + + // Renaming every field while deletes fail refuses each save and stores nothing more. + secrets.faults.remove = true; + for (const generation of [1, 2, 3]) { + const refused = yield* settings + .update({ installationId, changes: yield* declare(generation) }) + .pipe(Effect.flip); + expect(refused.reason).toBe("storage"); + expect(secrets.entries.size).toBe(32); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 32, + storage: 0, + }); + } + // A secret field retyped as text keeps its secret and gets no value beside it. + yield* writeManifest(directory, { + settings: [{ type: "text", key: "g0k00", label: "g0k00" }], + }); + yield* catalog.refresh({ installationId }); + const retyped = yield* settings + .update({ installationId, changes: [{ key: "g0k00", value: "plain" }] }) + .pipe(Effect.flip); + expect(retyped.reason).toBe("storage"); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 32, + storage: 0, + }); + + // Once deletes work, the next save retires the old secrets first. + secrets.faults.remove = false; + const changes = yield* declare(3); + const saved = yield* settings.update({ installationId, changes }); + expect(saved.secrets).toEqual(changes.map((change) => change.key)); + expect(secrets.entries.size).toBe(32); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 32, + storage: 0, + }); + + const ended = yield* settings + .subscribe(installationId) + .pipe(Stream.runDrain, Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* catalog.remove({ installationId }); + expect((yield* Fiber.join(ended)).reason).toBe("not-found"); + expect(secrets.entries.size).toBe(0); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 0, + storage: 0, + }); + }), + ), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginSettings.ts b/apps/server/src/plugins/PluginSettings.ts new file mode 100644 index 000000000000..f1914ddb14d9 --- /dev/null +++ b/apps/server/src/plugins/PluginSettings.ts @@ -0,0 +1,565 @@ +/** + * Saved settings, secrets, and private storage of plugin installations. + * + * Users save values for the fields an installation's manifest declares + * (`plugins.settings.*`); the plugin reads them, and keeps its own small + * key-value storage, through host methods its child process calls. Values + * belong to the installation, so they outlive disable, re-enable, restarts + * and source changes, and are deleted when the installation is removed. + * + * A secret's value lives in the server secret store and is only ever read by + * the plugin; clients learn whether one is saved and nothing else. A row in + * `plugin_setting_secrets` exists before its file is written and goes only + * after the file is deleted, so a failure or crash at any step leaves a row + * that the next clear, removal, or start finishes. Every write and the + * removal cleanup run one at a time, and a write first checks that the + * installation still exists, so nothing is saved for an installation after + * its cleanup. + * + * Only the fields the installation declares now are kept: each update first + * deletes values (and secrets) of keys that are no longer declared, or no + * longer of that kind, and snapshots only carry declared fields. An update + * whose retired secret cannot be deleted fails and saves nothing, keeping the + * row for the next attempt, so what is stored and sent stays within one + * declaration's bounds as manifests change, even while deletion fails. + */ +import { + PLUGIN_SETTINGS_CAPABILITY, + PluginCatalogError, + PluginSettingValue, + pluginSettingValueProblem, + resolvePluginSettingValue, + type PluginCatalogSnapshot, + type PluginInstallationId, + type PluginSettingField, + type PluginSettingsUpdateInput, + type PluginSettingsValues, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +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 { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import type { PluginRegistration } from "./PluginManifestLoader.ts"; +import { PluginCatalog } from "./PluginCatalog.ts"; +import { + PluginHostCallError, + PluginSupervisor, + type PluginHostMethod, +} from "./PluginSupervisor.ts"; + +type HostCall = Parameters[0]; + +/** Bounds of one installation's private storage. */ +export interface PluginStorageLimits { + readonly maxKeyLength: number; + readonly maxValueBytes: number; + readonly maxKeys: number; + readonly maxTotalBytes: number; +} + +const defaultPluginStorageLimits: PluginStorageLimits = { + maxKeyLength: 128, + maxValueBytes: 64 * 1024, + maxKeys: 256, + maxTotalBytes: 1024 * 1024, +}; + +const encodeName = (text: string) => Buffer.from(text, "utf8").toString("base64url"); +const secretName = (installationId: PluginInstallationId, key: string) => + `plugin-setting-${encodeName(installationId)}-${encodeName(key)}`; + +const SavedValueJson = Schema.fromJsonString(PluginSettingValue); +const decodeSavedValue = Schema.decodeUnknownOption(SavedValueJson); +const encodeSavedValue = Schema.encodeSync(SavedValueJson); +const StoredJson = Schema.fromJsonString(Schema.Json); +const decodeStoredJson = Schema.decodeUnknownOption(StoredJson); +const encodeStoredJson = Schema.encodeSync(StoredJson); + +const decodeKeyInput = Schema.decodeUnknownEffect(Schema.Struct({ key: Schema.String })); +const decodeSetInput = Schema.decodeUnknownEffect( + Schema.Struct({ key: Schema.String, value: Schema.Json }), +); + +const hasControlCharacter = (text: string) => { + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index); + if (code < 0x20 || code === 0x7f) return true; + } + return false; +}; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const catalogError = (reason: string, message: string, installationId: PluginInstallationId) => + new PluginCatalogError({ reason, message, installationId }); + +const hostError = (message: string) => new PluginHostCallError({ message }); + +interface SettingsEvent { + readonly installationId: PluginInstallationId; + readonly removed: boolean; +} + +export class PluginSettings extends Context.Service< + PluginSettings, + { + /** The saved values now, then after every change; fails `not-found` once the installation is removed. */ + readonly subscribe: ( + installationId: PluginInstallationId, + ) => Stream.Stream; + /** Checks every change against the declared fields, then saves them. Never returns a secret. */ + readonly update: ( + input: PluginSettingsUpdateInput, + ) => Effect.Effect; + } +>()("t3/plugins/PluginSettings") {} + +export const make = Effect.fn("PluginSettings.make")(function* ( + limits: PluginStorageLimits = defaultPluginStorageLimits, +) { + const sql = yield* SqlClient.SqlClient; + const secrets = yield* ServerSecretStore; + const catalog = yield* PluginCatalog; + const supervisor = yield* PluginSupervisor; + + // Writes, removal cleanup and secret reads run one at a time, so none sees another half done. + const lock = yield* Semaphore.make(1); + const events = yield* PubSub.sliding(256); + + const findInstallation = (installationId: PluginInstallationId) => + catalog.list.pipe( + Effect.map((snapshot) => + snapshot.installations.find( + (installation) => installation.installationId === installationId, + ), + ), + ); + + const notFound = (installationId: PluginInstallationId) => + catalogError("not-found", "That plugin is not installed here.", installationId); + + const storageFailed = (installationId: PluginInstallationId) => (cause: unknown) => + Effect.logWarning("Plugin settings storage failed", { installationId, cause }).pipe( + Effect.andThen( + Effect.fail( + catalogError("storage", "Could not save the plugin's settings.", installationId), + ), + ), + ); + + const savedRows = (installationId: PluginInstallationId) => + sql<{ readonly key: string; readonly value_json: string }>` + SELECT key, value_json FROM plugin_settings + WHERE installation_id = ${installationId} + ORDER BY key + `; + + /** Secrets that may have a file; `saved` is 0 while one is written or being deleted. */ + const secretRows = (installationId: PluginInstallationId) => + sql<{ readonly key: string; readonly saved: number }>` + SELECT key, saved FROM plugin_setting_secrets + WHERE installation_id = ${installationId} + ORDER BY key + `; + + const isSecret = (fields: ReadonlyArray, key: string) => + fields.some((field) => field.key === key && field.type === "secret"); + const isValue = (fields: ReadonlyArray, key: string) => + fields.some((field) => field.key === key && field.type !== "secret"); + + /** The saved values of the fields declared now. */ + const readValues = ( + installationId: PluginInstallationId, + fields: ReadonlyArray, + ) => + Effect.all([savedRows(installationId), secretRows(installationId)]).pipe( + Effect.map(([rows, secretKeys]): PluginSettingsValues => { + const values: Array<{ key: string; value: PluginSettingValue }> = []; + for (const row of rows) { + if (!isValue(fields, row.key)) continue; + const value = decodeSavedValue(row.value_json); + if (Option.isSome(value)) values.push({ key: row.key, value: value.value }); + } + return { + installationId, + values, + secrets: secretKeys + .filter((row) => row.saved === 1 && isSecret(fields, row.key)) + .map((row) => row.key), + }; + }), + Effect.catch(storageFailed(installationId)), + ); + + const current = Effect.fnUntraced(function* (installationId: PluginInstallationId) { + const installation = yield* findInstallation(installationId); + if (installation === undefined) return yield* notFound(installationId); + return yield* readValues(installationId, installation.manifest?.settings ?? []); + }); + + /** Saves a secret: its row is written first, so a file never exists without one. */ + const saveSecret = Effect.fnUntraced(function* ( + installationId: PluginInstallationId, + key: string, + value: string, + ) { + // A replaced secret stays saved; its file is swapped in one rename. + yield* sql` + INSERT INTO plugin_setting_secrets (installation_id, key, saved) + VALUES (${installationId}, ${key}, 0) + ON CONFLICT (installation_id, key) DO NOTHING + `; + yield* secrets.set(secretName(installationId, key), textEncoder.encode(value)); + yield* sql` + UPDATE plugin_setting_secrets SET saved = 1 + WHERE installation_id = ${installationId} AND key = ${key} + `; + }); + + /** Deletes a secret's file, then its row; a failure leaves the row for the next attempt. */ + const deleteSecret = Effect.fnUntraced(function* ( + installationId: PluginInstallationId, + key: string, + ) { + yield* sql` + UPDATE plugin_setting_secrets SET saved = 0 + WHERE installation_id = ${installationId} AND key = ${key} + `; + yield* secrets.remove(secretName(installationId, key)); + yield* sql` + DELETE FROM plugin_setting_secrets + WHERE installation_id = ${installationId} AND key = ${key} + `; + }); + + const deleteSecretOrWarn = (installationId: PluginInstallationId, key: string) => + deleteSecret(installationId, key).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not delete a plugin's secret; it is retried later", { + installationId, + cause, + }), + ), + ); + + /** + * Deletes what is saved for keys the declaration no longer has, or has as another kind. Fails + * if a secret's file cannot be deleted, so nothing new is saved beside it. + */ + const retireUndeclared = Effect.fnUntraced(function* ( + installationId: PluginInstallationId, + fields: ReadonlyArray, + ) { + for (const row of yield* secretRows(installationId)) + if (!isSecret(fields, row.key)) yield* deleteSecret(installationId, row.key); + for (const row of yield* savedRows(installationId)) + if (!isValue(fields, row.key)) + yield* sql` + DELETE FROM plugin_settings + WHERE installation_id = ${installationId} AND key = ${row.key} + `; + }); + + const update = Effect.fn("PluginSettings.update")(function* (input: PluginSettingsUpdateInput) { + const { installationId } = input; + const installation = yield* findInstallation(installationId); + if (installation === undefined) return yield* notFound(installationId); + const fields = installation.manifest?.settings ?? []; + const invalid = (message: string) => catalogError("invalid-setting", message, installationId); + const seen = new Set(); + const plan = []; + for (const change of input.changes) { + const field = fields.find((candidate) => candidate.key === change.key); + if (field === undefined) return yield* invalid(`This plugin has no setting "${change.key}".`); + if (seen.has(change.key)) return yield* invalid(`"${change.key}" is changed twice.`); + seen.add(change.key); + if (change.value !== null) { + const problem = pluginSettingValueProblem(field, change.value); + if (problem !== undefined) return yield* invalid(problem); + } + plan.push({ key: change.key, secret: field.type === "secret", value: change.value }); + } + const failed = storageFailed(installationId); + yield* retireUndeclared(installationId, fields).pipe(Effect.catch(failed)); + for (const step of plan) { + if (step.secret && step.value !== null) + yield* saveSecret(installationId, step.key, String(step.value)).pipe(Effect.catch(failed)); + else if (step.secret) + yield* deleteSecret(installationId, step.key).pipe(Effect.catch(failed)); + else if (step.value !== null) { + const json = encodeSavedValue(step.value); + yield* sql` + INSERT INTO plugin_settings (installation_id, key, value_json) + VALUES (${installationId}, ${step.key}, ${json}) + ON CONFLICT (installation_id, key) DO UPDATE SET value_json = excluded.value_json + `.pipe(Effect.catch(failed)); + } else { + yield* sql` + DELETE FROM plugin_settings WHERE installation_id = ${installationId} AND key = ${step.key} + `.pipe(Effect.catch(failed)); + } + } + return yield* readValues(installationId, fields); + }); + + /** Deletes everything saved for an installation that no longer exists. */ + const purge = Effect.fnUntraced(function* (installationId: PluginInstallationId) { + for (const row of yield* secretRows(installationId)) + yield* deleteSecretOrWarn(installationId, row.key); + yield* sql`DELETE FROM plugin_settings WHERE installation_id = ${installationId}`; + yield* sql`DELETE FROM plugin_storage WHERE installation_id = ${installationId}`; + }); + + const purgeRemoved = (installationIds: Iterable) => + lock.withPermit( + Effect.forEach( + installationIds, + (installationId) => + purge(installationId).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not delete a removed plugin's settings", { + installationId, + cause, + }), + ), + Effect.andThen(PubSub.publish(events, { installationId, removed: true })), + ), + { discard: true }, + ), + ); + + // ---- Host methods: what the plugin's own process may read and write ---- + + const owner = (registration: PluginRegistration) => + registration.installationId !== undefined && + registration.manifest.capabilities.includes(PLUGIN_SETTINGS_CAPABILITY) + ? Effect.succeed(registration.installationId) + : Effect.fail(hostError(`The plugin did not declare the "settings" capability.`)); + + const malformed = () => hostError("The request is malformed."); + + const checkStorageKey = (key: string) => + key.length > 0 && key.length <= limits.maxKeyLength && !hasControlCharacter(key) + ? Effect.void + : Effect.fail( + hostError( + `Storage keys must be 1 to ${limits.maxKeyLength} characters without control characters.`, + ), + ); + + const hostFailed = (cause: unknown) => + Effect.logWarning("Plugin storage failed", { cause }).pipe( + Effect.andThen(Effect.fail(hostError("The server could not read or save the value."))), + ); + + const settingsGet = Effect.fnUntraced(function* ({ registration, input }: HostCall) { + const installationId = yield* owner(registration); + const { key } = yield* decodeKeyInput(input).pipe(Effect.mapError(malformed)); + const field = registration.manifest.settings?.find((candidate) => candidate.key === key); + if (field === undefined) return yield* hostError(`"${key}" is not a declared setting.`); + if (field.type === "secret") { + // Under the lock, so no save or clear swaps the file between the check and the read. + return yield* lock.withPermit( + Effect.gen(function* () { + const rows = yield* sql<{ readonly saved: number }>` + SELECT saved FROM plugin_setting_secrets + WHERE installation_id = ${installationId} AND key = ${key} + `.pipe(Effect.catch(hostFailed)); + if (rows[0]?.saved !== 1) return { value: null }; + const secret = yield* secrets + .get(secretName(installationId, key)) + .pipe(Effect.catch(hostFailed)); + return { value: Option.isSome(secret) ? textDecoder.decode(secret.value) : null }; + }), + ); + } + const rows = yield* sql<{ readonly value_json: string }>` + SELECT value_json FROM plugin_settings + WHERE installation_id = ${installationId} AND key = ${key} + `.pipe(Effect.catch(hostFailed)); + const saved = + rows[0] === undefined + ? undefined + : Option.getOrUndefined(decodeSavedValue(rows[0].value_json)); + return { value: resolvePluginSettingValue(field, saved) ?? null }; + }); + + const storageGet = Effect.fnUntraced(function* ({ registration, input }: HostCall) { + const installationId = yield* owner(registration); + const { key } = yield* decodeKeyInput(input).pipe(Effect.mapError(malformed)); + yield* checkStorageKey(key); + const rows = yield* sql<{ readonly value_json: string }>` + SELECT value_json FROM plugin_storage + WHERE installation_id = ${installationId} AND key = ${key} + `.pipe(Effect.catch(hostFailed)); + const value = rows[0] === undefined ? Option.none() : decodeStoredJson(rows[0].value_json); + return Option.match(value, { + onNone: () => ({ found: false, value: null }), + onSome: (json) => ({ found: true, value: json }), + }); + }); + + const storageSet = Effect.fnUntraced(function* ({ registration, input, admitted }: HostCall) { + const installationId = yield* owner(registration); + const { key, value } = yield* decodeSetInput(input).pipe(Effect.mapError(malformed)); + yield* checkStorageKey(key); + const json = encodeStoredJson(value); + const bytes = Buffer.byteLength(json); + if (bytes > limits.maxValueBytes) + return yield* hostError(`The value is ${bytes} bytes; the limit is ${limits.maxValueBytes}.`); + return yield* lock.withPermit( + Effect.gen(function* () { + // Checked under the lock: a removed installation's cleanup may already have run. + if ((yield* findInstallation(installationId)) === undefined) + return yield* hostError("The plugin was removed."); + const usage = yield* sql<{ readonly keys: number; readonly bytes: number | null }>` + SELECT COUNT(*) AS keys, SUM(bytes) AS bytes FROM plugin_storage + WHERE installation_id = ${installationId} AND key != ${key} + `.pipe(Effect.catch(hostFailed)); + const others = usage[0] ?? { keys: 0, bytes: 0 }; + if (others.keys + 1 > limits.maxKeys) + return yield* hostError(`The plugin already stores ${limits.maxKeys} keys.`); + if ((others.bytes ?? 0) + bytes > limits.maxTotalBytes) + return yield* hostError( + `Saving this would store more than ${limits.maxTotalBytes} bytes for the plugin.`, + ); + // A revoked generation's write that waited for the lock is dropped here. + yield* admitted; + yield* sql` + INSERT INTO plugin_storage (installation_id, key, value_json, bytes) + VALUES (${installationId}, ${key}, ${json}, ${bytes}) + ON CONFLICT (installation_id, key) DO UPDATE SET + value_json = excluded.value_json, + bytes = excluded.bytes + `.pipe(Effect.catch(hostFailed)); + return null; + }), + ); + }); + + const storageDelete = Effect.fnUntraced(function* ({ registration, input, admitted }: HostCall) { + const installationId = yield* owner(registration); + const { key } = yield* decodeKeyInput(input).pipe(Effect.mapError(malformed)); + yield* checkStorageKey(key); + return yield* lock.withPermit( + Effect.gen(function* () { + yield* admitted; + yield* sql` + DELETE FROM plugin_storage WHERE installation_id = ${installationId} AND key = ${key} + `.pipe(Effect.catch(hostFailed)); + return null; + }), + ); + }); + + const storageKeys = Effect.fnUntraced(function* ({ registration }: HostCall) { + const installationId = yield* owner(registration); + const rows = yield* sql<{ readonly key: string }>` + SELECT key FROM plugin_storage WHERE installation_id = ${installationId} ORDER BY key + `.pipe(Effect.catch(hostFailed)); + return { keys: rows.map((row) => row.key) }; + }); + + yield* supervisor.serveHostMethod("settings.get", settingsGet); + yield* supervisor.serveHostMethod("storage.get", storageGet); + yield* supervisor.serveHostMethod("storage.set", storageSet); + yield* supervisor.serveHostMethod("storage.delete", storageDelete); + yield* supervisor.serveHostMethod("storage.keys", storageKeys); + + /** Each installation's settings declaration, to tell which ones a catalogue change touched. */ + const declarations = (snapshot: PluginCatalogSnapshot) => + new Map( + snapshot.installations.map((installation) => [ + installation.installationId, + JSON.stringify(installation.manifest?.settings ?? []), + ]), + ); + + // Delete what earlier runs saved for installations that are gone, finish secret writes and + // deletions an earlier run did not complete, then follow removals and changed declarations. + const listed = declarations(yield* catalog.list); + const stored = yield* sql<{ readonly installation_id: PluginInstallationId }>` + SELECT installation_id FROM plugin_settings + UNION + SELECT installation_id FROM plugin_setting_secrets + UNION + SELECT installation_id FROM plugin_storage + `.pipe(Effect.orDie); + yield* purgeRemoved( + stored + .map((row) => row.installation_id) + .filter((installationId) => !listed.has(installationId)), + ); + const unfinished = yield* sql<{ + readonly installation_id: PluginInstallationId; + readonly key: string; + }>`SELECT installation_id, key FROM plugin_setting_secrets WHERE saved = 0`.pipe(Effect.orDie); + yield* lock.withPermit( + Effect.forEach(unfinished, (row) => deleteSecretOrWarn(row.installation_id, row.key), { + discard: true, + }), + ); + let known = listed; + yield* catalog.subscribe.pipe( + Stream.runForEach((snapshot) => { + const next = declarations(snapshot); + const removed = [...known.keys()].filter((installationId) => !next.has(installationId)); + // Snapshots carry only declared fields, so subscribers re-read when the declaration changes. + const redeclared = [...next] + .filter( + ([installationId, fields]) => + known.has(installationId) && known.get(installationId) !== fields, + ) + .map(([installationId]) => installationId); + known = next; + return (removed.length === 0 ? Effect.void : purgeRemoved(removed)).pipe( + Effect.andThen( + Effect.forEach( + redeclared, + (installationId) => PubSub.publish(events, { installationId, removed: false }), + { discard: true }, + ), + ), + ); + }), + Effect.forkScoped, + ); + + return PluginSettings.of({ + subscribe: (installationId) => + Stream.unwrap( + // Subscribe before the first read so a change in between is not lost. + PubSub.subscribe(events).pipe( + Effect.map((subscription) => + Stream.concat( + Stream.fromEffect(current(installationId)), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.installationId === installationId), + Stream.mapEffect((event) => + event.removed ? Effect.fail(notFound(installationId)) : current(installationId), + ), + ), + ).pipe(Stream.changes), + ), + ), + ), + update: (input) => + lock + .withPermit(update(input)) + .pipe( + Effect.ensuring( + PubSub.publish(events, { installationId: input.installationId, removed: false }), + ), + ), + }); +}); + +export const layer = (limits?: PluginStorageLimits) => Layer.effect(PluginSettings, make(limits)); diff --git a/apps/server/src/plugins/PluginSettingsRpc.test.ts b/apps/server/src/plugins/PluginSettingsRpc.test.ts new file mode 100644 index 000000000000..4f4b8a1dcfbc --- /dev/null +++ b/apps/server/src/plugins/PluginSettingsRpc.test.ts @@ -0,0 +1,113 @@ +import { + AuthAccessWriteScope, + AuthAdministrativeScopes, + type AuthEnvironmentScope, + AuthOrchestrationReadScope, + AuthRelayReadScope, + AuthStandardClientScopes, + PluginInstallationId, + type PluginSettingsValues, + 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 SettingsMethod = + | typeof WS_METHODS.pluginsSettingsSubscribe + | typeof WS_METHODS.pluginsSettingsUpdate; +const settingsMethods: ReadonlySet = new Set([ + WS_METHODS.pluginsSettingsSubscribe, + WS_METHODS.pluginsSettingsUpdate, +]); + +const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !settingsMethods.has(tag), + ), +); + +const installationId = PluginInstallationId.make("fixture"); +const values: PluginSettingsValues = { installationId, values: [], secrets: ["token"] }; + +/** Serves the settings 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.pluginsSettingsSubscribe, () => + Stream.fromEffect( + Effect.sync(() => handled.push(WS_METHODS.pluginsSettingsSubscribe)).pipe( + Effect.as(values), + ), + ), + ), + group.toLayerHandler(WS_METHODS.pluginsSettingsUpdate, () => + Effect.sync(() => handled.push(WS_METHODS.pluginsSettingsUpdate)).pipe(Effect.as(values)), + ), + RpcAuthorization.layer(scopes), + ), + ), + ); + +const update = { installationId, changes: [{ key: "token", value: "fixture-secret" }] }; + +describe("plugin settings RPC scopes", () => { + it.effect("lets a standard pairing read values but not save them", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient(AuthStandardClientScopes, handled); + + expect( + yield* client[WS_METHODS.pluginsSettingsSubscribe]({ installationId }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ).toEqual([values]); + expect( + yield* client[WS_METHODS.pluginsSettingsUpdate](update).pipe(Effect.flip), + ).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthAccessWriteScope, + requiredPermission: AuthAccessWriteScope, + }); + expect(handled).toEqual([WS_METHODS.pluginsSettingsSubscribe]); + }).pipe(Effect.scoped), + ); + + it.effect("lets an administrative pairing save values", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient(AuthAdministrativeScopes, handled); + + expect(yield* client[WS_METHODS.pluginsSettingsUpdate](update)).toEqual(values); + expect(handled).toEqual([WS_METHODS.pluginsSettingsUpdate]); + }).pipe(Effect.scoped), + ); + + it.effect("refuses value reads without the orchestration read scope", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient([AuthRelayReadScope], handled); + + expect( + yield* client[WS_METHODS.pluginsSettingsSubscribe]({ installationId }).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/PluginSupervisor.test.ts b/apps/server/src/plugins/PluginSupervisor.test.ts new file mode 100644 index 000000000000..c3c6ab316097 --- /dev/null +++ b/apps/server/src/plugins/PluginSupervisor.test.ts @@ -0,0 +1,749 @@ +// @effect-diagnostics nodeBuiltinImport:off -- A TCP listener hears from a process a plugin started. +import * as NodeNet from "node:net"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import type { PluginHostState, PluginId } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +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 Scope from "effect/Scope"; +import { TestClock } from "effect/testing"; + +import { loadPluginDirectory } from "./PluginManifestLoader.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; + +const FIXTURE_DIR = `${import.meta.dirname}/testFixtures/plugin`; +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; + +const testOptions = { + heapLimitMb: 64, + maxMessageBytes: 64 * 1024, + activationTimeout: "5 seconds", + callTimeout: "5 seconds", + cancelGrace: "1 second", + stopGrace: "1 second", + maxRestarts: 2, + restartBackoff: "1 second", + maxRestartBackoff: "4 seconds", + stableUptime: "1 minute", +} satisfies Partial; + +const makeSupervisor = (overrides: Partial = {}) => + PluginSupervisor.make({ ...testOptions, ...overrides }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + ); + +/** Copies the fixture plugin into a scoped temp directory under its own manifest. */ +const preparePlugin = Effect.fn("preparePlugin")(function* ( + id: string, + manifest: Record = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-" }); + for (const file of yield* fs.readDirectory(FIXTURE_DIR)) + if (file.endsWith(".mjs")) + yield* fs.copyFile(path.join(FIXTURE_DIR, file), path.join(directory, file)); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id, + name: id, + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + proposedApi: true, + ...manifest, + }), + ); + return { directory, registration: yield* loadPluginDirectory(directory) }; +}); + +type Supervisor = PluginSupervisor.PluginSupervisor["Service"]; +type Subscription = PubSub.Subscription; + +const awaitState = Effect.fn("awaitState")(function* ( + supervisor: Supervisor, + subscription: Subscription, + pluginId: PluginId, + tag: Tag, +) { + let current = yield* supervisor.state(pluginId); + while (true) { + if (Option.isSome(current) && current.value._tag === tag) + return current.value as Extract; + const event = yield* PubSub.take(subscription); + if (event._tag === "StateChanged" && event.pluginId === pluginId) + current = Option.some(event.state); + } +}); + +const awaitLog = Effect.fn("awaitLog")(function* ( + subscription: Subscription, + pluginId: PluginId, + message: string, +) { + while (true) { + const event = yield* PubSub.take(subscription); + if (event._tag === "Log" && event.pluginId === pluginId && event.message === message) return; + } +}); + +const awaitLogMatching = Effect.fn("awaitLogMatching")(function* ( + subscription: Subscription, + pluginId: PluginId, + pattern: RegExp, +) { + while (true) { + const event = yield* PubSub.take(subscription); + if (event._tag === "Log" && event.pluginId === pluginId && pattern.test(event.message)) + return event.message; + } +}); + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +/** Collects what the first connection to a local port sends until it closes. */ +const listenOnce = () => + Effect.acquireRelease( + Effect.promise( + () => + new Promise<{ server: NodeNet.Server; port: number; received: Promise }>( + (resolve) => { + let report!: (text: string) => void; + const received = new Promise((done) => (report = done)); + const server = NodeNet.createServer((socket) => { + let text = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => (text += chunk)); + socket.on("close", () => report(text)); + }); + server.listen(0, "127.0.0.1", () => + resolve({ server, port: (server.address() as NodeNet.AddressInfo).port, received }), + ); + }, + ), + ), + ({ server }) => Effect.sync(() => server.close()), + ); + +const pidOf = (value: unknown) => (value as { readonly pid: number }).pid; + +const isProcessAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +it.layer(NodeServices.layer)("PluginSupervisor", (it) => { + describe("manifests", () => { + it.effect("loads the fixture and refuses incompatible or escaping plugins", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* loadPluginDirectory(FIXTURE_DIR); + expect(fixture.manifest).toMatchObject({ + id: "test.fixture", + apiVersion: 1, + capabilities: [], + proposedApi: true, + }); + expect(fixture.entryPath.endsWith(`${path.sep}main.mjs`)).toBe(true); + + const reason = (manifest: Record) => + preparePlugin("test.invalid", manifest).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + expect(yield* reason({ apiVersion: 2 })).toContain("targets plugin API version 2"); + expect(yield* reason({ capabilities: ["unimplemented"] })).toContain( + "does not support unimplemented", + ); + expect(yield* reason({ entry: "../main.mjs" })).toContain("is invalid"); + expect(yield* reason({ entry: "main.ts" })).toContain("is invalid"); + expect(yield* reason({ id: "Not-Qualified" })).toContain("is invalid"); + + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-outside-" }); + yield* fs.writeFileString( + path.join(outside, "escape.mjs"), + "export function activate() {}", + ); + const { directory } = yield* preparePlugin("test.symlink"); + yield* fs.symlink(path.join(outside, "escape.mjs"), path.join(directory, "link.mjs")); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id: "test.symlink", + name: "Symlink", + version: "1", + apiVersion: 1, + entry: "link.mjs", + }), + ); + const escaped = yield* loadPluginDirectory(directory).pipe(Effect.flip); + expect(escaped.message).toContain("resolves outside the plugin directory"); + }), + ); + }); + + describe("lifecycle", () => { + it.effect("starts no process until first use and stops it on disable and shutdown", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supervisor = yield* makeSupervisor(); + const { directory, registration } = yield* preparePlugin("test.lazy"); + const pluginId = registration.manifest.id; + const marker = path.join(directory, "activated.marker"); + + yield* supervisor.enable(registration); + expect(yield* supervisor.state(pluginId)).toEqual(Option.some({ _tag: "idle" })); + expect(yield* fs.exists(marker)).toBe(false); + const duplicate = yield* supervisor.enable(registration).pipe(Effect.flip); + expect(duplicate._tag).toBe("PluginAlreadyEnabledError"); + + const result = yield* supervisor.invoke(pluginId, "ping", { hello: "world" }); + expect(result).toMatchObject({ input: { hello: "world" } }); + const pid = pidOf(result); + expect(yield* fs.exists(marker)).toBe(true); + expect(yield* supervisor.state(pluginId)).toEqual(Option.some({ _tag: "running" })); + expect(isProcessAlive(pid)).toBe(true); + + yield* supervisor.disable(pluginId); + expect(isProcessAlive(pid)).toBe(false); + expect(yield* supervisor.state(pluginId)).toEqual(Option.none()); + const afterDisable = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(afterDisable._tag).toBe("PluginNotEnabledError"); + + // Closing the supervisor's scope stops every plugin it still runs. + const shutdownPid = yield* Effect.scoped( + Effect.gen(function* () { + const scoped = yield* makeSupervisor(); + yield* scoped.enable(registration); + return pidOf(yield* scoped.invoke(pluginId, "ping", null)); + }), + ); + expect(isProcessAlive(shutdownPid)).toBe(false); + }), + ); + + it.effect("kills a plugin stuck in a synchronous loop while other plugins keep answering", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const spinner = (yield* preparePlugin("test.spinner")).registration; + const bystander = (yield* preparePlugin("test.bystander")).registration; + yield* supervisor.enable(spinner); + yield* supervisor.enable(bystander); + const spinnerPid = pidOf(yield* supervisor.invoke(spinner.manifest.id, "ping", null)); + const bystanderPid = pidOf(yield* supervisor.invoke(bystander.manifest.id, "ping", null)); + + const spinning = yield* supervisor + .invoke(spinner.manifest.id, "spin", null, { timeout: "2 seconds" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + // The server's event loop is free while the spinner's is blocked. + const answer = yield* supervisor.invoke(bystander.manifest.id, "ping", "still here"); + expect(answer).toEqual({ pid: bystanderPid, input: "still here" }); + + yield* TestClock.adjust("2 seconds"); + const timedOut = yield* Fiber.join(spinning); + expect(timedOut._tag).toBe("PluginTimeoutError"); + expect(isProcessAlive(spinnerPid)).toBe(true); + + yield* TestClock.adjust("1 second"); + const backoff = yield* awaitState(supervisor, subscription, spinner.manifest.id, "backoff"); + expect(backoff.reason).toContain('did not stop "spin" within 1000ms of cancellation'); + expect(isProcessAlive(spinnerPid)).toBe(false); + expect(yield* supervisor.invoke(bystander.manifest.id, "ping", null)).toMatchObject({ + pid: bystanderPid, + }); + }), + ); + + it.effect("lets a handler that honours cancellation settle without a kill", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.cooperative"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + + const call = yield* supervisor + .invoke(pluginId, "cooperative", null, { timeout: "1 second" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(call))._tag).toBe("PluginTimeoutError"); + // The child answers the cancel before it answers this later call. + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + + yield* TestClock.adjust("1 second"); + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + expect(yield* supervisor.state(pluginId)).toEqual(Option.some({ _tag: "running" })); + }), + ); + + it.effect("fails in-flight calls on disable and drops the plugin's late answer", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.late"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + + const call = yield* supervisor + .invoke(pluginId, "late", null) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "late-started"); + // Deactivation aborts the handler, which then answers "late value". + yield* supervisor.disable(pluginId); + + const exit = yield* Fiber.join(call); + expect(Exit.isFailure(exit)).toBe(true); + expect(Option.getOrUndefined(Exit.findErrorOption(exit))?._tag).toBe("PluginStoppedError"); + expect(isProcessAlive(pid)).toBe(false); + }), + ); + }); + + describe("concurrency", () => { + it.effect("counts cancelled calls against the cap until the plugin answers them", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor({ maxConcurrentCalls: 1 }); + const subscription = yield* supervisor.subscribe; + const stalled = (yield* preparePlugin("test.stalled")).registration; + const polite = (yield* preparePlugin("test.polite")).registration; + yield* supervisor.enable(stalled); + yield* supervisor.enable(polite); + const stalledPid = pidOf(yield* supervisor.invoke(stalled.manifest.id, "ping", null)); + + const stalling = yield* supervisor + .invoke(stalled.manifest.id, "stall", null, { timeout: "1 second" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(stalling))._tag).toBe("PluginTimeoutError"); + // Retries after the timeout are refused while the plugin still holds the call. + const retries = yield* Effect.forEach(Array.from({ length: 4 }), () => + supervisor.invoke(stalled.manifest.id, "ping", null).pipe(Effect.flip), + ); + expect(retries.map((error) => error._tag)).toEqual(Array(4).fill("PluginBusyError")); + // The slot comes back only when the unanswered process is killed. + yield* TestClock.adjust("1 second"); + yield* awaitState(supervisor, subscription, stalled.manifest.id, "backoff"); + expect(isProcessAlive(stalledPid)).toBe(false); + + // A plugin that answers the cancel frees its slot without a kill. + const politePid = pidOf(yield* supervisor.invoke(polite.manifest.id, "ping", null)); + const cooperative = yield* supervisor + .invoke(polite.manifest.id, "cooperative", null, { timeout: "1 second" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + // A cancel the child reads with its invoke is answered before the handler runs, + // and then nothing would log "cooperative-settled". + yield* awaitLog(subscription, polite.manifest.id, "cooperative-started"); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(cooperative))._tag).toBe("PluginTimeoutError"); + yield* awaitLog(subscription, polite.manifest.id, "cooperative-settled"); + expect(pidOf(yield* supervisor.invoke(polite.manifest.id, "ping", null))).toBe(politePid); + }), + ); + }); + + describe("disable", () => { + it.effect("keeps stopping a plugin after the disabling caller is interrupted", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const supervisor = yield* makeSupervisor().pipe(Scope.provide(scope)); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.interrupted"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + yield* supervisor.invoke(pluginId, "holdDeactivate", null); + + const disabling = yield* supervisor + .disable(pluginId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "deactivate-held"); + yield* Fiber.interrupt(disabling); + expect(yield* supervisor.state(pluginId)).toEqual(Option.none()); + expect(isProcessAlive(pid)).toBe(true); + + // Shutdown still owns the stopping process and kills it after the grace. + const closing = yield* Scope.close(scope, Exit.void).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(closing); + expect(isProcessAlive(pid)).toBe(false); + }), + ); + + it.effect("makes a repeated disable wait for the interrupted stop to finish", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.retried"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + yield* supervisor.invoke(pluginId, "holdDeactivate", null); + + const disabling = yield* supervisor + .disable(pluginId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "deactivate-held"); + yield* Fiber.interrupt(disabling); + + const retry = yield* supervisor + .disable(pluginId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(retry.pollUnsafe()).toBeUndefined(); + expect(isProcessAlive(pid)).toBe(true); + expect(yield* supervisor.state(pluginId)).toEqual(Option.none()); + + yield* TestClock.adjust("1 second"); + yield* Fiber.join(retry); + expect(isProcessAlive(pid)).toBe(false); + }), + ); + + it.effect("fails a call waiting for activation once disable begins", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.racing", { + entry: "deferredActivate.mjs", + }); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + + const waiting = yield* supervisor + .invoke(pluginId, "ping", null) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "activating"); + // Deactivation lets activation finish, and the process keeps serving. + const disabling = yield* supervisor + .disable(pluginId) + .pipe(Effect.forkChild({ startImmediately: true })); + expect((yield* Fiber.join(waiting))._tag).toBe("PluginStoppedError"); + + // A re-enabled plugin with the same id waits for the old process to go. + const replacement = (yield* preparePlugin("test.racing")).registration; + yield* supervisor.enable(replacement); + const early = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(early.message).toContain("its previous process is still stopping"); + + yield* TestClock.adjust("1 second"); + yield* Fiber.join(disabling); + expect(yield* supervisor.invoke(pluginId, "ping", "fresh")).toMatchObject({ + input: "fresh", + }); + }), + ); + + it.effect( + "gives calls waiting on a start its outcome after the starting call is interrupted", + () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.abandoned", { + entry: "deferredActivate.mjs", + }); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + + const starting = yield* supervisor + .invoke(pluginId, "ping", null) + .pipe(Effect.forkChild({ startImmediately: true })); + const waiting = yield* supervisor + .invoke(pluginId, "ping", null) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "activating"); + // The start outlives its caller, and the waiting call hears how it ended. + const interrupting = yield* Fiber.interrupt(starting).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(interrupting); + expect((yield* Fiber.join(waiting)).message).toContain("did not activate within 5000ms"); + }), + ); + }); + + describe("faults", () => { + it.effect("reports a plugin that exhausts its heap", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.oom"); + yield* supervisor.enable(registration); + + const error = yield* supervisor + .invoke(registration.manifest.id, "oom", null) + .pipe(Effect.flip); + expect(error._tag).toBe("PluginCrashedError"); + expect(error.message).toContain("ran out of memory (heap limit 64 MB)"); + const state = yield* supervisor.state(registration.manifest.id); + expect(Option.getOrUndefined(state)?._tag).toBe("backoff"); + }), + ); + + it.effect("kills a plugin that sends malformed or oversized IPC", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const malformed = (yield* preparePlugin("test.malformed")).registration; + const oversized = (yield* preparePlugin("test.oversized")).registration; + yield* supervisor.enable(malformed); + yield* supervisor.enable(oversized); + + const garbage = yield* supervisor + .invoke(malformed.manifest.id, "malformed", null) + .pipe(Effect.flip); + expect(garbage.message).toContain("sent a malformed IPC message"); + + const flood = yield* supervisor + .invoke(oversized.manifest.id, "oversizedFrame", { bytes: 70_000 }) + .pipe(Effect.flip); + expect(flood.message).toContain("sent an IPC message larger than 65536 bytes"); + }), + ); + + it.effect("stops reading a plugin that floods logs and still delivers its result", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const flood = (yield* preparePlugin("test.flood")).registration; + const calm = (yield* preparePlugin("test.calm")).registration; + yield* supervisor.enable(flood); + yield* supervisor.enable(calm); + const floodPid = pidOf(yield* supervisor.invoke(flood.manifest.id, "ping", null)); + const calmPid = pidOf(yield* supervisor.invoke(calm.manifest.id, "ping", null)); + + // About 2.4 MB of logs in one synchronous burst against a 64 KiB read budget. + const flooding = yield* supervisor + .invoke(flood.manifest.id, "flood", { count: 8000, size: 250 }) + .pipe(Effect.forkChild({ startImmediately: true })); + expect(yield* supervisor.invoke(calm.manifest.id, "ping", "meanwhile")).toEqual({ + pid: calmPid, + input: "meanwhile", + }); + expect(yield* Fiber.join(flooding)).toEqual({ pid: floodPid, done: true }); + // The child saw the server stop reading and dropped logs, not the result. + const notice = yield* awaitLogMatching(subscription, flood.manifest.id, /^Dropped \d+ /); + expect(notice).toMatch(/^Dropped \d+ log messages while the server was busy\.$/); + expect(pidOf(yield* supervisor.invoke(flood.manifest.id, "ping", null))).toBe(floodPid); + }), + ); + + it.effect("bounds call and result sizes without stopping the plugin", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.bounds"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + + const bigInput = yield* supervisor + .invoke(pluginId, "ping", "x".repeat(70_000)) + .pipe(Effect.flip); + expect(bigInput._tag).toBe("PluginPayloadTooLargeError"); + const bigResult = yield* supervisor + .invoke(pluginId, "bigResult", { bytes: 70_000 }) + .pipe(Effect.flip); + expect(bigResult._tag).toBe("PluginCallFailedError"); + expect(bigResult.message).toContain("Result exceeds 65536 bytes"); + const thrown = yield* supervisor.invoke(pluginId, "throws", null).pipe(Effect.flip); + expect(thrown.message).toBe('Plugin test.bounds failed "throws": nope'); + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + }), + ); + + it.effect("fails results with no JSON form without stopping the plugin", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.unserializable"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + + for (const handler of ["functionResult", "symbolResult", "undefinedJsonResult"]) { + const error = yield* supervisor.invoke(pluginId, handler, null).pipe(Effect.flip); + expect(error._tag).toBe("PluginCallFailedError"); + expect(error.message).toBe(`Plugin ${pluginId} failed "${handler}": Result is not JSON.`); + } + // The serializer's own error is cut to the 2000 characters the server accepts. + const thrown = yield* supervisor + .invoke(pluginId, "throwingJsonResult", null) + .pipe(Effect.flip); + expect(thrown._tag).toBe("PluginCallFailedError"); + const prefix = `Plugin ${pluginId} failed "throwingJsonResult": `; + expect(thrown.message.startsWith(`${prefix}Result is not JSON: xxx`)).toBe(true); + expect(thrown.message.length).toBe(prefix.length + 2000); + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + }), + ); + + it.effect("keeps one plugin's crash away from another", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const crasher = (yield* preparePlugin("test.crasher")).registration; + const survivor = (yield* preparePlugin("test.survivor")).registration; + yield* supervisor.enable(crasher); + yield* supervisor.enable(survivor); + const survivorPid = pidOf(yield* supervisor.invoke(survivor.manifest.id, "ping", null)); + + const crash = yield* supervisor.invoke(crasher.manifest.id, "exit", null).pipe(Effect.flip); + expect(crash._tag).toBe("PluginCrashedError"); + expect(crash.message).toContain("exited with code 3"); + expect(pidOf(yield* supervisor.invoke(survivor.manifest.id, "ping", null))).toBe( + survivorPid, + ); + expect(yield* supervisor.state(survivor.manifest.id)).toEqual( + Option.some({ _tag: "running" }), + ); + }), + ); + + it.effect("closes the stderr of a plugin that exited while a process it started holds it", () => + Effect.gen(function* () { + const { port, received } = yield* listenOnce(); + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.stderr-holder"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const holderPid = pidOf( + yield* supervisor.invoke(pluginId, "holdStderr", { port: `${port}` }), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => isProcessAlive(holderPid) && process.kill(holderPid)), + ); + + // The plugin's exit is handled after the drain timeout, as its stderr never ends. + const crash = yield* supervisor.invoke(pluginId, "exit", null).pipe(Effect.flip); + expect(crash.message).toContain("exited with code 3"); + // Once the server closes its end, the holder's next write fails. + expect(yield* Effect.promise(() => received)).toBe("closed"); + }).pipe(TestClock.withLive), + ); + + it.effect("backs off after each crash and quarantines past the restart cap", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.flaky"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + + const crashAndWait = Effect.fn("crashAndWait")(function* (delay: Duration.Input) { + yield* supervisor.invoke(pluginId, "exit", null).pipe(Effect.flip); + const backoff = yield* awaitState(supervisor, subscription, pluginId, "backoff"); + const refused = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(refused._tag).toBe("PluginUnavailableError"); + yield* TestClock.adjust(delay); + yield* awaitState(supervisor, subscription, pluginId, "idle"); + return backoff; + }); + expect((yield* crashAndWait("1 second")).failures).toBe(1); + expect((yield* crashAndWait("2 seconds")).failures).toBe(2); + + yield* supervisor.invoke(pluginId, "exit", null).pipe(Effect.flip); + const quarantined = yield* awaitState(supervisor, subscription, pluginId, "quarantined"); + expect(quarantined).toMatchObject({ failures: 3 }); + expect(quarantined.reason).toContain("exited with code 3"); + + // Quarantine never lifts on its own. + yield* TestClock.adjust("10 minutes"); + const refused = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(refused.message).toContain("quarantined after 3 failures"); + + yield* supervisor.resume(pluginId); + expect(yield* supervisor.invoke(pluginId, "ping", "back")).toMatchObject({ input: "back" }); + }), + ); + + it.effect("refuses top-level await in an entry or its imports without spending restarts", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + for (const [id, entry] of [ + ["test.async-entry", "asyncEntry.mjs"], + ["test.async-dependency", "asyncDependency.mjs"], + ] as const) { + const { registration } = yield* preparePlugin(id, { entry }); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + // More attempts than the restart cap allows still never back off or quarantine. + for (let attempt = 0; attempt <= testOptions.maxRestarts + 1; attempt++) { + const error = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(error._tag).toBe("PluginIncompatibleError"); + expect(error.message).toContain("uses top-level await"); + const state = Option.getOrUndefined(yield* supervisor.state(pluginId)); + expect(state?._tag).toBe("incompatible"); + // Refused up front until someone resumes it. + const again = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(again._tag).toBe("PluginIncompatibleError"); + yield* supervisor.resume(pluginId); + } + } + }), + ); + + it.effect("fails activation that hangs, throws, or uses unrequested proposed APIs", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const hang = (yield* preparePlugin("test.hang", { entry: "spinActivate.mjs" })) + .registration; + const refuse = (yield* preparePlugin("test.refuse", { entry: "failActivate.mjs" })) + .registration; + const stable = (yield* preparePlugin("test.stable", { proposedApi: false })).registration; + yield* Effect.forEach([hang, refuse, stable], supervisor.enable, { discard: true }); + + const hanging = yield* supervisor + .invoke(hang.manifest.id, "ping", null) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("5 seconds"); + expect((yield* Fiber.join(hanging)).message).toContain("did not activate within 5000ms"); + + const refused = yield* supervisor + .invoke(refuse.manifest.id, "ping", null) + .pipe(Effect.flip); + expect(refused.message).toContain("activation failed: activation refused"); + + const gated = yield* supervisor.invoke(stable.manifest.id, "ping", null).pipe(Effect.flip); + expect(gated.message).toContain("activation failed"); + }), + ); + + it.effect("lets plugins register t3.tool handlers and keeps other t3 names reserved", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.reserved", { + entry: "reservedHandlers.mjs", + }); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + + expect(yield* supervisor.invoke(pluginId, "t3.tool.echo", { text: "hi" })).toEqual({ + handler: "t3.tool.echo", + input: { text: "hi" }, + }); + expect(yield* supervisor.invoke(pluginId, "refusals", null)).toEqual({ + "t3.events": 'Handler names starting with "t3." are reserved.', + "t3.other": 'Handler names starting with "t3." are reserved.', + }); + }), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginSupervisor.ts b/apps/server/src/plugins/PluginSupervisor.ts new file mode 100644 index 000000000000..1540b5450646 --- /dev/null +++ b/apps/server/src/plugins/PluginSupervisor.ts @@ -0,0 +1,1078 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Each plugin child gets an extra fd 3 pipe, which needs Node's child_process. +/** + * Runs each enabled plugin in its own child process. + * + * A child starts the first time a plugin is invoked, never at enable time, so + * zero enabled (or zero used) plugins means zero processes. Each child gets a + * V8 heap limit, a minimal environment, and a byte-bounded JSON line channel + * on fd 3; the server decodes everything it reads and kills a child that + * sends anything malformed or oversized. The server stops reading a child + * whose decoded-but-unhandled lines reach `maxMessageBytes`, so a chatty + * plugin is slowed down rather than buffered; the child then drops its logs, + * never its results. + * + * A call that outlives its deadline, or whose caller is interrupted, is + * cancelled cooperatively: the plugin's handler signal aborts and the child + * has `cancelGrace` to answer. A child that cannot answer (a synchronous loop + * never reads the cancel) is killed. Unexpected exits back the plugin off + * with doubling delays; more than `maxRestarts` consecutive failures park it + * in `quarantined` until `resume`. Disabling a plugin revokes its + * registration at once: in-flight calls and calls still waiting for + * activation fail, nothing new is sent, and no result produced after that + * point reaches a caller. The supervisor owns a stopping process until it has + * exited, even if the caller that disabled it goes away. + * + * A plugin can also call the server: capabilities serve host methods (such + * as `settings.get`) with `serveHostMethod`, and the supervisor runs each + * call off the child's read loop, at most `PLUGIN_MAX_HOST_CALLS` at a time + * per child. Host calls belong to the generation that made them: once it is + * revoked or its process exits, new calls are refused, calls being served + * are interrupted (disable returns only after they have ended), and no + * result reaches the child. Answers wait while the child has more than + * `maxMessageBytes` of earlier messages unread, and so does the read loop + * before it takes the next call, so a child that stops reading stops being + * read instead of growing the server's write buffer. + * + * Plugins are trusted OS-user code. The process boundary protects the + * server's availability, not its data. + */ +import * as NodeChildProcess from "node:child_process"; +import type * as NodeStream from "node:stream"; + +import type { PluginHostState, PluginId } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +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 Semaphore from "effect/Semaphore"; + +import { + decodePluginChildMessage, + encodePluginHostMessage, + PluginHandlerName, + type PluginHostMessage, + type PluginLogLevel, +} from "./PluginIpc.ts"; +import { + DEFAULT_PLUGIN_IPC_MAX_BYTES, + PLUGIN_IPC_FD, + PLUGIN_IPC_MAX_BYTES_LIMIT, + PLUGIN_MAX_HOST_CALLS, + makeLineDecoder, + makeReadBudget, +} from "./pluginIpcFraming.ts"; +import type { PluginRegistration } from "./PluginManifestLoader.ts"; + +/** Hidden CLI command that bin.ts routes to the plugin child runtime. */ +const PLUGIN_HOST_COMMAND = "__plugin-host"; + +export interface PluginSupervisorOptions { + readonly heapLimitMb: number; + readonly maxMessageBytes: number; + readonly activationTimeout: Duration.Input; + readonly callTimeout: Duration.Input; + readonly cancelGrace: Duration.Input; + readonly stopGrace: Duration.Input; + /** In-flight calls per plugin, counting cancelled calls the plugin has not answered yet. */ + readonly maxConcurrentCalls: number; + /** Plugin processes alive at once across the environment. */ + readonly maxRunningPlugins: number; + /** Consecutive failures that still restart; one more quarantines. */ + readonly maxRestarts: number; + readonly restartBackoff: Duration.Input; + readonly maxRestartBackoff: Duration.Input; + /** A child that ran this long before failing starts a fresh failure count. */ + readonly stableUptime: Duration.Input; +} + +const defaultPluginSupervisorOptions: PluginSupervisorOptions = { + heapLimitMb: 256, + maxMessageBytes: DEFAULT_PLUGIN_IPC_MAX_BYTES, + activationTimeout: Duration.seconds(10), + callTimeout: Duration.seconds(30), + cancelGrace: Duration.seconds(2), + stopGrace: Duration.seconds(2), + maxConcurrentCalls: 16, + maxRunningPlugins: 16, + maxRestarts: 3, + restartBackoff: Duration.seconds(1), + maxRestartBackoff: Duration.seconds(30), + stableUptime: Duration.minutes(1), +}; + +export class PluginAlreadyEnabledError extends Schema.TaggedError()( + "PluginAlreadyEnabledError", + { pluginId: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is already enabled.`; + } +} + +export class PluginNotEnabledError extends Schema.TaggedError()( + "PluginNotEnabledError", + { pluginId: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is not enabled.`; + } +} + +class PluginUnavailableError extends Schema.TaggedError()( + "PluginUnavailableError", + { pluginId: Schema.String, reason: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is unavailable: ${this.reason}`; + } +} + +class PluginCrashedError extends Schema.TaggedError()("PluginCrashedError", { + pluginId: Schema.String, + reason: Schema.String, +}) { + override get message(): string { + return `Plugin ${this.pluginId} stopped unexpectedly: ${this.reason}`; + } +} + +class PluginIncompatibleError extends Schema.TaggedError()( + "PluginIncompatibleError", + { pluginId: Schema.String, reason: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} cannot run on this server: ${this.reason}`; + } +} + +class PluginStoppedError extends Schema.TaggedError()("PluginStoppedError", { + pluginId: Schema.String, +}) { + override get message(): string { + return `Plugin ${this.pluginId} was stopped before the call finished.`; + } +} + +class PluginTimeoutError extends Schema.TaggedError()("PluginTimeoutError", { + pluginId: Schema.String, + handler: Schema.String, + timeoutMs: Schema.Number, +}) { + override get message(): string { + return `Plugin ${this.pluginId} did not answer "${this.handler}" within ${this.timeoutMs}ms.`; + } +} + +class PluginCallFailedError extends Schema.TaggedError()( + "PluginCallFailedError", + { pluginId: Schema.String, handler: Schema.String, reason: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} failed "${this.handler}": ${this.reason}`; + } +} + +class PluginBusyError extends Schema.TaggedError()("PluginBusyError", { + pluginId: Schema.String, + limit: Schema.Number, +}) { + override get message(): string { + return `Plugin ${this.pluginId} already has ${this.limit} calls in flight.`; + } +} + +class PluginPayloadTooLargeError extends Schema.TaggedError()( + "PluginPayloadTooLargeError", + { pluginId: Schema.String, bytes: Schema.Number, limit: Schema.Number }, +) { + override get message(): string { + return `The call to plugin ${this.pluginId} is ${this.bytes} bytes; the limit is ${this.limit}.`; + } +} + +export type PluginInvokeError = + | PluginNotEnabledError + | PluginUnavailableError + | PluginCrashedError + | PluginIncompatibleError + | PluginStoppedError + | PluginTimeoutError + | PluginCallFailedError + | PluginBusyError + | PluginPayloadTooLargeError; + +/** A host method's refusal; `message` reaches the plugin, so it must not carry secrets. */ +export class PluginHostCallError extends Schema.TaggedError()( + "PluginHostCallError", + { message: Schema.String }, +) {} + +/** + * Serves one host method for the plugin whose registration made the call. + * The call is interrupted when that generation is revoked; a method that + * writes runs `admitted` right before committing, after any wait. + */ +export type PluginHostMethod = (call: { + readonly registration: PluginRegistration; + readonly input: Schema.Json; + /** Fails once the calling generation is revoked or its process has exited. */ + readonly admitted: Effect.Effect; +}) => Effect.Effect; + +export type PluginSupervisorEvent = + | { readonly _tag: "StateChanged"; readonly pluginId: PluginId; readonly state: PluginHostState } + | { + readonly _tag: "Log"; + readonly pluginId: PluginId; + readonly level: PluginLogLevel; + readonly message: string; + }; + +type CallOutcome = Exit.Exit; + +interface PendingCall { + readonly handler: string; + readonly deferred: Deferred.Deferred; +} + +type ChildEvent = + | { readonly _tag: "Line"; readonly line: string; readonly bytes: number } + | { readonly _tag: "Overflow" } + | { readonly _tag: "Exited"; readonly code: number | null; readonly signal: string | null } + /** fd 3 and stderr have closed, so every line and the stderr tail have been read. */ + | { readonly _tag: "Drained" }; + +interface Child { + readonly pluginId: PluginId; + readonly process: NodeChildProcess.ChildProcess; + readonly channel: NodeStream.Duplex; + readonly startedAt: number; + readonly ready: Deferred.Deferred< + void, + PluginCrashedError | PluginIncompatibleError | PluginStoppedError + >; + readonly exited: Deferred.Deferred; + readonly pending: Map; + /** Cancelled calls whose answer has not arrived yet. */ + readonly settling: Map>; + nextRequestId: number; + stopping: boolean; + /** The child reported code that cannot load here; set before it is killed. */ + incompatible: string | undefined; + killReason: string | undefined; + stderrTail: string; + /** V8 reported reaching the heap limit; its message can scroll out of the tail. */ + outOfMemory: boolean; + /** Host calls from this child still being served. */ + hostCalls: number; + /** Owns the fibers serving this child's host calls; closed on revocation and exit. */ + readonly hostWork: Scope.Closeable; + /** Set once `hostWork` starts closing; done once it has closed. */ + hostWorkEnded: Deferred.Deferred | undefined; + /** One host-call answer at a time waits for the child to read earlier messages. */ + readonly replies: Semaphore.Semaphore; + /** Set while answers wait for the child to read; logged once per episode. */ + backedUp: boolean; +} + +interface Entry { + readonly registration: PluginRegistration; + readonly pluginId: PluginId; + state: PluginHostState; + /** Set by disable; a removed entry never starts, admits, or reports again. */ + removed: boolean; + failures: number; + child: Child | undefined; + starting: Deferred.Deferred | undefined; +} + +const STDERR_TAIL_BYTES = 4096; +const STOPPED_MESSAGE = "The plugin was stopped."; +// A grandchild that inherited stderr can hold it open after the plugin exits. +const DRAIN_TIMEOUT = Duration.millis(250); + +const LOG_SEVERITY = { debug: "Debug", info: "Info", warn: "Warn", error: "Error" } as const; + +// Enough for a trusted plugin to find its tools and temp space without +// inheriting the server's credentials or Node options. +const CHILD_ENV_KEYS = [ + "PATH", + "Path", + "HOME", + "USERPROFILE", + "TMPDIR", + "TEMP", + "TMP", + "SystemRoot", + "LANG", + "LC_ALL", +]; + +const isHandlerName = Schema.is(PluginHandlerName); + +const isAlive = (child: Child) => + child.process.exitCode === null && child.process.signalCode === null; + +const describeExit = (child: Child, code: number | null, signal: string | null, heapMb: number) => { + if (child.outOfMemory) return `ran out of memory (heap limit ${heapMb} MB).`; + const base = signal ? `was killed by ${signal}` : `exited with code ${code ?? "unknown"}`; + const lastLine = child.stderrTail.trim().split("\n").at(-1)?.slice(0, 300); + return lastLine ? `${base}: ${lastLine}` : `${base}.`; +}; + +export class PluginSupervisor extends Context.Service< + PluginSupervisor, + { + /** Registers a plugin; its process starts on first invoke. */ + readonly enable: ( + registration: PluginRegistration, + ) => Effect.Effect; + /** + * Stops the plugin's process, failing in-flight calls, and forgets it. + * Idempotent: a repeat waits for a stop still in progress. + */ + readonly disable: (pluginId: PluginId) => Effect.Effect; + /** Clears backoff, quarantine, or incompatibility so the next invoke starts a fresh process. */ + readonly resume: (pluginId: PluginId) => Effect.Effect; + readonly invoke: ( + pluginId: PluginId, + handler: string, + input: Schema.Json, + options?: { readonly timeout?: Duration.Input }, + ) => Effect.Effect; + readonly state: (pluginId: PluginId) => Effect.Effect>; + /** Subscribes before returning, so no event after this point is missed. */ + readonly subscribe: Effect.Effect< + PubSub.Subscription, + never, + Scope.Scope + >; + /** Answers plugins' `method` calls with `handler` until the scope closes. One handler per method. */ + readonly serveHostMethod: ( + method: string, + handler: PluginHostMethod, + ) => Effect.Effect; + } +>()("t3/plugins/PluginSupervisor") {} + +export const make = Effect.fn("PluginSupervisor.make")(function* ( + overrides: Partial = {}, +) { + const options = { ...defaultPluginSupervisorOptions, ...overrides }; + const maxMessageBytes = Math.min(options.maxMessageBytes, PLUGIN_IPC_MAX_BYTES_LIMIT); + const activationTimeout = Duration.fromInputUnsafe(options.activationTimeout); + const callTimeout = Duration.fromInputUnsafe(options.callTimeout); + const cancelGrace = Duration.fromInputUnsafe(options.cancelGrace); + const stopGrace = Duration.fromInputUnsafe(options.stopGrace); + const restartBackoffMs = Duration.toMillis(Duration.fromInputUnsafe(options.restartBackoff)); + const maxRestartBackoffMs = Duration.toMillis( + Duration.fromInputUnsafe(options.maxRestartBackoff), + ); + const stableUptimeMs = Duration.toMillis(Duration.fromInputUnsafe(options.stableUptime)); + + const invocation = yield* resolveSelfInvocation(); + const hostEnvironment = yield* HostProcess.Environment; + const heapFlag = `--max-old-space-size=${options.heapLimitMb}`; + const childEnvironment: Record = { ELECTRON_RUN_AS_NODE: "1" }; + for (const key of CHILD_ENV_KEYS) { + const value = hostEnvironment[key]; + if (value !== undefined) childEnvironment[key] = value; + } + // The single executable takes no Node flags on its command line. + const spawnArgs = + invocation.entrypoint === undefined + ? [PLUGIN_HOST_COMMAND] + : [heapFlag, invocation.entrypoint, PLUGIN_HOST_COMMAND]; + if (invocation.entrypoint === undefined) childEnvironment.NODE_OPTIONS = heapFlag; + + const entries = new Map(); + const hostMethods = new Map(); + /** Every child process not yet exited, including ones whose plugin was disabled. */ + const children = new Set(); + /** Completes once a disabled plugin's processes have all exited. */ + const stops = new Map>(); + const events = yield* PubSub.sliding(1024); + // Child readers and timers live here and end after every child has stopped. + const fibers = yield* Scope.make(); + + const setState = (entry: Entry, state: PluginHostState) => + Effect.suspend(() => { + if (entry.removed) return Effect.void; + entry.state = state; + return PubSub.publish(events, { _tag: "StateChanged", pluginId: entry.pluginId, state }); + }); + + const kill = (child: Child, reason: string) => { + child.killReason ??= reason; + if (isAlive(child)) child.process.kill("SIGKILL"); + }; + + /** Sends a message, or says why it cannot be sent. */ + const write = (child: Child, message: PluginHostMessage) => { + const encoded = encodePluginHostMessage(message); + if (Exit.isFailure(encoded)) return { _tag: "invalid" as const }; + const bytes = Buffer.byteLength(encoded.value); + if (bytes > maxMessageBytes) return { _tag: "tooLarge" as const, bytes }; + if (!child.channel.destroyed) child.channel.write(`${encoded.value}\n`); + return undefined; + }; + + const recordFailure = Effect.fnUntraced(function* ( + entry: Entry, + startedAt: number, + reason: string, + ) { + const now = yield* Clock.currentTimeMillis; + entry.failures = now - startedAt >= stableUptimeMs ? 1 : entry.failures + 1; + if (entry.failures > options.maxRestarts) { + yield* Effect.logWarning("Plugin quarantined", { pluginId: entry.pluginId, reason }); + return yield* setState(entry, { + _tag: "quarantined", + failures: entry.failures, + reason: reason.slice(0, 1000), + }); + } + const delay = Math.min(restartBackoffMs * 2 ** (entry.failures - 1), maxRestartBackoffMs); + const backoff: PluginHostState = { + _tag: "backoff", + failures: entry.failures, + reason: reason.slice(0, 1000), + retryAt: DateTime.formatIso(DateTime.makeUnsafe(now + delay)), + }; + yield* Effect.logWarning("Plugin failed; backing off", { + pluginId: entry.pluginId, + reason, + delayMs: delay, + }); + yield* setState(entry, backoff); + yield* Effect.sleep(Duration.millis(delay)).pipe( + Effect.andThen( + Effect.suspend(() => + entry.state === backoff ? setState(entry, { _tag: "idle" }) : Effect.void, + ), + ), + Effect.forkIn(fibers, { startImmediately: true }), + ); + }); + + // Only a write that filled the stream's own buffer is followed by `drain`, so a bound + // below that buffer waits for nothing else. + const hasRoom = (child: Child) => + child.channel.destroyed || + child.channel.writableLength < maxMessageBytes || + !child.channel.writableNeedDrain; + + /** Waits until the child has read enough of what was sent to it, or can no longer read. */ + const awaitRoom = (child: Child) => + Effect.suspend(() => { + if (hasRoom(child) || child.stopping) return Effect.void; + const waiting = Effect.callback((resume) => { + const channel = child.channel; + const done = () => { + cleanup(); + resume(Effect.void); + }; + const cleanup = () => { + channel.off("drain", done); + channel.off("close", done); + }; + channel.on("drain", done); + channel.on("close", done); + if (hasRoom(child)) done(); + return Effect.sync(cleanup); + }).pipe(Effect.raceFirst(Deferred.await(child.exited))); + if (child.backedUp) return waiting; + child.backedUp = true; + return Effect.logWarning("Plugin is not reading the server's answers; waiting", { + pluginId: child.pluginId, + unreadBytes: child.channel.writableLength, + }).pipe(Effect.andThen(waiting)); + }).pipe(Effect.ensuring(Effect.sync(() => (child.backedUp = !hasRoom(child))))); + + const hostCallFailed = (requestId: number, message: string): PluginHostMessage => ({ + _tag: "HostCallFailed", + requestId, + message: message.slice(0, 2000), + }); + + /** Sends a host call's answer to a live child; a revoked generation only learns it was stopped. */ + const sendAnswer = (child: Child, requestId: number, answer: PluginHostMessage) => { + if (!isAlive(child)) return; + if (child.stopping) { + // Never wait for a stopping child; it is killed if it does not exit. + if (hasRoom(child)) write(child, hostCallFailed(requestId, STOPPED_MESSAGE)); + return; + } + const unsent = write(child, answer); + if (unsent) + write( + child, + hostCallFailed( + requestId, + unsent._tag === "tooLarge" + ? `The answer exceeds ${maxMessageBytes} bytes.` + : "The answer is not JSON.", + ), + ); + }; + + const answer = (child: Child, requestId: number, message: PluginHostMessage) => + child.replies.withPermit( + awaitRoom(child).pipe( + Effect.andThen(Effect.sync(() => sendAnswer(child, requestId, message))), + ), + ); + + const outcomeMessage = ( + requestId: number, + outcome: Exit.Exit, + ): PluginHostMessage => + Exit.isSuccess(outcome) + ? { _tag: "HostCallSucceeded", requestId, value: outcome.value } + : hostCallFailed( + requestId, + Exit.findErrorOption(outcome).pipe( + Option.match({ + onNone: () => "The server could not answer.", + onSome: (error) => error.message, + }), + ), + ); + + /** Whether host calls from `child` may still start or commit. */ + const isAdmitted = (entry: Entry, child: Child) => + !entry.removed && !child.stopping && entry.child === child && isAlive(child); + + /** Runs a host call outside the read loop, owned by the child's generation. */ + const serveHostCall = ( + entry: Entry, + child: Child, + requestId: number, + method: string, + input: Schema.Json, + ) => { + const refuse = (message: string) => + answer(child, requestId, hostCallFailed(requestId, message)); + if (!isAdmitted(entry, child)) return refuse(STOPPED_MESSAGE); + const handler = hostMethods.get(method); + if (!handler) return refuse(`This server has no method "${method}".`); + if (child.hostCalls >= PLUGIN_MAX_HOST_CALLS) + return refuse(`${PLUGIN_MAX_HOST_CALLS} calls to the server are already in flight.`); + child.hostCalls++; + const admitted = Effect.suspend(() => + isAdmitted(entry, child) + ? Effect.void + : Effect.fail(new PluginHostCallError({ message: STOPPED_MESSAGE })), + ); + return Effect.suspend(() => + handler({ registration: entry.registration, input, admitted }), + ).pipe( + Effect.exit, + Effect.flatMap((outcome) => answer(child, requestId, outcomeMessage(requestId, outcome))), + Effect.onInterrupt(() => + Effect.sync(() => sendAnswer(child, requestId, hostCallFailed(requestId, STOPPED_MESSAGE))), + ), + Effect.ensuring(Effect.sync(() => child.hostCalls--)), + Effect.forkIn(child.hostWork, { startImmediately: true }), + Effect.asVoid, + ); + }; + + const handleLine = Effect.fnUntraced(function* (entry: Entry, child: Child, line: string) { + const decoded = decodePluginChildMessage(line); + if (Exit.isFailure(decoded)) return kill(child, "sent a malformed IPC message."); + const message = decoded.value; + switch (message._tag) { + case "Ready": + yield* Deferred.succeed(child.ready, undefined); + return; + case "ActivationFailed": + // The exit fails activation, after the state reflects the failure. + return kill(child, `activation failed: ${message.message}`); + case "Incompatible": + child.incompatible = message.message; + return kill(child, message.message); + case "Succeeded": + case "Failed": { + const pending = child.pending.get(message.requestId); + if (pending) { + child.pending.delete(message.requestId); + yield* message._tag === "Succeeded" + ? Deferred.succeed(pending.deferred, message.value) + : Deferred.fail( + pending.deferred, + new PluginCallFailedError({ + pluginId: entry.pluginId, + handler: pending.handler, + reason: message.message, + }), + ); + return; + } + const settling = child.settling.get(message.requestId); + child.settling.delete(message.requestId); + if (settling) yield* Deferred.succeed(settling, undefined); + return; + } + case "Log": + yield* Effect.logWithLevel(LOG_SEVERITY[message.level])(message.message).pipe( + Effect.annotateLogs({ pluginId: entry.pluginId, pluginLogLevel: message.level }), + ); + yield* PubSub.publish(events, { + _tag: "Log", + pluginId: entry.pluginId, + level: message.level, + message: message.message, + }); + return; + case "Deactivated": + return; + case "HostCall": + // A child that does not read its answers is not read either. + yield* awaitRoom(child); + return yield* serveHostCall(entry, child, message.requestId, message.method, message.input); + } + }); + + /** Closes a child's host work; a caller that comes while it is closing waits for the end. */ + const endHostWork = (child: Child) => + Effect.suspend(() => { + if (child.hostWorkEnded) return Deferred.await(child.hostWorkEnded); + const ended = Deferred.makeUnsafe(); + child.hostWorkEnded = ended; + return Scope.close(child.hostWork, Exit.void).pipe( + Effect.ensuring(Deferred.succeed(ended, undefined)), + ); + }); + + const handleExit = Effect.fnUntraced(function* ( + entry: Entry, + child: Child, + code: number | null, + signal: string | null, + ) { + // Host work of a dead process ends before anything else can run for this plugin. + yield* endHostWork(child); + // Anything still unread from the dead process is discarded, including a stderr + // that a process it started still holds open after the drain timeout. + child.channel.destroy(); + child.process.stderr?.destroy(); + const reason = child.killReason ?? describeExit(child, code, signal, options.heapLimitMb); + const crashed = child.stopping + ? new PluginStoppedError({ pluginId: entry.pluginId }) + : child.incompatible !== undefined + ? new PluginIncompatibleError({ pluginId: entry.pluginId, reason: child.incompatible }) + : new PluginCrashedError({ pluginId: entry.pluginId, reason }); + // Settle the state first so a caller woken below already sees it. + if (entry.child === child) entry.child = undefined; + if (!entry.removed) { + if (child.stopping) yield* setState(entry, { _tag: "idle" }); + // Retrying cannot help, so this spends none of the restart budget. + else if (child.incompatible !== undefined) + yield* setState(entry, { _tag: "incompatible", reason: child.incompatible.slice(0, 1000) }); + else yield* recordFailure(entry, child.startedAt, reason); + } + yield* Deferred.fail(child.ready, crashed); + const pending = [...child.pending.values()]; + child.pending.clear(); + for (const call of pending) yield* Deferred.fail(call.deferred, crashed); + const settling = [...child.settling.values()]; + child.settling.clear(); + for (const deferred of settling) yield* Deferred.succeed(deferred, undefined); + }); + + const spawnChild = Effect.fnUntraced(function* (entry: Entry) { + const queue = yield* Queue.unbounded(); + const startedAt = yield* Clock.currentTimeMillis; + const childProcess = NodeChildProcess.spawn(invocation.command, spawnArgs, { + cwd: entry.registration.directory, + env: childEnvironment, + // fd 3 must be overlapped on Windows for the child to open it as a socket. + stdio: ["ignore", "ignore", "pipe", "overlapped"], + windowsHide: true, + }); + const channel = childProcess.stdio[PLUGIN_IPC_FD] as NodeStream.Duplex; + const child: Child = { + pluginId: entry.pluginId, + process: childProcess, + channel, + startedAt, + ready: Deferred.makeUnsafe(), + exited: Deferred.makeUnsafe(), + pending: new Map(), + settling: new Map(), + nextRequestId: 0, + stopping: false, + incompatible: undefined, + killReason: undefined, + stderrTail: "", + outOfMemory: false, + hostCalls: 0, + hostWork: Scope.forkUnsafe(fibers, "parallel"), + hostWorkEnded: undefined, + replies: Semaphore.makeUnsafe(1), + backedUp: false, + }; + // Stream errors follow the child's death; its exit carries the outcome. A + // child that drops fd 3 but lives on is killed when a call cannot settle. + channel.on("error", () => {}); + childProcess.stderr?.on("error", () => {}); + childProcess.stderr?.on("data", (chunk: Buffer) => { + const text = child.stderrTail + chunk.toString("utf8"); + child.outOfMemory ||= /heap limit|heap out of memory/i.test(text); + child.stderrTail = text.slice(-STDERR_TAIL_BYTES); + }); + const budget = makeReadBudget({ + maxBytes: maxMessageBytes, + pause: () => channel.pause(), + resume: () => channel.resume(), + }); + channel.on( + "data", + makeLineDecoder({ + maxBytes: maxMessageBytes, + onLine: (line, bytes) => { + budget.hold(bytes); + Queue.offerUnsafe(queue, { _tag: "Line", line, bytes }); + }, + onOverflow: () => Queue.offerUnsafe(queue, { _tag: "Overflow" }), + }), + ); + let openStreams = 2; + const onStreamClosed = () => { + if (--openStreams === 0) Queue.offerUnsafe(queue, { _tag: "Drained" }); + }; + channel.once("close", onStreamClosed); + if (childProcess.stderr) childProcess.stderr.once("close", onStreamClosed); + else onStreamClosed(); + children.add(child); + const onExit = (code: number | null, signal: string | null) => { + if (!children.delete(child)) return; + Deferred.doneUnsafe(child.exited, Exit.void); + Queue.offerUnsafe(queue, { _tag: "Exited", code, signal }); + }; + childProcess.on("exit", onExit); + childProcess.on("error", (error) => { + child.killReason ??= `could not start: ${error.message}`; + if (childProcess.pid === undefined) onExit(null, null); + }); + + yield* Effect.gen(function* () { + let exit: { readonly code: number | null; readonly signal: string | null } | undefined; + let drained = false; + while (true) { + const event = yield* Queue.take(queue); + if (event._tag === "Line") { + yield* handleLine(entry, child, event.line); + budget.release(event.bytes); + } else if (event._tag === "Overflow") + kill(child, `sent an IPC message larger than ${maxMessageBytes} bytes.`); + else if (event._tag === "Drained") drained = true; + else { + exit = event; + yield* Effect.sleep(DRAIN_TIMEOUT).pipe( + Effect.andThen(Queue.offer(queue, { _tag: "Drained" })), + Effect.forkIn(fibers, { startImmediately: true }), + ); + } + if (exit && drained) return yield* handleExit(entry, child, exit.code, exit.signal); + } + }).pipe(Effect.forkIn(fibers)); + return child; + }); + + /** Marks a disabled plugin's child as stopping and fails everyone waiting on it. */ + const revokeChild = (entry: Entry, child: Child) => { + child.stopping = true; + const stopped = new PluginStoppedError({ pluginId: entry.pluginId }); + Deferred.doneUnsafe(child.ready, Exit.fail(stopped)); + for (const call of child.pending.values()) + Deferred.doneUnsafe(call.deferred, Exit.fail(stopped)); + child.pending.clear(); + }; + + /** + * Starts the plugin's process if needed and waits until it has activated. + * Claiming a start through publishing its outcome is uninterruptible, so an + * interrupted starter never strands the calls waiting on its start. + */ + const ensureChild = Effect.fnUntraced(function* (entry: Entry) { + const claim = yield* Effect.sync(() => { + if (entry.child && !entry.starting) return { _tag: "running" as const, child: entry.child }; + if (entry.starting) return { _tag: "wait" as const, deferred: entry.starting }; + // A disabled plugin's process counts until it has exited. + let running = children.size; + for (const other of entries.values()) if (other.starting && !other.child) running++; + if (running >= options.maxRunningPlugins) return { _tag: "limit" as const }; + for (const other of children) + if (other.pluginId === entry.pluginId) return { _tag: "previous" as const }; + const deferred = Deferred.makeUnsafe(); + entry.starting = deferred; + return { _tag: "start" as const, deferred }; + }); + if (claim._tag === "running") return claim.child; + if (claim._tag === "wait") return yield* Effect.interruptible(Deferred.await(claim.deferred)); + if (claim._tag === "limit") + return yield* new PluginUnavailableError({ + pluginId: entry.pluginId, + reason: `${options.maxRunningPlugins} plugin processes are already running.`, + }); + if (claim._tag === "previous") + return yield* new PluginUnavailableError({ + pluginId: entry.pluginId, + reason: "its previous process is still stopping.", + }); + + const started = yield* Effect.gen(function* () { + yield* setState(entry, { _tag: "starting" }); + if (entry.removed) return yield* new PluginStoppedError({ pluginId: entry.pluginId }); + const child = yield* spawnChild(entry); + entry.child = child; + if (entry.removed) revokeChild(entry, child); + const { manifest, entryPath } = entry.registration; + write(child, { + _tag: "Activate", + pluginId: manifest.id, + version: manifest.version, + apiVersion: manifest.apiVersion, + entryPath, + proposedApi: manifest.proposedApi, + capabilities: manifest.capabilities, + maxMessageBytes, + }); + const activated = yield* Deferred.await(child.ready).pipe( + Effect.timeoutOption(activationTimeout), + ); + if (Option.isNone(activated)) { + kill(child, `did not activate within ${Duration.toMillis(activationTimeout)}ms.`); + yield* Deferred.await(child.exited); + return yield* Deferred.await(child.ready).pipe(Effect.as(child)); + } + // Ready can arrive after disable began; that generation never runs. + if (child.stopping) return yield* new PluginStoppedError({ pluginId: entry.pluginId }); + yield* setState(entry, { _tag: "running" }); + return child; + }).pipe(Effect.exit); + entry.starting = undefined; + yield* Deferred.done(claim.deferred, started); + return yield* started; + }, Effect.uninterruptible); + + const cancel = (entry: Entry, child: Child, requestId: number, handler: string) => + Effect.suspend(() => { + if (!child.pending.delete(requestId)) return Effect.void; + if (!isAlive(child)) return Effect.void; + const settled = Deferred.makeUnsafe(); + child.settling.set(requestId, settled); + write(child, { _tag: "Cancel", requestId }); + return Deferred.await(settled).pipe( + Effect.timeoutOption(cancelGrace), + Effect.flatMap((answered) => + Effect.sync(() => { + if (Option.isNone(answered) && entry.child === child) + kill( + child, + `did not stop "${handler}" within ${Duration.toMillis(cancelGrace)}ms of cancellation.`, + ); + }), + ), + Effect.forkIn(fibers, { startImmediately: true }), + Effect.asVoid, + ); + }); + + const availability = (entry: Entry) => { + const state = entry.state; + if (state._tag === "backoff") + return new PluginUnavailableError({ + pluginId: entry.pluginId, + reason: `restarting after a failure at ${state.retryAt}: ${state.reason}`, + }); + if (state._tag === "quarantined") + return new PluginUnavailableError({ + pluginId: entry.pluginId, + reason: `quarantined after ${state.failures} failures: ${state.reason}`, + }); + if (state._tag === "incompatible") + return new PluginIncompatibleError({ pluginId: entry.pluginId, reason: state.reason }); + return undefined; + }; + + const invoke: PluginSupervisor["Service"]["invoke"] = Effect.fn("PluginSupervisor.invoke")( + function* (pluginId, handler, input, invokeOptions) { + const entry = entries.get(pluginId); + if (!entry) return yield* new PluginNotEnabledError({ pluginId }); + if (!isHandlerName(handler)) + return yield* new PluginCallFailedError({ + pluginId, + handler, + reason: "the handler name is invalid.", + }); + const unavailable = availability(entry); + if (unavailable) return yield* unavailable; + const child = yield* ensureChild(entry); + const timeout = Duration.fromInputUnsafe(invokeOptions?.timeout ?? callTimeout); + + const call = yield* Effect.sync(() => { + if (entry.removed || child.stopping) return new PluginStoppedError({ pluginId }); + if (Deferred.isDoneUnsafe(child.exited)) + return new PluginUnavailableError({ pluginId, reason: "its process just stopped." }); + // A cancelled call holds its slot until the plugin answers it or exits. + if (child.pending.size + child.settling.size >= options.maxConcurrentCalls) + return new PluginBusyError({ pluginId, limit: options.maxConcurrentCalls }); + const requestId = ++child.nextRequestId; + const deferred = Deferred.makeUnsafe(); + child.pending.set(requestId, { handler, deferred }); + const unsent = write(child, { _tag: "Invoke", requestId, handler, input }); + if (unsent) child.pending.delete(requestId); + if (unsent?._tag === "tooLarge") + return new PluginPayloadTooLargeError({ + pluginId, + bytes: unsent.bytes, + limit: maxMessageBytes, + }); + if (unsent) + return new PluginCallFailedError({ pluginId, handler, reason: "the input is not JSON." }); + return { requestId, deferred }; + }); + if (!("requestId" in call)) return yield* call; + + const outcome: Option.Option = yield* Deferred.await(call.deferred).pipe( + Effect.exit, + Effect.timeoutOption(timeout), + Effect.onInterrupt(() => cancel(entry, child, call.requestId, handler)), + ); + if (Option.isNone(outcome)) { + yield* cancel(entry, child, call.requestId, handler); + return yield* new PluginTimeoutError({ + pluginId, + handler, + timeoutMs: Duration.toMillis(timeout), + }); + } + return yield* outcome.value; + }, + ); + + /** Deactivates a removed plugin's child, killing it after `stopGrace`, and waits for its exit. */ + const stopEntry = Effect.fnUntraced(function* (entry: Entry) { + // Only reachable between claiming a start and spawning; the start then fails fast. + if (!entry.child && entry.starting) yield* Deferred.await(entry.starting).pipe(Effect.ignore); + const child = entry.child; + if (!child) return; + // A process that already exited may still be ending its host work. + if (Deferred.isDoneUnsafe(child.exited)) return yield* endHostWork(child); + revokeChild(entry, child); + // The revoked generation's host work ends before its plugin is asked to deactivate. + yield* endHostWork(child); + write(child, { _tag: "Deactivate" }); + const exited = yield* Deferred.await(child.exited).pipe(Effect.timeoutOption(stopGrace)); + if (Option.isNone(exited)) { + kill(child, `did not stop within ${Duration.toMillis(stopGrace)}ms.`); + yield* Deferred.await(child.exited); + } + }); + + const disable = Effect.fn("PluginSupervisor.disable")(function* (pluginId: PluginId) { + const entry = entries.get(pluginId); + const previous = stops.get(pluginId); + if (!entry) { + // A retry after an interrupted disable waits for the same stop. + if (previous) yield* Deferred.await(previous); + return; + } + entries.delete(pluginId); + entry.removed = true; + if (entry.child) revokeChild(entry, entry.child); + // The stop belongs to the supervisor: interrupting this caller only stops the wait. + // It also covers an earlier generation still stopping, so done means every process exited. + const stopped = Deferred.makeUnsafe(); + stops.set(pluginId, stopped); + yield* Effect.all([stopEntry(entry), previous ? Deferred.await(previous) : Effect.void], { + concurrency: "unbounded", + discard: true, + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (stops.get(pluginId) === stopped) stops.delete(pluginId); + Deferred.doneUnsafe(stopped, Exit.void); + }), + ), + Effect.forkIn(fibers, { startImmediately: true, uninterruptible: true }), + ); + yield* Deferred.await(stopped); + }); + + // Closing `fibers` waits for every stop in progress; anything left is killed. + yield* Effect.addFinalizer(() => + Effect.forEach([...entries.keys()], disable, { concurrency: "unbounded", discard: true }).pipe( + Effect.andThen(Scope.close(fibers, Exit.void)), + Effect.andThen( + Effect.forEach( + [...children], + (child) => { + kill(child, "the server stopped."); + return Deferred.await(child.exited); + }, + { discard: true }, + ), + ), + ), + ); + + return PluginSupervisor.of({ + enable: Effect.fn("PluginSupervisor.enable")(function* (registration) { + const pluginId = registration.manifest.id; + if (entries.has(pluginId)) return yield* new PluginAlreadyEnabledError({ pluginId }); + const entry: Entry = { + registration, + pluginId, + state: { _tag: "idle" }, + removed: false, + failures: 0, + child: undefined, + starting: undefined, + }; + entries.set(pluginId, entry); + yield* setState(entry, entry.state); + }), + disable, + resume: Effect.fn("PluginSupervisor.resume")(function* (pluginId) { + const entry = entries.get(pluginId); + if (!entry) return yield* new PluginNotEnabledError({ pluginId }); + if ( + entry.state._tag !== "backoff" && + entry.state._tag !== "quarantined" && + entry.state._tag !== "incompatible" + ) + return; + entry.failures = 0; + yield* setState(entry, { _tag: "idle" }); + }), + invoke, + state: (pluginId) => Effect.sync(() => Option.fromUndefinedOr(entries.get(pluginId)?.state)), + subscribe: PubSub.subscribe(events), + serveHostMethod: (method, handler) => + Effect.acquireRelease( + Effect.sync(() => { + if (hostMethods.has(method)) throw new Error(`Host method ${method} is already served.`); + hostMethods.set(method, handler); + }), + () => Effect.sync(() => hostMethods.delete(method)), + ), + }); +}); + +export const layer = (overrides: Partial = {}) => + Layer.effect(PluginSupervisor, make(overrides)); diff --git a/apps/server/src/plugins/PluginTools.test.ts b/apps/server/src/plugins/PluginTools.test.ts new file mode 100644 index 000000000000..5a0c1b44fc96 --- /dev/null +++ b/apps/server/src/plugins/PluginTools.test.ts @@ -0,0 +1,474 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + PLUGIN_TOOL_LIMITS, + PluginInstallation, + ThreadId, + type PluginToolDeclaration, + type PluginToolError, + type PluginToolsListResult, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +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 * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as PluginCatalog from "./PluginCatalog.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; +import { jsonBytes } from "./pluginToolDeclarations.ts"; +import * as PluginTools from "./PluginTools.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/toolsPlugin`; + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const context = { + environmentId: EnvironmentId.make("environment-tools"), + threadId: ThreadId.make("thread-tools"), +}; + +/** A real supervisor, catalogue, and tool service in `scope`, as one server start would run them. */ +const start = Effect.fn("start")(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 tools = yield* PluginTools.make.pipe( + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + ); + /** Adds, approves, and enables a plugin directory. */ + const install = Effect.fn("install")(function* (directory: string) { + const { installation } = yield* catalog.add({ directory }); + const installationId = installation.installationId; + yield* catalog.consent({ installationId, digest: installation.source!.digest }); + return (yield* catalog.enable({ installationId })).installation; + }); + return { supervisor, catalog, tools, install }; +}); + +/** A scoped copy of the committed fixture, so tests never share a plugin directory. */ +const copyFixture = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-tools-" }), + "plugin", + ); + yield* fs.copy(FIXTURE, directory); + return directory; +}); + +/** A scoped tool plugin that declares `tools` and answers every call with "pong". */ +const declaringPlugin = Effect.fn("declaringPlugin")(function* ( + id: string, + tools: ReadonlyArray>, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-tools-" }), + "plugin", + ); + yield* fs.makeDirectory(directory); + yield* fs.writeFileString( + path.join(directory, "main.mjs"), + [ + `export function activate(context) {`, + ...tools.map((tool) => ` context.proposed.handle("t3.tool.${tool.name}", () => "pong");`), + `}`, + ``, + ].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: ["tools"], + proposedApi: true, + tools, + }), + ); + return directory; +}); + +const tool = (name: string, description: string) => ({ + name, + description, + inputSchema: { type: "object", additionalProperties: false }, + sideEffect: "read", +}); + +const reasonOf = (error: PluginToolError) => error.reason; + +const withDatabase = (effect: Effect.Effect) => + effect.pipe(Effect.provide(SqlitePersistence.layerMemory)); + +const DIGEST = `sha256:${"0".repeat(64)}`; +const decodeInstallation = Schema.decodeUnknownSync(PluginInstallation); + +/** + * An in-memory catalogue of `count` enabled one-tool plugins. It records which + * plugins had their declarations read and how often the catalogue was listed. + */ +const inventory = (count: number) => { + const prepared = new Set(); + const counts = { lists: 0, revision: 0 }; + const rows = Array.from({ length: count }, (_, index) => { + const id = `test.p${String(index).padStart(4, "0")}`; + const row = decodeInstallation({ + installationId: `installation-${index}`, + generation: 1, + directory: `/plugins/${id}`, + manifest: { id, name: id, version: "1.0.0", capabilities: ["tools"], proposedApi: true }, + source: { digest: DIGEST, files: 1, bytes: 1 }, + problem: null, + inspectedAt: "2026-01-01T00:00:00.000Z", + consent: { digest: DIGEST, capabilities: ["tools"], grantedAt: "2026-01-01T00:00:00.000Z" }, + enabled: true, + addedAt: "2026-01-01T00:00:00.000Z", + }); + const declaration: PluginToolDeclaration = { + name: "ping", + description: "x".repeat(1_500), + sideEffect: "read", + openWorld: false, + get inputSchema() { + prepared.add(id); + return { type: "object", additionalProperties: false }; + }, + }; + return { ...row, manifest: { ...row.manifest!, tools: [declaration] } }; + }); + const unused = () => Effect.die("not used by PluginTools"); + const catalog = PluginCatalog.PluginCatalog.of({ + list: Effect.sync(() => { + counts.lists++; + return { installations: rows }; + }), + revision: Effect.sync(() => counts.revision), + subscribe: Stream.empty, + add: unused, + refresh: unused, + consent: unused, + enable: unused, + disable: unused, + remove: unused, + resume: unused, + invoke: () => Effect.succeed("pong"), + }); + return { rows, prepared, counts, catalog }; +}; + +it.layer(NodeServices.layer)("PluginTools", (it) => { + describe("granted plugins", () => { + it.effect("lists declared tools without starting the plugin and calls them in context", () => + withDatabase( + Effect.gen(function* () { + const { catalog, tools, install } = yield* start(yield* Scope.Scope); + const installation = yield* install(yield* copyFixture); + const grants = yield* tools.grants; + expect(grants).toEqual([{ installationId: installation.installationId, generation: 1 }]); + + const listed = yield* tools.list(grants); + expect(listed.tools.map((tool) => tool.tool)).toEqual([ + "test.tools/word_count", + "test.tools/echo_context", + "test.tools/wait_for_cancel", + "test.tools/big_result", + ]); + expect(listed.tools[0]).toMatchObject({ + tool: "test.tools/word_count", + plugin: { id: "test.tools", name: "Tools fixture" }, + title: "Count words", + description: "Count the words in a text.", + // Exactly what the manifest declares. + inputSchema: { + type: "object", + properties: { text: { type: "string", maxLength: 10000 } }, + required: ["text"], + additionalProperties: false, + }, + sideEffect: "read", + openWorld: false, + }); + expect(listed.tools[2]).toMatchObject({ sideEffect: "write", openWorld: true }); + expect(listed).not.toHaveProperty("nextCursor"); + expect(listed.notInThisSession).toEqual([]); + const hostState = Effect.map( + catalog.list, + (snapshot) => snapshot.installations[0]?.hostState, + ); + // Listing reads the consented manifest; only a call starts the plugin. + expect(yield* hostState).toEqual({ _tag: "idle" }); + + const call = (tool: string, input: unknown) => + tools.call(grants, { tool, input, context }); + expect(yield* call("test.tools/word_count", { text: "one two three" })).toEqual({ + words: 3, + }); + expect((yield* hostState)?._tag).toBe("running"); + // The context comes from the session, never from the input. + expect(yield* call("test.tools/echo_context", {})).toEqual({ input: {}, context }); + + const failures = yield* Effect.forEach( + [ + ["test.tools/word_count", { text: 5 }], + ["test.tools/word_count", {}], + ["test.tools/word_count", { text: "x".repeat(10_001) }], + ["test.tools/word_count", { text: "closed", extra: 1 }], + ["test.tools/echo_context", { context: { threadId: "other" } }], + ["test.tools/nope", {}], + ["word_count", {}], + ["test.tools/big_result", { length: 70_000 }], + ] as const, + ([tool, input]) => call(tool, input).pipe(Effect.flip, Effect.map(reasonOf)), + ); + expect(failures).toEqual([ + "invalid-input", + "invalid-input", + "invalid-input", + "invalid-input", + "invalid-input", + "unknown-tool", + "unknown-tool", + "result-too-large", + ]); + }), + ), + ); + + it.effect("refuses a disabled plugin at once, also mid-call, and its next registration", () => + withDatabase( + Effect.gen(function* () { + const { supervisor, catalog, tools, install } = yield* start(yield* Scope.Scope); + const installation = yield* install(yield* copyFixture); + const installationId = installation.installationId; + const grants = yield* tools.grants; + const events = yield* supervisor.subscribe; + + const waiting = yield* tools + .call(grants, { tool: "test.tools/wait_for_cancel", input: {}, context }) + .pipe(Effect.flip, Effect.forkChild); + yield* Stream.fromSubscription(events).pipe( + Stream.filter((event) => event._tag === "Log" && event.message === "wait-started"), + Stream.runHead, + ); + yield* catalog.disable({ installationId }); + const revoked = yield* Fiber.join(waiting); + expect(revoked.reason).toBe("unavailable"); + + expect(yield* tools.list(grants)).toEqual({ tools: [], notInThisSession: [] }); + const disabled = yield* tools + .call(grants, { tool: "test.tools/word_count", input: { text: "a" }, context }) + .pipe(Effect.flip); + expect(disabled.reason).toBe("unavailable"); + + // A re-enable is a new registration: the old session's grant does not reach it. + yield* catalog.enable({ installationId }); + const stale = yield* tools.list(grants); + expect(stale.tools).toEqual([]); + expect(stale.notInThisSession).toEqual([{ id: "test.tools", name: "Tools fixture" }]); + const notGranted = yield* tools + .call(grants, { tool: "test.tools/word_count", input: { text: "a" }, context }) + .pipe(Effect.flip); + expect(notGranted.reason).toBe("not-granted"); + const fresh = yield* tools.grants; + expect(fresh).toEqual([{ installationId, generation: 2 }]); + expect( + yield* tools.call(fresh, { + tool: "test.tools/word_count", + input: { text: "a b" }, + context, + }), + ).toEqual({ words: 2 }); + }), + ), + ); + it.effect("refuses a plugin once the catalogue finds its files changed", () => + withDatabase( + Effect.gen(function* () { + const { supervisor, catalog, tools, install } = yield* start(yield* Scope.Scope); + const fs = yield* FileSystem.FileSystem; + const directory = yield* copyFixture; + const edit = (note: string) => + fs.writeFileString(`${directory}/main.mjs`, `// ${note}\n`, { flag: "a" }); + const { installationId } = yield* install(directory); + + // Idle: the bytes are checked before a call would start a process. + const grants = yield* tools.grants; + yield* edit("changed while idle"); + const idle = yield* tools + .call(grants, { tool: "test.tools/word_count", input: { text: "a" }, context }) + .pipe(Effect.flip); + expect(idle.reason).toBe("unavailable"); + expect(yield* tools.list(grants)).toEqual({ tools: [], notInThisSession: [] }); + + // Running: a refresh finds the change and cancels the call in flight. + const [changed] = (yield* catalog.list).installations; + yield* catalog.consent({ installationId, digest: changed!.source!.digest }); + yield* catalog.enable({ installationId }); + const fresh = yield* tools.grants; + const events = yield* supervisor.subscribe; + const waiting = yield* tools + .call(fresh, { tool: "test.tools/wait_for_cancel", input: {}, context }) + .pipe(Effect.flip, Effect.forkChild); + yield* Stream.fromSubscription(events).pipe( + Stream.filter((event) => event._tag === "Log" && event.message === "wait-started"), + Stream.runHead, + ); + yield* edit("changed while running"); + yield* catalog.refresh({ installationId }); + expect((yield* Fiber.join(waiting)).reason).toBe("unavailable"); + expect(yield* tools.list(fresh)).toEqual({ tools: [], notInThisSession: [] }); + }), + ), + ); + }); + + describe("declarations", () => { + it.effect("refuses a manifest whose tools the host cannot enforce when it is added", () => + withDatabase( + Effect.gen(function* () { + const { catalog } = yield* start(yield* Scope.Scope); + const refused = (id: string, tools: ReadonlyArray>) => + declaringPlugin(id, tools).pipe( + Effect.flatMap((directory) => catalog.add({ directory })), + Effect.flip, + Effect.map((error) => error.message), + ); + expect( + yield* refused("test.pattern", [ + { + ...tool("ping", "Uses a pattern."), + inputSchema: { + type: "object", + properties: { id: { type: "string", pattern: "^(a+)+$" } }, + }, + }, + ]), + ).toContain("#/properties/id/pattern: this keyword is not supported."); + expect( + yield* refused("test.duplicate", [tool("ping", "One."), tool("ping", "Two.")]), + ).toContain("it declares the tool ping twice."); + expect( + yield* refused("test.scalar", [ + { ...tool("ping", "Takes a string."), inputSchema: { type: "string" } }, + ]), + ).toContain('the root must be "object"'); + }), + ), + ); + + it.effect("pages whole plugins by id within the byte limit and starts none of them", () => + withDatabase( + Effect.gen(function* () { + const { catalog, tools, install } = yield* start(yield* Scope.Scope); + // About 21 KB each to list, so a 64 KiB page holds two of them at most. + const large = (id: string) => + declaringPlugin( + id, + Array.from({ length: 10 }, (_, index) => tool(`tool_${index}`, "é".repeat(1_000))), + ); + for (const id of ["test.large-c", "test.large-a", "test.large-b"]) + yield* install(yield* large(id)); + const grants = yield* tools.grants; + // Enabled after the snapshot: listed by name only, under notInThisSession. + yield* install(yield* declaringPlugin("test.late", [tool("ping", "Late.")])); + + const pages: Array = []; + let cursor: string | undefined; + do { + const page: PluginToolsListResult = yield* tools.list( + grants, + cursor === undefined ? {} : { cursor }, + ); + pages.push(page); + cursor = page.nextCursor; + } while (cursor !== undefined); + + for (const page of pages) + expect(jsonBytes(page)).toBeLessThanOrEqual(PLUGIN_TOOL_LIMITS.maxListBytes); + expect(pages.length).toBeGreaterThan(1); + const order = pages.flatMap((page) => [ + ...new Set(page.tools.map((listing) => listing.plugin.id)), + ...page.notInThisSession.map((plugin) => `late:${plugin.id}`), + ]); + expect(order).toEqual(["test.large-a", "test.large-b", "test.large-c", "late:test.late"]); + expect(pages.flatMap((page) => page.tools)).toHaveLength(30); + + const one = yield* tools.list(grants, { plugin: "test.large-b" }); + expect(one.tools).toHaveLength(10); + expect(one).not.toHaveProperty("nextCursor"); + expect(jsonBytes(one)).toBeLessThanOrEqual(PLUGIN_TOOL_LIMITS.maxListBytes); + + const states = (yield* catalog.list).installations.map((row) => row.hostState?._tag); + expect(states).toEqual(["idle", "idle", "idle", "idle"]); + }), + ), + ); + + it.effect("prepares only the plugins a page shows, however many are installed", () => + Effect.gen(function* () { + const { rows, prepared, counts, catalog } = inventory(1_000); + const tools = yield* PluginTools.make.pipe( + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + ); + const grants = yield* tools.grants; + expect(grants).toHaveLength(1_000); + expect(prepared.size).toBe(0); + + const one = yield* tools.list(grants, { plugin: "test.p0500" }); + expect(one.tools.map((listing) => listing.tool)).toEqual(["test.p0500/ping"]); + expect([...prepared]).toEqual(["test.p0500"]); + + prepared.clear(); + const first = yield* tools.list(grants); + const shown = first.tools.map((listing) => listing.plugin.id); + expect(first.nextCursor).toBe(shown.at(-1)); + expect(shown.length).toBeLessThan(100); + // The page, and the one plugin after it that did not fit. + expect([...prepared].toSorted()).toEqual( + [...shown, `test.p${String(shown.length).padStart(4, "0")}`].toSorted(), + ); + + prepared.clear(); + const second = yield* tools.list(grants, { cursor: first.nextCursor! }); + expect(second.tools[0]?.plugin.id).toBe(`test.p${String(shown.length).padStart(4, "0")}`); + expect(prepared.size).toBe(second.tools.length); + expect(yield* tools.call(grants, { tool: "test.p0999/ping", input: {}, context })).toBe( + "pong", + ); + // Requests read the catalogue only when its records changed. + expect(counts.lists).toBe(1); + + rows[999] = { ...rows[999]!, enabled: false }; + counts.revision++; + const refused = yield* tools + .call(grants, { tool: "test.p0999/ping", input: {}, context }) + .pipe(Effect.flip); + expect(refused.reason).toBe("unavailable"); + expect((yield* tools.list(grants, { plugin: "test.p0999" })).tools).toEqual([]); + expect(counts.lists).toBe(2); + }), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginTools.ts b/apps/server/src/plugins/PluginTools.ts new file mode 100644 index 000000000000..0481b547fcd7 --- /dev/null +++ b/apps/server/src/plugins/PluginTools.ts @@ -0,0 +1,310 @@ +/** + * Tools that enabled plugins offer to agents, behind the two fixed MCP tools + * `plugin_tools_list` and `plugin_tool_call`. + * + * Tools are declared in the consented manifest, so listing them reads the + * catalogue and never starts a plugin; only a call does. A provider session + * holds a snapshot of grants, taken when its MCP credential was prepared: the + * tool plugins enabled at that moment, each with its registration generation. + * Every list and call intersects that snapshot with the catalogue as it is + * now, so a plugin disabled, removed, or re-registered since then is refused + * at once, and a plugin enabled later waits for a new session. Calls are + * pinned to the granted generation all the way into the supervisor, and + * disabling a plugin fails its calls in flight. + */ +import { + PLUGIN_TOOL_LIMITS, + PLUGIN_TOOLS_CAPABILITY, + type PluginInstallation, + type PluginInstallationId, + PluginToolError, + type PluginToolListing, + type PluginToolsListResult, + parseQualifiedPluginToolName, + pluginInstallationStatus, + pluginToolHandlerName, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { PluginCatalog } from "./PluginCatalog.ts"; +import { + jsonBytes, + preparePluginTools, + type PreparedPluginTools, +} from "./pluginToolDeclarations.ts"; + +/** One tool plugin a session may use: the registration that was enabled when it was prepared. */ +export interface PluginToolGrant { + readonly installationId: PluginInstallationId; + readonly generation: number; +} + +type Plugin = PluginToolListing["plugin"]; + +/** A live tool plugin, as the sorted index holds it. */ +interface Indexed { + readonly plugin: Plugin; + readonly installation: PluginInstallation; + /** Its size under notInThisSession, separator included. */ + readonly nameBytes: number; +} + +/** The live tool plugins by id, rebuilt only when the catalogue's records change. */ +interface Index { + readonly revision: number; + readonly ids: ReadonlyArray; + readonly byId: ReadonlyMap; +} + +const MAX_REASON_LENGTH = 500; +/** The page envelope with the longest cursor a plugin id allows. */ +const LIST_ENVELOPE_BYTES = jsonBytes({ + tools: [], + notInThisSession: [], + nextCursor: "x".repeat(128), +}); + +const decodeJsonObject = Schema.decodeUnknownExit(Schema.Record(Schema.String, Schema.Json)); +const cut = (text: string) => + text.length > MAX_REASON_LENGTH ? `${text.slice(0, MAX_REASON_LENGTH)}…` : text; + +const toolError = (reason: string, message: string) => + new PluginToolError({ reason, message: cut(message) }); + +const pluginOf = (installation: PluginInstallation): Plugin | undefined => + installation.manifest === null + ? undefined + : { id: installation.manifest.id, name: installation.manifest.name }; + +/** Enabled now, declaring tools, with consent that covers the tools capability. */ +const offersTools = (installation: PluginInstallation) => + pluginInstallationStatus(installation) === "enabled" && + (installation.manifest?.tools?.length ?? 0) > 0 && + installation.consent?.capabilities.includes(PLUGIN_TOOLS_CAPABILITY) === true; + +interface PluginToolCallRequest { + readonly tool: string; + readonly input: unknown; + readonly context: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; +} + +export class PluginTools extends Context.Service< + PluginTools, + { + /** The tool plugins enabled right now, for a session's credential to hold. */ + readonly grants: Effect.Effect>; + /** One page of the tools of granted plugins still enabled under the same registration. */ + readonly list: ( + grants: ReadonlyArray, + options?: { readonly plugin?: string; readonly cursor?: string }, + ) => Effect.Effect; + /** Validates `input` against the tool's schema and calls it under the granted registration. */ + readonly call: ( + grants: ReadonlyArray, + request: PluginToolCallRequest, + ) => Effect.Effect; + } +>()("t3/plugins/PluginTools") {} + +export const make = Effect.gen(function* () { + const catalog = yield* PluginCatalog; + // A registration's manifest cannot change: its bytes are checked before every fresh process. + const prepared = new Map< + PluginInstallationId, + { readonly generation: number; readonly tools: PreparedPluginTools | undefined } + >(); + let index: Index = { revision: -1, ids: [], byId: new Map() }; + // A session's grants are fixed for its credential, so their lookup is built once. + const grantLookups = new WeakMap< + ReadonlyArray, + ReadonlyMap + >(); + + /** + * The live tool plugins. Reading the catalogue and sorting happen once per + * change to its records, never per request, so a request's own work is the + * page it returns. + */ + const current = Effect.gen(function* () { + const revision = yield* catalog.revision; + if (revision === index.revision) return index; + const byId = new Map(); + for (const installation of (yield* catalog.list).installations) { + const plugin = pluginOf(installation); + // Ids are unique among registered plugins; the oldest installation wins otherwise. + if (plugin !== undefined && offersTools(installation) && !byId.has(plugin.id)) + byId.set(plugin.id, { plugin, installation, nameBytes: jsonBytes(plugin) + 1 }); + } + const live = new Map( + [...byId.values()].map(({ installation }) => [installation.installationId, installation]), + ); + for (const [installationId, cached] of prepared) + if (live.get(installationId)?.generation !== cached.generation) + prepared.delete(installationId); + // Reading `revision` first means a change during the read rebuilds again next time. + index = { revision, ids: [...byId.keys()].toSorted(), byId }; + return index; + }); + + const isGranted = (grants: ReadonlyArray, installation: PluginInstallation) => { + let lookup = grantLookups.get(grants); + if (lookup === undefined) { + lookup = new Map(grants.map((grant) => [grant.installationId, grant.generation])); + grantLookups.set(grants, lookup); + } + return lookup.get(installation.installationId) === installation.generation; + }; + + /** The installation's declared tools, prepared once per registration. */ + const toolsOf = Effect.fnUntraced(function* (installation: PluginInstallation, plugin: Plugin) { + const cached = prepared.get(installation.installationId); + if (cached?.generation === installation.generation) return cached.tools; + const result = preparePluginTools(plugin, installation.manifest?.tools ?? []); + // The loader refuses a manifest whose tools do not prepare, so this is not expected. + if ("problem" in result) + yield* Effect.logWarning("Plugin tools could not be prepared", { + installationId: installation.installationId, + problem: result.problem, + }); + const tools = "problem" in result ? undefined : result; + prepared.set(installation.installationId, { generation: installation.generation, tools }); + return tools; + }); + + const grants = current.pipe( + Effect.map(({ byId }) => + [...byId.values()].map(({ installation: { installationId, generation } }) => ({ + installationId, + generation, + })), + ), + ); + + const list = Effect.fn("PluginTools.list")(function* ( + grants: ReadonlyArray, + options?: { readonly plugin?: string; readonly cursor?: string }, + ) { + const { ids, byId } = yield* current; + const cursor = options?.cursor; + const only = options?.plugin; + let from = cursor === undefined ? 0 : firstAfter(ids, cursor); + let to = ids.length; + if (only !== undefined) { + const at = firstAfter(ids, only) - 1; + [from, to] = at >= from && ids[at] === only ? [at, at + 1] : [0, 0]; + } + + // Every plugin fits a page on its own (the loader bounds its listing), so each page + // makes progress and the whole result, envelope included, stays within the limit. + // Only the plugins on the page, and the one after it, are prepared. + const result: { tools: Array; notInThisSession: Array } = { + tools: [], + notInThisSession: [], + }; + let budget = PLUGIN_TOOL_LIMITS.maxListBytes - LIST_ENVELOPE_BYTES; + let last: string | undefined; + for (let position = from; position < to; position++) { + const id = ids[position]!; + const { plugin, installation, nameBytes } = byId.get(id)!; + const granted = isGranted(grants, installation); + const tools = granted ? yield* toolsOf(installation, plugin) : undefined; + if (granted && tools === undefined) continue; + const bytes = tools?.listingBytes ?? nameBytes; + if (bytes > budget) return { ...result, ...(last === undefined ? {} : { nextCursor: last }) }; + budget -= bytes; + last = id; + if (tools === undefined) result.notInThisSession.push(plugin); + else for (const tool of tools.tools.values()) result.tools.push(tool.listing); + } + return result satisfies PluginToolsListResult; + }); + + const call = Effect.fn("PluginTools.call")(function* ( + grants: ReadonlyArray, + request: PluginToolCallRequest, + ) { + const parsed = parseQualifiedPluginToolName(request.tool); + if (Option.isNone(parsed)) + return yield* toolError( + "unknown-tool", + `${request.tool} is not a plugin tool name. Use a tool value from plugin_tools_list.`, + ); + const { pluginId, name } = parsed.value; + const live = (yield* current).byId.get(pluginId); + if (live === undefined) + return yield* toolError("unavailable", `Plugin ${pluginId} is not enabled.`); + const { plugin, installation } = live; + if (!isGranted(grants, installation)) + return yield* toolError( + "not-granted", + `Plugin ${pluginId} was enabled after this session started. Start a new session to use its tools.`, + ); + const tool = (yield* toolsOf(installation, plugin))?.tools.get(name); + if (tool === undefined) + return yield* toolError("unknown-tool", `Plugin ${pluginId} has no tool named ${name}.`); + const validated = tool.validate(request.input ?? {}); + if (Exit.isFailure(validated)) + return yield* toolError( + "invalid-input", + `The input does not match ${request.tool}'s inputSchema: ${Option.match( + Exit.findErrorOption(validated), + { onNone: () => "unknown error", onSome: (error) => error.message }, + )}`, + ); + const input = decodeJsonObject(validated.value); + if (Exit.isFailure(input)) + return yield* toolError("invalid-input", "The input must be a JSON object."); + const value = yield* catalog + .invoke( + installation.installationId, + pluginToolHandlerName(name), + { input: input.value, context: request.context }, + { timeout: `${tool.timeoutSeconds} seconds`, generation: installation.generation }, + ) + .pipe( + Effect.mapError((error) => { + switch (error._tag) { + case "PluginCatalogError": + case "PluginStoppedError": + case "PluginNotEnabledError": + case "PluginUnavailableError": + case "PluginIncompatibleError": + return toolError("unavailable", error.message); + case "PluginTimeoutError": + return toolError("timeout", error.message); + default: + return toolError("failed", error.message); + } + }), + ); + if (jsonBytes(value) > PLUGIN_TOOL_LIMITS.maxResultBytes) + return yield* toolError( + "result-too-large", + `${request.tool} returned more than ${PLUGIN_TOOL_LIMITS.maxResultBytes} bytes.`, + ); + return value; + }); + + return PluginTools.of({ grants, list, call }); +}); + +/** The position of the first id after `cursor` in sorted `ids`. */ +const firstAfter = (ids: ReadonlyArray, cursor: string) => { + let low = 0; + let high = ids.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (ids[middle]! <= cursor) low = middle + 1; + else high = middle; + } + return low; +}; + +export const layer = Layer.effect(PluginTools, make); diff --git a/apps/server/src/plugins/pluginApi.ts b/apps/server/src/plugins/pluginApi.ts new file mode 100644 index 000000000000..dd6cbb55d6a5 --- /dev/null +++ b/apps/server/src/plugins/pluginApi.ts @@ -0,0 +1,117 @@ +import type { PluginEvent as PluginEventSchema } from "@t3tools/contracts"; + +/** + * The surface a plugin entry module sees, plugin API version 1. + * + * An entry module exports `activate(context)` and optionally `deactivate()`: + * + * ```js + * export function activate(context) { + * context.log.info(`started ${context.plugin.id}`); + * } + * ``` + * + * `activate` runs once per child process, the first time the server needs the + * plugin. `context.signal` aborts when the plugin is disabled or the server + * stops; `deactivate` then gets a short grace period before the process is + * killed. Members under `context.proposed` exist only when the manifest sets + * `proposedApi: true` and may change without an API version bump. + * + * Actions (capability `actions`): each action the manifest declares runs the + * handler registered as `context.proposed.handle("action:", handler)`. + * Its input is `{ action, target }`, with `target` as described by + * `PluginActionTargetContext` in PluginActions.ts. Return `{ message }` to + * show the user a short result, or throw to report a failure. + */ +export type PluginJson = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: PluginJson }; + +interface PluginLog { + debug(message: string): void; + info(message: string): void; + warn(message: string): void; + error(message: string): void; +} + +interface PluginHandlerContext { + /** Aborts when the server cancels the call; the handler should settle promptly. */ + readonly signal: AbortSignal; +} + +export type PluginHandler = ( + input: PluginJson, + context: PluginHandlerContext, +) => PluginJson | Promise; + +interface PluginDisposable { + dispose(): void; +} + +/** One event from the environment, as JSON (see `PluginEvent` in contracts). */ +export type PluginEvent = typeof PluginEventSchema.Encoded; + +export type PluginEventHandler = ( + event: PluginEvent, + context: PluginHandlerContext, +) => void | Promise; +/** + * Reads the settings declared under `settings` in the manifest. Present with + * the `settings` capability. + */ +interface PluginSettingsApi { + /** + * The saved value of a declared setting, else its default, else undefined. + * A secret resolves to its saved text. Rejects for an undeclared key. + */ + get(key: string): Promise; +} + +/** + * Small JSON values the plugin keeps between runs, private to this + * installation and deleted when it is removed. Bounded: keys up to 128 + * characters, values up to 64 KiB of JSON, 256 keys and 1 MiB in total; a + * write past a bound rejects. Present with the `settings` capability. + */ +interface PluginStorageApi { + get(key: string): Promise; + set(key: string, value: PluginJson): Promise; + delete(key: string): Promise; + keys(): Promise>; +} + +export interface PluginProposedApi { + /** + * Registers the entry point the server calls by `name`. Names are unique per + * plugin; names starting with `t3.` are reserved. + */ + handle(name: string, handler: PluginHandler): PluginDisposable; + /** + * Receives environment events, in log order, when the manifest declares the + * `events` capability. Register during `activate`. The server acknowledges a + * page of events once every handler returned for each of them; a throw or + * rejection fails the page, and the same events arrive again later. + * Delivery is at-least-once: deduplicate side effects by `event.deliveryId` + * and ignore event types you do not know. + */ + onEvent(handler: PluginEventHandler): PluginDisposable; + readonly settings: PluginSettingsApi | undefined; + readonly storage: PluginStorageApi | undefined; +} + +export interface PluginContext { + readonly apiVersion: 1; + readonly plugin: { readonly id: string; readonly version: string }; + readonly signal: AbortSignal; + readonly log: PluginLog; + readonly proposed: PluginProposedApi | undefined; +} + +export interface PluginModule { + activate(context: PluginContext): void | Promise; + deactivate?(): void | Promise; +} diff --git a/apps/server/src/plugins/pluginHostChild.test.ts b/apps/server/src/plugins/pluginHostChild.test.ts new file mode 100644 index 000000000000..b28962874fdc --- /dev/null +++ b/apps/server/src/plugins/pluginHostChild.test.ts @@ -0,0 +1,114 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Spawns the real plugin host child to talk to it over fd 3. +import * as NodeChildProcess from "node:child_process"; +import * as NodeReadline from "node:readline"; +import type * as NodeStream from "node:stream"; + +import { afterEach, describe, expect, it } from "@effect/vitest"; + +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; +const FIXTURE_DIR = `${import.meta.dirname}/testFixtures/plugin`; + +const children = new Set(); +afterEach(() => { + for (const child of children) child.kill(); + children.clear(); +}); + +/** Speaks the raw fd 3 protocol with one plugin child, as the supervisor would. */ +const startChild = () => { + const child = NodeChildProcess.spawn(process.execPath, [BIN_PATH, "__plugin-host"], { + cwd: FIXTURE_DIR, + stdio: ["ignore", "ignore", "ignore", "pipe"], + }); + children.add(child); + const exited = new Promise<[number | null, NodeJS.Signals | null]>((resolve) => + child.once("exit", (code, signal) => resolve([code, signal])), + ); + const channel = child.stdio[3] as NodeStream.Duplex; + const lines = NodeReadline.createInterface({ input: channel })[Symbol.asyncIterator](); + return { + exited, + send: (line: string) => channel.write(`${line}\n`), + read: async () => { + const next = await lines.next(); + return next.done ? undefined : (JSON.parse(next.value) as Record); + }, + }; +}; + +const activateMessage = (entry: string) => + JSON.stringify({ + _tag: "Activate", + pluginId: "test.child", + version: "1.0.0", + apiVersion: 1, + entryPath: `${FIXTURE_DIR}/${entry}`, + proposedApi: true, + maxMessageBytes: 64 * 1024, + capabilities: [], + }); + +describe("plugin host child", () => { + it("drops what a plugin registered before its activation failed", async () => { + const child = startChild(); + child.send(activateMessage("registerThenFail.mjs")); + // The activation signal aborts before the failure is reported. + expect(await child.read()).toEqual({ + _tag: "Log", + level: "info", + message: "activation-aborted", + }); + expect(await child.read()).toEqual({ + _tag: "ActivationFailed", + message: "activation refused", + }); + + child.send(JSON.stringify({ _tag: "Invoke", requestId: 1, handler: "leftover", input: null })); + expect(await child.read()).toEqual({ + _tag: "Failed", + requestId: 1, + message: 'No handler named "leftover".', + }); + + child.send( + JSON.stringify({ _tag: "Invoke", requestId: 2, handler: "t3.events", input: { events: [] } }), + ); + expect(await child.read()).toEqual({ + _tag: "Failed", + requestId: 2, + message: "The plugin declares the events capability but registered no onEvent handler.", + }); + + // Deactivation skips the module's deactivate: it never started. + child.send(JSON.stringify({ _tag: "Deactivate" })); + expect(await child.read()).toEqual({ _tag: "Deactivated" }); + expect(await child.read()).toBeUndefined(); + expect(await child.exited).toEqual([0, null]); + }); + + it("answers a call cancelled before its handler started", async () => { + const child = startChild(); + child.send(activateMessage("cancellable.mjs")); + expect(await child.read()).toEqual({ _tag: "Ready" }); + + // Read in one chunk, the cancel lands before the handler can listen for it. + child.send( + [ + JSON.stringify({ _tag: "Invoke", requestId: 1, handler: "cooperative", input: null }), + JSON.stringify({ _tag: "Cancel", requestId: 1 }), + ].join("\n"), + ); + expect(await child.read()).toEqual({ + _tag: "Failed", + requestId: 1, + message: "Call cancelled.", + }); + }); + + it("exits with a failure on a line that is not JSON", async () => { + const child = startChild(); + child.send("{not json"); + expect(await child.exited).toEqual([1, null]); + }); +}); diff --git a/apps/server/src/plugins/pluginHostChild.ts b/apps/server/src/plugins/pluginHostChild.ts new file mode 100644 index 000000000000..0bb6603b5043 --- /dev/null +++ b/apps/server/src/plugins/pluginHostChild.ts @@ -0,0 +1,328 @@ +// @effect-diagnostics nodeBuiltinImport:off +// The child hosts one plugin and runs without the Effect runtime: bin.ts +// dispatches `__plugin-host` here before the CLI module graph loads, so each +// plugin process pays only for this file. Nothing here may run on import. +import * as NodeModule from "node:module"; +import * as NodeNet from "node:net"; + +import type { PLUGIN_TOOL_HANDLER_PREFIX as ContractToolHandlerPrefix } from "@t3tools/contracts"; +import type { + PluginContext, + PluginEvent, + PluginEventHandler, + PluginHandler, + PluginJson, + PluginModule, + PluginProposedApi, +} from "./pluginApi.ts"; +import type { PluginChildMessage, PluginHostMessage, PluginLogLevel } from "./PluginIpc.ts"; +import { + DEFAULT_PLUGIN_IPC_MAX_BYTES, + PLUGIN_IPC_FD, + PLUGIN_EVENTS_HANDLER, + PLUGIN_IPC_MAX_BYTES_LIMIT, + PLUGIN_MAX_HOST_CALLS, + makeLineDecoder, +} from "./pluginIpcFraming.ts"; + +// Restates the contract's prefix, which plugins may register under: importing +// @t3tools/contracts at runtime would load Effect into every plugin process. +const PLUGIN_TOOL_HANDLER_PREFIX: typeof ContractToolHandlerPrefix = "t3.tool."; + +// Every launcher loads the entry through require, so a plugin behaves the same +// under Node, Electron, and the single executable (which can only import() +// built-ins). require accepts CommonJS and ES modules, except an ES module +// graph that uses top-level await. +const loadEntry = (entryPath: string): Partial => + NodeModule.createRequire(entryPath)(entryPath); + +const isAsyncModuleError = (error: unknown) => + error instanceof Error && "code" in error && error.code === "ERR_REQUIRE_ASYNC_MODULE"; + +// The server refuses a longer `Failed.message`, and a line it cannot decode kills the child. +const errorMessage = (error: unknown, prefix = "") => + `${prefix}${error instanceof Error ? error.message : String(error)}`.slice(0, 2000); + +/** Serves one plugin over fd 3 until the server deactivates it or goes away. */ +export const runPluginHostChild = (): void => { + const channel = new NodeNet.Socket({ fd: PLUGIN_IPC_FD, readable: true, writable: true }); + let maxBytes = DEFAULT_PLUGIN_IPC_MAX_BYTES; + let activated: { module: Partial; controller: AbortController } | undefined; + const handlers = new Map(); + const eventHandlers = new Set(); + const requests = new Map(); + const hostCalls = new Map< + number, + { resolve: (value: PluginJson) => void; reject: (error: Error) => void } + >(); + let nextHostCallId = 0; + + const write = (line: string) => { + if (!channel.destroyed && channel.writable) channel.write(`${line}\n`); + }; + const send = (message: PluginChildMessage) => write(JSON.stringify(message)); + // Logs are lossy: while the server is not reading, they are counted and + // dropped instead of buffered. Results and lifecycle messages always queue. + let droppedLogs = 0; + const log = (level: PluginLogLevel, message: unknown) => { + if (channel.writableNeedDrain) droppedLogs++; + else send({ _tag: "Log", level, message: String(message).slice(0, 4000) }); + }; + channel.on("drain", () => { + if (droppedLogs === 0) return; + const message = `Dropped ${droppedLogs} log messages while the server was busy.`; + droppedLogs = 0; + send({ _tag: "Log", level: "warn", message }); + }); + + const settle = (requestId: number, outcome: PluginJson | Error) => { + requests.delete(requestId); + if (outcome instanceof Error) { + send({ _tag: "Failed", requestId, message: errorMessage(outcome) }); + return; + } + let value: string | undefined; + try { + value = JSON.stringify(outcome ?? null); + } catch (error) { + send({ _tag: "Failed", requestId, message: errorMessage(error, "Result is not JSON: ") }); + return; + } + // A function, symbol or `toJSON` returning undefined has no JSON form at all. + if (value === undefined) { + send({ _tag: "Failed", requestId, message: "Result is not JSON." }); + return; + } + const line = `{"_tag":"Succeeded","requestId":${requestId},"value":${value}}`; + if (Buffer.byteLength(line) > maxBytes) { + send({ _tag: "Failed", requestId, message: `Result exceeds ${maxBytes} bytes.` }); + return; + } + write(line); + }; + + /** Asks the server for something a capability provides; settles with the server's answer. */ + const hostCall = (method: string, input: PluginJson) => + new Promise((resolve, reject) => { + const requestId = ++nextHostCallId; + let line: string; + try { + line = JSON.stringify({ _tag: "HostCall", requestId, method, input }); + } catch (error) { + reject(new Error(`The value is not JSON: ${errorMessage(error)}`)); + return; + } + if (Buffer.byteLength(line) > maxBytes) { + reject(new Error(`The request exceeds ${maxBytes} bytes.`)); + return; + } + if (hostCalls.size >= PLUGIN_MAX_HOST_CALLS) { + reject(new Error(`${PLUGIN_MAX_HOST_CALLS} calls to the server are already in flight.`)); + return; + } + hostCalls.set(requestId, { resolve, reject }); + write(line); + }); + + const settingsApi = (): Pick => ({ + settings: { + get: async (key) => { + const { value } = (await hostCall("settings.get", { key })) as { + value: string | number | boolean | null; + }; + return value ?? undefined; + }, + }, + storage: { + get: async (key) => { + const result = (await hostCall("storage.get", { key })) as { + found: boolean; + value: PluginJson; + }; + return result.found ? result.value : undefined; + }, + set: async (key, value) => { + await hostCall("storage.set", { key, value }); + }, + delete: async (key) => { + await hostCall("storage.delete", { key }); + }, + keys: async () => ((await hostCall("storage.keys", {})) as { keys: string[] }).keys, + }, + }); + + const activate = async (message: Extract) => { + maxBytes = message.maxMessageBytes; + const controller = new AbortController(); + const proposed: PluginProposedApi | undefined = message.proposedApi + ? { + handle(name: string, handler: PluginHandler) { + if (name.startsWith("t3.") && !name.startsWith(PLUGIN_TOOL_HANDLER_PREFIX)) + throw new Error(`Handler names starting with "t3." are reserved.`); + if (handlers.has(name)) throw new Error(`Handler "${name}" is already registered.`); + handlers.set(name, handler); + return { + dispose() { + if (handlers.get(name) === handler) handlers.delete(name); + }, + }; + }, + onEvent(handler: PluginEventHandler) { + // A wrapper per registration, so registering one function twice runs it twice. + const registered: PluginEventHandler = (event, context) => handler(event, context); + eventHandlers.add(registered); + return { dispose: () => void eventHandlers.delete(registered) }; + }, + ...(message.capabilities.includes("settings") + ? settingsApi() + : { settings: undefined, storage: undefined }), + } + : undefined; + const context: PluginContext = { + apiVersion: 1, + plugin: { id: message.pluginId, version: message.version }, + signal: controller.signal, + log: { + debug: (text) => log("debug", text), + info: (text) => log("info", text), + warn: (text) => log("warn", text), + error: (text) => log("error", text), + }, + proposed, + }; + let module: Partial; + try { + module = loadEntry(message.entryPath); + } catch (error) { + send( + isAsyncModuleError(error) + ? { + _tag: "Incompatible", + message: + "The plugin's entry or a module it imports uses top-level await, which plugins cannot use. Move asynchronous setup into activate().", + } + : { _tag: "ActivationFailed", message: errorMessage(error) }, + ); + return; + } + try { + if (typeof module.activate !== "function") + throw new Error("The plugin entry does not export an activate function."); + activated = { module, controller }; + await module.activate(context); + send({ _tag: "Ready" }); + } catch (error) { + // A plugin that failed to activate serves nothing it registered before + // failing, and is not deactivated later as if it had started. + activated = undefined; + handlers.clear(); + eventHandlers.clear(); + controller.abort(new Error("Activation failed.")); + send({ _tag: "ActivationFailed", message: errorMessage(error) }); + } + }; + + // Runs every onEvent handler for each event of the page, in order. Any failure fails the + // whole page, so the server keeps its cursor before it and delivers the page again. + const deliverEvents: PluginHandler = async (input, context) => { + if (eventHandlers.size === 0) + throw new Error( + "The plugin declares the events capability but registered no onEvent handler.", + ); + const { events } = input as unknown as { readonly events: ReadonlyArray }; + for (const event of events) { + for (const handler of eventHandlers) { + context.signal.throwIfAborted(); + try { + await handler(event, context); + } catch (error) { + throw new Error(`onEvent failed for ${event.deliveryId}: ${errorMessage(error)}`, { + cause: error, + }); + } + } + } + return null; + }; + + const invoke = (message: Extract) => { + const handler = + message.handler === PLUGIN_EVENTS_HANDLER ? deliverEvents : handlers.get(message.handler); + if (!handler) { + settle(message.requestId, new Error(`No handler named "${message.handler}".`)); + return; + } + const controller = new AbortController(); + requests.set(message.requestId, controller); + Promise.resolve() + .then(() => { + // A cancel read with its invoke aborts before the handler could listen for it. + if (controller.signal.aborted) throw controller.signal.reason; + return handler(message.input, { signal: controller.signal }); + }) + .then( + (value) => settle(message.requestId, value), + (error) => settle(message.requestId, new Error(errorMessage(error))), + ); + }; + + const deactivate = async () => { + const reason = new Error("Plugin deactivated."); + activated?.controller.abort(reason); + for (const controller of requests.values()) controller.abort(reason); + try { + await activated?.module.deactivate?.(); + } catch (error) { + log("error", `deactivate failed: ${errorMessage(error)}`); + } + send({ _tag: "Deactivated" }); + channel.end(() => process.exit(0)); + }; + + const receive = (line: string) => { + let message: PluginHostMessage; + try { + message = JSON.parse(line) as PluginHostMessage; + } catch { + // A corrupt stream ends the child the same way an oversized line does. + process.exit(1); + } + switch (message._tag) { + case "Activate": + void activate(message); + return; + case "Invoke": + invoke(message); + return; + case "Cancel": + requests.get(message.requestId)?.abort(new Error("Call cancelled.")); + return; + case "Deactivate": + void deactivate(); + return; + case "HostCallSucceeded": + case "HostCallFailed": { + const pending = hostCalls.get(message.requestId); + if (!pending) return; + hostCalls.delete(message.requestId); + if (message._tag === "HostCallSucceeded") pending.resolve(message.value); + else pending.reject(new Error(message.message)); + return; + } + } + }; + + channel.on( + "data", + // The server bounds what it sends by its configured limit; this only stops a + // corrupt stream from growing without end. + makeLineDecoder({ + maxBytes: PLUGIN_IPC_MAX_BYTES_LIMIT, + onLine: receive, + onOverflow: () => process.exit(1), + }), + ); + // The server closed the channel or died: nothing can reach this plugin again. + channel.on("end", () => process.exit(0)); + channel.on("error", () => process.exit(1)); +}; diff --git a/apps/server/src/plugins/pluginIpcFraming.test.ts b/apps/server/src/plugins/pluginIpcFraming.test.ts new file mode 100644 index 000000000000..fb14a91d0cf2 --- /dev/null +++ b/apps/server/src/plugins/pluginIpcFraming.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { makeLineDecoder, makeReadBudget } from "./pluginIpcFraming.ts"; + +const line = (bytes: number) => Buffer.from(`${"x".repeat(bytes - 1)}\n`); + +describe("plugin IPC read budget", () => { + it("stops reading a flood until the reader catches up", () => { + let paused = false; + let pauses = 0; + const budget = makeReadBudget({ + maxBytes: 1000, + pause: () => { + paused = true; + pauses++; + }, + resume: () => (paused = false), + }); + const unhandled: Array = []; + let held = 0; + let peak = 0; + const decode = makeLineDecoder({ + maxBytes: 1000, + onLine: (_line, bytes) => { + budget.hold(bytes); + unhandled.push(bytes); + held += bytes; + peak = Math.max(peak, held); + }, + onOverflow: () => expect.unreachable(), + }); + const handleOne = () => { + const bytes = unhandled.shift() ?? 0; + held -= bytes; + budget.release(bytes); + }; + + // A source honours pause by delivering nothing until it resumes; each + // chunk carries five 99-byte lines. + const chunk = Buffer.concat(Array.from({ length: 5 }, () => line(100))); + for (let delivered = 0; delivered < 200;) { + if (!paused) { + decode(chunk); + delivered++; + } else handleOne(); + } + expect(pauses).toBeGreaterThan(1); + expect(peak).toBeLessThan(1000 + chunk.length); + while (unhandled.length > 0) handleOne(); + expect(paused).toBe(false); + }); +}); + +describe("plugin IPC line decoder", () => { + it("joins a line trickled in one byte at a time", () => { + const lines: Array<[string, number]> = []; + const decode = makeLineDecoder({ + maxBytes: 1000, + onLine: (text, bytes) => lines.push([text, bytes]), + onOverflow: () => expect.unreachable(), + }); + const text = `{"message":"${"é".repeat(300)}"}`; + for (const byte of Buffer.from(`${text}\n`)) decode(Buffer.from([byte])); + expect(lines).toEqual([[text, Buffer.byteLength(text) + 1]]); + }); + + it("charges empty lines against the read budget", () => { + let paused = false; + const budget = makeReadBudget({ + maxBytes: 100, + pause: () => (paused = true), + resume: () => (paused = false), + }); + const decode = makeLineDecoder({ + maxBytes: 100, + onLine: (_line, bytes) => budget.hold(bytes), + onOverflow: () => expect.unreachable(), + }); + decode(Buffer.from("\n".repeat(150))); + expect(paused).toBe(true); + }); + + it("stops at the limit for a line trickled in one byte at a time", () => { + let overflows = 0; + const decode = makeLineDecoder({ + maxBytes: 1000, + onLine: () => expect.unreachable(), + onOverflow: () => overflows++, + }); + for (let index = 0; index < 1000; index++) decode(Buffer.from("x")); + expect(overflows).toBe(0); + decode(Buffer.from("x")); + decode(Buffer.from("x\n{}\n")); + expect(overflows).toBe(1); + }); +}); diff --git a/apps/server/src/plugins/pluginIpcFraming.ts b/apps/server/src/plugins/pluginIpcFraming.ts new file mode 100644 index 000000000000..29fde2d4d850 --- /dev/null +++ b/apps/server/src/plugins/pluginIpcFraming.ts @@ -0,0 +1,110 @@ +// Shared by the server and the plugin child, which loads without the Effect +// runtime: keep this module free of imports. + +/** File descriptor of the newline-delimited JSON channel inside the child. */ +export const PLUGIN_IPC_FD = 3; + +/** + * Handler name the server delivers event pages to. The child runtime answers + * it with the plugin's `onEvent` handlers, so plugins cannot register it. + */ +export const PLUGIN_EVENTS_HANDLER = "t3.events"; + +/** Default upper bound for one encoded IPC message, newline excluded. */ +export const DEFAULT_PLUGIN_IPC_MAX_BYTES = 1024 * 1024; + +/** Largest per-message bound the server may be configured with. */ +export const PLUGIN_IPC_MAX_BYTES_LIMIT = 16 * 1024 * 1024; + +/** Host calls one child may have waiting for an answer; the child and the server refuse more. */ +export const PLUGIN_MAX_HOST_CALLS = 16; + +/** + * Bounds the bytes of complete lines a reader holds before handling them. + * `hold` pauses the source once `maxBytes` are held; `release` resumes it + * when the backlog falls below half. The source may deliver one more chunk + * after pausing, so the backlog stays under `maxBytes` plus one chunk. + */ +export const makeReadBudget = (input: { + readonly maxBytes: number; + readonly pause: () => void; + readonly resume: () => void; +}) => { + let held = 0; + let paused = false; + return { + hold: (bytes: number): void => { + held += bytes; + if (!paused && held >= input.maxBytes) { + paused = true; + input.pause(); + } + }, + release: (bytes: number): void => { + held -= bytes; + if (paused && held < input.maxBytes / 2) { + paused = false; + input.resume(); + } + }, + }; +}; + +/** + * Splits a byte stream into UTF-8 lines and refuses to buffer more than + * `maxBytes` for one line, so a peer cannot make the reader hold an unbounded + * message in memory. After an overflow it stops delivering lines. + */ +export const makeLineDecoder = (input: { + readonly maxBytes: number; + /** Receives each line with its size in bytes. */ + readonly onLine: (line: string, bytes: number) => void; + readonly onOverflow: () => void; +}) => { + // The unterminated tail is copied into one buffer that grows by doubling, so a + // line trickled in tiny chunks costs its bytes, not one allocation per chunk, + // and does not keep the chunks it arrived in alive. + let pending: Buffer | undefined; + let buffered = 0; + let overflowed = false; + const append = (piece: Buffer) => { + const needed = buffered + piece.length; + if (pending === undefined || needed > pending.length) { + const grown = Buffer.allocUnsafe( + Math.min(input.maxBytes, Math.max(needed, 2 * (pending?.length ?? 0), 256)), + ); + pending?.copy(grown, 0, 0, buffered); + pending = grown; + } + piece.copy(pending, buffered); + buffered = needed; + }; + return (chunk: Buffer): void => { + let start = 0; + while (!overflowed) { + const newline = chunk.indexOf(10, start); + const piece = chunk.subarray(start, newline === -1 ? chunk.length : newline); + if (buffered + piece.length > input.maxBytes) { + overflowed = true; + pending = undefined; + input.onOverflow(); + return; + } + if (newline === -1) { + if (piece.length > 0) append(piece); + return; + } + // The delimiter counts too, so even an empty line holds read budget. + const bytes = buffered + piece.length + 1; + const line = ( + pending === undefined || buffered === 0 + ? piece + : Buffer.concat([pending.subarray(0, buffered), piece]) + ).toString("utf8"); + pending = undefined; + buffered = 0; + start = newline + 1; + input.onLine(line, bytes); + } + }; +}; diff --git a/apps/server/src/plugins/pluginSource.test.ts b/apps/server/src/plugins/pluginSource.test.ts new file mode 100644 index 000000000000..6231d535e0ed --- /dev/null +++ b/apps/server/src/plugins/pluginSource.test.ts @@ -0,0 +1,99 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { digestPluginSource } from "./pluginSource.ts"; + +const makeTree = Effect.fn("makeTree")(function* (files: Record) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-source-" }); + for (const [name, content] of Object.entries(files)) { + yield* fs.makeDirectory(path.dirname(path.join(directory, name)), { recursive: true }); + yield* fs.writeFileString(path.join(directory, name), content); + } + return directory; +}); + +it.layer(NodeServices.layer)("digestPluginSource", (it) => { + describe("exact bytes", () => { + it.effect("changes with any content, addition, or rename", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* makeTree({ "main.mjs": "export {}", "lib/util.js": "1" }); + const first = yield* digestPluginSource(directory); + expect(first).toMatchObject({ files: 2, bytes: 10 }); + expect(first.digest).toMatch(/^sha256:[0-9a-f]{64}$/); + expect((yield* digestPluginSource(directory)).digest).toBe(first.digest); + + // Same bytes and the same length, one character different. + yield* fs.writeFileString(path.join(directory, "lib/util.js"), "2"); + const edited = yield* digestPluginSource(directory); + expect(edited.digest).not.toBe(first.digest); + + yield* fs.rename(path.join(directory, "lib/util.js"), path.join(directory, "lib/other.js")); + const renamed = yield* digestPluginSource(directory); + expect(renamed.digest).not.toBe(edited.digest); + + yield* fs.writeFileString(path.join(directory, "extra.txt"), ""); + expect((yield* digestPluginSource(directory)).digest).not.toBe(renamed.digest); + }), + ); + + it.effect("covers hidden paths, which an entry or import may use", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* makeTree({ ".git/main.mjs": "export {}", ".DS_Store": "x" }); + const first = yield* digestPluginSource(directory); + expect(first).toMatchObject({ files: 2, bytes: 10 }); + yield* fs.writeFileString(path.join(directory, ".git/main.mjs"), "export { }"); + expect((yield* digestPluginSource(directory)).digest).not.toBe(first.digest); + }), + ); + + it.effect("refuses symbolic links instead of following or skipping them", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outside = yield* makeTree({ "secret.js": "outside" }); + const directory = yield* makeTree({ "main.mjs": "export {}" }); + yield* fs.symlink(path.join(outside, "secret.js"), path.join(directory, "linked.js")); + const error = yield* digestPluginSource(directory).pipe(Effect.flip); + expect(error.reason).toBe("linked.js is a symbolic link."); + + yield* fs.remove(path.join(directory, "linked.js")); + yield* fs.symlink(outside, path.join(directory, "vendor")); + const linkedDirectory = yield* digestPluginSource(directory).pipe(Effect.flip); + expect(linkedDirectory.reason).toBe("vendor is a symbolic link."); + + // Names that tools own are not exempt either. + yield* fs.remove(path.join(directory, "vendor")); + yield* fs.symlink(outside, path.join(directory, ".git")); + const linkedGit = yield* digestPluginSource(directory).pipe(Effect.flip); + expect(linkedGit.reason).toBe(".git is a symbolic link."); + }), + ); + + it.effect("refuses trees past the file or byte limit", () => + Effect.gen(function* () { + const directory = yield* makeTree({ "a.js": "12345", "b.js": "67890" }); + const tooMany = yield* digestPluginSource(directory, { maxFiles: 1, maxBytes: 100 }).pipe( + Effect.flip, + ); + expect(tooMany.reason).toBe("it has more than 1 files."); + const tooLarge = yield* digestPluginSource(directory, { maxFiles: 10, maxBytes: 9 }).pipe( + Effect.flip, + ); + expect(tooLarge.reason).toBe("it is larger than 9 bytes."); + expect(yield* digestPluginSource(directory, { maxFiles: 2, maxBytes: 10 })).toMatchObject({ + files: 2, + bytes: 10, + }); + }), + ); + }); +}); diff --git a/apps/server/src/plugins/pluginSource.ts b/apps/server/src/plugins/pluginSource.ts new file mode 100644 index 000000000000..d4ded2fca269 --- /dev/null +++ b/apps/server/src/plugins/pluginSource.ts @@ -0,0 +1,130 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Digesting opens every file with O_NOFOLLOW, which Effect's FileSystem cannot ask for. +/** + * Digest of a plugin directory's exact bytes, which consent binds to. + * + * Every regular file under the directory counts, by relative path and + * content, so an edit, addition, removal, or rename changes the digest. No + * name is exempt: the entry or anything it imports may live under any path, + * hidden ones included. Symbolic links, special files, and trees past the + * limits are refused rather than skipped: a digest that silently left + * something out would not describe what runs. A plugin developed in a git + * checkout should be added from a build directory, since `.git` counts too. + * + * The directory stays writable by its owner, so a digest describes the bytes + * at the moment it was taken. Code the plugin loads from outside its + * directory is not covered. + */ +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import type { PluginSource } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export interface PluginSourceLimits { + readonly maxFiles: number; + readonly maxBytes: number; +} + +export const defaultPluginSourceLimits: PluginSourceLimits = { + maxFiles: 10_000, + maxBytes: 64 * 1024 * 1024, +}; + +class PluginSourceError extends Schema.TaggedError()("PluginSourceError", { + directory: Schema.String, + reason: Schema.String, +}) { + override get message(): string { + return `Cannot read the plugin in ${this.directory}: ${this.reason}`; + } +} + +class Refusal { + readonly reason: string; + constructor(reason: string) { + this.reason = reason; + } +} + +// Neither is defined on Windows, where opening never follows a link or waits on a FIFO. +// Non-blocking, so a file swapped for a FIFO opens at once and is refused below. +const OPEN_FLAGS = + NodeFS.constants.O_RDONLY | + (NodeFS.constants.O_NOFOLLOW ?? 0) | + (NodeFS.constants.O_NONBLOCK ?? 0); + +const walk = async ( + root: string, + limits: PluginSourceLimits, + signal: AbortSignal, +): Promise => { + const files: Array = []; + let declaredBytes = 0; + const visit = async (relative: string) => { + const entries = await NodeFSP.readdir(NodePath.join(root, relative), { withFileTypes: true }); + for (const entry of entries) { + if (signal.aborted) throw new Refusal("the inspection was cancelled."); + const child = relative === "" ? entry.name : `${relative}/${entry.name}`; + if (entry.isSymbolicLink()) throw new Refusal(`${child} is a symbolic link.`); + if (entry.isDirectory()) { + await visit(child); + continue; + } + if (!entry.isFile()) throw new Refusal(`${child} is not a regular file.`); + files.push(child); + if (files.length > limits.maxFiles) + throw new Refusal(`it has more than ${limits.maxFiles} files.`); + declaredBytes += (await NodeFSP.lstat(NodePath.join(root, child))).size; + if (declaredBytes > limits.maxBytes) + throw new Refusal(`it is larger than ${limits.maxBytes} bytes.`); + } + }; + await visit(""); + // Code unit order of the relative path, independent of how the OS lists entries. + files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + + const digest = NodeCrypto.createHash("sha256"); + let bytes = 0; + for (const file of files) { + const content = NodeCrypto.createHash("sha256"); + let size = 0; + // A file swapped for a link after listing is refused, not followed. + const handle = await NodeFSP.open(NodePath.join(root, file), OPEN_FLAGS); + try { + if (!(await handle.stat()).isFile()) throw new Refusal(`${file} is not a regular file.`); + for await (const chunk of handle.createReadStream({ autoClose: false, signal })) { + size += chunk.length; + if (bytes + size > limits.maxBytes) + throw new Refusal(`it is larger than ${limits.maxBytes} bytes.`); + content.update(chunk); + } + } finally { + await handle.close(); + } + bytes += size; + digest.update(`${file}\0${size}\0${content.digest("hex")}\n`); + } + return { digest: `sha256:${digest.digest("hex")}`, files: files.length, bytes }; +}; + +/** Digests `directory` (a real path), or says why its bytes cannot be pinned. */ +export const digestPluginSource = ( + directory: string, + limits: PluginSourceLimits = defaultPluginSourceLimits, +) => + Effect.tryPromise({ + try: (signal) => walk(directory, limits, signal), + catch: (cause) => + new PluginSourceError({ + directory, + reason: + cause instanceof Refusal + ? cause.reason + : (cause as NodeJS.ErrnoException).code === "ELOOP" + ? "a file was replaced by a symbolic link while it was read." + : "a file could not be read.", + }), + }).pipe(Effect.withSpan("pluginSource.digest")); diff --git a/apps/server/src/plugins/pluginToolDeclarations.test.ts b/apps/server/src/plugins/pluginToolDeclarations.test.ts new file mode 100644 index 000000000000..fedc928e7595 --- /dev/null +++ b/apps/server/src/plugins/pluginToolDeclarations.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, it } from "@effect/vitest"; +import { PLUGIN_TOOL_LIMITS, PluginToolDeclaration } from "@t3tools/contracts"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { compileInputSchema, jsonBytes, preparePluginTools } from "./pluginToolDeclarations.ts"; + +const validator = (inputSchema: Record) => { + const compiled = compileInputSchema(inputSchema); + if ("problem" in compiled) throw new Error(compiled.problem); + return (input: unknown) => Exit.isSuccess(compiled.validate(input)); +}; + +const problemOf = (inputSchema: Record) => { + const compiled = compileInputSchema(inputSchema); + return "problem" in compiled ? compiled.problem : undefined; +}; + +const decodeDeclaration = Schema.decodeUnknownSync(PluginToolDeclaration); + +describe("compileInputSchema", () => { + it("rejects extra keys on closed objects at every level and keeps them on open ones", () => { + const accepts = validator({ + type: "object", + properties: { + text: { type: "string" }, + options: { + type: "object", + properties: { exact: { type: "boolean" } }, + additionalProperties: false, + }, + extra: { type: "object", properties: { a: { type: "number" } } }, + }, + required: ["text"], + additionalProperties: false, + }); + expect(accepts({ text: "yes" })).toBe(true); + expect(accepts({ text: "yes", extra: 1 })).toBe(false); + expect(accepts({ text: "yes", options: { exact: true } })).toBe(true); + expect(accepts({ text: "yes", options: { exact: true, fuzzy: 1 } })).toBe(false); + expect(accepts({ text: "yes", extra: { a: 1, anything: [1, "x"] } })).toBe(true); + expect(accepts({ text: "yes", extra: { a: "1" } })).toBe(false); + expect(accepts({})).toBe(false); + expect(accepts({ text: null })).toBe(false); + + const open = validator({ type: "object", properties: { n: { type: "integer" } } }); + expect(open({ n: 1, other: true })).toBe(true); + expect(open({ n: 1.5 })).toBe(false); + expect(open([])).toBe(false); + expect(validator({ type: "object", additionalProperties: false })({ a: 1 })).toBe(false); + }); + + it("counts string lengths in code points, as JSON Schema does", () => { + const accepts = validator({ + type: "object", + properties: { x: { type: "string", minLength: 2, maxLength: 2 } }, + }); + expect(accepts({ x: "😀😀" })).toBe(true); + expect(accepts({ x: "😀" })).toBe(false); + expect(accepts({ x: "abc" })).toBe(false); + }); + + it("enforces numbers, arrays, enums, unions and type lists with JSON Schema's meaning", () => { + const accepts = validator({ + type: "object", + properties: { + n: { type: "number", exclusiveMinimum: 0, maximum: 10 }, + i: { type: "integer", minimum: -2 }, + tags: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 2 }, + mode: { type: "string", enum: ["a", "b"] }, + flag: { const: true }, + id: { anyOf: [{ type: "string", maxLength: 3 }, { type: "integer" }] }, + note: { type: ["string", "null"], maxLength: 1 }, + any: { description: "Anything." }, + }, + }); + expect( + accepts({ + n: 10, + i: 1.0, + tags: ["x"], + mode: "b", + flag: true, + id: 7, + note: null, + any: { deep: [1] }, + }), + ).toBe(true); + for (const bad of [ + { n: 0 }, + { n: 10.5 }, + { i: -3 }, + { i: 0.5 }, + { tags: [] }, + { tags: ["a", "b", "c"] }, + { tags: [1] }, + { mode: "c" }, + { flag: false }, + { id: "abcd" }, + { id: 1.5 }, + { note: "ab" }, + ]) + expect(accepts(bad), JSON.stringify(bad)).toBe(false); + }); + + it("resolves root definitions, including recursion through properties", () => { + const accepts = validator({ + type: "object", + properties: { tree: { $ref: "#/$defs/Node" } }, + $defs: { + Node: { + type: "object", + properties: { + name: { type: "string" }, + children: { type: "array", items: { $ref: "#/$defs/Node" } }, + }, + required: ["name"], + additionalProperties: false, + }, + }, + }); + expect(accepts({ tree: { name: "a", children: [{ name: "b", children: [] }] } })).toBe(true); + expect(accepts({ tree: { name: "a", children: [{ children: [] }] } })).toBe(false); + expect(accepts({ tree: { name: "a", children: [{ name: "b", x: 1 }] } })).toBe(false); + }); + + it("accepts an Effect Schema exported as the module doc shows and enforces it", () => { + const Range = Schema.Struct({ + from: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + to: Schema.optionalKey(Schema.Int), + }).annotate({ identifier: "Range" }); + const Input = Schema.Struct({ + text: Schema.String.check(Schema.isMaxLength(100)), + mode: Schema.Literals(["fast", "exact"]), + ranges: Schema.Array(Range), + note: Schema.optionalKey(Schema.NullOr(Schema.String)), + }); + const { schema, definitions } = Schema.toJsonSchemaDocument(Input, { + onExcessProperty: "error", + }); + const inputSchema = + Object.keys(definitions).length === 0 ? schema : { ...schema, $defs: definitions }; + expect(inputSchema).toHaveProperty("$defs.Range"); + const accepts = validator(inputSchema); + expect(accepts({ text: "t", mode: "fast", ranges: [{ from: 1 }], note: null })).toBe(true); + expect(accepts({ text: "t", mode: "fast", ranges: [{ from: -1 }] })).toBe(false); + expect(accepts({ text: "t", mode: "fast", ranges: [{ from: 1, step: 2 }] })).toBe(false); + expect(accepts({ text: "t", mode: "slow", ranges: [] })).toBe(false); + }); + + it("names whatever is outside the subset instead of weakening it", () => { + const object = (properties: Record) => ({ type: "object", properties }); + const cases: ReadonlyArray, string]> = [ + [object({ id: { type: "string", pattern: "^(a+)+$" } }), "#/properties/id/pattern"], + [object({ id: { oneOf: [{ type: "string" }] } }), "#/properties/id/oneOf"], + [object({ id: { allOf: [{ type: "string" }] } }), "#/properties/id/allOf"], + [object({ n: { type: "number", multipleOf: 2 } }), "#/properties/n/multipleOf"], + [object({ n: { maxLength: 2 } }), "#/properties/n/maxLength: needs a type"], + [object({ n: { type: "number", maxLength: 2 } }), "#/properties/n/maxLength: needs a type"], + [object({ x: { $ref: "#/$defs/X", type: "string" } }), "type cannot be used beside $ref"], + [object({ x: { $ref: "#/$defs/Missing" } }), "#/properties/x/$ref"], + [object({ x: { type: "array", items: [{ type: "string" }] } }), "a schema must be an object"], + [ + object({ x: { type: "object", additionalProperties: { type: "string" } } }), + "true or false", + ], + [object({ x: { enum: [{ a: 1 }] } }), "enum and const take"], + [object({ x: { type: "string", enum: [1] } }), "does not match its type"], + [{ ...object({}), required: ["missing"] }, "#/required"], + [{ type: "string" }, 'the root must be "object"'], + [{ type: ["object", "null"] }, 'the root must be "object"'], + [ + { + ...object({ x: { $ref: "#/$defs/A" } }), + $defs: { A: { anyOf: [{ $ref: "#/$defs/B" }] }, B: { anyOf: [{ $ref: "#/$defs/A" }] } }, + }, + "reference cycle must pass through properties or items", + ], + ]; + for (const [schema, expected] of cases) expect(problemOf(schema)).toContain(expected); + + let deep: Record = { type: "string" }; + for (let level = 0; level <= PLUGIN_TOOL_LIMITS.maxInputSchemaDepth; level++) + deep = { type: "array", items: deep }; + expect(problemOf(object({ deep }))).toContain("nest deeper than"); + expect( + problemOf(object({ big: { type: "string", description: "x".repeat(16 * 1024) } })), + ).toContain("exceeds 16384 bytes"); + }); + + describe("the subset boundary", () => { + const object = (properties: Record) => ({ type: "object", properties }); + const property = (schema: unknown) => object({ x: schema }); + + it.each>([ + { type: "object" }, + { type: "object", properties: {}, required: [], additionalProperties: true }, + { type: "object", $defs: {} }, + { type: "object", $defs: { Unused: { type: "string", minLength: 1 } } }, + { + ...property({ $ref: "#/$defs/Used", description: "Annotations may sit beside $ref." }), + $defs: { Used: { type: "string" } }, + }, + property({ anyOf: [{ type: "string" }, { type: "null" }], title: "Beside anyOf too." }), + property({ + type: "string", + title: "t", + description: "d", + $comment: "c", + format: "uri", + examples: [], + deprecated: false, + readOnly: true, + writeOnly: false, + default: null, + }), + property({ type: ["string", "null"], maxLength: 0 }), + property({ enum: [1, "a", true, null] }), + property({ const: null }), + property({ type: "number", exclusiveMinimum: 0, exclusiveMaximum: 1.5 }), + property({ type: "array", minItems: 0 }), + ])("accepts %j", (schema) => expect(problemOf(schema)).toBeUndefined()); + + it.each, string]>([ + // A default only replaces an absent keyword; null is a wrong shape. + ["null properties", { type: "object", properties: null }, "#/properties: must be an object"], + ["null required", { type: "object", required: null }, "#/required"], + [ + "null additionalProperties", + { type: "object", additionalProperties: null }, + "#/additionalProperties: must be true or false", + ], + ["null $defs", { type: "object", $defs: null }, "#/$defs: must be an object"], + ["array $defs", { type: "object", $defs: [] }, "#/$defs: must be an object"], + // Names are own properties, never inherited ones. + [ + "an inherited required name", + { ...object({}), required: ["toString"], additionalProperties: false }, + "#/required", + ], + ["an inherited definition", property({ $ref: "#/$defs/toString" }), "#/properties/x/$ref"], + // Every definition is checked, referenced or not. + [ + "an unused definition outside the subset", + { type: "object", $defs: { Unused: { type: "string", pattern: "x" } } }, + "#/$defs/Unused/pattern", + ], + [ + // A is compiled through the property first; reusing it must not skip the cycle check. + "an unguarded cycle beside a guarded path to the same definition", + { + ...property({ $ref: "#/$defs/A" }), + $defs: { + A: { + anyOf: [ + { type: "object", properties: { child: { $ref: "#/$defs/B" } } }, + { $ref: "#/$defs/B" }, + ], + }, + B: { $ref: "#/$defs/A" }, + }, + }, + "#/$defs/B/$ref: a reference cycle must pass through properties or items", + ], + [ + "an unused definition with an unguarded cycle", + { type: "object", $defs: { A: { anyOf: [{ $ref: "#/$defs/A" }] } } }, + "reference cycle", + ], + ["a bad definition name", { type: "object", $defs: { "a b": {} } }, "#/$defs/a b"], + ["nested $defs", property({ $defs: {} }), "only supported at the root"], + ["a draft-7 ref", property({ $ref: "#/definitions/X" }), "#/properties/x/$ref"], + // Annotations are shown, so they must have their shape. + ["a numeric title", property({ type: "string", title: 1 }), "#/properties/x/title"], + ["a null description", property({ description: null }), "#/properties/x/description"], + ["object examples", property({ examples: {} }), "#/properties/x/examples"], + ["a string deprecated", property({ deprecated: "yes" }), "#/properties/x/deprecated"], + ["a null readOnly", property({ readOnly: null }), "#/properties/x/readOnly"], + ["a numeric writeOnly", property({ writeOnly: 0 }), "#/properties/x/writeOnly"], + ["a numeric format", property({ type: "string", format: 1 }), "#/properties/x/format"], + ["an array $comment", property({ $comment: [] }), "#/properties/x/$comment"], + // Supported keywords with the wrong shape. + ["an empty type list", property({ type: [] }), "#/properties/x/type"], + ["a repeated type", property({ type: ["string", "string"] }), "#/properties/x/type"], + ["an unknown type", property({ type: "date" }), "#/properties/x/type"], + ["a null type", property({ type: null }), "#/properties/x/type"], + ["a negative minLength", property({ type: "string", minLength: -1 }), "minLength"], + ["a fractional minLength", property({ type: "string", minLength: 1.5 }), "minLength"], + ["a null maxLength", property({ type: "string", maxLength: null }), "maxLength"], + ["a string maxItems", property({ type: "array", maxItems: "2" }), "maxItems"], + ["a null minimum", property({ type: "number", minimum: null }), "minimum"], + [ + "a draft-4 exclusiveMinimum", + property({ type: "number", exclusiveMinimum: true }), + "exclusiveMinimum", + ], + ["null items", property({ type: "array", items: null }), "#/properties/x/items"], + ["boolean items", property({ type: "array", items: true }), "#/properties/x/items"], + ["a boolean schema", object({ x: true }), "#/properties/x: a schema must be an object"], + ["a null schema", object({ x: null }), "#/properties/x: a schema must be an object"], + ["an empty anyOf", property({ anyOf: [] }), "#/properties/x/anyOf"], + ["an object anyOf", property({ anyOf: {} }), "#/properties/x/anyOf"], + ["an empty enum", property({ enum: [] }), "enum and const take"], + ["a null enum", property({ enum: null }), "enum and const take"], + ["an object const", property({ const: {} }), "enum and const take"], + ["enum and const", property({ enum: [1], const: 1 }), "use enum or const"], + // Keywords outside the subset, named. + ...[ + "$schema", + "$id", + "$anchor", + "$dynamicRef", + "definitions", + "patternProperties", + "propertyNames", + "minProperties", + "maxProperties", + "dependentRequired", + "dependentSchemas", + "unevaluatedProperties", + "unevaluatedItems", + "prefixItems", + "contains", + "uniqueItems", + "if", + "not", + "nullable", + "contentMediaType", + ].map( + (keyword) => + [ + keyword, + property({ type: "object", [keyword]: {} }), + `#/properties/x/${keyword}: this keyword is not supported`, + ] as const, + ), + ])("refuses %s", (_, schema, expected) => expect(problemOf(schema)).toContain(expected)); + }); + + it("shows defaults and formats without applying or asserting them", () => { + const accepts = validator({ + type: "object", + properties: { url: { type: "string", format: "uri", default: "https://example.com" } }, + additionalProperties: false, + }); + expect(accepts({})).toBe(true); + expect(accepts({ url: "not a uri" })).toBe(true); + }); +}); + +describe("preparePluginTools", () => { + const plugin = { id: "test.prepare", name: "Prepare" }; + const tool = (name: string, description: string) => + decodeDeclaration({ name, description, inputSchema: { type: "object" }, sideEffect: "read" }); + + it("lists the declared schema unchanged and counts exact UTF-8 listing bytes", () => { + const declared = decodeDeclaration({ + name: "lookup", + title: "Look up", + description: "Look up a word, déjà vu.", + inputSchema: { type: "object", properties: { q: { type: "string" } }, required: ["q"] }, + sideEffect: "read", + openWorld: true, + timeoutSeconds: 5, + }); + const prepared = preparePluginTools(plugin, [declared]); + if ("problem" in prepared) throw new Error(prepared.problem); + const listing = prepared.tools.get("lookup")!.listing; + expect(listing).toEqual({ + tool: "test.prepare/lookup", + plugin, + title: "Look up", + description: "Look up a word, déjà vu.", + inputSchema: declared.inputSchema, + sideEffect: "read", + openWorld: true, + }); + expect(prepared.tools.get("lookup")!.timeoutSeconds).toBe(5); + expect(prepared.listingBytes).toBe(jsonBytes(listing) + 1); + }); + + it("refuses duplicate names and a listing past its byte limit, multibyte included", () => { + expect(preparePluginTools(plugin, [tool("a", "One."), tool("a", "Two.")])).toEqual({ + problem: "it declares the tool a twice.", + }); + // 32 tools of 1,000 three-byte characters: under 48 KiB in characters, over it in bytes. + const wide = Array.from({ length: 32 }, (_, index) => tool(`t_${index}`, "€".repeat(1000))); + expect(preparePluginTools(plugin, wide)).toEqual({ + problem: `its tools take more than ${PLUGIN_TOOL_LIMITS.maxPluginListingBytes} bytes to list.`, + }); + }); +}); diff --git a/apps/server/src/plugins/pluginToolDeclarations.ts b/apps/server/src/plugins/pluginToolDeclarations.ts new file mode 100644 index 000000000000..a623a344b357 --- /dev/null +++ b/apps/server/src/plugins/pluginToolDeclarations.ts @@ -0,0 +1,421 @@ +/** + * Turns a manifest's tool declarations into what the host lists and enforces. + * + * Input schemas are compiled from the JSON Schema subset documented in the + * contracts' PluginTools module into an Effect Schema. Each accepted keyword + * is built with JSON Schema's meaning (closed objects reject extra keys, + * string lengths count code points), so the declared schema an agent sees is + * exactly what calls are checked against. Anything outside the subset is a + * problem naming the keyword; nothing is silently dropped. The manifest loader + * refuses a plugin with a problem, so an enabled plugin's tools always compile. + */ +import { + PLUGIN_TOOL_LIMITS, + type PluginToolDeclaration, + type PluginToolListing, + qualifyPluginToolName, +} from "@t3tools/contracts"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import type * as SchemaAST from "effect/SchemaAST"; + +interface PreparedPluginTool { + readonly listing: PluginToolListing; + readonly validate: (input: unknown) => Exit.Exit; + readonly timeoutSeconds: number; +} + +export interface PreparedPluginTools { + readonly tools: ReadonlyMap; + /** Serialized size of the listings as one page holds them, separators included. */ + readonly listingBytes: number; +} + +type JsonObject = { readonly [key: string]: unknown }; +type Compiled = Schema.Top; + +class SchemaProblem { + readonly message: string; + constructor(message: string) { + this.message = message; + } +} + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +export const jsonBytes = (value: unknown) => Buffer.byteLength(encodeJson(value), "utf8"); + +const isString = (value: unknown) => typeof value === "string"; +const isBoolean = (value: unknown) => typeof value === "boolean"; +const TYPES = new Set(["object", "array", "string", "number", "integer", "boolean", "null"]); +/** Annotations and the shape each must have; `default` may be any JSON value. */ +const ANNOTATIONS = new Map boolean>([ + ["title", isString], + ["description", isString], + ["default", () => true], + ["examples", Array.isArray], + ["deprecated", isBoolean], + ["readOnly", isBoolean], + ["writeOnly", isBoolean], + ["format", isString], + ["$comment", isString], +]); +const KEYWORDS_BY_TYPE: Record> = { + object: ["properties", "required", "additionalProperties"], + array: ["items", "minItems", "maxItems"], + string: ["minLength", "maxLength"], + number: ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"], + integer: ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"], +}; +const TYPED_KEYWORDS = new Set(Object.values(KEYWORDS_BY_TYPE).flat()); +const DEFINITION_NAME = /^[A-Za-z0-9_.-]{1,64}$/; + +const isObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); +const isPrimitive = (value: unknown) => + value === null || ["string", "number", "boolean"].includes(typeof value); +const isCount = (value: unknown): value is number => + Number.isSafeInteger(value) && Number(value) >= 0; + +const withChecks = ( + schema: Schema.Codec, + checks: ReadonlyArray>, +): Compiled => { + const [first, ...rest] = checks; + return (first === undefined ? schema : schema.check(first, ...rest)) as Compiled; +}; + +const codePoints = (text: string) => { + let count = 0; + for (const _ of text) count++; + return count; +}; + +const literal = (value: unknown): Compiled => + value === null ? Schema.Null : Schema.Literal(value as string | number | boolean); + +const matchesType = (value: unknown, type: string) => + type === "null" + ? value === null + : type === "integer" + ? Number.isInteger(value) + : type === "array" || type === "object" + ? false + : typeof value === type; + +/** + * Refuses a reference cycle that consumes no input: one reached through only + * `$ref` and `anyOf`, never `properties` or `items`. This walks the whole + * definition graph on its own so that compiling a definition once and reusing + * it cannot hide a cycle on another path. Malformed schemas are left for + * `compile` to name. + */ +const refuseUnguardedCycles = (definitions: JsonObject) => { + // Each definition's references that are reached without consuming input. + const edges = new Map>(); + const collect = (schema: unknown, path: string, into: Array<{ name: string; path: string }>) => { + if (!isObject(schema)) return; + const ref = schema.$ref; + if (typeof ref === "string" && ref.startsWith("#/$defs/")) { + const name = ref.slice(8); + if (Object.hasOwn(definitions, name)) into.push({ name, path: `${path}/$ref` }); + } else if (Array.isArray(schema.anyOf)) + schema.anyOf.forEach((member, index) => collect(member, `${path}/anyOf/${index}`, into)); + }; + for (const [name, schema] of Object.entries(definitions)) { + const into: Array<{ name: string; path: string }> = []; + collect(schema, `#/$defs/${name}`, into); + edges.set(name, into); + } + const state = new Map(); + const visit = (name: string) => { + state.set(name, "visiting"); + for (const edge of edges.get(name)!) { + const seen = state.get(edge.name); + if (seen === "visiting") + throw new SchemaProblem( + `${edge.path}: a reference cycle must pass through properties or items.`, + ); + if (seen === undefined) visit(edge.name); + } + state.set(name, "done"); + }; + for (const name of edges.keys()) if (!state.has(name)) visit(name); +}; + +/** + * Compiles one `inputSchema`. Throws `SchemaProblem` with a JSON-pointer-like + * path; `compileInputSchema` turns it into a result. + */ +const compile = (root: JsonObject): Compiled => { + const definitions = root.$defs === undefined ? {} : root.$defs; + if (!isObject(definitions)) throw new SchemaProblem("#/$defs: must be an object."); + for (const name of Object.keys(definitions)) + if (!DEFINITION_NAME.test(name)) + throw new SchemaProblem( + `#/$defs/${name}: definition names are 1 to 64 letters, digits, "_", "." or "-".`, + ); + refuseUnguardedCycles(definitions); + const done = new Map(); + const inProgress = new Set(); + + /** The compiled definition `name`; any cycle back into it consumes input. */ + const definition = (name: string, depth: number): Compiled => { + const compiled = done.get(name); + if (compiled !== undefined) return compiled; + if (inProgress.has(name)) return Schema.suspend((): Compiled => done.get(name)!); + inProgress.add(name); + const result = node(definitions[name], `#/$defs/${name}`, depth); + inProgress.delete(name); + done.set(name, result); + return result; + }; + + const node = (schema: unknown, path: string, depth: number): Compiled => { + if (depth > PLUGIN_TOOL_LIMITS.maxInputSchemaDepth) + throw new SchemaProblem( + `${path}: schemas nest deeper than ${PLUGIN_TOOL_LIMITS.maxInputSchemaDepth}.`, + ); + if (!isObject(schema)) throw new SchemaProblem(`${path}: a schema must be an object.`); + for (const [key, valid] of ANNOTATIONS) + if (Object.hasOwn(schema, key) && !valid(schema[key])) + throw new SchemaProblem(`${path}/${key}: not a valid ${key} annotation.`); + const keywords = Object.keys(schema).filter( + (key) => !ANNOTATIONS.has(key) && !(path === "#" && key === "$defs"), + ); + if (path !== "#" && "$defs" in schema) + throw new SchemaProblem(`${path}: $defs is only supported at the root.`); + const only = (keyword: string) => { + const others = keywords.filter((key) => key !== keyword); + if (others.length > 0) + throw new SchemaProblem(`${path}: ${others[0]} cannot be used beside ${keyword}.`); + }; + + if ("$ref" in schema) { + only("$ref"); + const ref = schema.$ref; + const name = typeof ref === "string" && ref.startsWith("#/$defs/") ? ref.slice(8) : ""; + if (!Object.hasOwn(definitions, name)) + throw new SchemaProblem( + `${path}/$ref: only "#/$defs/" references to root definitions are supported.`, + ); + return definition(name, depth + 1); + } + + if ("anyOf" in schema) { + only("anyOf"); + const members = schema.anyOf; + if (!Array.isArray(members) || members.length === 0) + throw new SchemaProblem(`${path}/anyOf: must be a non-empty array.`); + return Schema.Union( + members.map((member, index) => node(member, `${path}/anyOf/${index}`, depth + 1)), + ); + } + + const types = + schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type]; + if ( + types.some((type) => typeof type !== "string" || !TYPES.has(type)) || + new Set(types).size !== types.length || + (schema.type !== undefined && types.length === 0) + ) + throw new SchemaProblem( + `${path}/type: must be one or more distinct of ${[...TYPES].join(", ")}.`, + ); + for (const keyword of keywords) { + if (keyword === "type" || keyword === "enum" || keyword === "const") continue; + if (!TYPED_KEYWORDS.has(keyword)) + throw new SchemaProblem(`${path}/${keyword}: this keyword is not supported.`); + if (!types.some((type: string) => KEYWORDS_BY_TYPE[type]?.includes(keyword))) + throw new SchemaProblem(`${path}/${keyword}: needs a type it applies to.`); + } + + if ("enum" in schema || "const" in schema) { + if ("enum" in schema && "const" in schema) + throw new SchemaProblem(`${path}: use enum or const, not both.`); + const values = "const" in schema ? [schema.const] : schema.enum; + if (!Array.isArray(values) || values.length === 0 || !values.every(isPrimitive)) + throw new SchemaProblem( + `${path}: enum and const take string, number, boolean, or null values.`, + ); + const other = keywords.find((key) => key !== "type" && key !== "enum" && key !== "const"); + if (other !== undefined) + throw new SchemaProblem(`${path}/${other}: cannot be used beside enum or const.`); + if ( + types.length > 0 && + !values.every((value) => types.some((type) => matchesType(value, type))) + ) + throw new SchemaProblem(`${path}: an enum or const value does not match its type.`); + return values.length === 1 ? literal(values[0]) : Schema.Union(values.map(literal)); + } + + if (types.length === 0) return Schema.Json as Compiled; + const byType = types.map((type: string): Compiled => { + switch (type) { + case "null": + return Schema.Null; + case "boolean": + return Schema.Boolean; + case "string": + return stringSchema(schema, path); + case "number": + case "integer": + return numberSchema(schema, path, type === "integer"); + case "array": { + const items = + schema.items === undefined + ? (Schema.Json as Compiled) + : node(schema.items, `${path}/items`, depth + 1); + return withChecks(Schema.Array(items as Schema.Codec), [ + ...(schema.minItems === undefined + ? [] + : [Schema.isMinLength(count(schema, "minItems", path))]), + ...(schema.maxItems === undefined + ? [] + : [Schema.isMaxLength(count(schema, "maxItems", path))]), + ]); + } + default: + return objectSchema(schema, path, depth); + } + }); + return byType.length === 1 ? byType[0]! : Schema.Union(byType); + }; + + const objectSchema = (schema: JsonObject, path: string, depth: number): Compiled => { + // Only an absent keyword takes its default; `null` is not a valid value for any of them. + const properties = schema.properties === undefined ? {} : schema.properties; + if (!isObject(properties)) throw new SchemaProblem(`${path}/properties: must be an object.`); + const required = schema.required === undefined ? [] : schema.required; + if ( + !Array.isArray(required) || + !required.every((name) => typeof name === "string" && Object.hasOwn(properties, name)) || + new Set(required).size !== required.length + ) + throw new SchemaProblem(`${path}/required: must list distinct names from properties.`); + const additional = + schema.additionalProperties === undefined ? true : schema.additionalProperties; + if (typeof additional !== "boolean") + throw new SchemaProblem(`${path}/additionalProperties: must be true or false.`); + const fields: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (key === "__proto__") + throw new SchemaProblem(`${path}/properties: __proto__ is not supported.`); + const property = node(value, `${path}/properties/${key}`, depth + 1); + fields[key] = required.includes(key) ? property : Schema.optionalKey(property); + } + // An empty struct accepts any object, so a closed empty object is a record of nothing. + if (!additional && Object.keys(fields).length === 0) + return Schema.Record(Schema.String, Schema.Never); + const struct = Schema.Struct(fields); + // Calls are decoded with `onExcessProperty: "error"`, so a closed object rejects + // extra keys and an open one keeps them. + return additional + ? Schema.StructWithRest(struct, [Schema.Record(Schema.String, Schema.Json)]) + : struct; + }; + + if (root.type !== "object") throw new SchemaProblem(`#/type: the root must be "object".`); + const compiled = node(root, "#", 0); + // A definition nothing references is still part of the declaration, so it is held to the subset too. + for (const name of Object.keys(definitions)) definition(name, 1); + return compiled; +}; + +const count = (schema: JsonObject, keyword: string, path: string) => { + const value = schema[keyword]; + if (!isCount(value)) + throw new SchemaProblem(`${path}/${keyword}: must be a non-negative integer.`); + return value; +}; + +const stringSchema = (schema: JsonObject, path: string): Compiled => { + const min = schema.minLength === undefined ? undefined : count(schema, "minLength", path); + const max = schema.maxLength === undefined ? undefined : count(schema, "maxLength", path); + if (min === undefined && max === undefined) return Schema.String; + return Schema.String.check( + Schema.makeFilter((text: string) => { + const length = codePoints(text); + if (min !== undefined && length < min) return `Expected at least ${min} characters`; + return max === undefined || length <= max || `Expected at most ${max} characters`; + }), + ); +}; + +const numberSchema = (schema: JsonObject, path: string, integer: boolean): Compiled => { + const bound = (keyword: string) => { + const value = schema[keyword]; + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isFinite(value)) + throw new SchemaProblem(`${path}/${keyword}: must be a number.`); + return value; + }; + return withChecks(Schema.Number, [ + ...(integer + ? [Schema.makeFilter((value: number) => Number.isInteger(value) || "Expected an integer")] + : []), + ...[ + [bound("minimum"), Schema.isGreaterThanOrEqualTo], + [bound("exclusiveMinimum"), Schema.isGreaterThan], + [bound("maximum"), Schema.isLessThanOrEqualTo], + [bound("exclusiveMaximum"), Schema.isLessThan], + ].flatMap(([value, check]) => + typeof value === "number" ? [(check as typeof Schema.isLessThan)(value)] : [], + ), + ]); +}; + +/** The validator for one declared `inputSchema`, or why it is outside the subset. */ +export const compileInputSchema = ( + inputSchema: JsonObject, +): + | { readonly validate: (input: unknown) => Exit.Exit } + | { readonly problem: string } => { + if (jsonBytes(inputSchema) > PLUGIN_TOOL_LIMITS.maxInputSchemaBytes) + return { problem: `inputSchema exceeds ${PLUGIN_TOOL_LIMITS.maxInputSchemaBytes} bytes.` }; + try { + return { + validate: Schema.decodeUnknownExit(compile(inputSchema) as Schema.Codec, { + onExcessProperty: "error", + }), + }; + } catch (error) { + if (error instanceof SchemaProblem) return { problem: `inputSchema ${error.message}` }; + throw error; + } +}; + +/** Prepares every declared tool of one plugin, or names the first that cannot be offered. */ +export const preparePluginTools = ( + plugin: { readonly id: string; readonly name: string }, + declarations: ReadonlyArray, +): PreparedPluginTools | { readonly problem: string } => { + const tools = new Map(); + let listingBytes = 0; + for (const declaration of declarations) { + if (tools.has(declaration.name)) + return { problem: `it declares the tool ${declaration.name} twice.` }; + const compiled = compileInputSchema(declaration.inputSchema); + if ("problem" in compiled) + return { problem: `the tool ${declaration.name}'s ${compiled.problem}` }; + const listing: PluginToolListing = { + tool: qualifyPluginToolName(plugin.id, declaration.name), + plugin: { id: plugin.id, name: plugin.name }, + ...(declaration.title === undefined ? {} : { title: declaration.title }), + description: declaration.description, + inputSchema: declaration.inputSchema, + sideEffect: declaration.sideEffect, + openWorld: declaration.openWorld, + }; + listingBytes += jsonBytes(listing) + 1; + tools.set(declaration.name, { + listing, + validate: compiled.validate, + timeoutSeconds: declaration.timeoutSeconds ?? PLUGIN_TOOL_LIMITS.defaultTimeoutSeconds, + }); + } + if (listingBytes > PLUGIN_TOOL_LIMITS.maxPluginListingBytes) + return { + problem: `its tools take more than ${PLUGIN_TOOL_LIMITS.maxPluginListingBytes} bytes to list.`, + }; + return { tools, listingBytes }; +}; diff --git a/apps/server/src/plugins/testFixtures/actions/main.mjs b/apps/server/src/plugins/testFixtures/actions/main.mjs new file mode 100644 index 000000000000..a98f4e059e7e --- /dev/null +++ b/apps/server/src/plugins/testFixtures/actions/main.mjs @@ -0,0 +1,18 @@ +// Action handlers for PluginActions.test.ts and the lane's live proof. +export function activate(context) { + const handle = context.proposed.handle; + handle("action:echo-target", ({ target }) => ({ + message: `${target.kind} ${target.threadId} in ${target.cwd}`, + })); + handle("action:say-hello", () => ({ message: `Hello from ${context.plugin.id}` })); + handle("action:fail", () => { + throw new Error("The fixture failed on purpose."); + }); + handle( + "action:wait", + (_input, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("cancelled"))); + }), + ); +} diff --git a/apps/server/src/plugins/testFixtures/actions/t3-plugin.json b/apps/server/src/plugins/testFixtures/actions/t3-plugin.json new file mode 100644 index 000000000000..0295f85f87ea --- /dev/null +++ b/apps/server/src/plugins/testFixtures/actions/t3-plugin.json @@ -0,0 +1,36 @@ +{ + "id": "test.actions", + "name": "Actions fixture", + "version": "1.0.0", + "apiVersion": 1, + "entry": "main.mjs", + "proposedApi": true, + "capabilities": ["actions"], + "actions": [ + { + "name": "echo-target", + "title": "Echo target", + "description": "Says which thread it ran on.", + "target": "thread", + "placements": ["command-palette", "thread-menu", "composer-slash"] + }, + { + "name": "say-hello", + "title": "Say hello", + "target": "environment", + "placements": ["command-palette"] + }, + { + "name": "fail", + "title": "Fail on purpose", + "target": "environment", + "placements": ["command-palette"] + }, + { + "name": "wait", + "title": "Wait until cancelled", + "target": "environment", + "placements": ["command-palette"] + } + ] +} diff --git a/apps/server/src/plugins/testFixtures/plugin/asyncDependency.mjs b/apps/server/src/plugins/testFixtures/plugin/asyncDependency.mjs new file mode 100644 index 000000000000..25624522e6fc --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/asyncDependency.mjs @@ -0,0 +1,6 @@ +// Top-level await only in an imported module: refused on every runtime. +import { settings } from "./asyncSettings.mjs"; + +export function activate(context) { + context.log.info(settings.greeting); +} diff --git a/apps/server/src/plugins/testFixtures/plugin/asyncEntry.mjs b/apps/server/src/plugins/testFixtures/plugin/asyncEntry.mjs new file mode 100644 index 000000000000..b73658c3f904 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/asyncEntry.mjs @@ -0,0 +1,6 @@ +// Top-level await in the entry itself: refused on every runtime. +const settings = await Promise.resolve({ greeting: "hi" }); + +export function activate(context) { + context.log.info(settings.greeting); +} diff --git a/apps/server/src/plugins/testFixtures/plugin/asyncSettings.mjs b/apps/server/src/plugins/testFixtures/plugin/asyncSettings.mjs new file mode 100644 index 000000000000..9c06799d6fa2 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/asyncSettings.mjs @@ -0,0 +1 @@ +export const settings = await Promise.resolve({ greeting: "hi" }); diff --git a/apps/server/src/plugins/testFixtures/plugin/cancellable.mjs b/apps/server/src/plugins/testFixtures/plugin/cancellable.mjs new file mode 100644 index 000000000000..3e5b2d8c0df5 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/cancellable.mjs @@ -0,0 +1,10 @@ +// Settles only from its abort listener, as a handler that honours cancellation does. +export function activate(context) { + context.proposed.handle( + "cooperative", + (_input, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("cancelled"))); + }), + ); +} diff --git a/apps/server/src/plugins/testFixtures/plugin/deferredActivate.mjs b/apps/server/src/plugins/testFixtures/plugin/deferredActivate.mjs new file mode 100644 index 000000000000..382ada3cd078 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/deferredActivate.mjs @@ -0,0 +1,11 @@ +// Finishes activating only once deactivation has begun, then holds the +// process open, so a late Ready can race the server's disable. +export function activate(context) { + context.proposed.handle("ping", (input) => ({ pid: process.pid, input })); + context.log.info("activating"); + return new Promise((resolve) => context.signal.addEventListener("abort", () => resolve())); +} + +export function deactivate() { + return new Promise(() => {}); +} diff --git a/apps/server/src/plugins/testFixtures/plugin/failActivate.mjs b/apps/server/src/plugins/testFixtures/plugin/failActivate.mjs new file mode 100644 index 000000000000..f7cbf8d6b6ef --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/failActivate.mjs @@ -0,0 +1,3 @@ +export function activate() { + throw new Error("activation refused"); +} diff --git a/apps/server/src/plugins/testFixtures/plugin/main.mjs b/apps/server/src/plugins/testFixtures/plugin/main.mjs new file mode 100644 index 000000000000..aafd1c71fa1c --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/main.mjs @@ -0,0 +1,114 @@ +// Misbehaves on request so PluginSupervisor.test.ts can exercise each failure path. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +const IPC_FD = 3; + +// Writes to the stderr it inherited until the write fails, then reports "closed" to the port. +const STDERR_HOLDER = ` +const socket = require("node:net").connect(Number(process.argv[1]), "127.0.0.1"); +const timer = setInterval(() => { + try { + require("node:fs").writeSync(2, "held\\n"); + } catch (error) { + if (error.code !== "EPIPE") return; + clearInterval(timer); + socket.end("closed", () => process.exit(0)); + } +}, 10); +setTimeout(() => process.exit(1), 10_000); +`; + +let holdDeactivate = false; +let log; + +export function activate(context) { + NodeFS.writeFileSync(NodePath.join(process.cwd(), "activated.marker"), String(process.pid)); + log = context.log; + const handle = context.proposed.handle; + handle("ping", (input) => ({ pid: process.pid, input })); + // The next deactivation never finishes, so only a kill stops this process. + handle("holdDeactivate", () => { + holdDeactivate = true; + return null; + }); + handle("throws", () => { + throw new Error("nope"); + }); + handle("spin", () => { + for (;;) {} + }); + handle( + "cooperative", + (_input, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + reject(new Error("cancelled")); + // Logged after the cancel's answer is written, so it arrives after it. + setImmediate(() => log.info("cooperative-settled")); + }); + log.info("cooperative-started"); + }), + ); + handle("flood", (input) => { + for (let index = 0; index < input.count; index++) log.debug("x".repeat(input.size)); + return { pid: process.pid, done: true }; + }); + // Ignores cancellation and never answers. + handle("stall", () => new Promise(() => {})); + // Ignores cancellation and answers anyway, after the server stopped waiting. + handle( + "late", + (_input, { signal }) => + new Promise((resolve) => { + context.log.info("late-started"); + signal.addEventListener("abort", () => resolve("late value")); + }), + ); + handle("exit", () => process.exit(3)); + // Starts a process that inherits stderr and outlives this plugin. + handle("holdStderr", (input) => { + const holder = NodeChildProcess.spawn(process.execPath, ["-e", STDERR_HOLDER, input.port], { + stdio: ["ignore", "ignore", "inherit"], + }); + holder.unref(); + return { pid: holder.pid }; + }); + handle("oom", () => { + const hog = []; + for (;;) hog.push(Array.from({ length: 100_000 }, Math.random)); + }); + handle("bigResult", (input) => "x".repeat(input.bytes)); + // Results with no JSON form, which the child must answer with a failure. + handle("functionResult", () => () => {}); + handle("symbolResult", () => Symbol("result")); + handle("undefinedJsonResult", () => ({ toJSON: () => undefined })); + handle("throwingJsonResult", () => ({ + toJSON() { + throw new Error("x".repeat(3000)); + }, + })); + handle("malformed", () => { + NodeFS.writeSync(IPC_FD, "{not json}\n"); + return new Promise(() => {}); + }); + // fd 3 is non-blocking: keep writing one unterminated line until the server kills us. + handle("oversizedFrame", (input) => { + const data = Buffer.alloc(input.bytes, "x"); + for (let offset = 0; offset < data.length;) { + try { + offset += NodeFS.writeSync(IPC_FD, data, offset); + } catch (error) { + if (error.code !== "EAGAIN") throw error; + } + } + return new Promise(() => {}); + }); +} + +export function deactivate() { + if (!holdDeactivate) return; + log.info("deactivate-held"); + return new Promise(() => {}); +} diff --git a/apps/server/src/plugins/testFixtures/plugin/registerThenFail.mjs b/apps/server/src/plugins/testFixtures/plugin/registerThenFail.mjs new file mode 100644 index 000000000000..061cdce41868 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/registerThenFail.mjs @@ -0,0 +1,14 @@ +// Registers a handler and an event handler, watches its activation signal, then fails to activate. +let log; + +export function activate(context) { + log = context.log; + context.proposed.handle("leftover", () => "served after a failed activation"); + context.proposed.onEvent(() => log.info("event-delivered")); + context.signal.addEventListener("abort", () => log.info("activation-aborted")); + throw new Error("activation refused"); +} + +export function deactivate() { + log.info("deactivate-called"); +} diff --git a/apps/server/src/plugins/testFixtures/plugin/reservedHandlers.mjs b/apps/server/src/plugins/testFixtures/plugin/reservedHandlers.mjs new file mode 100644 index 000000000000..4e0cb84d34b1 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/reservedHandlers.mjs @@ -0,0 +1,13 @@ +// Registers one handler under each `t3.` namespace so PluginSupervisor.test.ts +// can check which ones the child runtime reserves. +export function activate(context) { + const refusals = {}; + for (const name of ["t3.tool.echo", "t3.events", "t3.other"]) { + try { + context.proposed.handle(name, (input) => ({ handler: name, input })); + } catch (error) { + refusals[name] = error.message; + } + } + context.proposed.handle("refusals", () => refusals); +} diff --git a/apps/server/src/plugins/testFixtures/plugin/spinActivate.mjs b/apps/server/src/plugins/testFixtures/plugin/spinActivate.mjs new file mode 100644 index 000000000000..138194072231 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/spinActivate.mjs @@ -0,0 +1,3 @@ +export function activate() { + for (;;) {} +} diff --git a/apps/server/src/plugins/testFixtures/plugin/t3-plugin.json b/apps/server/src/plugins/testFixtures/plugin/t3-plugin.json new file mode 100644 index 000000000000..b92816dd17de --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/t3-plugin.json @@ -0,0 +1,8 @@ +{ + "id": "test.fixture", + "name": "Supervisor fixture", + "version": "1.0.0", + "apiVersion": 1, + "entry": "main.mjs", + "proposedApi": true +} diff --git a/apps/server/src/plugins/testFixtures/rawHostCallChild.mjs b/apps/server/src/plugins/testFixtures/rawHostCallChild.mjs new file mode 100644 index 000000000000..8ef2f7183174 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/rawHostCallChild.mjs @@ -0,0 +1,66 @@ +// Stands in for the plugin child runtime to drive the server's IPC directly, as a plugin +// writing raw lines to fd 3 could. `raw-child.json` in the working directory (the plugin +// directory) picks the behaviour; summaries go back as Log messages. +// echo: answers every Invoke with its input. +// flood: sends `requests` HostCalls of `method` and stops reading until SIGUSR2. +// burst: sends `requests` HostCalls of `method` and reads every answer. +import * as NodeFS from "node:fs"; +import * as NodeNet from "node:net"; + +const config = JSON.parse(NodeFS.readFileSync("raw-child.json", "utf8")); +NodeFS.writeFileSync("raw-child.pid", String(process.pid)); +const channel = new NodeNet.Socket({ fd: 3, readable: true, writable: true }); +const send = (message) => channel.write(`${JSON.stringify(message)}\n`); +const log = (message) => send({ _tag: "Log", level: "info", message }); + +let answered = 0; +const failures = []; +const hostCalls = () => { + for (let requestId = 1; requestId <= config.requests; requestId++) + send({ _tag: "HostCall", requestId, method: config.method, input: {} }); +}; + +const receive = (message) => { + switch (message._tag) { + case "Activate": + send({ _tag: "Ready" }); + if (config.mode === "echo") return; + hostCalls(); + if (config.mode === "flood") channel.pause(); + return; + case "Invoke": + send({ _tag: "Succeeded", requestId: message.requestId, value: message.input }); + return; + case "Deactivate": + process.exit(0); + return; + case "HostCallSucceeded": + case "HostCallFailed": + answered++; + if (message._tag === "HostCallFailed") { + failures.push(message.message); + log(`refused: ${message.message}`); + } + if (answered === config.requests) + log( + `answered ${answered}, refused ${failures.length}: ${JSON.stringify([...new Set(failures)])}`, + ); + return; + } +}; + +process.on("SIGUSR2", () => channel.resume()); +// A paused socket does not keep the process alive; the server ends it with Deactivate or a kill. +setInterval(() => {}, 1 << 30); +let buffered = ""; +channel.on("data", (chunk) => { + buffered += chunk.toString("utf8"); + let newline; + while ((newline = buffered.indexOf("\n")) !== -1) { + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + receive(JSON.parse(line)); + } +}); +channel.on("end", () => process.exit(0)); +channel.on("error", () => process.exit(1)); diff --git a/apps/server/src/plugins/testFixtures/settingsPlugin/main.mjs b/apps/server/src/plugins/testFixtures/settingsPlugin/main.mjs new file mode 100644 index 000000000000..51f1c28689a9 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/settingsPlugin/main.mjs @@ -0,0 +1,56 @@ +// Reads its settings and uses its storage on request so PluginSettings.test.ts and the live +// proof can check what the plugin sees. It never writes into its own directory. +let modeAtActivation; +let api; + +export async function activate(context) { + const { handle, settings, storage } = context.proposed; + api = { log: context.log, settings }; + // Host calls work before the plugin reports ready. + modeAtActivation = await settings.get("mode"); + handle("activationMode", () => modeAtActivation ?? null); + handle("read", async ({ key }) => { + const value = await settings.get(key); + return value === undefined ? { unset: true } : { value }; + }); + handle("store", async ({ key, value }) => { + await storage.set(key, value); + return null; + }); + handle("load", async ({ key }) => { + const value = await storage.get(key); + return value === undefined ? { missing: true } : { value }; + }); + handle("drop", async ({ key }) => { + await storage.delete(key); + return null; + }); + handle("keys", () => storage.keys()); + // Reports how each call settled, so a refusal is visible to the caller. + handle("attempt", async ({ method, key, value }) => { + try { + const result = method === "set" ? await storage.set(key, value) : await settings.get(key); + return { ok: true, result: result ?? null }; + } catch (error) { + return { ok: false, message: error.message }; + } + }); + handle("exit", () => process.exit(1)); + // Logs each refusal, so a test can hold the accepted calls until one is refused. + handle("burst", async ({ count }) => { + const calls = Array.from({ length: count }, (_, index) => storage.get(`burst-${index}`)); + for (const call of calls) call.catch(() => context.log.info("host-call-refused")); + const results = await Promise.allSettled(calls); + return results.map((result) => (result.status === "fulfilled" ? "ok" : result.reason.message)); + }); +} + +// Reports whether the server still answers a revoked plugin. +export async function deactivate() { + try { + await api.settings.get("mode"); + api.log.info("deactivate: answered"); + } catch (error) { + api.log.info(`deactivate: ${error.message}`); + } +} diff --git a/apps/server/src/plugins/testFixtures/settingsPlugin/t3-plugin.json b/apps/server/src/plugins/testFixtures/settingsPlugin/t3-plugin.json new file mode 100644 index 000000000000..1a33f7a3a9aa --- /dev/null +++ b/apps/server/src/plugins/testFixtures/settingsPlugin/t3-plugin.json @@ -0,0 +1,38 @@ +{ + "id": "test.settings", + "name": "Settings fixture", + "version": "1.0.0", + "apiVersion": 1, + "entry": "main.mjs", + "proposedApi": true, + "capabilities": ["settings"], + "settings": [ + { "type": "text", "key": "apiUrl", "label": "API URL", "default": "https://api.example.com" }, + { + "type": "secret", + "key": "token", + "label": "API token", + "description": "Sent with every request." + }, + { "type": "boolean", "key": "verbose", "label": "Verbose logging", "default": false }, + { + "type": "number", + "key": "retries", + "label": "Retries", + "default": 2, + "min": 0, + "max": 5, + "integer": true + }, + { + "type": "select", + "key": "mode", + "label": "Mode", + "default": "safe", + "options": [ + { "value": "safe", "label": "Safe" }, + { "value": "fast", "label": "Fast" } + ] + } + ] +} diff --git a/apps/server/src/plugins/testFixtures/toolsPlugin/main.mjs b/apps/server/src/plugins/testFixtures/toolsPlugin/main.mjs new file mode 100644 index 000000000000..e481b66397eb --- /dev/null +++ b/apps/server/src/plugins/testFixtures/toolsPlugin/main.mjs @@ -0,0 +1,19 @@ +// A tool plugin for PluginTools.test.ts. Its tools are declared in +// t3-plugin.json; activate only registers their handlers. +export function activate(context) { + const { handle } = context.proposed; + context.log.info("activated"); + handle("t3.tool.word_count", ({ input }) => ({ + words: input.text.split(/\s+/).filter(Boolean).length, + })); + handle("t3.tool.echo_context", (call) => call); + handle( + "t3.tool.wait_for_cancel", + (_call, { signal }) => + new Promise((_resolve, reject) => { + context.log.info("wait-started"); + signal.addEventListener("abort", () => reject(new Error("cancelled"))); + }), + ); + handle("t3.tool.big_result", ({ input }) => "x".repeat(input.length)); +} diff --git a/apps/server/src/plugins/testFixtures/toolsPlugin/t3-plugin.json b/apps/server/src/plugins/testFixtures/toolsPlugin/t3-plugin.json new file mode 100644 index 000000000000..7fcd0a8dfa9f --- /dev/null +++ b/apps/server/src/plugins/testFixtures/toolsPlugin/t3-plugin.json @@ -0,0 +1,51 @@ +{ + "id": "test.tools", + "name": "Tools fixture", + "version": "1.0.0", + "apiVersion": 1, + "entry": "main.mjs", + "capabilities": ["tools"], + "proposedApi": true, + "tools": [ + { + "name": "word_count", + "title": "Count words", + "description": "Count the words in a text.", + "inputSchema": { + "type": "object", + "properties": { "text": { "type": "string", "maxLength": 10000 } }, + "required": ["text"], + "additionalProperties": false + }, + "sideEffect": "read" + }, + { + "name": "echo_context", + "description": "Return the input and the session context the host passed.", + "inputSchema": { + "type": "object", + "properties": { "note": { "type": "string" } }, + "additionalProperties": false + }, + "sideEffect": "read" + }, + { + "name": "wait_for_cancel", + "description": "Wait until the call is cancelled.", + "inputSchema": { "type": "object" }, + "sideEffect": "write", + "openWorld": true + }, + { + "name": "big_result", + "description": "Return a string of the given length.", + "inputSchema": { + "type": "object", + "properties": { "length": { "type": "integer", "minimum": 0 } }, + "required": ["length"], + "additionalProperties": false + }, + "sideEffect": "read" + } + ] +} diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index f312b3d58320..12b71f401d2e 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -1,5 +1,6 @@ import * as Layer from "effect/Layer"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; @@ -20,7 +21,8 @@ export type ProviderOrchestrationAdapterInfrastructure = * Infrastructure shared by the V2 adapters materialized inside provider * instances. `providerContinuationRequestsLayer` must be the same layer * reference the orchestration runtime provides to its continuation worker so - * Effect layer memoization yields one shared queue. + * Effect layer memoization yields one shared queue. The contribution status + * store follows the same rule with the WebSocket server that streams it. */ export const layer = Layer.mergeAll( ClaudeAdapterV2.layerQueryRunner, @@ -29,4 +31,5 @@ export const layer = Layer.mergeAll( CursorKeychain.layer, IdAllocator.layer, ProviderContinuationRequests.layer, + ContributionStatusStore.layer, ); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index f4d3e7b57723..74bf3b636c35 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -120,6 +120,8 @@ export function shouldPublishAgentAwarenessEvent( case "thread.runtime-mode-updated": case "thread.interaction-mode-updated": case "run.background-work-cancelled": + case "run.finalized": + case "run.finalization-failed": case "run-attempt.created": case "run-attempt.updated": case "node.updated": diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3760500e4056..d0a72fbd3d66 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -26,6 +26,7 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; import * as ServerConfig from "./config.ts"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import { withUntracedRequests } from "./http.ts"; import * as ServerHttp from "./http.ts"; import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; @@ -62,6 +63,13 @@ import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; import * as DeviceHubProxy from "./device/DeviceHubProxy.ts"; +import * as PluginCatalog from "./plugins/PluginCatalog.ts"; +import * as PluginEventDelivery from "./plugins/PluginEventDelivery.ts"; +import * as PluginEventFeed from "./plugins/PluginEventFeed.ts"; +import * as PluginActions from "./plugins/PluginActions.ts"; +import * as PluginSettings from "./plugins/PluginSettings.ts"; +import * as PluginSupervisor from "./plugins/PluginSupervisor.ts"; +import * as PluginTools from "./plugins/PluginTools.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as ServerBrowser from "./preview/ServerBrowser.ts"; @@ -420,6 +428,14 @@ const layerDevice = DeviceService.layer.pipe( Layer.provide(NetService.layer), ); +// Zero enabled plugins means zero plugin processes; each starts on first use. +const layerPlugin = Layer.mergeAll(PluginTools.layer, PluginSettings.layer()).pipe( + Layer.provideMerge(PluginCatalog.layer()), + Layer.provide(PluginSupervisor.layer()), + // Shared with the event feed: the catalogue starts event cursors on enable. + Layer.provideMerge(PluginEventDelivery.layer), +); + const layerWorkspaceEntries = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer)); const layerWorkspaceFileSystem = WorkspaceFileSystem.layer.pipe( @@ -575,6 +591,12 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ProviderUsageLimitsIngestion.layer, layerProviderInstallationRefresh, ReplayMarkers.layer, + // Resolves action targets from the orchestrator's threads and projects. + PluginActions.layer, + // The orchestrator's own event sink, so commits wake event delivery. + PluginEventFeed.layer().pipe( + Layer.provide(Layer.merge(ProjectionStoreV2.layer, RuntimeLayer.layerEventSink)), + ), ).pipe( // Core Services Layer.provideMerge(layerOrchestrationApplication), @@ -586,7 +608,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerSourceControlProviderRegistry), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), - Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), + Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice, layerPlugin)), + // The same layer reference provider adapters write through, so memoization + // gives producers and the WebSocket stream one store. + Layer.provideMerge(ContributionStatusStore.layer), Layer.provideMerge(layerPersistence), // Both read a user-owned file out of the state directory and stream changes // to clients; neither depends on the other. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fce520ca7e3b..b69a6a667073 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -89,7 +89,7 @@ import { ChatAttachmentId, PersistChatAttachmentsError, RpcClientId, - EnvironmentAuthorizationError, + type EnvironmentAuthorizationError, type ProjectId, type ProviderDriverKind, type ProviderInstanceId, @@ -124,6 +124,9 @@ import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts" import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; +import * as PluginCatalog from "./plugins/PluginCatalog.ts"; +import * as PluginSettings from "./plugins/PluginSettings.ts"; +import * as PluginActions from "./plugins/PluginActions.ts"; import { archivedShellStreamItemFromThreadShell, buildActiveShellSnapshot, @@ -212,6 +215,7 @@ import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as DefectReporter from "./observability/DefectReporter.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForDeviceList, rpcAuthorizationError } from "./auth/RpcAuthorization.ts"; import * as RpcAuthorization from "./auth/RpcAuthorization.ts"; @@ -1223,6 +1227,9 @@ const layerWsRpc = ( const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const secretRequests = yield* SecretRequests.SecretRequests; + const pluginCatalog = yield* PluginCatalog.PluginCatalog; + const pluginSettings = yield* PluginSettings.PluginSettings; + const pluginActions = yield* PluginActions.PluginActions; const pullRequests = yield* PullRequestService.PullRequestService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const deviceService = yield* DeviceService.DeviceService; @@ -1322,6 +1329,7 @@ const layerWsRpc = ( const hostResources = yield* HostResources.HostResources; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; + const contributionStatus = yield* ContributionStatusStore.ContributionStatusStore; const relayClient = yield* RelayClient.RelayClient; // A webhook URL starts agent runs, so only sessions that may operate // see it; read-only sessions still see the task itself. @@ -2051,6 +2059,20 @@ const layerWsRpc = ( Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( Effect.andThen(scheduledTasks.delete(input)), ), + [WS_METHODS.pluginsList]: (_input) => pluginCatalog.list, + [WS_METHODS.pluginsSubscribe]: (_input) => pluginCatalog.subscribe, + [WS_METHODS.pluginsAdd]: (input) => pluginCatalog.add(input), + [WS_METHODS.pluginsRefresh]: (input) => pluginCatalog.refresh(input), + [WS_METHODS.pluginsConsent]: (input) => pluginCatalog.consent(input), + [WS_METHODS.pluginsEnable]: (input) => pluginCatalog.enable(input), + [WS_METHODS.pluginsDisable]: (input) => pluginCatalog.disable(input), + [WS_METHODS.pluginsRemove]: (input) => pluginCatalog.remove(input), + [WS_METHODS.pluginsResume]: (input) => pluginCatalog.resume(input), + [WS_METHODS.pluginsSettingsSubscribe]: (input) => + pluginSettings.subscribe(input.installationId), + [WS_METHODS.pluginsSettingsUpdate]: (input) => pluginSettings.update(input), + [WS_METHODS.pluginActionsSubscribe]: (_input) => pluginActions.subscribe, + [WS_METHODS.pluginActionsInvoke]: (input) => pluginActions.invoke(input), [WS_METHODS.scheduledTasksRunNow]: (input) => Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( Effect.andThen(scheduledTasks.runNow(input)), @@ -3113,6 +3135,8 @@ const layerWsRpc = ( Stream.concat(Stream.make(latest), changes), ), ), + [WS_METHODS.subscribeContributionStatus]: (_input) => + ContributionStatusStore.subscriptionStream(contributionStatus), }); return handlers; }), diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index c582e5a5b6c8..63ef0c3d0778 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -54,6 +54,16 @@ export type OpenPreviewMutation = (input: { readonly input: PreviewOpenInput; }) => Promise>; +/** + * False once the caller has left the thread it started in. Work that has not + * asked the server for a browser by then ends as an interruption. A browser the + * server already opened is still applied to the thread it was opened for, so + * its session never lingers unseen. + */ +type ScopeCheck = (() => boolean) | undefined; + +const leftScope = (isScopeCurrent: ScopeCheck) => isScopeCurrent?.() === false; + export async function openUrlInPreview(input: { readonly threadRef: ScopedThreadRef; readonly url: string; @@ -62,10 +72,12 @@ export async function openUrlInPreview(input: { readonly profileId?: PreviewOpenInput["profileId"]; /** Open the tab without switching the thread to it. */ readonly background?: boolean; + readonly isScopeCurrent?: ScopeCheck; }): Promise> { const defaults = await resolveBrowserDefaults().catch( (cause: unknown) => new BrowserSettingsReadError({ cause }), ); + if (leftScope(input.isScopeCurrent)) return AsyncResult.failure(Cause.interrupt()); if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } @@ -124,6 +136,7 @@ export async function openFileInPreview(input: { readonly input: { readonly resource: AssetResource }; }) => Promise>; readonly openPreview: OpenPreviewMutation; + readonly isScopeCurrent?: ScopeCheck; }): Promise< AtomCommandResult< void, @@ -151,6 +164,7 @@ export async function openFileInPreview(input: { }, }, }); + if (leftScope(input.isScopeCurrent)) return AsyncResult.failure(Cause.interrupt()); if (assetResult._tag === "Failure") { return AsyncResult.failure(assetResult.cause); } @@ -164,5 +178,6 @@ export async function openFileInPreview(input: { threadRef: input.threadRef, url: assetUrl, openPreview: input.openPreview, + isScopeCurrent: input.isScopeCurrent, }); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2aeca85e6aa2..ca4bffd97e6c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -70,7 +70,6 @@ import { type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, - type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type ThreadLinkedPullRequest, @@ -150,10 +149,7 @@ import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; import { Fragment, - lazy, - memo, type SetStateAction, - Suspense, useCallback, useEffect, useEffectEvent, @@ -166,6 +162,15 @@ import { flushSync } from "react-dom"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import { isMacPlatform } from "../lib/utils"; +import { RegisteredSidePanel } from "~/panels/bundledPanels"; +import { + PanelHostContext, + threadBoundAnnotationSender, + type PanelHost, + type ThreadAnnotationSender, +} from "~/panels/panelHost"; +import { PersistentThreadTerminalDrawer } from "~/panels/terminal/PersistentThreadTerminalDrawer"; +import type { TerminalLaunchContext } from "~/panels/terminal/TerminalSidePanel"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -281,12 +286,8 @@ import { pullRequestPanelContext, threadPullRequestPanelTarget, } from "./pullRequest/pullRequestDetail.logic"; -import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; -import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; -import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; -import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; import { useDeviceState } from "~/state/device"; import { DeviceSetup } from "./device/DeviceSetup"; import { Dialog } from "./ui/dialog"; @@ -296,7 +297,6 @@ import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { resolveChatShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, @@ -698,16 +698,8 @@ function useDraftHeroLayoutTransition( } as const; } -const PreviewPanel = lazy(() => - import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), -); -const DiffPanel = lazy(() => import("./DiffPanel")); const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => settings.browserAutoShowFloatingPreview; -const DevicePanel = lazy(() => - import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), -); -const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", @@ -844,14 +836,6 @@ type ChatViewProps = draftId: DraftId; }; -interface TerminalLaunchContext { - threadId: ThreadId; - cwd: string; - worktreePath: string | null; -} - -type PersistentTerminalLaunchContext = Pick; - function useLocalDispatchState(input: { activeThread: Thread | undefined; activeLatestRun: Thread["latestRun"] | null; @@ -922,619 +906,6 @@ function useLocalDispatchState(input: { }; } -/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ -function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { - if (left.length !== right.length) { - return false; - } - if (left.length === 0) { - return true; - } - const sortedLeft = left.toSorted((a, b) => a.localeCompare(b)); - const sortedRight = right.toSorted((a, b) => a.localeCompare(b)); - for (let index = 0; index < sortedLeft.length; index += 1) { - if (sortedLeft[index] !== sortedRight[index]) { - return false; - } - } - return true; -} - -/** - * Server knows about fewer sessions than the client, but every server id still exists locally. - * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id - * and later re-add it as a separate group (no split layout). - */ -function serverTerminalIdsStrictSubsetOfClient( - serverIds: readonly string[], - clientIds: readonly string[], -): boolean { - if (serverIds.length >= clientIds.length || clientIds.length === 0) { - return false; - } - const clientSet = new Set(clientIds); - for (const id of serverIds) { - if (!clientSet.has(id)) { - return false; - } - } - return true; -} - -interface PersistentThreadTerminalDrawerProps { - threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; - threadId: ThreadId; - active: boolean; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - splitShortcutLabel: string | undefined; - splitVerticalShortcutLabel: string | undefined; - newShortcutLabel: string | undefined; - closeShortcutLabel: string | undefined; - keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; -} - -const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ - threadRef, - threadId, - active, - launchContext, - focusRequestId, - splitShortcutLabel, - splitVerticalShortcutLabel, - newShortcutLabel, - closeShortcutLabel, - keybindings, - onAddTerminalContext, -}: PersistentThreadTerminalDrawerProps) { - const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); - const hasTerminalWriteAccess = useCallback( - () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope), - [threadRef.environmentId], - ); - const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); - const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); - const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); - const serverThread = useThreadShell(threadRef); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const project = useProject(projectRef); - const terminalUiState = useTerminalUiStateStore((state) => - selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), - ); - const visible = active && terminalUiState.terminalOpen; - const knownTerminalSessions = useKnownTerminalSessions({ - environmentId: threadRef.environmentId, - threadId, - }); - const panelSurfaces = useRightPanelStore( - (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, - ); - const panelTerminalIds = useMemo( - () => - new Set( - panelSurfaces.flatMap((surface) => - surface.kind === "terminal" ? surface.terminalIds : [], - ), - ), - [panelSurfaces], - ); - const drawerTerminalSessions = useMemo( - () => - knownTerminalSessions?.filter( - (session) => !panelTerminalIds.has(session.target.terminalId), - ) ?? [], - [knownTerminalSessions, panelTerminalIds], - ); - const terminalLabelsById = useMemo(() => { - const next = new Map(); - for (const session of drawerTerminalSessions) { - next.set( - session.target.terminalId, - resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), - ); - } - return next; - }, [drawerTerminalSessions]); - const terminalLaunchLocationsById = useMemo(() => { - const next = new Map< - string, - { - readonly cwd: string; - readonly worktreePath: string | null; - readonly runtimeEnv: Record; - } - >(); - if (!project) { - return next; - } - - for (const session of drawerTerminalSessions) { - const summary = session.state.summary; - if (!summary) { - continue; - } - const worktreePathForLaunch = - launchContext !== null ? launchContext.worktreePath : summary.worktreePath; - next.set(session.target.terminalId, { - cwd: launchContext?.cwd ?? summary.cwd, - worktreePath: worktreePathForLaunch, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: worktreePathForLaunch, - }), - }); - } - - return next; - }, [drawerTerminalSessions, launchContext, project]); - const serverOrderedTerminalIds = useMemo( - () => drawerTerminalSessions.map((session) => session.target.terminalId), - [drawerTerminalSessions], - ); - // Every client-side id source participates in allocation: the server list - // lags fresh opens, and panel terminals are filtered out of the drawer's - // sessions — an id collision attaches two viewports to one PTY session. - const allocatableTerminalIds = useMemo( - () => [ - ...new Set([ - ...serverOrderedTerminalIds, - ...terminalUiState.terminalIds, - ...panelTerminalIds, - ]), - ], - [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], - ); - const allocateTerminalId = useCallback( - () => - nextTerminalId( - allocatableTerminalIds, - knownTerminalSessions === null || - !readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope) - ? randomUUID() - : undefined, - ), - [allocatableTerminalIds, knownTerminalSessions, threadRef.environmentId], - ); - const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); - const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); - const storeSplitTerminalVertical = useTerminalUiStateStore( - (state) => state.splitTerminalVertical, - ); - const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); - const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); - const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); - const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); - - useEffect(() => { - if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { - return; - } - if ( - serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) - ) { - return; - } - reconcileTerminalIds(threadRef, serverOrderedTerminalIds); - }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); - const [localFocusRequestId, setLocalFocusRequestId] = useState(0); - const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const effectiveWorktreePath = useMemo(() => { - if (launchContext !== null) { - return launchContext.worktreePath; - } - return worktreePath; - }, [launchContext, worktreePath]); - const cwd = useMemo( - () => - launchContext?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath: effectiveWorktreePath, - }) - : null), - [effectiveWorktreePath, launchContext?.cwd, project], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: effectiveWorktreePath, - }) - : {}, - [effectiveWorktreePath, project], - ); - - const bumpFocusRequestId = useCallback(() => { - if (!visible) { - return; - } - setLocalFocusRequestId((value) => value + 1); - }, [visible]); - - const setTerminalHeight = useCallback( - (height: number) => { - storeSetTerminalHeight(threadRef, height); - }, - [storeSetTerminalHeight, threadRef], - ); - - const splitTerminal = useCallback(() => { - if (!hasTerminalWriteAccess() || !cwd) { - return; - } - const terminalId = allocateTerminalId(); - storeSplitTerminal(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - allocateTerminalId, - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - runtimeEnv, - storeSplitTerminal, - threadId, - threadRef, - openTerminal, - hasTerminalWriteAccess, - ]); - const splitTerminalVertical = useCallback(() => { - if (!hasTerminalWriteAccess() || !cwd) { - return; - } - const terminalId = allocateTerminalId(); - storeSplitTerminalVertical(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - allocateTerminalId, - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - openTerminal, - hasTerminalWriteAccess, - runtimeEnv, - storeSplitTerminalVertical, - threadId, - threadRef, - ]); - - const createNewTerminal = useCallback(() => { - if (!hasTerminalWriteAccess() || !cwd) { - return; - } - const terminalId = allocateTerminalId(); - storeNewTerminal(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - allocateTerminalId, - runtimeEnv, - storeNewTerminal, - threadId, - threadRef, - openTerminal, - hasTerminalWriteAccess, - ]); - - const activateTerminal = useCallback( - (terminalId: string) => { - storeSetActiveTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [bumpFocusRequestId, storeSetActiveTerminal, threadRef], - ); - - const closeTerminal = useCallback( - (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const fallbackExitWrite = () => - writeTerminal({ - environmentId: threadRef.environmentId, - input: { threadId, terminalId, data: "exit\n" }, - }); - - void (async () => { - const closeResult = await closeTerminalMutation({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - deleteHistory: true, - }, - }); - if ( - closeResult._tag === "Failure" && - !isAtomCommandInterrupted(closeResult) && - hasTerminalWriteAccess() - ) { - await fallbackExitWrite(); - } - })(); - - storeCloseTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [ - bumpFocusRequestId, - storeCloseTerminal, - threadId, - threadRef, - closeTerminalMutation, - hasTerminalWriteAccess, - writeTerminal, - ], - ); - - const handleAddTerminalContext = useCallback( - (selection: TerminalContextSelection) => { - if (!visible) { - return; - } - onAddTerminalContext(selection); - }, - [onAddTerminalContext, visible], - ); - - if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { - return null; - } - - return ( -
-
- -
-
- ); -}); - -interface PersistentThreadTerminalPanelProps { - visible: boolean; - threadRef: ScopedThreadRef; - surface: Extract; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; - onSplitTerminal: () => void; - onSplitTerminalVertical: () => void; - onNewTerminal: () => void; - onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; - splitShortcutLabel?: string | undefined; - splitVerticalShortcutLabel?: string | undefined; - newShortcutLabel?: string | undefined; - closeShortcutLabel?: string | undefined; -} - -const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ - visible, - threadRef, - surface, - launchContext, - focusRequestId, - keybindings, - onAddTerminalContext, - onSplitTerminal, - onSplitTerminalVertical, - onNewTerminal, - onActiveTerminalChange, - onCloseTerminal, - splitShortcutLabel, - splitVerticalShortcutLabel, - newShortcutLabel, - closeShortcutLabel, -}: PersistentThreadTerminalPanelProps) { - const serverThread = useThreadShell(threadRef); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const project = useProject(projectRef); - const knownTerminalSessions = useKnownTerminalSessions({ - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - }); - const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const activeSummary = - knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId) - ?.state.summary ?? null; - const worktreePath = - launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; - const cwd = useMemo( - () => - launchContext?.cwd ?? - activeSummary?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath, - }) - : null), - [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath, - }) - : {}, - [project, worktreePath], - ); - const terminalLabelsById = useMemo(() => { - const labels = new Map(); - for (const terminalId of surface.terminalIds) { - const summary = - knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state - .summary ?? null; - labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); - } - return labels; - }, [knownTerminalSessions, surface.terminalIds]); - const terminalLaunchLocationsById = useMemo(() => { - const locations = new Map< - string, - { - readonly cwd: string; - readonly worktreePath: string | null; - readonly runtimeEnv: Record; - } - >(); - for (const terminalId of surface.terminalIds) { - const summary = - knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state - .summary ?? null; - const terminalWorktreePath = - launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; - const terminalCwd = - launchContext?.cwd ?? - summary?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, - }) - : null); - if (!terminalCwd || !project) continue; - locations.set(terminalId, { - cwd: terminalCwd, - worktreePath: terminalWorktreePath, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, - }), - }); - } - return locations; - }, [ - knownTerminalSessions, - launchContext?.cwd, - launchContext?.worktreePath, - project, - surface.terminalIds, - threadWorktreePath, - ]); - - if (!project || !cwd) return null; - - return ( - undefined} - onAddTerminalContext={onAddTerminalContext} - terminalLabelsById={terminalLabelsById} - terminalLaunchLocationsById={terminalLaunchLocationsById} - keybindings={keybindings} - /> - ); -}); - // Errors surface through two maps (draft-keyed and thread-keyed) whose entries // can race around promotion, so each write carries its time to let the latest // one win when they collide. @@ -5581,13 +4952,6 @@ export default function ChatView(props: ChatViewProps) { ); if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(activeThreadRef); }, [activePreviewMiniPlayer, activeThreadRef, deviceState.sessions, deviceStateLoaded]); - const openFileSurface = useCallback( - (relativePath: string) => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); - }, - [activeProject, activeThreadRef], - ); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. const persistedLinkedThreadPullRequest = isServerThread @@ -11003,33 +10367,62 @@ export default function ChatView(props: ChatViewProps) { pendingSidebarFileDrops, ]); + // Plain server threads share one ChatView, so the sender carries its thread + // and a host only forwards to a sender for the thread it was built for. + // Updated after commit so a discarded render cannot lend its `onSend`. + const annotationSenderRef = useRef(null); + useLayoutEffect(() => { + annotationSenderRef.current = activeThreadKey + ? { + threadKey: activeThreadKey, + send: (annotation, image) => { + void onSend(undefined, "auto", "foreground", { annotation, image }); + }, + } + : null; + }); + // Memoized so mounted panels re-render only when a host field changes. + const panelHost = useMemo( + () => + activeThreadRef + ? { + threadRef: activeThreadRef, + visible: rightPanelOpen, + composerDraftTarget, + workspaceMutationId, + sendAnnotation: threadBoundAnnotationSender( + () => annotationSenderRef.current, + scopedThreadKey(activeThreadRef), + ), + } + : null, + [ + activeThreadRef, + annotationSenderRef, + composerDraftTarget, + rightPanelOpen, + workspaceMutationId, + ], + ); + // Empty state: no active thread if (!activeThread) { return ; } - const rightPanelContent = activeThreadRef ? ( + const rightPanelSurfaceContent = activeThreadRef ? ( renderedRightPanelSurface?.kind === "preview" ? ( - - { - void onSend(undefined, "auto", "foreground", { annotation, image }); - }} - /> - + ) : renderedRightPanelSurface?.kind === "terminal" ? ( - ) : renderedRightPanelSurface?.kind === "diff" ? ( - - - - ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( - - ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( - + ) : renderedRightPanelSurface?.kind === "pull-request" ? ( - // No onClose: the surface tab's own X owns closing here, and a second X in the header - // would be the same action twice. The thread context also drops the checkout button, so it - // is only right for the thread's own pull request, whose branch is already under the - // reader's feet. A link the agent wrote can open any other one here, and that one has to be - // checkable out like it is anywhere else. - { - if (activeThreadRef) - useRightPanelStore.getState().openPullRequest(activeThreadRef, { - projectId: reference.projectId, - repository: reference.repository, - number: reference.number, - ...(reference.host ? { host: reference.host } : {}), - }); - }} - threadRef={activeThreadRef} reference={{ projectId: renderedRightPanelSurface.projectId as ProjectId, ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}), @@ -11096,76 +10463,75 @@ export default function ChatView(props: ChatViewProps) { }, renderedRightPanelSurface, )} - composerDraftTarget={composerDraftTarget} onBack={ activeThreadRef !== null && pullRequestsSurfaceAvailable && visiblePullRequestCount > 1 ? addPullRequestsSurface : undefined } /> - ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( - + ) : renderedRightPanelSurface?.kind === "pull-requests" ? ( + ) : renderedRightPanelSurface?.kind === "device" ? ( - - { - closeRightPanelSurface(renderedRightPanelSurface); - useRightPanelStore.getState().show(activeThreadRef); - }} - /> - + { + closeRightPanelSurface(renderedRightPanelSurface); + useRightPanelStore.getState().show(activeThreadRef); + }} + /> ) : (renderedRightPanelSurface?.kind === "files" || renderedRightPanelSurface?.kind === "file") && ((activeProject && activeWorkspaceRoot) || (renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment)) ? ( - - - + ) : null ) : null; + const rightPanelContent = ( + {rightPanelSurfaceContent} + ); + const sidePanelLaunchers = { + preview: { available: canOperatePreview && browserAvailable, onOpen: createBrowserSurface }, + diff: { available: isServerThread && isGitRepo, onOpen: addDiffSurface }, + terminal: { + available: activeProject !== null && canOperateTerminal, + onOpen: addTerminalSurface, + }, + files: { available: activeProject !== null, onOpen: addFilesSurface }, + device: { available: activeThreadRef !== null, onOpen: addDeviceSurface }, + "pull-request": { available: pullRequestSurfaceAvailable, onOpen: addPullRequestSurface }, + "pull-requests": { available: pullRequestsSurfaceAvailable, onOpen: addPullRequestsSurface }, + }; const threadDetailsPanelProps: ThreadDetailsPanelProps = { anchor: threadPanelPopoverAnchorRef, handle: threadPanelPopoverHandle, @@ -12000,21 +11366,8 @@ export default function ChatView(props: ChatViewProps) { onCloseAllSurfaces={closeAllRightPanelSurfaces} onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} + panels={sidePanelLaunchers} onAddBrowserInProfile={createBrowserSurface} - onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} - onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} > {rightPanelContent} @@ -12059,21 +11412,8 @@ export default function ChatView(props: ChatViewProps) { onCloseAllSurfaces={closeAllRightPanelSurfaces} onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} + panels={sidePanelLaunchers} onAddBrowserInProfile={createBrowserSurface} - onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} - onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} > {rightPanelContent} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 2bd7c118c69f..3c6909529ad9 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + PluginActionId, + ProjectId, + ProviderInstanceId, + ThreadId, + type PluginAction, +} from "@t3tools/contracts"; import type { Project, Thread } from "../types"; import { makeThreadFixture } from "../test-fixtures"; import { @@ -9,6 +16,7 @@ import { buildProjectActionItems, buildThreadActionItems, buildLinkedThreadActionItems, + buildPluginActionItems, enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, @@ -894,3 +902,45 @@ describe("virtualized command palette rows", () => { expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull(); }); }); + +describe("plugin actions in the palette", () => { + const environmentId = EnvironmentId.make("environment-plugins"); + const threadId = ThreadId.make("thread-plugins"); + const deploy: PluginAction = { + id: PluginActionId.make("plugin-deploy:deploy"), + pluginId: "plugin-deploy", + pluginName: "Deploy", + name: "deploy", + title: "Deploy this thread", + target: "thread", + placements: ["command-palette"], + }; + const items = (canOperate: boolean, runAction = vi.fn(async () => {})) => + buildPluginActionItems({ + environmentId, + actions: [deploy], + canOperate, + threadId, + projectId: null, + icon: null, + runAction, + }); + + it("runs an offered action in the palette's environment on its target", async () => { + const runAction = vi.fn(async () => {}); + const offered = items(true, runAction); + expect(offered.map((item) => item.title)).toEqual(["Deploy this thread"]); + + await offered[0]?.run(); + + expect(runAction).toHaveBeenCalledWith({ + environmentId, + action: deploy, + target: { _tag: "thread", threadId }, + }); + }); + + it("offers nothing to a connection that cannot operate the environment", () => { + expect(items(false)).toEqual([]); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 49e963f68313..bd4bd420b00f 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -4,9 +4,14 @@ import { type EnvironmentId, type FilesystemBrowseEntry, type KeybindingCommand, + type PluginAction, + type PluginActionTarget, + type ProjectId, + type ThreadId, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem"; +import { pluginActionLabels, pluginActionsAt } from "@t3tools/client-runtime/state/pluginActions"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; @@ -39,6 +44,43 @@ export function buildLinkedThreadActionItems( })); } +/** + * The environment's palette plugin actions. Running one needs + * `orchestration:operate`, so a connection without it is offered none. + */ +export function buildPluginActionItems(input: { + readonly environmentId: EnvironmentId; + readonly actions: ReadonlyArray; + readonly canOperate: boolean; + readonly threadId: ThreadId | null; + readonly projectId: ProjectId | null; + readonly icon: ReactNode; + readonly runAction: (input: { + readonly environmentId: EnvironmentId; + readonly action: PluginAction; + readonly target: PluginActionTarget; + }) => Promise; +}): CommandPaletteActionItem[] { + 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) => ({ + kind: "action", + value: `plugin-action:${environmentId}:${action.id}`, + searchTerms: [action.title, action.name, action.pluginName, "plugin"], + title: labels[index] ?? action.title, + description: action.description ?? action.pluginName, + icon: input.icon, + run: async () => { + await input.runAction({ environmentId, action, target }); + }, + })); +} + export function browseInputEndPaddingClass(input: { readonly willCreateProjectPath: boolean; readonly hasHighlightedBrowseItem: boolean; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 37d80d33b7df..bf52a7e0bf5b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -62,6 +62,7 @@ import { MonitorIcon, MoonIcon, PaletteIcon, + PlugIcon, RotateCcwIcon, SettingsIcon, SquarePenIcon, @@ -130,6 +131,8 @@ import { resolveProjectPathForDispatch, } from "../lib/projectPaths"; import { onOpenCommandPalette } from "../commandPaletteBus"; +import { runPluginAction } from "../pluginActions"; +import { usePluginActions } from "../state/pluginActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { @@ -163,6 +166,7 @@ import { buildRootGroups, buildThreadActionItems, buildLinkedThreadActionItems, + buildPluginActionItems, buildCommandPaletteRows, enumerateCommandPaletteItems, findHighlightedCommandPaletteItem, @@ -1146,6 +1150,13 @@ function OpenCommandPaletteDialog(props: { const currentProjectEnvironmentId = activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null; const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + // Plugin actions belong to the environment the palette was opened in. + const pluginActionEnvironmentId = currentProjectEnvironmentId ?? primaryEnvironmentId; + const pluginActions = usePluginActions(pluginActionEnvironmentId); + const canRunPluginActions = useEnvironmentScope( + pluginActionEnvironmentId, + AuthOrchestrationOperateScope, + ); // Where "without a project" threads start: the current environment when it // offers them, otherwise the first connected one that does. const scratchTargetEnvironmentId = scratchEnvironmentId( @@ -2034,6 +2045,20 @@ function OpenCommandPaletteDialog(props: { }); } + if (pluginActionEnvironmentId !== null) { + actionItems.push( + ...buildPluginActionItems({ + environmentId: pluginActionEnvironmentId, + actions: pluginActions, + canOperate: canRunPluginActions, + threadId: activeThread?.id ?? null, + projectId: currentProjectId, + icon: , + runAction: runPluginAction, + }), + ); + } + actionItems.push({ kind: "action", value: "action:open-file-picker", diff --git a/apps/web/src/components/PluginActionSubscriptions.tsx b/apps/web/src/components/PluginActionSubscriptions.tsx new file mode 100644 index 000000000000..edce2939613b --- /dev/null +++ b/apps/web/src/components/PluginActionSubscriptions.tsx @@ -0,0 +1,21 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import { useEnvironmentIds } from "../state/environments"; +import { useMountPluginActions } from "../state/pluginActions"; + +function EnvironmentPluginActions(props: { readonly environmentId: EnvironmentId }) { + useMountPluginActions(props.environmentId); + return null; +} + +/** + * Keeps every environment's plugin actions subscribed, so thread menus built + * when they open list them without waiting. A server without plugin actions + * receives no subscription. + */ +export function PluginActionSubscriptions() { + const environmentIds = useEnvironmentIds(); + return environmentIds.map((environmentId) => ( + + )); +} diff --git a/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx new file mode 100644 index 000000000000..96ae673b849d --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom + +import { + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, + INCOGNITO_BROWSER_PROFILE_ID, +} from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// The profile list normally comes from client settings; the built-ins are enough to choose from. +vi.mock("~/browser/browserDefaults", async (importOriginal) => ({ + ...(await importOriginal()), + useBrowserDefaults: () => ({ profiles: BUILT_IN_BROWSER_PROFILES }), +})); + +import { RightPanelTabs } from "./RightPanelTabs"; + +let root: Root; +let container: HTMLDivElement; +let opened: string[]; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + // Browser is desktop-only; the launcher offers it once the preview bridge exists. + vi.stubGlobal("desktopBridge", { preview: {} }); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // jsdom lacks the Web Animations API that the tab bar's scroll area waits on. + Element.prototype.getAnimations ??= () => []; + opened = []; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +// Records Browser opens the way ChatView's createBrowserSurface does: one +// handler for the default open and the profile chooser, where an omitted +// profile means the default one. +function Harness() { + const openBrowser = (profileId?: string) => { + opened.push(profileId ?? DEFAULT_BROWSER_PROFILE_ID); + }; + return ( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={[]} + environmentId={null} + activeSurfaceId={null} + pendingSurfaceIds={new Set()} + previewSessions={{}} + desktopByTabId={{}} + terminalLabelsById={new Map()} + onActivate={() => undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + panels={{ + preview: { available: true, onOpen: openBrowser }, + diff: { available: false, onOpen: () => undefined }, + terminal: { available: false, onOpen: () => undefined }, + device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, + files: { available: false, onOpen: () => undefined }, + }} + onAddBrowserInProfile={openBrowser} + > + {null} + + ); +} + +function launcherRow(label: string): HTMLButtonElement { + const launcher = container.querySelector('[aria-label="Open a surface"]'); + const row = [...(launcher?.querySelectorAll("button") ?? [])].find((button) => + button.textContent?.startsWith(label), + ); + if (!row) throw new Error(`No launcher row ${label}`); + return row; +} + +function menuItem(label: string): HTMLElement { + const item = [...document.querySelectorAll('[role="menuitem"]')].find( + (element) => element.textContent === label, + ); + if (!item) throw new Error(`No menu item ${label}`); + return item; +} + +async function click(element: Element) { + await act(async () => { + element.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("opening Browser from the launcher", () => { + it("opens the default profile from the row and another profile from the chooser", async () => { + await act(async () => root.render()); + + await click(launcherRow("Browser")); + expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID]); + + await click(container.querySelector('[aria-label="Open browser in a profile"]')!); + await click(menuItem("Incognito")); + expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID]); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx index e7a952ba43c5..d7b38b0e5a75 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx @@ -76,21 +76,16 @@ async function renderPanel(overrides: Partial content diff --git a/apps/web/src/components/RightPanelTabs.terminal.test.tsx b/apps/web/src/components/RightPanelTabs.terminal.test.tsx new file mode 100644 index 000000000000..df3ab0a2f327 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.terminal.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom + +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; + +import { RightPanelTabs } from "./RightPanelTabs"; + +const threadRef: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // jsdom lacks the Web Animations API that the tab bar's scroll area waits on. + Element.prototype.getAnimations ??= () => []; + useRightPanelStore.setState({ byThreadKey: {} }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +// Shows the thread's surfaces from the real right-panel store and opens the +// terminal into it the way ChatView's addTerminalSurface does. +function Harness({ terminalAvailable }: { terminalAvailable: boolean }) { + const surfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + return ( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={surfaces} + environmentId={threadRef.environmentId} + activeSurfaceId={surfaces[0]?.id ?? null} + pendingSurfaceIds={new Set()} + previewSessions={{}} + desktopByTabId={{}} + terminalLabelsById={new Map()} + onActivate={() => undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + panels={{ + preview: { available: false, onOpen: () => undefined }, + diff: { available: false, onOpen: () => undefined }, + terminal: { + available: terminalAvailable, + onOpen: () => useRightPanelStore.getState().openTerminal(threadRef, "term-1"), + }, + device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, + files: { available: false, onOpen: () => undefined }, + }} + onAddBrowserInProfile={() => undefined} + > + {null} + + ); +} + +function launcherRow(label: string): HTMLElement { + const launcher = container.querySelector('[aria-label="Open a surface"]'); + // Unavailable rows render as aria-disabled elements rather than buttons. + const row = [ + ...(launcher?.querySelectorAll('button, [aria-disabled="true"]') ?? []), + ].find((element) => element.textContent?.startsWith(label)); + if (!row) throw new Error(`No launcher row ${label}`); + return row; +} + +async function click(element: Element) { + await act(async () => { + element.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +const threadSurfaces = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces; + +describe("opening Terminal from the launcher", () => { + it("opens a terminal surface from the row when a project allows it", async () => { + await act(async () => root.render()); + + await click(launcherRow("Terminal")); + expect(threadSurfaces()).toMatchObject([{ kind: "terminal", activeTerminalId: "term-1" }]); + expect(container.querySelector('[aria-label="Open a surface"]')).toBeNull(); + expect(container.textContent).toContain("Terminal 1"); + }); + + it("keeps the row disabled and opens nothing without a project", async () => { + await act(async () => root.render()); + + const row = launcherRow("Terminal"); + expect(row.getAttribute("aria-disabled")).toBe("true"); + await click(row); + expect(threadSurfaces()).toEqual([]); + expect(container.querySelector('[aria-label="Open a surface"]')).not.toBeNull(); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 823028eddff1..f45ca4e0011d 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -2,11 +2,12 @@ import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { RightPanelTabs, resolvePullRequestTabLink, + rightPanelSurfaceActions, shouldOpenDefaultBrowserProfileFromMenuClick, surfaceShortcutActionForKey, surfaceShortcutTargetsTypingContext, @@ -21,6 +22,41 @@ describe("browser profile submenu", () => { }); }); +describe("right panel surface actions", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const inputs = () => ({ + panels: { + preview: { available: true, onOpen: () => undefined }, + diff: { available: true, onOpen: () => undefined }, + terminal: { available: true, onOpen: () => undefined }, + device: { available: true, onOpen: () => undefined }, + "pull-request": { available: true, onOpen: () => undefined }, + "pull-requests": { available: true, onOpen: () => undefined }, + files: { available: true, onOpen: () => undefined }, + }, + }); + + it("keeps launcher order, letters and copy for registered and local surfaces", () => { + const actions = rightPanelSurfaceActions(inputs()); + expect(actions.map((action) => [action.shortcut, action.label])).toEqual([ + ["B", "Browser"], + ["T", "Terminal"], + ["F", "Files"], + ["D", "Diff"], + ["P", "Pull request"], + ["L", "Linked pull requests"], + ["M", "Device"], + ]); + expect(actions.find((action) => action.id === "diff")).toMatchObject({ + unavailableHint: "Available for Git repositories.", + unavailableReason: "Diff is only available for server threads in Git repositories.", + }); + }); +}); + function shortcutEvent( key: string, overrides: Partial[1]> = {}, @@ -125,21 +161,16 @@ function renderTabs( onCloseSurfacesToRight={() => undefined} onCloseAllSurfaces={() => undefined} onCopyFilePath={() => undefined} - onAddBrowser={() => undefined} + panels={{ + preview: { available: true, onOpen: () => undefined }, + diff: { available: false, onOpen: () => undefined }, + terminal: { available: false, onOpen: () => undefined }, + device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, + files: { available: false, onOpen: () => undefined }, + }} onAddBrowserInProfile={() => undefined} - onAddTerminal={() => undefined} - onAddPullRequest={() => undefined} - onAddPullRequests={() => undefined} - onAddDiff={() => undefined} - onAddFiles={() => undefined} - onAddDevice={() => undefined} - browserAvailable - terminalAvailable={false} - diffAvailable={false} - filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} - deviceAvailable={false} >
content
, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5c8323a7ae34..4cf3653e42ef 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -25,20 +25,11 @@ import { import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { - Smartphone, - ChevronDown, - ChevronLeft, - ChevronRight, - FileDiff, - Files, - Globe2, - Plus, - TerminalSquare, -} from "lucide-react"; +import { ChevronDown, ChevronLeft, ChevronRight, Plus } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { type ComponentProps, + type ComponentType, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -52,6 +43,7 @@ import { } from "react"; import { isElectron } from "~/env"; +import { getSidePanelMetadata, type SidePanelId } from "~/panels/bundledPanels"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; @@ -130,26 +122,9 @@ interface RightPanelTabsProps { /** Tabs are draggable only when the owner can persist the new order. */ onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; - onAddBrowser: () => void; - /** - * Separate from `onAddBrowser` on purpose: that one is passed directly as a - * DOM click handler, and a `(profileId?: string)` signature would silently - * accept the MouseEvent as a profile id. - */ + /** Whether each registered panel can open here, and how; titles and icons come from its definition. */ + panels: Readonly>; onAddBrowserInProfile: (profileId: string) => void; - onAddTerminal: () => void; - onAddDiff: () => void; - onAddFiles: () => void; - onAddPullRequest: () => void; - onAddPullRequests: () => void; - onAddDevice: () => void; - browserAvailable: boolean; - terminalAvailable: boolean; - diffAvailable: boolean; - filesAvailable: boolean; - pullRequestAvailable: boolean; - pullRequestsAvailable: boolean; - deviceAvailable: boolean; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } @@ -170,16 +145,6 @@ export function shouldOpenDefaultBrowserProfileFromMenuClick( return pointerType !== "touch"; } -const SURFACE_DISABLED_REASONS = { - browser: "Browser previews are only available in the T3 Code desktop app.", - terminal: "Terminal surfaces are only available from a project thread.", - files: "Files are only available when a project is open.", - diff: "Diff is only available for server threads in Git repositories.", - pullRequest: "This thread's branch has no pull request yet.", - pullRequests: "No linked pull requests are available for this thread.", - device: "Devices are only available from a thread.", -} as const; - /** Overlays that must win over the launcher's letter shortcuts. */ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ '[data-slot="dialog-popup"]', @@ -192,16 +157,57 @@ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ '[data-slot="autocomplete-popup"]', ].join(","); -/** One-line unavailability hints for the empty-state rows. */ -const SURFACE_UNAVAILABLE_HINTS = { - browser: "Only available in the desktop app.", - terminal: "Available when a project is open.", - files: "Available when a project is open.", - diff: "Available for Git repositories.", - pullRequest: "No pull request on this branch yet.", - pullRequests: "No linked pull requests available.", - device: "Available from a thread.", -} as const; +interface SidePanelLauncher { + available: boolean; + onOpen: () => void; +} + +interface SurfaceAction { + id: string; + label: string; + icon: ComponentType<{ className?: string }>; + shortcut: string; + available: boolean; + /** One-line reason for the empty launcher rows. */ + unavailableHint: string; + /** Full reason for the add menu tooltip. */ + unavailableReason: string; + onClick: () => void; +} + +type SurfaceActionInputs = Pick; + +/** + * The surfaces the empty launcher and the add menu offer, in launcher order. + * Registered panels describe themselves; the rest are listed here until they + * move onto the panel registry. + */ +export function rightPanelSurfaceActions(props: SurfaceActionInputs): SurfaceAction[] { + const registered = (id: SidePanelId): SurfaceAction => { + const panel = getSidePanelMetadata(id); + const launcher = props.panels[id]; + return { + id, + label: panel.title, + icon: panel.icon, + shortcut: panel.launcherKey, + available: (panel.isSupported?.() ?? true) && launcher.available, + unavailableHint: panel.unavailableHint, + unavailableReason: panel.unavailableReason, + // Never forward the click event as an argument. + onClick: () => launcher.onOpen(), + }; + }; + return [ + registered("preview"), + registered("terminal"), + registered("files"), + registered("diff"), + registered("pull-request"), + registered("pull-requests"), + registered("device"), + ]; +} type TabContextMenuAction = | "rename" @@ -332,87 +338,13 @@ function SurfaceMenuItem(props: { * surfaces stay visible with a one-line reason. */ function RightPanelEmptyState(props: { - onAddBrowser: () => void; + actions: readonly SurfaceAction[]; onAddBrowserInProfile: (profileId: string) => void; browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; - onAddTerminal: () => void; - onAddDiff: () => void; - onAddFiles: () => void; - onAddPullRequest: () => void; - onAddPullRequests: () => void; - onAddDevice: () => void; - browserAvailable: boolean; - terminalAvailable: boolean; - diffAvailable: boolean; - filesAvailable: boolean; - pullRequestAvailable: boolean; - pullRequestsAvailable: boolean; - deviceAvailable: boolean; }) { // -1 means no highlight: it only appears on hover or arrow use. const [highlight, setHighlight] = useState(-1); - - const actions = [ - { - label: "Browser", - icon: Globe2, - shortcut: "B", - available: props.browserAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, - onClick: props.onAddBrowser, - }, - { - label: "Terminal", - icon: TerminalSquare, - shortcut: "T", - available: props.terminalAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, - onClick: props.onAddTerminal, - }, - { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.files, - onClick: props.onAddFiles, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, - onClick: props.onAddDiff, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, - shortcut: "P", - available: props.pullRequestAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, - onClick: props.onAddPullRequest, - }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, - onClick: props.onAddPullRequests, - }, - { - label: "Device", - description: "Watch an iOS Simulator or Android Emulator.", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.device, - onClick: props.onAddDevice, - }, - ] as const; - - type SurfaceAction = (typeof actions)[number]; + const actions = props.actions; const availableActions = actions.filter((action) => action.available); const highlightIndex = @@ -511,7 +443,7 @@ function RightPanelEmptyState(props: { // wrapper: the chooser overlays the row, and a pointer moving // onto it must not read as leaving the row.
setHighlight(availableActions.indexOf(action))} onMouseLeave={() => @@ -532,7 +464,7 @@ function RightPanelEmptyState(props: { 1 && "pr-7", + action.id === "preview" && props.browserProfiles.length > 1 && "pr-7", )} > {action.label} @@ -544,7 +476,7 @@ function RightPanelEmptyState(props: { default profile, the chevron picks another. Only worth showing once there is something to choose between. */} - {action.label === "Browser" && props.browserProfiles.length > 1 ? ( + {action.id === "preview" && props.browserProfiles.length > 1 ? ( ) : ( 0) return snapshot.navStatus.title; try { - return new URL(snapshot.navStatus.url).host || "Browser"; + return new URL(snapshot.navStatus.url).host || fallback; } catch { - return "Browser"; + return fallback; } } } @@ -635,10 +568,11 @@ function surfaceTitle( function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) { const publicProviderUrl = faviconUrlForOrigin(url, 32); + const Icon = getSidePanelMetadata("preview").icon; return ( } + fallback={} className="size-3 shrink-0 rounded-sm object-contain" /> ); @@ -676,10 +610,14 @@ function SurfaceIcon({ favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null; return ; } - case "diff": - return ; - case "files": - return ; + case "diff": { + const Icon = getSidePanelMetadata("diff").icon; + return ; + } + case "files": { + const Icon = getSidePanelMetadata("files").icon; + return ; + } case "file": return ( ); - case "terminal": - return ; + case "terminal": { + const Icon = getSidePanelMetadata("terminal").icon; + return ; + } case "pull-request": return ( ); - case "pull-requests": - return ; - case "device": + case "pull-requests": { + const Icon = getSidePanelMetadata("pull-requests").icon; + return ; + } + case "device": { + const DeviceIcon = getSidePanelMetadata("device").icon; return surface.target?.platform === "ios" ? ( ) : surface.target?.platform === "android" ? ( ) : ( - + ); + } } } @@ -934,64 +878,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }); }, []); - const addSurfaceActions = [ - { - label: "Browser", - icon: Globe2, - shortcut: "B", - available: props.browserAvailable, - disabledReason: SURFACE_DISABLED_REASONS.browser, - onClick: props.onAddBrowser, - }, - { - label: "Terminal", - icon: TerminalSquare, - shortcut: "T", - available: props.terminalAvailable, - disabledReason: SURFACE_DISABLED_REASONS.terminal, - onClick: props.onAddTerminal, - }, - { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_DISABLED_REASONS.files, - onClick: props.onAddFiles, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_DISABLED_REASONS.diff, - onClick: props.onAddDiff, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, - shortcut: "P", - available: props.pullRequestAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequest, - onClick: props.onAddPullRequest, - }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequests, - onClick: props.onAddPullRequests, - }, - { - label: "Device", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_DISABLED_REASONS.device, - onClick: props.onAddDevice, - }, - ] as const; + const addSurfaceActions = rightPanelSurfaceActions(props); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); @@ -1353,9 +1240,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { // while hover or arrow reveals the profiles. The choice // lives at open time because a tab's profile is fixed then — // Electron only honours a partition before attach. - if (action.label === "Browser" && action.available) { + if (action.id === "preview" && action.available) { return ( - + @@ -1474,22 +1361,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{props.activeSurfaceId === null ? ( ) : ( props.children diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d668fa7871b2..571bc99d74b6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -180,6 +180,7 @@ import { buildThreadActionMenuItems, threadActionRequiresOperate, } from "./threadActionMenu.logic"; +import { runPluginAction, threadMenuPluginActions } from "../pluginActions"; import { animateSidebarLayoutChanges, applySidebarThreadDrop, @@ -4603,6 +4604,7 @@ export default function Sidebar() { const isPinned = thread.pinnedAt != null; // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const pluginActions = threadMenuPluginActions(threadRef, thread.projectId); const threadProjectGroup = projectGroupsRef.current.find((project) => project.memberProjectRefs.some( @@ -4640,12 +4642,18 @@ export default function Sidebar() { titleRegeneration: supportsTitleRegeneration, }, snoozePresets, + pluginActions, }), position, ), ); if (clicked._tag === "Failure" || clicked.value === null) return; if (threadActionRequiresOperate(clicked.value) && !checkThreadOperations([thread])) return; + const pluginAction = pluginActions.find((entry) => entry.id === clicked.value); + if (pluginAction) { + await runPluginAction(pluginAction); + return; + } if (clicked.value?.startsWith("snooze:")) { const preset = clicked.value === "snooze:custom" diff --git a/apps/web/src/components/chat/ChatComposer.pluginActions.test.tsx b/apps/web/src/components/chat/ChatComposer.pluginActions.test.tsx new file mode 100644 index 000000000000..6d9183b54500 --- /dev/null +++ b/apps/web/src/components/chat/ChatComposer.pluginActions.test.tsx @@ -0,0 +1,443 @@ +// @vitest-environment jsdom + +import { + EnvironmentId, + PluginActionId, + ProjectId, + ProviderInstanceId, + ThreadId, + type PluginAction, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { createModelSelection } from "@t3tools/shared/model"; +import type { Editor } from "@tiptap/core"; +import { act, createRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { buildLocalDraftThread } from "../ChatView.logic"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { ChatComposer, type ChatComposerHandle, type ChatComposerProps } from "./ChatComposer"; + +const environmentId = EnvironmentId.make("environment-plugins"); +const threadId = ThreadId.make("thread-plugins"); +const projectId = ProjectId.make("project-plugins"); +const instanceId = ProviderInstanceId.make("codex"); + +const deploy: PluginAction = { + id: PluginActionId.make("plugin-deploy:deploy"), + pluginId: "plugin-deploy", + pluginName: "Deploy", + name: "deploy", + title: "Deploy this thread", + target: "thread", + placements: ["composer-slash"], +}; +const openDashboard: PluginAction = { + id: PluginActionId.make("plugin-dashboard:open-dashboard"), + pluginId: "plugin-dashboard", + pluginName: "Dashboard", + name: "open-dashboard", + title: "Open the project dashboard", + target: "project", + placements: ["composer-slash"], +}; + +const pluginActionsMock = vi.hoisted(() => ({ + runPluginAction: vi.fn<(input: unknown) => Promise>(async () => true), + // The live operate grant, which can change after the menu was offered. + canRunNow: true, +})); + +// The environment's action list and the RPC that runs one are the boundaries. +vi.mock("../../state/pluginActions", () => ({ + usePluginActions: () => [deploy, openDashboard], +})); +vi.mock("../../pluginActions", () => ({ + runPluginAction: pluginActionsMock.runPluginAction, + canRunPluginActionsNow: () => pluginActionsMock.canRunNow, +})); + +const modelSelection = createModelSelection(instanceId, "gpt-5.4"); +const thread = buildLocalDraftThread( + threadId, + { + threadId, + environmentId, + projectId, + logicalProjectKey: "project-plugins", + createdAt: "2026-10-04T00:00:00.000Z", + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + }, + modelSelection, +); +const threadRef: ScopedThreadRef = { environmentId, threadId }; +const draftId = DraftId.make("draft-plugins"); + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // A desktop viewport: no media query matches (jsdom has no matchMedia). + vi.stubGlobal("matchMedia", (media: string) => ({ + matches: false, + media, + addEventListener() {}, + removeEventListener() {}, + })); + // jsdom has no FontFaceSet; the resting controls re-measure on its events. + if (!("fonts" in document)) { + Object.defineProperty(document, "fonts", { configurable: true, value: new EventTarget() }); + } + // jsdom does no layout, so scrolling an element into view has nothing to do. + Element.prototype.scrollIntoView ??= () => undefined; + Element.prototype.getAnimations ??= () => []; + // ProseMirror measures the caret to keep it in view after each edit. + Range.prototype.getClientRects ??= () => document.createElement("div").getClientRects(); + Range.prototype.getBoundingClientRect ??= () => new DOMRect(); + pluginActionsMock.runPluginAction.mockReset().mockResolvedValue(true); + pluginActionsMock.canRunNow = true; + // Both server-thread tests type into the same thread's draft. + useComposerDraftStore.getState().setPrompt(threadRef, ""); + useComposerDraftStore.getState().setPrompt(draftId, ""); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +const noop = () => undefined; + +function composerProps( + route: "server" | "draft", + onSend: ChatComposerProps["onSend"], + promptRef: React.RefObject, + canOperateThread: boolean, +): ChatComposerProps { + const isServer = route === "server"; + return { + composerDraftTarget: isServer ? threadRef : draftId, + environmentId, + canOperateThread, + attachmentUploadsCapabilityKnown: true, + supportsAttachmentUploads: false, + supportsQuestionAttachments: false, + maxFileAttachmentBytes: null, + routeKind: route, + routeThreadRef: threadRef, + draftId: isServer ? null : draftId, + multipleModelSelections: null, + supportsMultipleModels: false, + onMultipleModelSelectionsChange: noop, + activeThreadId: isServer ? threadId : null, + activeThreadEnvironmentId: environmentId, + activeThread: thread, + activeThreadShell: isServer ? thread : null, + promptHistoryMessages: [], + isServerThread: isServer, + isLocalDraftThread: !isServer, + forceExpandedOnMobile: false, + projectSelectionRequired: false, + phase: "ready", + canInterrupt: false, + isConnecting: false, + isSendBusy: false, + canResume: false, + sendDisabledReason: null, + isPreparingWorktree: false, + bannerItems: [], + resumeCompactionTokens: null, + keepFullHistory: false, + onToggleKeepFullHistory: noop, + environmentUnavailable: null, + activePendingApproval: null, + pendingApprovals: [], + pendingUserInputs: [], + activePendingProgress: null, + activePendingResolvedAnswers: null, + activePendingIsResponding: false, + activePendingDraftAnswers: {}, + activePendingQuestionIndex: 0, + respondingRequestIds: [], + showPlanFollowUpPrompt: false, + activeProposedPlan: null, + activeTasksProgress: null, + activeTaskSteps: null, + threadSyncPhase: null, + runtimeMode: "full-access", + interactionMode: "default", + lockedProvider: null, + providerStatuses: [], + providerCatalogKnown: true, + activeProjectDefaultModelSelection: null, + activeThreadModelSelection: modelSelection, + activeContextWindow: null, + compactThreadUnavailable: true, + compactDisabled: true, + compactDisabledReason: null, + resolvedTheme: "light", + settings: DEFAULT_UNIFIED_SETTINGS, + keybindings: [], + terminalOpen: false, + gitCwd: null, + pullRequestProjectId: null, + pullRequestRepository: null, + restingControlsHost: null, + restingControlsHaveLeadingContext: false, + onRestingControlsVisibilityChange: noop, + getTimelineScrollableNode: () => null, + isTimelineAtLogicalEnd: () => true, + timelineOverflows: false, + onComposerOverlayHeightChange: noop, + onRestingChange: noop, + promptRef, + composerImagesRef: { current: [] }, + composerFilesRef: { current: [] }, + composerTerminalContextsRef: { current: [] }, + composerRef: createRef(), + onPageScrollKeyDown: noop, + onPageScrollKeyUp: noop, + onPageScrollRelease: noop, + editingQueuedAttachments: null, + onRemoveEditingQueuedAttachment: noop, + onCompactContext: noop, + onSend, + onResume: noop, + onInterrupt: noop, + onImplementPlanInNewThread: noop, + onRespondToApproval: async () => undefined, + onSelectActivePendingUserInputOption: noop, + onAdvanceActivePendingUserInput: noop, + onDismissActivePendingUserInput: noop, + onPreviousActivePendingUserInputQuestion: noop, + onChangeActivePendingUserInputCustomAnswer: noop, + onProviderModelSelect: noop, + onOpenProviderSetup: noop, + getModelDisabledReason: () => null, + toggleInteractionMode: noop, + handleRuntimeModeChange: noop, + handleInteractionModeChange: noop, + focusComposer: noop, + scheduleComposerFocus: noop, + setThreadError: noop, + onExpandImage: noop, + onFileOpen: noop, + }; +} + +async function renderComposer(route: "server" | "draft", canOperateThread = true) { + const onSend = vi.fn(); + const promptRef: React.RefObject = { current: "" }; + const render = (canOperate: boolean) => + act(async () => + root.render(), + ); + await render(canOperateThread); + return { onSend, promptRef, render }; +} + +function promptEditor(): HTMLElement & { editor?: Editor } { + const element = container.querySelector('[data-testid="composer-editor"]'); + if (!element) throw new Error("The composer editor did not render"); + return element; +} + +// Types `beforeCaret` then `afterCaret` into the real editor ("\n" starts a +// new line), leaving the caret between them as if the user moved it back. +async function typePrompt(beforeCaret: string, afterCaret = "") { + const editor = promptEditor().editor; + if (!editor) throw new Error("The composer editor has no Tiptap instance"); + const type = (text: string) => + text.split("\n").forEach((line, index) => { + if (index > 0) editor.commands.splitBlock(); + if (line) editor.commands.insertContent(line); + }); + let caret = 0; + await act(async () => { + editor.commands.focus(); + type(beforeCaret); + caret = editor.state.selection.from; + type(afterCaret); + }); + await act(async () => { + editor.commands.setTextSelection(caret); + }); +} + +function editorLines(): string[] { + return [...promptEditor().querySelectorAll("p")].map((line) => line.textContent ?? ""); +} + +function menuOptions(): HTMLElement[] { + return [...document.querySelectorAll("[data-composer-item-id]")]; +} + +function menuOption(label: string): HTMLElement { + const option = menuOptions().find((element) => element.textContent?.includes(label)); + if (!option) throw new Error(`No composer menu option ${label}`); + return option; +} + +async function pressEnter() { + await act(async () => { + promptEditor().dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + ); + }); +} + +async function click(element: Element) { + await act(async () => { + element.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + element.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("ChatComposer plugin actions in the slash menu", () => { + it("runs a thread action picked with Enter and removes the typed command", async () => { + const { onSend, promptRef } = await renderComposer("server"); + await typePrompt("please\n/depl", " now"); + expect(menuOption("/deploy").textContent).toContain("Deploy this thread"); + + await pressEnter(); + + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledTimes(1); + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledWith({ + environmentId, + action: deploy, + target: { _tag: "thread", threadId }, + }); + expect(promptRef.current).toBe("please\n now"); + expect(editorLines()).toEqual(["please", " now"]); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("runs a thread action picked with the pointer and removes the typed command", async () => { + const { onSend, promptRef } = await renderComposer("server"); + await typePrompt("please\n/depl", " now"); + + await click(menuOption("/deploy")); + + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledTimes(1); + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledWith({ + environmentId, + action: deploy, + target: { _tag: "thread", threadId }, + }); + expect(promptRef.current).toBe("please\n now"); + expect(editorLines()).toEqual(["please", " now"]); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("offers only project actions on a draft and runs them on the project", async () => { + const { onSend, promptRef } = await renderComposer("draft"); + await typePrompt("/"); + const labels = menuOptions().map((element) => element.textContent ?? ""); + expect(labels.some((label) => label.includes("/open-dashboard"))).toBe(true); + expect(labels.some((label) => label.includes("/deploy"))).toBe(false); + + await click(menuOption("/open-dashboard")); + + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledTimes(1); + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledWith({ + environmentId, + action: openDashboard, + target: { _tag: "project", projectId }, + }); + expect(promptRef.current).toBe(""); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("offers no plugin actions to a read-only connection and keeps the typed command", async () => { + const { promptRef } = await renderComposer("server", false); + await typePrompt("please\n/depl", " now"); + expect(menuOptions().some((element) => element.textContent?.includes("/deploy"))).toBe(false); + + await pressEnter(); + + expect(pluginActionsMock.runPluginAction).not.toHaveBeenCalled(); + expect(promptRef.current).toBe("please\n/depl now"); + expect(editorLines()).toEqual(["please", "/depl now"]); + }); + + it("keeps the typed command when the grant is lost while the menu is open", async () => { + const { promptRef, render } = await renderComposer("server"); + await typePrompt("please\n/depl", " now"); + expect(menuOption("/deploy").textContent).toContain("Deploy this thread"); + + await render(false); + await pressEnter(); + + expect(pluginActionsMock.runPluginAction).not.toHaveBeenCalled(); + expect(promptRef.current).toBe("please\n/depl now"); + }); + + it("reads the live grant when the action is picked and keeps the draft if it is gone", async () => { + const { promptRef } = await renderComposer("server"); + await typePrompt("please\n/depl", " now"); + expect(menuOption("/deploy").textContent).toContain("Deploy this thread"); + + // The cached grant still offers the action, but the connection lost it. + pluginActionsMock.canRunNow = false; + await pressEnter(); + + expect(pluginActionsMock.runPluginAction).not.toHaveBeenCalled(); + expect(promptRef.current).toBe("please\n/depl now"); + expect(editorLines()).toEqual(["please", "/depl now"]); + }); + + it("removes the command when it is picked, before the action settles, and runs it once", async () => { + let finish: (ran: boolean) => void = () => {}; + pluginActionsMock.runPluginAction.mockReturnValue(new Promise((resolve) => (finish = resolve))); + const { promptRef } = await renderComposer("server"); + await typePrompt("please\n/depl", " now"); + + await pressEnter(); + expect(promptRef.current).toBe("please\n now"); + // The command is gone, so picking again has nothing to run. + await pressEnter(); + await act(async () => finish(true)); + + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledTimes(1); + }); + + it("leaves the draft alone when the action settles after the composer is gone", async () => { + let finish: (ran: boolean) => void = () => {}; + pluginActionsMock.runPluginAction.mockReturnValue(new Promise((resolve) => (finish = resolve))); + await renderComposer("server"); + await typePrompt("please\n/depl", " now"); + await pressEnter(); + + await act(async () => root.unmount()); + useComposerDraftStore.getState().setPrompt(threadRef, "/depl newer draft"); + await act(async () => finish(true)); + + expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt).toBe( + "/depl newer draft", + ); + root = createRoot(container); + }); +}); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0d55c1c83e06..311eef324feb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -267,6 +267,9 @@ import { ComposerCommandMenu, composerSuggestionOptionId, } from "./ComposerCommandMenu"; +import { pluginActionsAt } from "@t3tools/client-runtime/state/pluginActions"; +import { canRunPluginActionsNow, runPluginAction } from "../../pluginActions"; +import { usePluginActions } from "../../state/pluginActions"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { ComposerImageThumbnail } from "./ComposerImageThumbnail"; @@ -2667,6 +2670,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), ); + const pluginActions = usePluginActions(environmentId); + // Slash entries run on the routed server thread; a draft has no thread yet. + const pluginActionThreadId = routeKind === "server" ? activeThreadId : null; + const pluginActionProjectId = activeThread?.projectId ?? pullRequestProjectId; + const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -2747,8 +2755,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const visibleProviderSlashCommandItems = providerSlashCommandItems.filter( (item) => item.command.name !== "compact" || compactSlashCommandAvailable, ); + // Running a plugin action needs `orchestration:operate`. + const pluginActionItems = ( + canOperateThread + ? pluginActionsAt(pluginActions, "composer-slash", { + threadId: pluginActionThreadId, + projectId: pluginActionProjectId, + }) + : [] + ).map(({ action, target }) => ({ + id: `plugin-action:${action.id}`, + type: "plugin-action" as const, + action, + target, + label: `/${action.name}`, + description: action.title, + })); const slashCommandItems = slashCommandItemsForPromptPosition( - [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems], + [ + ...builtInSlashCommandItems, + ...visibleProviderSlashCommandItems, + ...skillItems, + ...pluginActionItems, + ], composerTrigger.rangeStart === 0, ); return searchSlashCommandItems(slashCommandItems, query); @@ -2823,9 +2852,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compactSlashCommandAvailable, composerTrigger, environmentId, + canOperateThread, environmentThreadShells, exactPullRequestLookup.data, planModeUiEnabled, + pluginActionProjectId, + pluginActionThreadId, + pluginActions, pullRequestLookup.data, pullRequestProjectId, pullRequestRepository, @@ -4041,6 +4074,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } 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 (!canOperateThread || !canRunPluginActionsNow(environmentId)) return; + // Runs now, like the built-ins; nothing reaches the agent as prompt text. + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + void runPluginAction({ environmentId, action: item.action, target: item.target }); + } + return; + } if (item.type === "provider-slash-command") { if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) { const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { @@ -4148,7 +4195,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerDraftReviewComment, addComposerDraftThreadContexts, applyPromptReplacement, + canOperateThread, composerDraftTarget, + environmentId, handleInteractionModeChange, planModeUiEnabled, onUsageLimitsCommand, diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index c2b1a805011b..d0dcd23b50d9 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -39,6 +39,7 @@ import { import { observeResize } from "~/lib/observeResize"; import { cn } from "~/lib/utils"; import { useClientSettings } from "../../hooks/useSettings"; +import { ThreadContributionStatus } from "./ThreadContributionStatus"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -436,6 +437,12 @@ export const ChatHeader = memo(function ChatHeader({ )} + {isServerThread ? ( + + ) : null}
); }); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index e89f4375ef85..53e86196894d 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -4,6 +4,8 @@ import { type ProviderSkillSourceKind, } from "@t3tools/client-runtime/providerSkills"; import { + type PluginAction, + type PluginActionTarget, type ProjectEntry, type ProviderDriverKind, type PullRequestContextMetadata, @@ -16,6 +18,7 @@ import { FolderIcon, MessagesSquareIcon, PackageIcon, + PlugIcon, SettingsIcon, UserRoundIcon, type LucideIcon, @@ -75,6 +78,14 @@ export type ComposerCommandItem = thread: ScopedThreadRef; label: string; description: string; + } + | { + id: string; + type: "plugin-action"; + action: PluginAction; + target: PluginActionTarget; + label: string; + description: string; }; export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { @@ -227,6 +238,12 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { showSkillSuffix={props.triggerKind === "skill"} /> ) : null} + {props.item.type === "plugin-action" ? ( + + + ) : null} ); diff --git a/apps/web/src/components/chat/ThreadContributionStatus.logic.test.ts b/apps/web/src/components/chat/ThreadContributionStatus.logic.test.ts new file mode 100644 index 000000000000..d7a1cb968ab3 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.logic.test.ts @@ -0,0 +1,212 @@ +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { createContributionStatusEnvironmentAtoms } from "@t3tools/client-runtime/state/contribution-status"; +import { + type ContributionStatusEntry, + type ContributionStatusSnapshot, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + type ServerConfig, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { Atom, AtomRegistry } from "effect/reactivity"; + +import { contributionStatusChips } from "./ThreadContributionStatus.logic"; + +const THREAD = ThreadId.make("thread-1"); +const OTHER_THREAD = ThreadId.make("thread-2"); + +const entry = ( + items: ContributionStatusEntry["items"], + options: { session?: string; instance?: string; threadId?: ThreadId } = {}, +): ContributionStatusEntry => ({ + threadId: options.threadId ?? THREAD, + source: { + kind: "provider-session", + providerSessionId: ProviderSessionId.make(options.session ?? "session-1"), + providerInstanceId: ProviderInstanceId.make(options.instance ?? "pi"), + driver: ProviderDriverKind.make("pi"), + }, + items, +}); + +describe("contributionStatusChips", () => { + it("keeps the server's order and keys each chip by source plus item key", () => { + const chips = contributionStatusChips([ + entry([ + { key: "mode", text: "● plan" }, + { key: "tokens", text: "12k" }, + ]), + entry([{ key: "mode", text: "● plan" }], { session: "session-2", instance: "pi_work" }), + ]); + assert.deepStrictEqual( + chips.map((chip) => chip.text), + ["● plan", "12k", "● plan"], + ); + // The same item key from another source is a different chip. + assert.strictEqual(new Set(chips.map((chip) => chip.id)).size, 3); + + // A new provider session taking over with identical text still re-keys the chip. + const [before] = contributionStatusChips([entry([{ key: "mode", text: "● plan" }])]); + const [after] = contributionStatusChips([ + entry([{ key: "mode", text: "● plan" }], { session: "session-3" }), + ]); + assert.notStrictEqual(before!.id, after!.id); + }); + + it("defaults the tone, ignores empty tooltips, and names the producing instance", () => { + const [plain, styled] = contributionStatusChips([ + entry([ + { key: "a", text: "idle", tooltip: "" }, + { key: "b", text: "failing", tone: "error", tooltip: "2 checks failed" }, + ]), + ]); + assert.deepStrictEqual([plain!.tone, plain!.tooltip], ["neutral", null]); + assert.deepStrictEqual([styled!.tone, styled!.tooltip], ["error", "2 checks failed"]); + assert.strictEqual(plain!.origin, "From a Pi extension. It can lag a session change."); + + const [custom] = contributionStatusChips([ + entry([{ key: "a", text: "x" }], { instance: "pi_work" }), + ]); + assert.strictEqual(custom!.origin, "From a Pi Work extension. It can lag a session change."); + }); + + it("renders no chips for no entries", () => { + assert.deepStrictEqual(contributionStatusChips([]), []); + }); +}); + +const config = (contributionStatus: boolean) => + ({ + environment: { + serverVersion: "0.0.1", + capabilities: contributionStatus ? { contributionStatus: true } : {}, + }, + }) as ServerConfig; + +/** Environments whose servers push status frames, read through the real client selector. */ +const makeHarness = Effect.fn("makeHarness")(function* () { + const makeServer = Effect.fn("makeServer")(function* (id: string, supported: boolean) { + const frames = yield* Queue.unbounded(); + let subscriptions = 0; + return { + environmentId: EnvironmentId.make(id), + config: Atom.make(config(supported)), + subscriptions: () => subscriptions, + push: (frame: ContributionStatusSnapshot) => Queue.offer(frames, frame), + frames: Stream.suspend(() => { + subscriptions += 1; + return Stream.fromQueue(frames); + }), + }; + }); + const servers = [ + yield* makeServer("env-a", true), + yield* makeServer("env-b", true), + yield* makeServer("env-old", false), + ] as const; + const byId = new Map(servers.map((server) => [server.environmentId, server])); + // Each environment's subscription reads its own server's frames. + const registryService = { + followStream: (environmentId: EnvironmentId) => byId.get(environmentId)!.frames, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]; + const runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registryService), + ); + const atoms = createContributionStatusEnvironmentAtoms(runtime, { + configValueAtom: (environmentId) => byId.get(environmentId)!.config, + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const chipTexts = (environmentId: EnvironmentId, threadId = THREAD) => + contributionStatusChips(registry.get(atoms.threadStatus(environmentId, threadId))).map( + (chip) => chip.text, + ); + /** Waits until the header's chips match: the client-side receipt for a frame. */ + const waitForChips = (environmentId: EnvironmentId, expected: ReadonlyArray) => + AtomRegistry.toStream(registry, atoms.threadStatus(environmentId, THREAD)).pipe( + Stream.map((entries) => contributionStatusChips(entries).map((chip) => chip.text)), + Stream.filter((actual) => actual.join("\n") === expected.join("\n")), + Stream.runHead, + ); + const mount = (environmentId: EnvironmentId, threadId = THREAD) => + Effect.acquireRelease( + Effect.sync(() => registry.mount(atoms.threadStatus(environmentId, threadId))), + (unmount) => Effect.sync(unmount), + ); + return { servers, chipTexts, waitForChips, mount }; +}); + +describe("thread status chips from the client selector", () => { + it.effect("shows each environment's own statuses for the same thread id", () => + Effect.scoped( + Effect.gen(function* () { + const { servers, chipTexts, waitForChips, mount } = yield* makeHarness(); + const [a, b] = servers; + yield* mount(a.environmentId); + yield* mount(b.environmentId); + yield* mount(a.environmentId, OTHER_THREAD); + + yield* a.push({ + entries: [ + entry([{ key: "mode", text: "● plan" }]), + entry([{ key: "mode", text: "● other" }], { threadId: OTHER_THREAD }), + ], + }); + yield* b.push({ entries: [entry([{ key: "mode", text: "● build" }])] }); + yield* waitForChips(a.environmentId, ["● plan"]); + yield* waitForChips(b.environmentId, ["● build"]); + assert.deepStrictEqual(chipTexts(a.environmentId, OTHER_THREAD), ["● other"]); + }), + ), + ); + + it.effect("replaces and clears chips with each frame", () => + Effect.scoped( + Effect.gen(function* () { + const { servers, waitForChips, mount } = yield* makeHarness(); + const [a] = servers; + yield* mount(a.environmentId); + + yield* a.push({ entries: [entry([{ key: "mode", text: "● startup" }])] }); + yield* waitForChips(a.environmentId, ["● startup"]); + yield* a.push({ + entries: [ + entry([ + { key: "mode", text: "● resume" }, + { key: "tokens", text: "12k" }, + ]), + ], + }); + yield* waitForChips(a.environmentId, ["● resume", "12k"]); + yield* a.push({ entries: [] }); + yield* waitForChips(a.environmentId, []); + }), + ), + ); + + it.effect("shows nothing and never subscribes when the server lacks the capability", () => + Effect.scoped( + Effect.gen(function* () { + const { servers, chipTexts, waitForChips, mount } = yield* makeHarness(); + const [current, , old] = servers; + yield* mount(old.environmentId); + yield* mount(current.environmentId); + + yield* old.push({ entries: [entry([{ key: "mode", text: "● stale" }])] }); + // Once a supported environment has delivered, any subscription would have started. + yield* current.push({ entries: [entry([{ key: "mode", text: "● plan" }])] }); + yield* waitForChips(current.environmentId, ["● plan"]); + assert.strictEqual(old.subscriptions(), 0); + assert.deepStrictEqual(chipTexts(old.environmentId), []); + }), + ), + ); +}); diff --git a/apps/web/src/components/chat/ThreadContributionStatus.logic.ts b/apps/web/src/components/chat/ThreadContributionStatus.logic.ts new file mode 100644 index 000000000000..0393585f2345 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.logic.ts @@ -0,0 +1,47 @@ +import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display"; +import { + type ContributionStatusEntry, + type ContributionStatusSource, + type ContributionStatusTone, + contributionStatusSourceKey, + ProviderDriverKind, +} from "@t3tools/contracts"; + +const PI_DRIVER = ProviderDriverKind.make("pi"); + +export interface ContributionStatusChip { + /** Source key plus item key, so a provider-session takeover re-keys the chip. */ + readonly id: string; + readonly text: string; + readonly tone: ContributionStatusTone; + readonly tooltip: string | null; + /** Static help naming where the status came from. Never claims it is current. */ + readonly origin: string; +} + +function contributionStatusOrigin(source: ContributionStatusSource): string { + const name = resolveProviderInstanceDisplayName({ + instanceId: source.providerInstanceId, + driver: source.driver, + }); + return source.driver === PI_DRIVER + ? `From a ${name} extension. It can lag a session change.` + : `From ${name}. It can lag a session change.`; +} + +/** One chip per status item, in the server's order. */ +export function contributionStatusChips( + entries: ReadonlyArray, +): ReadonlyArray { + return entries.flatMap((entry) => { + const sourceKey = contributionStatusSourceKey(entry.source); + const origin = contributionStatusOrigin(entry.source); + return entry.items.map((item) => ({ + id: JSON.stringify([sourceKey, item.key]), + text: item.text, + tone: item.tone ?? "neutral", + tooltip: item.tooltip || null, + origin, + })); + }); +} diff --git a/apps/web/src/components/chat/ThreadContributionStatus.test.tsx b/apps/web/src/components/chat/ThreadContributionStatus.test.tsx new file mode 100644 index 000000000000..e62607d06bd1 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom + +import { + type ContributionStatusEntry, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + entries: [] as ReadonlyArray, +})); + +vi.mock("../../state/contributionStatus", () => ({ + useThreadContributionStatus: () => testState.entries, +})); + +import { ThreadContributionStatus } from "./ThreadContributionStatus"; + +const THREAD = ThreadId.make("thread-1"); +const ENVIRONMENT = EnvironmentId.make("environment-1"); +const PI_ORIGIN = "From a Pi extension. It can lag a session change."; + +const entry = (items: ContributionStatusEntry["items"]): ContributionStatusEntry => ({ + threadId: THREAD, + source: { + kind: "provider-session", + providerSessionId: ProviderSessionId.make("session-1"), + providerInstanceId: ProviderInstanceId.make("pi"), + driver: ProviderDriverKind.make("pi"), + }, + items, +}); + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + testState.entries = []; + vi.unstubAllGlobals(); +}); + +async function renderStatus(entries: ReadonlyArray) { + testState.entries = entries; + await act(async () => { + root.render(); + }); +} + +/** The trigger's accessible name: its explicit label, else its text. */ +function accessibleName(element: Element) { + return element.getAttribute("aria-label") ?? element.textContent ?? ""; +} + +function statusButton() { + const button = [...container.querySelectorAll("button")].find((candidate) => + accessibleName(candidate).startsWith("Provider status"), + ); + if (!button) throw new Error("Status button was not rendered"); + return button; +} + +function popupText() { + return document.querySelector('[data-slot="popover-popup"]')?.textContent ?? null; +} + +describe("ThreadContributionStatus", () => { + it("counts statuses past the inline ones and lists every one when tapped", async () => { + await renderStatus([ + entry([ + { key: "a", text: "● plan" }, + { key: "b", text: "build 3/9" }, + { key: "c", text: "lint clean", tone: "success" }, + { key: "d", text: "disk almost full", tone: "warning" }, + ]), + ]); + + const button = statusButton(); + expect(button.textContent).toBe("● planbuild 3/9+2"); + expect(popupText()).toBeNull(); + + await act(async () => { + button.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }), + ); + button.click(); + }); + + const text = popupText(); + for (const status of ["● plan", "build 3/9", "lint clean", "disk almost full", PI_ORIGIN]) { + expect(text).toContain(status); + } + }); + + it("is a focusable native button, so keyboard users reach the same list", async () => { + await renderStatus([entry([{ key: "mode", text: "● plan" }])]); + + const button = statusButton(); + button.focus(); + expect(document.activeElement).toBe(button); + expect(button.tagName).toBe("BUTTON"); + }); + + it("names the control with every status text, even when an item has a tooltip", async () => { + const longText = `● ${"x".repeat(78)}`; + await renderStatus([ + entry([ + { key: "a", text: "Plan", tooltip: "12 files to change" }, + { key: "b", text: longText }, + { key: "c", text: "disk almost full", tone: "warning" }, + ]), + ]); + + expect(accessibleName(statusButton())).toBe( + `Provider status: Plan, ${longText}, disk almost full`, + ); + + await act(async () => statusButton().click()); + const text = popupText(); + expect(text).toContain("Plan"); + expect(text).toContain("12 files to change"); + expect(text).toContain(longText); + }); + + it("renders nothing without statuses", async () => { + await renderStatus([]); + expect(container.childElementCount).toBe(0); + }); +}); diff --git a/apps/web/src/components/chat/ThreadContributionStatus.tsx b/apps/web/src/components/chat/ThreadContributionStatus.tsx new file mode 100644 index 000000000000..404dcbe83252 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.tsx @@ -0,0 +1,106 @@ +import type { ContributionStatusTone, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { memo, useMemo } from "react"; + +import { useThreadContributionStatus } from "../../state/contributionStatus"; +import { Badge } from "../ui/badge"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { + type ContributionStatusChip, + contributionStatusChips, +} from "./ThreadContributionStatus.logic"; + +/** Chips shown inline; the rest are counted in a "+N" badge and listed in the popover. */ +const VISIBLE_CHIPS = 2; + +const TONE_VARIANT = { + neutral: "outline", + info: "info", + success: "success", + warning: "warning", + error: "error", +} as const satisfies Record; + +const TONE_TEXT = { + neutral: "", + info: "text-info-foreground", + success: "text-success-foreground", + warning: "text-warning-foreground", + error: "text-destructive-foreground", +} as const satisfies Record; + +/** + * One trigger for every status: the first chips inline, then a "+N" count. + * Click, tap, Enter or hover opens a list with each status's full text, + * tooltip and origin, so a truncated or hidden status is always reachable. + */ +function ContributionStatusChips(props: { readonly chips: ReadonlyArray }) { + const { chips } = props; + const visible = chips.slice(0, VISIBLE_CHIPS); + const hiddenCount = chips.length - visible.length; + const origins = [...new Set(chips.map((chip) => chip.origin))]; + return ( + + chip.text).join(", ")}`} + data-thread-contribution-status + className="relative isolate flex min-w-0 max-w-[45%] shrink cursor-pointer items-center gap-1 rounded-sm outline-none before:pointer-events-none before:absolute before:inset-0 before:z-10 before:rounded-sm focus-visible:before:ring-2 focus-visible:before:ring-inset focus-visible:before:ring-ring pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11" + /> + } + > + {visible.map((chip) => ( + + {chip.text} + + ))} + {hiddenCount > 0 ? ( + + +{hiddenCount} + + ) : null} + + +
    + {chips.map((chip) => ( +
  • + + {chip.text} + + {chip.tooltip ? ( + {chip.tooltip} + ) : null} +
  • + ))} +
+ {origins.map((origin) => ( +

+ {origin} +

+ ))} +
+
+ ); +} + +/** + * Advisory statuses a provider (today, Pi extensions) set on the open thread. + * Renders nothing when there are none or the server predates the channel. + */ +export const ThreadContributionStatus = memo(function ThreadContributionStatus(props: { + environmentId: EnvironmentId; + threadId: ThreadId; +}) { + const entries = useThreadContributionStatus(props.environmentId, props.threadId); + const chips = useMemo(() => contributionStatusChips(entries), [entries]); + if (chips.length === 0) return null; + return ; +}); diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts index 350ceba3a457..bab594ba4315 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderDriverKind } from "@t3tools/contracts"; +import { PluginActionId, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; import type { ComposerCommandItem } from "./ComposerCommandMenu"; import { @@ -221,3 +221,29 @@ describe("searchSlashCommandItems", () => { ]); }); }); + +describe("plugin actions in the slash menu", () => { + const deploy = { + id: "plugin-action:installation-1:1:deploy", + type: "plugin-action", + action: { + id: PluginActionId.make("installation-1:1:deploy"), + pluginId: "acme.deploy", + pluginName: "Deploy", + name: "deploy", + title: "Deploy this branch", + target: "thread", + placements: ["composer-slash"], + }, + target: { _tag: "thread", threadId: ThreadId.make("thread-1") }, + label: "/deploy", + description: "Deploy this branch", + } satisfies ComposerCommandItem; + + it("match by name and stay offered after other text, since they run on selection", () => { + expect(searchSlashCommandItems([deploy], "dep")).toEqual([deploy]); + expect(searchSlashCommandItems([deploy], "branch")).toEqual([deploy]); + expect(searchSlashCommandItems([deploy], "zzz")).toEqual([]); + expect(slashCommandItemsForPromptPosition([deploy], false)).toEqual([deploy]); + }); +}); diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts index 0d33a3316413..919a6f1543de 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -9,14 +9,15 @@ import { scoreProviderSkill } from "../../providerSkillSearch"; type SlashSearchItem = Extract< ComposerCommandItem, - { type: "slash-command" | "provider-slash-command" | "skill" } + { type: "slash-command" | "provider-slash-command" | "skill" | "plugin-action" } >; /** * A provider expands a slash command only when it opens the whole message; * anywhere else it reaches the agent as literal text, so it is not offered - * there. Built-ins apply locally on selection and skills insert a `$` mention - * the server dispatches from any position, so both stay available. + * there. Built-ins and plugin actions apply locally on selection and skills + * insert a `$` mention the server dispatches from any position, so they stay + * available. */ export function slashCommandItemsForPromptPosition( items: ReadonlyArray, @@ -42,7 +43,11 @@ function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | n } const primaryValue = - item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase(); + item.type === "slash-command" + ? item.command.toLowerCase() + : item.type === "plugin-action" + ? item.action.name.toLowerCase() + : item.command.name.toLowerCase(); const description = item.description.toLowerCase(); const scores = [ @@ -104,7 +109,9 @@ export function searchSlashCommandItems( ? `0\u0000${item.command}` : item.type === "provider-slash-command" ? `1\u0000${item.command.name}\u0000${item.provider}` - : `2\u0000${item.skill.name}\u0000${item.provider}`, + : item.type === "plugin-action" + ? `3\u0000${item.action.name}\u0000${item.action.id}` + : `2\u0000${item.skill.name}\u0000${item.provider}`, }, Number.POSITIVE_INFINITY, ); diff --git a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx index 6bf4302fbcc1..b903e57087a0 100644 --- a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx +++ b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { DiffFileHeaderSkeleton } from "../DiffPanelShell"; +import { DiffFileHeaderSkeleton } from "./DiffLoadingState"; /** Load the next batch before the reader reaches the end of the current files. */ export function DiffFileLoadingBoundary({ load, count }: { load: () => void; count: number }) { diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/diffs/DiffLoadingState.tsx similarity index 55% rename from apps/web/src/components/DiffPanelShell.tsx rename to apps/web/src/components/diffs/DiffLoadingState.tsx index 456f70516b23..34b1e4960587 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/diffs/DiffLoadingState.tsx @@ -1,50 +1,4 @@ -import type { ReactNode } from "react"; - -import { isElectron } from "~/env"; -import { cn } from "~/lib/utils"; - -import { Skeleton } from "./ui/skeleton"; - -export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; - -function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { - const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded"; - return cn( - "flex items-center justify-between gap-2", - mode === "embedded" ? "px-2" : "px-4", - shouldUseDragRegion - ? "drag-region h-[var(--workspace-topbar-height)] border-b border-border wco:pr-(--workspace-native-controls-inset)" - : "flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent", - ); -} - -export function DiffPanelShell(props: { - mode: DiffPanelMode; - header: ReactNode; - children: ReactNode; -}) { - const shouldUseDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; - - return ( -
- {shouldUseDragRegion ? ( -
{props.header}
- ) : ( -
- {props.header} -
- )} - {props.children} -
- ); -} +import { Skeleton } from "../ui/skeleton"; export function DiffFileHeaderSkeleton({ titleWidth, diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index d9722303ab72..548042d140bb 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -7,14 +7,13 @@ import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { ChevronsDownUp, ChevronsUpDown } from "lucide"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; import { MorphIcon } from "~/components/MorphIcon"; import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useTheme } from "~/hooks/useTheme"; import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; @@ -40,8 +39,13 @@ interface FileBrowserPanelProps { onOpenFile: (relativePath: string) => void; onRefreshSelectedFile?: () => void; workspaceMutationId: string | null; + /** Inserts a mention into the chat composer of the thread this panel belongs to. */ + addToChat: (text: string) => AddToChatResult; } +/** "dropped" means the panel left the thread before the action settled. */ +export type AddToChatResult = "inserted" | "no-composer" | "not-ready" | "dropped"; + function treePath(entry: ProjectEntry): string { return entry.kind === "directory" ? `${entry.path}/` : entry.path; } @@ -104,9 +108,9 @@ export default function FileBrowserPanel({ onOpenFile, onRefreshSelectedFile, workspaceMutationId, + addToChat, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); - const composerRef = useComposerHandleContext(); const fileContextMenu = useFileContextMenu(environmentId); const { entries: directoryEntries, @@ -213,8 +217,8 @@ export default function FileBrowserPanel({ return; } if (clicked === "add-to-chat") { - const composer = composerRef?.current; - if (!composer) { + const result = addToChat(`${mention} `); + if (result === "no-composer") { toastManager.add({ type: "error", title: "Unable to add to chat", @@ -222,8 +226,7 @@ export default function FileBrowserPanel({ }); return; } - const inserted = composer.insertTextAtEnd(`${mention} `, { ensureLeadingBoundary: true }); - if (!inserted) { + if (result === "not-ready") { toastManager.add({ type: "error", title: "Unable to add to chat", @@ -239,6 +242,14 @@ export default function FileBrowserPanel({ useEffect(() => { showEntryContextMenuRef.current = showEntryContextMenu; }); + // The tree keeps its first selection callback, so it reads the current + // opener through a ref; otherwise a click after a thread switch opens the + // file in the thread the panel first showed. Updated at commit, so a click + // that lands before passive effects run already opens in the new thread. + const onOpenFileRef = useRef(onOpenFile); + useLayoutEffect(() => { + onOpenFileRef.current = onOpenFile; + }, [onOpenFile]); // The tree reads decorations at render time; a folder still loading its // children shows a spinner in its row instead of a banner that shifts the tree. @@ -283,7 +294,7 @@ export default function FileBrowserPanel({ const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { treeSelectionPathRef.current = selectedPath; - onOpenFile(selectedPath); + onOpenFileRef.current(selectedPath); } }, paths: [], diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d5f054dadab0..87865d8e8e7f 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -58,7 +58,7 @@ import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; -import { DiffPanelLoadingState } from "../DiffPanelShell"; +import { DiffPanelLoadingState } from "../diffs/DiffLoadingState"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { DiffFileTree } from "../diffs/DiffFileTree"; import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 137be41c9606..89f1e28c1772 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -116,7 +116,7 @@ import { import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; -import { DiffPanelLoadingState } from "../DiffPanelShell"; +import { DiffPanelLoadingState } from "../diffs/DiffLoadingState"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 6bae63cffec6..02f64935a994 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -91,6 +91,30 @@ describe("buildThreadActionMenuItems", () => { expect(allowed.every((item) => !item.disabled)).toBe(true); }); + it("lists plugin actions as their own section before Copy", () => { + const items = buildThreadActionMenuItems({ + ...baseState, + pluginActions: [ + { id: "plugin-action:a", label: "Open dashboard" }, + { id: "plugin-action:b", label: "Deploy" }, + ], + }); + const first = items.findIndex((item) => item.id === "plugin-action:a"); + expect(items.slice(first, first + 3).map((item) => [item.id, item.separatorBefore])).toEqual([ + ["plugin-action:a", true], + ["plugin-action:b", undefined], + ["copy", true], + ]); + expect(ids(baseState)).not.toContain("plugin-action:a"); + const denied = buildThreadActionMenuItems({ + ...baseState, + canOperate: false, + pluginActions: [{ id: "plugin-action:a", label: "Open dashboard" }], + }); + // Running a plugin action needs orchestration:operate, like the thread mutations. + expect(denied.find((item) => item.id === "plugin-action:a")?.disabled).toBe(true); + }); + it("hides lifecycle items when the environment lacks the capabilities", () => { expect( ids({ diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index f51328266cab..36d20d93ec1b 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -28,7 +28,8 @@ export type ThreadActionMenuId = | "copy-branch" | "copy-thread-id" | "archive" - | "delete"; + | "delete" + | `plugin-action:${string}`; export type DraftActionMenuId = | "copy" @@ -99,6 +100,11 @@ export interface ThreadActionMenuState { readonly titleRegeneration: boolean; }; readonly snoozePresets: ReadonlyArray; + /** Actions enabled plugins offer on this thread's menu, in listing order. */ + readonly pluginActions?: ReadonlyArray<{ + readonly id: `plugin-action:${string}`; + readonly label: string; + }>; } /** Local navigation, read markers, and copying remain available to read-only clients. */ @@ -216,6 +222,12 @@ export function buildThreadActionMenuItems( }, ] : []), + ...(state.pluginActions ?? []).map((action, index) => ({ + id: action.id, + label: action.label, + icon: "plug", + ...(index === 0 ? { separatorBefore: true } : {}), + })), { id: "copy", label: "Copy", diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index db14ed8c860c..1ae7b8070154 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -96,6 +96,12 @@ const ICON_PATHS: Record ({ useCallback: (callback: unknown) => callback, useMemo: (factory: () => unknown) => factory(), })); +// The hook reads plugin actions for the menu; these tests cover the built-in items only. +vi.mock("../pluginActions", () => ({ + threadMenuPluginActions: () => [], + runPluginAction: async () => undefined, +})); vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({ navigate: async () => recordEffect("project-settings") }), })); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 2fda09240b6b..fb9c221d538b 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -36,6 +36,7 @@ import { } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; import { readLocalApi } from "../localApi"; +import { runPluginAction, threadMenuPluginActions } from "../pluginActions"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, @@ -147,6 +148,7 @@ export function useThreadActionMenu(input: { }; const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const pluginActions = threadMenuPluginActions(threadRef, thread.projectId); const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), branch: thread.branch ?? null, @@ -160,6 +162,7 @@ export function useThreadActionMenu(input: { isRunning: !threadRuntimeCanArchive(thread.runtime), supports, snoozePresets, + pluginActions, }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; @@ -174,6 +177,11 @@ export function useThreadActionMenu(input: { ); return; } + const pluginAction = pluginActions.find((entry) => entry.id === action); + if (pluginAction) { + await runPluginAction(pluginAction); + return; + } if (action.startsWith("snooze:")) { const preset = action === "snooze:custom" diff --git a/apps/web/src/panels/bundledPanels.test.tsx b/apps/web/src/panels/bundledPanels.test.tsx new file mode 100644 index 000000000000..287bb346698c --- /dev/null +++ b/apps/web/src/panels/bundledPanels.test.tsx @@ -0,0 +1,225 @@ +import { EnvironmentId, ProjectId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { act, Suspense } from "react"; +import { create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const loaded = vi.hoisted(() => ({ + diff: 0, + preview: 0, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + previewRenders: [] as unknown[], +})); +vi.mock("./diff/DiffSidePanel", () => { + loaded.diff += 1; + return { default: () => null }; +}); +vi.mock("./terminal/TerminalSidePanel", () => { + loaded.terminal += 1; + return { default: () => null }; +}); +vi.mock("./device/DeviceSidePanel", () => { + loaded.device += 1; + return { default: () => null }; +}); +vi.mock("./files/FilesSidePanel", () => { + loaded.files += 1; + return { default: () => null }; +}); +vi.mock("./preview/PreviewSidePanel", () => { + loaded.preview += 1; + return { + default: function PreviewSidePanel(props: unknown) { + loaded.previewRenders.push({ props, host: usePanelHost() }); + return null; + }, + }; +}); + +vi.mock("./pullRequest/PullRequestSidePanel", () => { + loaded.pullRequest += 1; + return { default: () => null }; +}); +vi.mock("./pullRequest/PullRequestsSidePanel", () => { + loaded.pullRequests += 1; + return { default: () => null }; +}); + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { RegisteredSidePanel } from "./bundledPanels"; +import { PanelHostContext, usePanelHost, type PanelHost } from "./panelHost"; + +const threadRef: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; +const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, +}; + +describe("bundled side panels", () => { + it("loads only the selected panel body and lends it the host", async () => { + expect(loaded).toMatchObject({ + diff: 0, + preview: 0, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + }); + await act(async () => { + create( + + + + + , + ); + }); + expect(loaded).toMatchObject({ + diff: 0, + preview: 1, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + }); + expect(loaded.previewRenders).toEqual([{ props: { tabId: "tab-1" }, host }]); + }); + + it("loads only the terminal body when the terminal is selected", async () => { + const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", + }; + await act(async () => { + create( + + + undefined} + onSplitTerminal={() => undefined} + onSplitTerminalVertical={() => undefined} + onNewTerminal={() => undefined} + onActiveTerminalChange={() => undefined} + onCloseTerminal={() => undefined} + /> + + , + ); + }); + expect(loaded).toMatchObject({ diff: 0, terminal: 1 }); + }); +}); + +const filesProps = { + cwd: "/repo", + projectName: "repo", + relativePath: null, + availableEditors: [], + revealLine: null, + revealRequestId: 0, + onPendingChange: () => undefined, + selectedFilePending: false, +}; + +// Never called. The project typecheck compiles these pairings, and each +// expect-error directive fails it if a wrong pairing starts to compile. +export function typeFixtures( + widenedId: "diff" | "preview", + terminalSurface: Extract, + deviceSurface: Extract, + dismiss: () => void, +) { + const terminalProps = { + surface: terminalSurface, + launchContext: null, + focusRequestId: 0, + onAddTerminalContext: () => undefined, + onSplitTerminal: () => undefined, + onSplitTerminalVertical: () => undefined, + onNewTerminal: () => undefined, + onActiveTerminalChange: () => undefined, + onCloseTerminal: () => undefined, + }; + const reference = { projectId: ProjectId.make("project"), repository: "owner/repo", number: 7 }; + const pullRequest = { + reference, + context: "thread" as const, + shortcutsEnabled: true, + getShortcutContext: () => ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + }), + }; + return ( + <> + + + + {/* @ts-expect-error Terminal requires its surface and callbacks. */} + + {/* @ts-expect-error The host owns visibility; the terminal does not take it as a prop. */} + + {/* @ts-expect-error Terminal props on Preview. */} + + + + + {/* @ts-expect-error Pull request detail needs its reference and shortcut inputs. */} + + {/* @ts-expect-error Pull request props on the linked list. */} + + {/* @ts-expect-error Pull request props on Preview. */} + + {/* @ts-expect-error The host owns the composer draft target. */} + + + {/* @ts-expect-error Files props on Preview. */} + + {/* @ts-expect-error Files needs its surface inputs. */} + + {/* @ts-expect-error The host owns the composer draft target. */} + + {/* @ts-expect-error Preview props on Diff. */} + + {/* @ts-expect-error Device props on Preview. */} + + {/* @ts-expect-error Preview props on Device. */} + + {/* @ts-expect-error Device needs its surface and setup dismissal. */} + + {/* @ts-expect-error The host owns visibility; panels do not take it as a prop. */} + + {/* @ts-expect-error The host owns the thread; panels do not take it as a prop. */} + + {/* @ts-expect-error Wrong input shape. */} + + {/* @ts-expect-error Unknown id. */} + + {/* @ts-expect-error A widened id cannot borrow one panel's props. */} + + + ); +} diff --git a/apps/web/src/panels/bundledPanels.tsx b/apps/web/src/panels/bundledPanels.tsx new file mode 100644 index 000000000000..b8211ca1b505 --- /dev/null +++ b/apps/web/src/panels/bundledPanels.tsx @@ -0,0 +1,113 @@ +import { FileDiff, Files, Globe2, Smartphone, TerminalSquare } from "lucide-react"; +import { Suspense, type ComponentType } from "react"; + +import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; + +import { PullRequestPanelPending } from "./pullRequest/PullRequestPanelPending"; +import { createPanelRegistry, type PanelMetadata, type PanelProps } from "./panelRegistry"; + +const bundledPanels = createPanelRegistry([ + { + id: "diff", + title: "Diff", + icon: FileDiff, + launcherKey: "D", + unavailableHint: "Available for Git repositories.", + unavailableReason: "Diff is only available for server threads in Git repositories.", + load: () => import("./diff/DiffSidePanel"), + }, + { + id: "preview", + title: "Browser", + icon: Globe2, + launcherKey: "B", + unavailableHint: "Only available in the desktop app.", + unavailableReason: "Browser previews are only available in the T3 Code desktop app.", + load: () => import("./preview/PreviewSidePanel"), + }, + { + id: "terminal", + title: "Terminal", + icon: TerminalSquare, + launcherKey: "T", + unavailableHint: "Available when a project is open.", + unavailableReason: "Terminal surfaces are only available from a project thread.", + load: () => import("./terminal/TerminalSidePanel"), + }, + { + id: "device", + title: "Device", + icon: Smartphone, + launcherKey: "M", + unavailableHint: "Available from a thread.", + unavailableReason: "Devices are only available from a thread.", + load: () => import("./device/DeviceSidePanel"), + }, + { + id: "pull-request", + title: "Pull request", + icon: PullRequestGlyph.pullRequest, + launcherKey: "P", + unavailableHint: "No pull request on this branch yet.", + unavailableReason: "This thread's branch has no pull request yet.", + // The detail's code is large; the first open would otherwise show an empty panel. + fallback: , + load: () => import("./pullRequest/PullRequestSidePanel"), + }, + { + id: "pull-requests", + title: "Linked pull requests", + icon: PullRequestGlyph.link, + launcherKey: "L", + unavailableHint: "No linked pull requests available.", + unavailableReason: "No linked pull requests are available for this thread.", + fallback: , + load: () => import("./pullRequest/PullRequestsSidePanel"), + }, + { + id: "files", + title: "Files", + icon: Files, + launcherKey: "F", + unavailableHint: "Available when a project is open.", + unavailableReason: "Files are only available when a project is open.", + load: () => import("./files/FilesSidePanel"), + }, +]); + +export type SidePanelId = (typeof bundledPanels.definitions)[number]["id"]; + +/** Metadata for launchers and tabs; reading it never loads a panel body. */ +export function getSidePanelMetadata(id: SidePanelId): PanelMetadata { + return bundledPanels.get(id); +} + +type SidePanel = ReturnType; +type SidePanelPropKey = SidePanel extends infer Panel + ? Panel extends SidePanel + ? keyof PanelProps + : never + : never; + +/** + * One member per registered id. Other panels' prop keys are forbidden on each + * member, so a widened id cannot carry props the selected panel does not take. + */ +type RegisteredSidePanelProps = SidePanel extends infer Panel + ? Panel extends SidePanel + ? { id: Panel["id"] } & PanelProps & { + [Key in Exclude>]?: never; + } + : never + : never; + +export function RegisteredSidePanel({ id, ...props }: RegisteredSidePanelProps) { + const panel = bundledPanels.get(id); + // The union caller already paired id with its props; destructuring loses that correlation. + const Component = panel.Component as ComponentType; + return ( + + + + ); +} diff --git a/apps/web/src/panels/device/DeviceSidePanel.test.tsx b/apps/web/src/panels/device/DeviceSidePanel.test.tsx new file mode 100644 index 000000000000..828d50d6c904 --- /dev/null +++ b/apps/web/src/panels/device/DeviceSidePanel.test.tsx @@ -0,0 +1,246 @@ +import { + EnvironmentId, + ThreadId, + type DeviceServiceState, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext } from "../panelHost"; + +type CommandResult = + | { _tag: "Success"; value: { hostId: string; deviceId: string } } + | { _tag: "Failure"; cause: unknown }; +const mocks = vi.hoisted(() => ({ + open: vi.fn<(request: unknown) => Promise>(), + close: vi.fn<(request: unknown) => Promise>(), + openDevice: vi.fn(), + closeSurface: vi.fn(), +})); +const phone = { + hostId: "local", + id: "phone", + name: "Phone", + platform: "ios", + version: "iOS 19", + booted: false, + physical: false, +} as const; +const deviceState: DeviceServiceState = { + hosts: [ + { + id: "local", + kind: "local", + label: "This Mac", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: true, + }, + ], + hostStatus: "ready", + hostStatuses: { local: { status: "ready" } }, + devices: [phone], + sessions: [ + { + threadId: ThreadId.make("thread-1"), + hostId: "local", + deviceId: "phone", + platform: "ios", + openedAt: "2026-01-01T00:00:00.000Z", + }, + ], + onboardingCompleted: true, + agentAccessEnabled: true, + hubBasePath: "/api/device-hub", + revision: 1, +}; +vi.mock("~/state/device", () => ({ + deviceEnvironment: { list: "list", open: "open", close: "close" }, + useDeviceState: () => ({ state: deviceState, loaded: true }), +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: string) => + command === "open" + ? mocks.open + : command === "close" + ? mocks.close + : () => Promise.resolve({ _tag: "Success", value: {} }), +})); +vi.mock("~/state/query", () => ({ formatEnvironmentQueryError: () => "Device failed" })); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { + getState: () => ({ openDevice: mocks.openDevice, closeSurface: mocks.closeSurface }), + }, +})); +vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null })); +vi.mock("~/components/device/DeviceLoadingView", () => ({ DeviceLoadingView: () => null })); +vi.mock("~/components/device/DeviceWorkspace", () => ({ DeviceWorkspace: () => null })); +vi.mock("~/components/preview/PreviewPanelShell", () => ({ + PreviewPanelShell: ({ children }: { children: ReactNode }) => children, +})); + +// Same thread id in another environment: the surface id collides, so the panel +// stays mounted across the switch, as it does under ChatView's surface key. +const picking = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-1"), +}; +const next = { environmentId: EnvironmentId.make("environment-b"), threadId: picking.threadId }; +const picker: Extract = { id: "device", kind: "device" }; +const streaming: Extract = { + id: "device:phone", + kind: "device", + target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }, +}; + +// Mounted the way ChatView mounts it: through the registry, lazily, on the panel host. +const render = (threadRef: ScopedThreadRef, surface = picker) => ( + undefined, + }} + > + undefined} + /> + +); +const mount = async (threadRef: ScopedThreadRef, surface = picker) => { + const renderer = await act(async () => create(render(threadRef, surface))); + // Wait for the registered lazy body to load and replace the Suspense fallback. + await act(async () => { + await import("./DeviceSidePanel"); + }); + return renderer; +}; +const deferred = () => { + let settle!: (result: CommandResult) => void; + const promise = new Promise((resolve) => (settle = resolve)); + return { promise, settle }; +}; +const startPhone = (renderer: ReactTestRenderer) => + act(async () => { + renderer.root.findByProps({ "aria-label": "Start Phone" }).props.onClick(); + }); +const errors = (renderer: ReactTestRenderer) => renderer.root.findAllByProps({ role: "alert" }); +const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("registered Device panel", () => { + it("opens a picked device when the pick settles in the same thread", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await mount(picking); + + await startPhone(renderer); + expect(mocks.open).toHaveBeenCalledWith({ + environmentId: picking.environmentId, + input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", platform: "ios" }, + }); + await act(async () => pick.settle(opened)); + + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, { + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", + }); + }); + + it("keeps the next thread's picker usable while an earlier pick is pending", async () => { + mocks.open.mockReturnValue(deferred().promise); + const renderer = await mount(picking); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + + expect(renderer.root.findByProps({ "aria-label": "Start Phone" }).props.disabled).toBe(false); + }); + + it("opens a pick that settles after a switch in the thread it started in", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await mount(picking); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + await act(async () => pick.settle(opened)); + + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, { + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", + }); + expect(renderer.root.findByProps({ "aria-label": "Start Phone" }).props.disabled).toBe(false); + }); + + it("hides a failed pick that settles after the panel moved, even back again", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await mount(picking); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + await act(async () => renderer.update(render(picking))); + await act(async () => pick.settle({ _tag: "Failure", cause: new Error("boom") })); + + expect(errors(renderer)).toHaveLength(0); + expect(mocks.openDevice).not.toHaveBeenCalled(); + }); + + it("does not carry an operation error into another thread", async () => { + mocks.open.mockResolvedValue({ _tag: "Failure", cause: new Error("boom") }); + const renderer = await mount(picking); + + await startPhone(renderer); + expect(errors(renderer)).toHaveLength(1); + await act(async () => renderer.update(render(next))); + + expect(errors(renderer)).toHaveLength(0); + }); + + it("closes a powered-off surface in its own thread after the panel moved", async () => { + const powerOff = deferred(); + mocks.close.mockReturnValue(powerOff.promise); + const renderer = await mount(picking, streaming); + + await act(async () => { + renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff(); + }); + expect(mocks.close).toHaveBeenCalledWith({ + environmentId: picking.environmentId, + input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", shutdown: true }, + }); + await act(async () => renderer.update(render(next, streaming))); + await act(async () => powerOff.settle(opened)); + + expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(picking, streaming.id); + }); + + it("closes the surface when a power-off settles in the same thread", async () => { + mocks.close.mockResolvedValue(opened); + const renderer = await mount(picking, streaming); + + await act(async () => { + renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff(); + }); + + expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(picking, streaming.id); + }); +}); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/panels/device/DeviceSidePanel.tsx similarity index 78% rename from apps/web/src/components/device/DevicePanel.tsx rename to apps/web/src/panels/device/DeviceSidePanel.tsx index 7e6804043f44..c3dd34d8ff77 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/panels/device/DeviceSidePanel.tsx @@ -1,12 +1,7 @@ -import { DeviceHostUpdates } from "./DeviceHostUpdates"; -import type { - DevicePlatform, - DeviceServiceState, - DeviceSummary, - ScopedThreadRef, -} from "@t3tools/contracts"; +import { DeviceHostUpdates } from "~/components/device/DeviceHostUpdates"; +import type { DevicePlatform, DeviceServiceState, DeviceSummary } from "@t3tools/contracts"; import { Smartphone, X } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; @@ -19,10 +14,12 @@ import { cn } from "~/lib/utils"; import { deviceEnvironment, useDeviceState } from "~/state/device"; import { formatEnvironmentQueryError } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; -import { DeviceLoadingView } from "./DeviceLoadingView"; -import { DeviceSetup } from "./DeviceSetup"; -import { DeviceWorkspace } from "./DeviceWorkspace"; -import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; +import { DeviceLoadingView } from "~/components/device/DeviceLoadingView"; +import { DeviceSetup } from "~/components/device/DeviceSetup"; +import { DeviceWorkspace } from "~/components/device/DeviceWorkspace"; +import { PreviewPanelShell } from "~/components/preview/PreviewPanelShell"; + +import { usePanelHost } from "../panelHost"; const platformLabel = (platform: DevicePlatform) => platform === "ios" ? "iOS Simulators" : "Android Emulators"; @@ -30,30 +27,40 @@ const platformLabel = (platform: DevicePlatform) => const deviceKey = (device: Pick) => `${device.hostId}\u0000${device.id}`; -/** Each surface owns one host/device; only the visible surface streams. */ -export function DevicePanel(props: { - readonly mode: PreviewPanelMode; - readonly threadRef: ScopedThreadRef; +/** + * Each surface owns one host/device; only the visible surface streams. + * RightPanelTabs owns placement, so the side panel is always embedded. + */ +export default function DeviceSidePanel(props: { readonly surface: Extract; - readonly visible: boolean; readonly onDismissSetup: () => void; }) { - const { environmentId, threadId } = props.threadRef; + const { threadRef, visible } = usePanelHost(); + const { environmentId, threadId } = threadRef; const { state, loaded } = useDeviceState(environmentId); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); const open = useAtomCommand(deviceEnvironment.open); const close = useAtomCommand(deviceEnvironment.close); const [operationError, setOperationError] = useState(null); const [pendingDevice, setPendingDevice] = useState(null); + // Operation state belongs to one scoped thread; drop it when the panel moves on. + const scopeKey = `${environmentId}\u0000${threadId}`; + const [operationScopeKey, setOperationScopeKey] = useState(scopeKey); + if (operationScopeKey !== scopeKey) { + setOperationScopeKey(scopeKey); + setOperationError(null); + setPendingDevice(null); + } + const isCurrentScope = useScopeGuard(scopeKey); const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null; const hostDisabled = state.hostStatus === "disabled"; // Opening setup never grants permission to install or start helpers. useEffect(() => { - if (!props.visible || !loaded || hostDisabled) return; + if (!visible || !loaded || hostDisabled) return; void list({ environmentId, input: {} }); - }, [environmentId, list, loaded, props.visible, hostDisabled]); + }, [environmentId, list, loaded, visible, hostDisabled]); const sessions = useMemo( () => state.sessions.filter((session) => session.threadId === threadId), @@ -79,6 +86,7 @@ export function DevicePanel(props: { if (!device) return; setOperationError(null); setPendingDevice(device); + const stillCurrent = isCurrentScope(); try { const result = await open({ environmentId, @@ -89,39 +97,44 @@ export function DevicePanel(props: { platform: device.platform, }, }); - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); - else - useRightPanelStore.getState().openDevice(props.threadRef, { + // The server opened the device for the starting thread, so its tab opens + // there even after a switch; only this panel's own state is scope-guarded. + if (result._tag === "Failure") { + if (stillCurrent()) setOperationError(formatEnvironmentQueryError(result.cause)); + } else { + useRightPanelStore.getState().openDevice(threadRef, { hostId: result.value.hostId, deviceId: result.value.deviceId, platform: device.platform, name: device.name, }); + } } finally { - setPendingDevice(null); + if (stillCurrent()) setPendingDevice(null); } }; // Floating the device closes the panel, like the browser's floating preview. const floatActive = () => { if (!activeDevice) return; - usePreviewMiniPlayerStore.getState().open(props.threadRef, { + usePreviewMiniPlayerStore.getState().open(threadRef, { kind: "device", hostId: activeDevice.hostId, deviceId: activeDevice.id, platform: activeDevice.platform, name: activeDevice.name, }); - useRightPanelStore.getState().close(props.threadRef); + useRightPanelStore.getState().close(threadRef); }; const closeActive = (powerOff: boolean) => { if (!powerOff) { - useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + useRightPanelStore.getState().closeSurface(threadRef, props.surface.id); return; } if (!activeSession) return; setOperationError(null); + const stillCurrent = isCurrentScope(); void close({ environmentId, input: { @@ -131,8 +144,11 @@ export function DevicePanel(props: { shutdown: powerOff, }, }).then((result) => { - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); - else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + if (result._tag === "Failure") { + if (stillCurrent()) setOperationError(formatEnvironmentQueryError(result.cause)); + } else { + useRightPanelStore.getState().closeSurface(threadRef, props.surface.id); + } }); }; @@ -153,7 +169,7 @@ export function DevicePanel(props: { if (loaded && (!state.onboardingCompleted || hostDisabled)) { return ( { if (!isOpen) props.onDismissSetup(); }} @@ -166,7 +182,7 @@ export function DevicePanel(props: { } return ( - + {hostReady && !activeDevice && state.hostStatusDetail ? (
host.id === activeDevice.hostId)?.label ?? "Device host" } hostDiagnostics={state.hostStatusDetail} - visible={props.visible} + visible={visible} onFloat={floatActive} onClose={() => closeActive(false)} onPowerOff={() => closeActive(true)} @@ -313,6 +329,27 @@ export function DevicePanel(props: { ); } +/** + * Binds async work to the committed scope. Call the returned function when the + * work starts; the check it returns is false once the panel moved to another + * thread (even back again) or unmounted, so late results are dropped. + */ +function useScopeGuard(scopeKey: string) { + const scopeRef = useRef<{ readonly key: string } | null>(null); + useLayoutEffect(() => { + // A fresh token per commit of a scope, so returning to a thread is a new scope. + const scope = { key: scopeKey }; + scopeRef.current = scope; + return () => { + scopeRef.current = null; + }; + }, [scopeKey]); + return () => { + const started = scopeRef.current; + return () => started !== null && scopeRef.current === started; + }; +} + function groupDevices(state: DeviceServiceState) { const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = []; for (const platform of ["ios", "android"] as const) { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/panels/diff/DiffSidePanel.tsx similarity index 93% rename from apps/web/src/components/DiffPanel.tsx rename to apps/web/src/panels/diff/DiffSidePanel.tsx index 633094738e9e..77bcf54af89e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/panels/diff/DiffSidePanel.tsx @@ -1,4 +1,3 @@ -import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs"; import { useParams } from "@tanstack/react-router"; @@ -7,7 +6,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import type { ScopedThreadRef, RunId } from "@t3tools/contracts"; +import type { RunId } from "@t3tools/contracts"; import { ArrowRightIcon, CheckIcon, @@ -21,45 +20,31 @@ import { import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { useCodeViewFileReveal } from "~/components/diffs/useCodeViewFileReveal"; import { useFilesystemReadAccess } from "~/state/filesystem"; -import { useOpenInPreferredEditor } from "../editorPreferences"; -import { useFileContextMenuHandler } from "../fileContextMenu"; -import { type DraftId } from "../composerDraftStore"; -import { openDiffFilePrimaryAction } from "../diffFileActions"; -import { useCheckpointDiff } from "~/lib/checkpointDiffState"; -import { cn } from "~/lib/utils"; -import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; -import { useLocalStorage } from "../hooks/useLocalStorage"; -import { useTheme } from "../hooks/useTheme"; +import { DiffFilePathCopyButton } from "~/components/DiffFilePathCopyButton"; +import { DiffStatLabel } from "~/components/chat/DiffStatLabel"; import { - buildFileDiffContentVersion, - buildFileDiffIdentityKey, - getDiffCollapseIconClassName, - getDiffLineStat, - getRenderablePatch, - resolveDiffThemeName, - resolveFileDiffPath, -} from "../lib/diffRendering"; -import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; -import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; -import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; -import { useProject, useThreadProjection, useThreadShell } from "../state/entities"; -import { resolveThreadRouteRef } from "../threadRoutes"; -import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; -import { formatShortTimestamp } from "../timestampFormat"; -import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; -import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { DiffStatLabel } from "./chat/DiffStatLabel"; -import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; -import { DiffFileTree } from "./diffs/DiffFileTree"; -import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; -import { Button } from "./ui/button"; + AnnotatableCodeView, + type AnnotatableCodeViewHandle, +} from "~/components/diffs/AnnotatableCodeView"; +import { DiffFileTree } from "~/components/diffs/DiffFileTree"; +import { diffFileTreeEntries } from "~/components/diffs/diffFileTree.logic"; +import { Button } from "~/components/ui/button"; import { MorphIcon } from "~/components/MorphIcon"; -import { ToggleGroup, Toggle } from "./ui/toggle-group"; -import { Switch } from "./ui/switch"; +import { ToggleGroup, Toggle } from "~/components/ui/toggle-group"; +import { Switch } from "~/components/ui/switch"; import { Combobox, ComboboxEmpty, @@ -68,7 +53,7 @@ import { ComboboxList, ComboboxPopup, ComboboxTrigger, -} from "./ui/combobox"; +} from "~/components/ui/combobox"; import { DropdownMenu, DropdownMenuContent, @@ -78,19 +63,48 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, -} from "./ui/menu"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { useEnvironmentQuery } from "../state/query"; -import { useAtomCommand } from "../state/use-atom-command"; -import { serverEnvironment } from "../state/server"; -import { reviewEnvironment } from "../state/review"; -import { vcsEnvironment } from "../state/vcs"; -import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; -import { createGitDiffFileContentsLoader } from "../lib/diffFileContents"; +} from "~/components/ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { useReviewFilePatches } from "~/components/diffs/useReviewFilePatches"; +import { DiffFileLoadingBoundary } from "~/components/diffs/DiffFileLoadingBoundary"; +import { DiffFileStatus } from "~/components/diffs/DiffFileStatus"; +import { DiffPanelLoadingState } from "~/components/diffs/DiffLoadingState"; -import { useReviewFilePatches } from "./diffs/useReviewFilePatches"; -import { DiffFileLoadingBoundary } from "./diffs/DiffFileLoadingBoundary"; -import { DiffFileStatus } from "./diffs/DiffFileStatus"; +import { usePanelHost } from "../panelHost"; + +import { useOpenInPreferredEditor } from "~/editorPreferences"; +import { useFileContextMenuHandler } from "~/fileContextMenu"; +import { openDiffFilePrimaryAction } from "~/diffFileActions"; +import { useCheckpointDiff } from "~/lib/checkpointDiffState"; +import { cn } from "~/lib/utils"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "~/diffPanelStore"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useTheme } from "~/hooks/useTheme"; +import { + buildFileDiffContentVersion, + buildFileDiffIdentityKey, + getDiffCollapseIconClassName, + getDiffLineStat, + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, +} from "~/lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; +import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "~/lib/diffCollapse"; +import { useTurnDiffSummaries } from "~/hooks/useTurnDiffSummaries"; +import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; +import { useProject, useThreadProjection, useThreadShell } from "~/state/entities"; +import { resolveThreadRouteRef } from "~/threadRoutes"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { formatShortTimestamp } from "~/timestampFormat"; + +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { serverEnvironment } from "~/state/server"; +import { reviewEnvironment } from "~/state/review"; +import { vcsEnvironment } from "~/state/vcs"; +import { buildBaseRefChoices, filterBaseRefChoices } from "~/lib/baseRefChoices"; +import { createGitDiffFileContentsLoader } from "~/lib/diffFileContents"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -187,17 +201,23 @@ function DiffFileHeaderSuffix({ ); } -interface DiffPanelProps { - mode?: DiffPanelMode; - composerDraftTarget: ScopedThreadRef | DraftId; - workspaceMutationId: string | null; +// RightPanelTabs owns placement and desktop chrome; Diff keeps its embedded toolbar. +function DiffSidePanelFrame({ header, children }: { header: ReactNode; children: ReactNode }) { + return ( +
+
+ {header} +
+ {children} +
+ ); } -export default function DiffPanel({ - mode = "inline", - composerDraftTarget, - workspaceMutationId, -}: DiffPanelProps) { +export default function DiffSidePanel() { + const { composerDraftTarget, workspaceMutationId } = usePanelHost(); const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const diffLayout = settings.diffLayout; @@ -1061,7 +1081,7 @@ export default function DiffPanel({ ); return ( - + {!activeThread ? (
Select a thread to inspect turn diffs. @@ -1269,6 +1289,6 @@ export default function DiffPanel({
)} -
+ ); } diff --git a/apps/web/src/panels/files/FilesSidePanel.test.tsx b/apps/web/src/panels/files/FilesSidePanel.test.tsx new file mode 100644 index 000000000000..ae85a6e66605 --- /dev/null +++ b/apps/web/src/panels/files/FilesSidePanel.test.tsx @@ -0,0 +1,612 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { + act, + cloneElement, + Suspense, + use, + useLayoutEffect, + type ReactElement, + type ReactNode, +} from "react"; +import { create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { ChatComposerHandle } from "~/components/chat/ChatComposer"; +import { toastManager } from "~/components/ui/toast"; +import { ComposerHandleContext, type ComposerHandleRef } from "~/composerHandleContext"; +import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +const { siblingsByEnvironment, late, Wrapper, MenuContext, RadioContext } = await vi.hoisted( + async () => { + const { createContext: createHoistedContext } = await import("react"); + // Settles when a test says so, like a native menu or a server round trip. + const deferred = () => { + let resolve!: (value: A) => void; + const promise = new Promise
((settle) => (resolve = settle)); + return { promise, resolve }; + }; + const late = { + deferred, + // The tree's right-click handler, as handed to the Pierre tree. + openTreeMenu: null as null | ((item: unknown, context: unknown) => void), + // The tree's selection handler from its first render; the real tree keeps that one. + selectTreeRows: null as null | ((paths: ReadonlyArray) => void), + menuChoice: deferred(), + assetUrl: deferred(), + session: deferred(), + // Saved browser settings; already loaded unless a test holds them. + browserDefaults: null as null | Promise, + previewRequests: 0, + }; + return { + late, + // Files in the `src` folder, per environment. + siblingsByEnvironment: new Map>(), + Wrapper: ({ children }: { children?: ReactNode }) => children, + MenuContext: createHoistedContext<(open: boolean) => void>(() => undefined), + RadioContext: createHoistedContext<(value: string) => void>(() => undefined), + }; + }, +); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); +vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); +// This client holds every scope, so files can be read, written and previewed. +vi.mock("~/state/filesystem", () => ({ + useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), +})); +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +// The desktop app hosts these browsers itself, so no environment's server browser is consulted. +vi.mock("~/browser/previewRuntime", async (importOriginal) => ({ + ...(await importOriginal()), + previewRuntimeFor: () => undefined, + isPreviewAvailableFor: () => true, + usePreviewAvailable: () => true, +})); +vi.mock("~/state/environments", () => ({ + useEnvironmentHttpBaseUrl: () => "http://localhost:3773/", + usePrimaryEnvironmentId: () => null, +})); +vi.mock("~/remoteOpen", () => ({ useRemoteOpenState: () => ({ mode: "local-exec" }) })); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), + useUpdateClientSettings: () => vi.fn(), +})); +vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) })); +// The panel's only command and query runner: open a preview session, create an asset URL. +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: () => () => { + late.previewRequests += 1; + return late.session.promise; + }, +})); +vi.mock("~/state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => () => late.assetUrl.promise, +})); +vi.mock("~/browser/browserDefaults", async (importOriginal) => ({ + ...(await importOriginal()), + resolveBrowserDefaults: () => + late.browserDefaults ?? Promise.resolve({ viewport: { _tag: "fill" }, profileId: "default" }), +})); +vi.mock("~/components/files/projectFilesQueryState", async (importOriginal) => ({ + ...(await importOriginal()), + useProjectFileQuery: () => ({ + data: null, + error: null, + isPending: true, + isNotFile: false, + refresh: vi.fn(), + }), + useProjectEntriesQuery: (environmentId: string, _cwd: string, directoryPath?: string) => ({ + data: + directoryPath === "src" + ? { + entries: (siblingsByEnvironment.get(environmentId) ?? []).map((path) => ({ + path, + kind: "file", + parentPath: "src", + })), + truncated: false, + } + : null, + error: null, + isPending: false, + refresh: vi.fn(), + }), +})); +vi.mock("~/components/DiffWorkerPoolProvider", () => ({ DiffWorkerPoolProvider: Wrapper })); +// The Pierre tree is a web component; this stand-in keeps the real panel's right-click handler. +vi.mock("@pierre/trees/react", () => ({ + FileTree: () => null, + useFileTree: (options: { + composition: { contextMenu: { onOpen: (item: unknown, context: unknown) => void } }; + onSelectionChange: (paths: ReadonlyArray) => void; + }) => { + late.openTreeMenu = options.composition.contextMenu.onOpen; + late.selectTreeRows ??= options.onSelectionChange; + return { + model: { + isSearchOpen: () => false, + getItem: () => null, + getSelectedPaths: () => [], + subscribe: () => () => undefined, + setGitStatus: () => undefined, + resetPaths: () => undefined, + batch: () => undefined, + closeSearch: () => undefined, + scrollToPath: () => undefined, + }, + }; + }, + useFileTreeSearch: () => ({ value: "", close: () => undefined, setValue: () => undefined }), + useFileTreeSelector: () => false, +})); +vi.mock("~/components/files/useDirectoryEntries", () => ({ + useDirectoryEntries: () => ({ + entries: [ + { path: "src", kind: "directory" }, + { path: "README.md", kind: "file" }, + ], + load: () => undefined, + refresh: () => undefined, + isPending: false, + ready: true, + error: null, + }), +})); +vi.mock("~/state/queries", () => ({ + useProjectPathSearch: () => ({ entries: [], isPending: false, refresh: () => undefined }), +})); +vi.mock("~/fileContextMenu", () => ({ + useFileContextMenu: () => ({ buildItems: () => [], activate: vi.fn() }), +})); +vi.mock("~/localApi", () => ({ + readLocalApi: () => ({ contextMenu: { show: () => late.menuChoice.promise } }), +})); +vi.mock("~/components/chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: Wrapper, + TooltipTrigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => + render ? cloneElement(render, {}, children) : children, + TooltipPopup: () => null, +})); +// Menus render through a portal; these stand-ins keep their open and pick semantics. +vi.mock("~/components/ui/menu", () => ({ + Menu: ({ + children, + onOpenChange, + }: { + children?: ReactNode; + onOpenChange: (open: boolean) => void; + }) => {children}, + MenuTrigger: function MenuTrigger({ render }: { render: ReactElement }) { + const onOpenChange = use(MenuContext); + return cloneElement(render as ReactElement<{ onClick: () => void }>, { + onClick: () => onOpenChange(true), + }); + }, + MenuPopup: Wrapper, + MenuGroup: Wrapper, + MenuItem: Wrapper, + MenuSeparator: () => null, + MenuRadioGroup: ({ + children, + onValueChange, + }: { + children?: ReactNode; + onValueChange: (value: string) => void; + }) => {children}, + MenuRadioItem: function MenuRadioItem({ + children, + value, + }: { + children?: ReactNode; + value: string; + }) { + const onValueChange = use(RadioContext); + return ( + + ); + }, +})); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +// The same thread id on two environments is two threads. +const refOn = (environmentId: string, thread = "thread-a"): ScopedThreadRef => ({ + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(thread), +}); +let renderer: ReactTestRenderer | undefined; + +// The chat layout owns one composer ref; navigation swaps the composer behind it. +const composerRef: ComposerHandleRef = { current: null }; +const composers = new Map< + string, + ReturnType> +>(); +function composerOf(threadRef: ScopedThreadRef) { + const key = scopedThreadKey(threadRef); + if (!composers.has(key)) + composers.set( + key, + vi.fn(() => true), + ); + return composers.get(key)!; +} + +// Runs `run` inside the commit, after the panel's layout effects and before any passive effect. +function DuringCommit({ run }: { run: (() => void) | undefined }) { + useLayoutEffect(() => run?.(), [run]); + return null; +} + +async function renderFileFor( + threadRef: ScopedThreadRef, + relativePath: string | null = "src/a.ts", + composerDraftTarget: PanelHost["composerDraftTarget"] = threadRef, + duringCommit?: () => void, +) { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + const element = ( + + + + undefined} + selectedFilePending={false} + /> + + + + + ); + await act(async () => { + composerRef.current = { + insertTextAtEnd: composerOf(threadRef), + } as unknown as ChatComposerHandle; + if (renderer) renderer.update(element); + else renderer = create(element); + }); +} + +async function press(node: ReactTestInstance) { + await act(async () => node.props.onClick()); +} + +async function openSrcFolder() { + const browse = renderer!.root.findAll( + (node) => node.type === "button" && node.props["aria-label"] === "Browse src", + ); + expect(browse).toHaveLength(1); + await press(browse[0]!); +} + +const siblingValues = () => + renderer!.root + .findAll((node) => node.type === "button" && node.props["data-value"] !== undefined) + .map((node) => node.props["data-value"] as string); + +const surfacesOf = (threadRef: ScopedThreadRef) => + useRightPanelStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.surfaces; + +// Right-clicks a tree row and returns a way to pick from the native menu later. +function rightClickTreeRow() { + late.menuChoice = late.deferred(); + const closed = late.deferred(); + late.openTreeMenu!( + { path: "src/a.ts" }, + { + anchorElement: { getBoundingClientRect: () => ({ left: 0, bottom: 0 }) }, + close: closed.resolve, + }, + ); + return (choice: string) => + act(async () => { + late.menuChoice.resolve(choice); + await closed.promise; + }); +} + +const insertedAnywhere = () => [...composers.values()].some((insert) => insert.mock.calls.length); + +const browsersOf = (threadRef: ScopedThreadRef) => + (surfacesOf(threadRef) ?? []).filter((surface) => surface.kind === "preview"); + +// Presses "Open file in preview browser" with the asset URL and preview session still pending. +async function openInBrowser() { + late.assetUrl = late.deferred(); + late.session = late.deferred(); + await press( + renderer!.root.find( + (node) => + node.type === "button" && node.props["aria-label"] === "Open file in preview browser", + ), + ); +} + +const settleAsset = () => + act(async () => { + late.assetUrl.resolve(AsyncResult.success({ relativeUrl: "/a/index.html", expiresAt: 0 })); + }); +const settleSession = () => + act(async () => { + late.session.resolve( + AsyncResult.success({ + threadId: ThreadId.make("thread-a"), + tabId: "tab-1", + navStatus: { _tag: "Loading", url: "http://localhost:3773/a/index.html", title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-10-04T00:00:00.000Z", + }), + ); + }); + +const failAsset = () => + act(async () => { + late.assetUrl.resolve(AsyncResult.failure(Cause.fail(new Error("asset unavailable")))); + }); +const failSession = () => + act(async () => { + late.session.resolve(AsyncResult.failure(Cause.fail(new Error("preview unavailable")))); + }); +const browserErrorToasts = () => + vi + .mocked(toastManager.add) + .mock.calls.filter(([toast]) => toast.title === "Unable to open file in browser"); + +// Transform the lazy body once up front, so mounting it settles inside one act(). +beforeAll(() => import("./FilesSidePanel"), 30_000); +beforeEach(() => { + late.selectTreeRows = null; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const storage = new Map(); + vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + vi.stubGlobal("window", { + desktopBridge: { preview: {} }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + }); + siblingsByEnvironment.clear(); + composers.clear(); + late.previewRequests = 0; + late.browserDefaults = null; + vi.spyOn(toastManager, "add"); + resetPreviewStateForTests(); + siblingsByEnvironment.set("environment-a", ["src/a.ts", "src/b.ts"]); + siblingsByEnvironment.set("environment-b", ["src/a.ts", "src/c.ts"]); + useRightPanelStore.setState({ + byThreadKey: {}, + threadPanelVisibilityByThreadKey: {}, + userActionRevisionByThreadKey: {}, + }); +}); +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("files side panel", () => { + it("opens a sibling file as a tab in the host's own thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef); + await openSrcFolder(); + expect(siblingValues()).toEqual(["src/a.ts", "src/b.ts"]); + + await press( + renderer!.root.find( + (node) => node.type === "button" && node.props["data-value"] === "src/b.ts", + ), + ); + expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "src/b.ts" }]); + expect(surfacesOf(refOn("environment-b"))).toBeUndefined(); + }); + + it("follows the host to another environment's thread with the same id", async () => { + await renderFileFor(refOn("environment-a")); + const threadRef = refOn("environment-b"); + await renderFileFor(threadRef); + await openSrcFolder(); + expect(siblingValues()).toEqual(["src/a.ts", "src/c.ts"]); + + await press( + renderer!.root.find( + (node) => node.type === "button" && node.props["data-value"] === "src/c.ts", + ), + ); + expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "src/c.ts" }]); + expect(surfacesOf(refOn("environment-a"))).toBeUndefined(); + }); + + it("opens a tree click in the thread showing now, not the one the tree first showed", async () => { + // Threads in the same project share one tree, so it outlives the switch. + await renderFileFor(refOn("environment-a")); + const threadRef = refOn("environment-a", "thread-b"); + await renderFileFor(threadRef); + + await act(async () => late.selectTreeRows!(["README.md"])); + expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "README.md" }]); + expect(surfacesOf(refOn("environment-a"))).toBeUndefined(); + }); + + it("opens a tree click that lands before the switch's passive effects in the new thread", async () => { + await renderFileFor(refOn("environment-a")); + const threadRef = refOn("environment-a", "thread-b"); + await renderFileFor(threadRef, "src/a.ts", threadRef, () => + late.selectTreeRows!(["README.md"]), + ); + + expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "README.md" }]); + expect(surfacesOf(refOn("environment-a"))).toBeUndefined(); + }); +}); + +describe("files actions that settle late", () => { + it("adds a tree entry to the chat of the thread it was picked in", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, null); + const pick = rightClickTreeRow(); + await pick("add-to-chat"); + expect(composerOf(threadRef)).toHaveBeenCalledExactlyOnceWith("[a.ts](src/a.ts) ", { + ensureLeadingBoundary: true, + }); + }); + + it("drops a late Add to chat after moving to the same thread id in another environment", async () => { + await renderFileFor(refOn("environment-a"), null); + const pick = rightClickTreeRow(); + await renderFileFor(refOn("environment-b"), null); + await pick("add-to-chat"); + expect(insertedAnywhere()).toBe(false); + }); + + it("drops a late Add to chat after moving to another thread in the project", async () => { + await renderFileFor(refOn("environment-a"), null); + const pick = rightClickTreeRow(); + await renderFileFor(refOn("environment-a", "thread-other"), null); + await pick("add-to-chat"); + expect(insertedAnywhere()).toBe(false); + + // The new thread's own menu still reaches its composer. + await rightClickTreeRow()("add-to-chat"); + expect(composerOf(refOn("environment-a", "thread-other"))).toHaveBeenCalledOnce(); + }); + + it("keeps a late Add to chat dropped after leaving and returning to the thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, null); + const pick = rightClickTreeRow(); + await renderFileFor(refOn("environment-a", "thread-other"), null); + await renderFileFor(threadRef, null); + await pick("add-to-chat"); + expect(insertedAnywhere()).toBe(false); + }); + + it("opens a Browser tab in the thread that is still showing", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await settleAsset(); + await settleSession(); + expect(browsersOf(threadRef).map((surface) => surface.id)).toEqual(["browser:tab-1"]); + }); + + it("still opens a browser after the composer switches drafts in the same thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + // Editing a queued message moves the composer to a draft; the thread stays. + await renderFileFor(threadRef, "index.html", "draft-1" as PanelHost["composerDraftTarget"]); + await settleAsset(); + await settleSession(); + expect(browsersOf(threadRef).map((surface) => surface.id)).toEqual(["browser:tab-1"]); + }); + + it("never asks for a browser when the thread was left while the file was prepared", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await renderFileFor(refOn("environment-b"), "index.html"); + await settleAsset(); + expect(late.previewRequests).toBe(0); + expect(browsersOf(threadRef)).toEqual([]); + expect(browsersOf(refOn("environment-b"))).toEqual([]); + }); + + it("keeps a browser the server opened after the thread was left in that thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await settleAsset(); + expect(late.previewRequests).toBe(1); + await renderFileFor(refOn("environment-a", "thread-other"), "index.html"); + await settleSession(); + expect(readThreadPreviewState(threadRef).snapshot?.tabId).toBe("tab-1"); + expect(browsersOf(threadRef).map((surface) => surface.id)).toEqual(["browser:tab-1"]); + expect(browsersOf(refOn("environment-a", "thread-other"))).toEqual([]); + }); + + it("keeps a late open dropped after leaving and returning to the thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await renderFileFor(refOn("environment-a", "thread-other"), "index.html"); + await renderFileFor(threadRef, "index.html"); + await settleAsset(); + expect(late.previewRequests).toBe(0); + expect(browsersOf(threadRef)).toEqual([]); + }); + + it("reports a browser failure in the thread that is still showing", async () => { + await renderFileFor(refOn("environment-a"), "index.html"); + await openInBrowser(); + await failAsset(); + expect(browserErrorToasts()).toHaveLength(1); + }); + + it("stays silent when the file fails to prepare after the thread was left", async () => { + await renderFileFor(refOn("environment-a"), "index.html"); + await openInBrowser(); + await renderFileFor(refOn("environment-b"), "index.html"); + await failAsset(); + expect(browserErrorToasts()).toEqual([]); + }); + + it("stays silent when browser settings fail to load after the panel closed", async () => { + await renderFileFor(refOn("environment-a"), "index.html"); + let rejectSettings!: (error: Error) => void; + late.browserDefaults = new Promise((_, reject) => (rejectSettings = reject)); + await openInBrowser(); + await settleAsset(); + act(() => renderer!.unmount()); + renderer = undefined; + await act(async () => rejectSettings(new Error("settings unreadable"))); + expect(late.previewRequests).toBe(0); + expect(browserErrorToasts()).toEqual([]); + }); + + it("stays silent when a browser fails to open after leaving and returning", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await settleAsset(); + await renderFileFor(refOn("environment-a", "thread-other"), "index.html"); + await renderFileFor(threadRef, "index.html"); + await failSession(); + expect(browserErrorToasts()).toEqual([]); + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/panels/files/FilesSidePanel.tsx similarity index 94% rename from apps/web/src/components/files/FilePreviewPanel.tsx rename to apps/web/src/panels/files/FilesSidePanel.tsx index efd7a7fbc90f..c3b97c4c5909 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/panels/files/FilesSidePanel.tsx @@ -3,9 +3,9 @@ import { AuthPreviewOperateScope, type EditorId, type EnvironmentId, - type ResolvedKeybindingsConfig, type ScopedThreadRef, } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; import { AuthFilesystemWriteScope } from "@t3tools/contracts"; import { @@ -30,12 +30,13 @@ import { } from "@pierre/diffs/edit"; import type { WorkerPoolManager } from "@pierre/diffs/worker"; import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react"; -import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; +import { DiffWorkerPoolProvider } from "~/components/DiffWorkerPoolProvider"; import { useFilesystemReadAccess } from "~/state/filesystem"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { FolderTree, Globe2, WrapTextIcon } from "lucide-react"; import { Code2, Eye, Table2 } from "lucide"; @@ -63,21 +64,23 @@ import { ScrollArea } from "~/components/ui/scroll-area"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { buildFileReviewComment } from "~/reviewCommentContext"; +import { useRightPanelStore } from "~/rightPanelStore"; import { assetEnvironment } from "~/state/assets"; import { usePreviewAvailable } from "~/browser/previewRuntime"; import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; +import { primaryServerKeybindingsAtom } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; -import { AttachmentFilePreview } from "./AttachmentFilePreview"; -import { AudioPreview } from "./AudioPreview"; -import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame"; -import { DelimitedTablePreview } from "./DelimitedTablePreview"; -import FileBrowserPanel from "./FileBrowserPanel"; -import { FileBreadcrumbs } from "./FileBreadcrumbs"; -import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { AttachmentFilePreview } from "~/components/files/AttachmentFilePreview"; +import { AudioPreview } from "~/components/files/AudioPreview"; +import { BrowserDocumentFrame, isPdfPreviewFile } from "~/components/files/BrowserDocumentFrame"; +import { DelimitedTablePreview } from "~/components/files/DelimitedTablePreview"; +import FileBrowserPanel from "~/components/files/FileBrowserPanel"; +import { FileBreadcrumbs } from "~/components/files/FileBreadcrumbs"; +import { FileMarkdownPreview } from "~/components/files/FileMarkdownPreview"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, @@ -86,8 +89,8 @@ import { nextFileCommentId, normalizeFileCommentRange, remapFileCommentAnnotations, -} from "./fileCommentAnnotations"; -import { installFileEditorDismissal } from "./fileEditorDismissal"; +} from "~/components/files/fileCommentAnnotations"; +import { installFileEditorDismissal } from "~/components/files/fileEditorDismissal"; import { FILE_LINK_REVEAL_ATTRIBUTE, FILE_LINK_REVEAL_UNSAFE_CSS, @@ -95,11 +98,11 @@ import { FileSurfaceAction, FileSurfaceFailure, FileSurfaceLoading, -} from "./fileSurfaceChrome"; -import SourceFilePreview from "./ReadOnlySourcePreview"; -import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; -import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; -import { projectFileCacheKey } from "./fileContentRevision"; +} from "~/components/files/fileSurfaceChrome"; +import SourceFilePreview from "~/components/files/ReadOnlySourcePreview"; +import { resolveCenteredFileLineScrollTop } from "~/components/files/fileLineReveal"; +import { DiffCommentAnnotation } from "~/components/diffs/DiffCommentAnnotation"; +import { projectFileCacheKey } from "~/components/files/fileContentRevision"; import { filePreviewReadErrorMessage, isMarkdownPreviewFile, @@ -107,31 +110,28 @@ import { setMarkdownTaskChecked, shouldShowFileExplorer, workspaceAssetResource, -} from "./filePreviewMode"; -import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; +} from "~/components/files/filePreviewMode"; +import { useFileSaveCoordinator } from "~/components/files/useFileSaveCoordinator"; import { getOptimisticProjectFileQueryData, getProjectFileContents, setProjectFileQueryData, useProjectFileQuery, -} from "./projectFilesQueryState"; +} from "~/components/files/projectFilesQueryState"; -interface FilePreviewPanelProps { - environmentId: EnvironmentId; +import { usePanelHost } from "../panelHost"; +import { useScopedComposerInsert, useScopeLifetime } from "./fileScope"; + +interface FilesSidePanelProps { cwd: string; projectName: string; relativePath: string | null; attachment?: ChatFileAttachment; - threadRef: ScopedThreadRef; - composerDraftTarget: ScopedThreadRef | DraftId; - keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; revealLine: number | null; revealRequestId: number; - onOpenFile: (relativePath: string) => void; onPendingChange: (relativePath: string, pending: boolean) => void; selectedFilePending: boolean; - workspaceMutationId: string | null; } const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; @@ -1025,23 +1025,38 @@ function initialExplorerOpen(): boolean { } } -export default function FilePreviewPanel({ - environmentId, +// Renders both the Files explorer surface and single file surfaces. +export default function FilesSidePanel({ cwd, projectName, relativePath: requestedPath, attachment, - threadRef, - composerDraftTarget, - keybindings, availableEditors, revealLine, revealRequestId, - onOpenFile, onPendingChange, selectedFilePending, - workspaceMutationId, -}: FilePreviewPanelProps) { +}: FilesSidePanelProps) { + const { threadRef, composerDraftTarget, workspaceMutationId } = usePanelHost(); + const environmentId = threadRef.environmentId; + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const onOpenFile = useCallback( + (path: string) => useRightPanelStore.getState().openFile(threadRef, path), + [threadRef], + ); + // Menu actions settle late; they are dropped once the host moves to another + // thread or draft, never applied to the newer one. + const isScopeCurrent = useScopeLifetime( + `${scopedThreadKey(threadRef)}|${ + typeof composerDraftTarget === "string" + ? composerDraftTarget + : scopedThreadKey(composerDraftTarget) + }`, + ); + const addToChat = useScopedComposerInsert(isScopeCurrent); + // Opening a browser belongs to the thread, not the draft: entering or + // leaving queued-message editing keeps the thread on screen. + const isThreadVisitCurrent = useScopeLifetime(scopedThreadKey(threadRef)); const relativePath = attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; // A draft's composer target is its draft id; a thread the server knows is a ref. @@ -1203,13 +1218,19 @@ export default function FilePreviewPanel({ void (async () => { const result = await openFileInPreview({ threadRef, + isScopeCurrent: isThreadVisitCurrent, filePath: absolutePath, workspaceRoot: cwd, httpBaseUrl: environmentHttpBaseUrl, createAssetUrl, openPreview, }); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + // Errors are reported only to the visit that started the open. + if ( + result._tag === "Success" || + isAtomCommandInterrupted(result) || + !isThreadVisitCurrent() + ) { return; } const error = squashAtomCommandFailure(result); @@ -1228,6 +1249,7 @@ export default function FilePreviewPanel({ createAssetUrl, cwd, environmentHttpBaseUrl, + isThreadVisitCurrent, openPreview, threadRef, ]); @@ -1495,6 +1517,7 @@ export default function FilePreviewPanel({ selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} workspaceMutationId={workspaceMutationId} + addToChat={addToChat} {...(previewPath && !isMedia && !isPdf ? { onRefreshSelectedFile: file.refresh } : {})} diff --git a/apps/web/src/panels/files/fileScope.ts b/apps/web/src/panels/files/fileScope.ts new file mode 100644 index 000000000000..7ba1fbc93385 --- /dev/null +++ b/apps/web/src/panels/files/fileScope.ts @@ -0,0 +1,46 @@ +import { useCallback, useLayoutEffect, useRef, useState } from "react"; + +import type { AddToChatResult } from "~/components/files/FileBrowserPanel"; +import { useComposerHandleContext } from "~/composerHandleContext"; + +/** + * Tells async work started in one visit to a scope whether the panel is still + * on that visit. The returned check is stable for the visit, so work keeps the + * check from the visit it started in. It turns false once the panel moves to + * another scope (another thread, or the same thread id in another environment) + * or unmounts, and stays false if the panel later returns to the same scope. + */ +export function useScopeLifetime(scopeKey: string): () => boolean { + // A fresh visit object each time the scope changes, so A -> B -> A is a new visit. + const [visit, setVisit] = useState(() => ({ scopeKey })); + if (visit.scopeKey !== scopeKey) setVisit({ scopeKey }); + const liveRef = useRef(null); + // Set on commit, so a discarded render never retires live work. + useLayoutEffect(() => { + liveRef.current = visit; + return () => { + liveRef.current = null; + }; + }, [visit]); + return useCallback(() => liveRef.current === visit, [visit]); +} + +/** + * Inserts text at the end of the layout's chat composer while `isScopeCurrent` + * holds. The composer ref is shared by the whole chat layout, so an action that + * settles after navigation is dropped instead of reaching the newer thread. + */ +export function useScopedComposerInsert(isScopeCurrent: () => boolean) { + const composerRef = useComposerHandleContext(); + return useCallback( + (text: string): AddToChatResult => { + if (!isScopeCurrent()) return "dropped"; + const composer = composerRef?.current; + if (!composer) return "no-composer"; + return composer.insertTextAtEnd(text, { ensureLeadingBoundary: true }) + ? "inserted" + : "not-ready"; + }, + [composerRef, isScopeCurrent], + ); +} diff --git a/apps/web/src/panels/panelHost.test.ts b/apps/web/src/panels/panelHost.test.ts new file mode 100644 index 000000000000..5a2423868c5b --- /dev/null +++ b/apps/web/src/panels/panelHost.test.ts @@ -0,0 +1,77 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, type PreviewAnnotationPayload } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { threadBoundAnnotationSender, type ThreadAnnotationSender } from "./panelHost"; + +const THREAD_A = scopedThreadKey( + scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-a")), +); +const THREAD_B = scopedThreadKey( + scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-b")), +); +const THREAD_A_ELSEWHERE = scopedThreadKey( + scopeThreadRef(EnvironmentId.make("environment-2"), ThreadId.make("thread-a")), +); + +const annotation: PreviewAnnotationPayload = { + id: "annotation-1", + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-07-27T00:00:00.000Z", +}; + +describe("threadBoundAnnotationSender", () => { + it("drops a pick that settles after the chat view moved to another thread", async () => { + const sendA = vi.fn(); + const sendB = vi.fn(); + const senderRef: { current: ThreadAnnotationSender | null } = { + current: { threadKey: THREAD_A, send: sendA }, + }; + const hostSendForA = threadBoundAnnotationSender(() => senderRef.current, THREAD_A); + + let resolvePick!: (value: PreviewAnnotationPayload) => void; + const pick = new Promise((resolve) => { + resolvePick = resolve; + }); + const settled = pick.then((picked) => hostSendForA(picked, null)); + + // The shared ChatView renders thread B before A's pick is cancelled. + senderRef.current = { threadKey: THREAD_B, send: sendB }; + resolvePick(annotation); + await settled; + + expect(sendB).not.toHaveBeenCalled(); + expect(sendA).not.toHaveBeenCalled(); + }); + + it("treats the same thread id in another environment as a different thread", () => { + const sendElsewhere = vi.fn(); + const senderRef = { current: { threadKey: THREAD_A_ELSEWHERE, send: sendElsewhere } }; + + threadBoundAnnotationSender(() => senderRef.current, THREAD_A)(annotation, null); + + expect(sendElsewhere).not.toHaveBeenCalled(); + }); + + it("uses the newest sender after a same-thread rerender", () => { + const firstRender = vi.fn(); + const latestRender = vi.fn(); + const senderRef: { current: ThreadAnnotationSender | null } = { + current: { threadKey: THREAD_A, send: firstRender }, + }; + const hostSend = threadBoundAnnotationSender(() => senderRef.current, THREAD_A); + + senderRef.current = { threadKey: THREAD_A, send: latestRender }; + hostSend(annotation, null); + + expect(firstRender).not.toHaveBeenCalled(); + expect(latestRender).toHaveBeenCalledWith(annotation, null); + }); +}); diff --git a/apps/web/src/panels/panelHost.ts b/apps/web/src/panels/panelHost.ts new file mode 100644 index 000000000000..09805d1f3227 --- /dev/null +++ b/apps/web/src/panels/panelHost.ts @@ -0,0 +1,55 @@ +import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts"; +import { createContext, use } from "react"; + +import type { ComposerImageAttachment, DraftId } from "~/composerDraftStore"; + +/** + * What the chat view lends the panel it is rendering. A panel reads this + * instead of receiving the same values as props, so only panel-specific + * inputs stay on its props. + */ +export interface PanelHost { + /** Thread the panel belongs to; its environmentId scopes every server call. */ + readonly threadRef: ScopedThreadRef; + /** False while the right panel is collapsed but the panel stays mounted. */ + readonly visible: boolean; + /** Draft that comments and attached context land in. */ + readonly composerDraftTarget: ScopedThreadRef | DraftId; + /** Changes when a turn finishes mutating the workspace, so file views can refresh. */ + readonly workspaceMutationId: string | null; + /** Sends an annotation as its own message through the composer of the render that lent it. */ + readonly sendAnnotation: ( + annotation: PreviewAnnotationPayload, + image: ComposerImageAttachment | null, + ) => void; +} + +/** The newest annotation sender, tagged with the thread whose render created it. */ +export interface ThreadAnnotationSender { + readonly threadKey: string; + readonly send: PanelHost["sendAnnotation"]; +} + +/** + * A stable `sendAnnotation` for one thread. It forwards to the newest sender + * only while that sender belongs to the same thread, so a pick that settles + * after the chat view moved to another thread is never sent there. + */ +export function threadBoundAnnotationSender( + latestSender: () => ThreadAnnotationSender | null, + threadKey: string, +): PanelHost["sendAnnotation"] { + return (annotation, image) => { + const latest = latestSender(); + if (latest?.threadKey !== threadKey) return; + latest.send(annotation, image); + }; +} + +export const PanelHostContext = createContext(null); + +export function usePanelHost(): PanelHost { + const host = use(PanelHostContext); + if (!host) throw new Error("usePanelHost must be used inside a panel host"); + return host; +} diff --git a/apps/web/src/panels/panelRegistry.test.tsx b/apps/web/src/panels/panelRegistry.test.tsx new file mode 100644 index 000000000000..b2c81ab122a3 --- /dev/null +++ b/apps/web/src/panels/panelRegistry.test.tsx @@ -0,0 +1,144 @@ +import { act, Suspense, useEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createPanelRegistry } from "./panelRegistry"; + +const metadata = { + icon: () => null, + launcherKey: "X", + unavailableHint: "Unavailable.", + unavailableReason: "Unavailable here.", +} as const; + +// These exercise lazy evaluation and actual mount/disposal, without inspecting markup. +describe("panel registry", () => { + it("rejects duplicate ids without loading either panel", () => { + const load = vi.fn(); + const definition = { ...metadata, id: "diff", title: "Diff", load } as const; + expect(() => createPanelRegistry([definition, definition])).toThrow("Duplicate panel id: diff"); + expect(load).not.toHaveBeenCalled(); + }); + + it("does no work until opened and keeps one mount across lookups", async () => { + let finish!: (value: { default: typeof Panel }) => void; + const loaded = new Promise<{ default: typeof Panel }>((resolve) => { + finish = resolve; + }); + const mounted = vi.fn(); + const disposed = vi.fn(); + function Panel() { + useEffect(() => { + mounted(); + return disposed; + }, []); + return null; + } + const load = vi.fn(() => loaded); + const registry = createPanelRegistry([{ ...metadata, id: "diff", title: "Diff", load }]); + expect(load).not.toHaveBeenCalled(); + const Component = registry.get("diff").Component; + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create( + + + , + ); + }); + expect(load).toHaveBeenCalledTimes(1); + expect(mounted).not.toHaveBeenCalled(); + await act(async () => { + finish({ default: Panel }); + await loaded; + }); + expect(mounted).toHaveBeenCalledTimes(1); + // A parent re-render looks the panel up again; it must keep the same mount and state. + const Again = registry.get("diff").Component; + expect(Again).toBe(Component); + await act(async () => { + renderer.update( + + + , + ); + }); + expect(load).toHaveBeenCalledTimes(1); + expect(mounted).toHaveBeenCalledTimes(1); + expect(disposed).not.toHaveBeenCalled(); + await act(async () => { + renderer.unmount(); + }); + expect(disposed).toHaveBeenCalledTimes(1); + }); + + it("keeps heterogeneous panels independent and their lazy identities stable", async () => { + const events: string[] = []; + function Notes({ text }: { text: string }) { + useEffect(() => { + events.push(`mount notes:${text}`); + return () => { + events.push("dispose notes"); + }; + }, [text]); + return null; + } + function Counter({ count }: { count: number }) { + useEffect(() => { + events.push(`mount counter:${count}`); + }, [count]); + return null; + } + const loadNotes = vi.fn(async () => ({ default: Notes })); + const loadCounter = vi.fn(async () => ({ default: Counter })); + const registry = createPanelRegistry([ + { ...metadata, id: "notes", title: "Notes", load: loadNotes }, + { ...metadata, id: "counter", title: "Counter", load: loadCounter }, + ]); + const NotesPanel = registry.get("notes").Component; + // Compile-only: the project typecheck rejects these pairings. + const compileOnly = () => [ + // @ts-expect-error Missing required text. + , + // @ts-expect-error Counter's props on Notes. + , + // @ts-expect-error Unknown id. + registry.get("missing"), + ]; + expect(compileOnly).toBeTypeOf("function"); + expect(registry.get("notes").Component).toBe(NotesPanel); + expect(loadNotes).not.toHaveBeenCalled(); + expect(loadCounter).not.toHaveBeenCalled(); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create( + + + , + ); + }); + await act(async () => { + renderer.update( + + + , + ); + }); + expect(loadNotes).toHaveBeenCalledTimes(1); + expect(loadCounter).not.toHaveBeenCalled(); + expect(events).toEqual(["mount notes:a"]); + + const CounterPanel = registry.get("counter").Component; + await act(async () => { + renderer.update( + + + , + ); + }); + expect(loadCounter).toHaveBeenCalledTimes(1); + expect(events).toEqual(["mount notes:a", "dispose notes", "mount counter:2"]); + expect(registry.get("counter").Component).toBe(CounterPanel); + }); +}); diff --git a/apps/web/src/panels/panelRegistry.ts b/apps/web/src/panels/panelRegistry.ts new file mode 100644 index 000000000000..b3b925413a50 --- /dev/null +++ b/apps/web/src/panels/panelRegistry.ts @@ -0,0 +1,60 @@ +import { lazy, type ComponentType, type ReactNode } from "react"; + +/** Panel bodies are function components; their props are inferred per id. */ +type PanelBody = (props: never) => ReactNode; + +export interface PanelDefinition { + id: Id; + /** Launcher label and tab title fallback. */ + title: string; + icon: ComponentType<{ className?: string }>; + /** Letter that opens the panel from the empty launcher and the add menu. */ + launcherKey: string; + /** Whether this client can show the panel at all, such as desktop-only panels on web. */ + isSupported?: () => boolean; + /** One-line reason shown in the empty launcher while unavailable. */ + unavailableHint: string; + /** Full reason shown in the add menu tooltip while unavailable. */ + unavailableReason: string; + /** Shown while the body's code loads; null when the panel has nothing lighter to show. */ + fallback?: ReactNode; + load: () => Promise<{ default: Body }>; +} + +type AnyPanelDefinition = PanelDefinition; + +/** Everything a launcher or tab may read about a panel without loading it. */ +export type PanelMetadata = Omit; + +/** Props of the body a definition lazily loads, inferred from its `load` import. */ +export type PanelProps = Awaited< + ReturnType +>["default"] extends (props: infer Props) => ReactNode + ? Props + : never; + +// Distributes, so a widened id yields a union of bodies rather than a body accepting either props. +type RegisteredPanel = Definition extends AnyPanelDefinition + ? Definition & { Component: ComponentType> } + : never; + +// Registration only creates lazy component identities. Reads and workers belong to mounts. +export function createPanelRegistry( + definitions: readonly Definition[], +) { + const panels = new Map }>(); + for (const definition of definitions) { + if (panels.has(definition.id)) throw new Error(`Duplicate panel id: ${definition.id}`); + const load = definition.load as () => Promise<{ default: ComponentType }>; + panels.set(definition.id, { ...definition, Component: lazy(load) }); + } + return { + definitions, + // lazy() erases the per-id props; get() restores them from the definition's id. + get: (id: Id) => { + const panel = panels.get(id); + if (!panel) throw new Error(`Unknown panel id: ${id}`); + return panel as RegisteredPanel>; + }, + }; +} diff --git a/apps/web/src/panels/preview/PreviewSidePanel.test.tsx b/apps/web/src/panels/preview/PreviewSidePanel.test.tsx new file mode 100644 index 000000000000..f71ed8e97828 --- /dev/null +++ b/apps/web/src/panels/preview/PreviewSidePanel.test.tsx @@ -0,0 +1,275 @@ +import { + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_PREVIEW_APPEARANCE, + DEFAULT_PREVIEW_ZOOM_FACTOR, + EnvironmentId, + FILL_PREVIEW_VIEWPORT, + ThreadId, + type PreviewAnnotationPayload, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { act, useEffect, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// Drives the registered Preview through the real RegisteredSidePanel -> +// PreviewSidePanel -> PreviewView path. Only the desktop bridge, stores and +// leaf chrome are stubbed; the chrome row stub exposes the pick button. +const mocks = vi.hoisted(() => ({ + pickElement: vi.fn(), + cancelPickElement: vi.fn(async (_runtimeTabId: string) => undefined), + addPreviewAnnotation: vi.fn(), + togglePick: null as (() => void) | null, + pickActive: false, + mounts: 0, + disposals: 0, +})); + +const BROWSER_DEFAULTS = { + viewport: FILL_PREVIEW_VIEWPORT, + zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, + appearance: DEFAULT_PREVIEW_APPEARANCE, + autoShowFloatingPreview: true, + profiles: BUILT_IN_BROWSER_PROFILES, + profileId: DEFAULT_BROWSER_PROFILE_ID, +}; + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { pickElement: mocks.pickElement, cancelPickElement: mocks.cancelPickElement }, +})); +vi.mock("~/components/preview/usePreviewSession", () => ({ + // Lives exactly as long as the PreviewView instance, so it counts remounts. + usePreviewSession: () => { + useEffect(() => { + mocks.mounts += 1; + return () => { + mocks.disposals += 1; + }; + }, []); + }, +})); +vi.mock("~/components/preview/PreviewChromeRow", () => ({ + PreviewChromeRow: (props: { onPickElement?: () => void; pickActive?: boolean }) => { + mocks.togglePick = props.onPickElement ?? null; + mocks.pickActive = props.pickActive ?? false; + return null; + }, +})); +vi.mock("~/components/preview/PreviewPanelShell", () => ({ + PreviewPanelShell: (props: { children: ReactNode }) => props.children, +})); +vi.mock("~/components/preview/PreviewEmptyState", () => ({ PreviewEmptyState: () => null })); +vi.mock("~/components/preview/PreviewMoreMenu", () => ({ PreviewMoreMenu: () => null })); +vi.mock("~/components/preview/PreviewUnreachable", () => ({ PreviewUnreachable: () => null })); +vi.mock("~/components/preview/ZoomIndicator", () => ({ ZoomIndicator: () => null })); +vi.mock("~/components/preview/AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); +vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); +vi.mock("~/browser/browserSurfaceStore", () => ({ + useBrowserSurfaceStore: (select: (state: { byTabId: object }) => unknown) => + select({ byTabId: {} }), +})); +vi.mock("~/browser/browserDefaults", () => ({ + useBrowserDefaults: () => BROWSER_DEFAULTS, + getBrowserDefaults: () => BROWSER_DEFAULTS, + browserResponsiveViewportForToggle: () => FILL_PREVIEW_VIEWPORT, +})); +vi.mock("~/browser/browserRecording", () => ({ + findActiveBrowserRecordingRuntimeTabId: () => null, + isBrowserRecordingStartCancelledError: () => false, + startBrowserRecording: vi.fn(), + stopBrowserRecording: vi.fn(), + useActiveBrowserRecordingTabIds: () => new Set(), +})); +vi.mock("~/browserHistoryStore", () => ({ + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT: 50, + recordVisitForThread: vi.fn(), + removeUrlForThread: vi.fn(), + setTitleForThreadUrl: vi.fn(), + useThreadRecentHistory: () => [], +})); +vi.mock("~/composerDraftStore", () => ({ + useComposerDraftStore: (select: (store: object) => unknown) => + select({ addPreviewAnnotation: mocks.addPreviewAnnotation, addImage: vi.fn() }), +})); +vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn() })); +vi.mock("~/previewStateStore", () => ({ + isPreviewSupportedInRuntime: () => true, + rememberPreviewUrl: vi.fn(), + updatePreviewServerSnapshot: vi.fn(), + useThreadPreviewState: (threadRef: ScopedThreadRef) => ({ + activeTabId: "tab-1", + serverEpoch: null, + desktopByTabId: {}, + recentlySeenUrls: [], + sessions: { + "tab-1": { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { _tag: "Success", url: "http://localhost:3000/", title: "App" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-10-04T00:00:00.000Z", + }, + }, + }), +})); +vi.mock("~/previewMiniPlayerStore", () => ({ + browserMiniPlayerSource: (tabId: string) => ({ kind: "browser", tabId }), + selectThreadPreviewMiniPlayerTabId: () => null, + usePreviewMiniPlayerStore: Object.assign( + (select: (state: { byThreadKey: object }) => unknown) => select({ byThreadKey: {} }), + { getState: () => ({ open: vi.fn(), close: vi.fn() }) }, + ), +})); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ close: vi.fn() }) }, +})); +vi.mock("~/state/environments", () => ({ + useEnvironment: () => ({ label: "Local" }), + useEnvironmentHttpBaseUrl: () => "http://localhost:3773", + usePrimaryEnvironmentId: () => null, +})); +vi.mock("~/state/preview", () => ({ previewEnvironment: { open: {}, resize: {} } })); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +// This client holds every scope, so preview and annotation sends stay enabled. +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("~/components/ui/toast", () => ({ + stackedThreadToast: vi.fn(), + toastManager: { add: vi.fn() }, +})); + +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +function thread(id: string): ScopedThreadRef { + return { environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make(id) }; +} + +function hostFor(threadRef: ScopedThreadRef): PanelHost { + // A fresh closure per render, as ChatView lends it. + return { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: vi.fn(), + }; +} + +function panel(host: PanelHost) { + return ( + + + + ); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +const annotation: PreviewAnnotationPayload = { + id: "annotation-1", + pageUrl: "http://localhost:3000/", + pageTitle: "App", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-10-04T00:00:00.000Z", +}; + +describe("registered Preview side panel", () => { + let renderer: ReactTestRenderer | null = null; + + // The registry loads the body lazily; warm the module so a render settles in one act. + beforeAll(() => import("./PreviewSidePanel")); + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mocks.pickElement.mockReset(); + mocks.cancelPickElement.mockClear(); + mocks.addPreviewAnnotation.mockClear(); + mocks.togglePick = null; + mocks.pickActive = false; + mocks.mounts = 0; + mocks.disposals = 0; + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); + }); + + async function render(host: PanelHost) { + await act(async () => { + if (renderer) renderer.update(panel(host)); + else renderer = create(panel(host)); + }); + } + + async function startPick() { + const pick = deferred<{ annotation: PreviewAnnotationPayload; submission: "send" } | null>(); + mocks.pickElement.mockReturnValueOnce(pick.promise); + await act(async () => mocks.togglePick?.()); + return pick; + } + + it("keeps an in-flight pick across a same-thread host re-render", async () => { + const threadA = thread("thread-a"); + const first = hostFor(threadA); + await render(first); + const pick = await startPick(); + expect(mocks.pickActive).toBe(true); + + const second = hostFor({ ...threadA }); + await render(second); + expect(mocks).toMatchObject({ mounts: 1, disposals: 0, pickActive: true }); + expect(mocks.cancelPickElement).not.toHaveBeenCalled(); + + await act(async () => pick.resolve({ annotation, submission: "send" })); + expect(mocks.pickActive).toBe(false); + // The pick reports to the render that started it, as before the registry. + expect(first.sendAnnotation).toHaveBeenCalledWith(annotation, null); + expect(second.sendAnnotation).not.toHaveBeenCalled(); + expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(threadA, annotation); + }); + + it("stays mounted across a thread switch and drops a pick that settles after it", async () => { + const threadA = thread("thread-a"); + const threadB = thread("thread-b"); + const hostA = hostFor(threadA); + await render(hostA); + const pick = await startPick(); + expect(mocks.pickElement).toHaveBeenCalledWith(previewRuntimeTabId(threadA, null, "tab-1")); + + const hostB = hostFor(threadB); + await render(hostB); + expect(mocks).toMatchObject({ mounts: 1, disposals: 0, pickActive: false }); + // The old thread's picker is told to stop; the panel itself is not torn down. + expect(mocks.cancelPickElement).toHaveBeenCalledWith( + previewRuntimeTabId(threadA, null, "tab-1"), + ); + + // The switch cancelled the pick, so its late result reaches neither thread. + await act(async () => pick.resolve({ annotation, submission: "send" })); + expect(hostA.sendAnnotation).not.toHaveBeenCalled(); + expect(hostB.sendAnnotation).not.toHaveBeenCalled(); + expect(mocks.addPreviewAnnotation).not.toHaveBeenCalled(); + expect(mocks.disposals).toBe(0); + }); +}); diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/panels/preview/PreviewSidePanel.tsx similarity index 58% rename from apps/web/src/components/preview/PreviewPanel.tsx rename to apps/web/src/panels/preview/PreviewSidePanel.tsx index 81501ae7d727..8a2cdc571b86 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/panels/preview/PreviewSidePanel.tsx @@ -1,43 +1,28 @@ "use client"; -import { - AuthPreviewOperateScope, - type PreviewAnnotationPayload, - type ScopedThreadRef, -} from "@t3tools/contracts"; +import { AuthPreviewOperateScope } from "@t3tools/contracts"; -import type { ComposerImageAttachment } from "~/composerDraftStore"; +import { PreviewPanelShell } from "~/components/preview/PreviewPanelShell"; +import { PreviewView } from "~/components/preview/PreviewView"; import { usePreviewAvailable } from "~/browser/previewRuntime"; import { useEnvironmentScope } from "~/state/session"; -import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; -import { PreviewView } from "./PreviewView"; +import { usePanelHost } from "../panelHost"; -interface Props { - mode: PreviewPanelMode; - threadRef: ScopedThreadRef; +interface PreviewSidePanelProps { tabId?: string | null; configuredUrls?: ReadonlyArray | undefined; - visible: boolean; - onSendAnnotation?: ( - annotation: PreviewAnnotationPayload, - image: ComposerImageAttachment | null, - ) => void; } -export function PreviewPanel({ - mode, - threadRef, - tabId, - configuredUrls, - visible, - onSendAnnotation, -}: Props) { +// RightPanelTabs owns placement, so the side panel is always embedded. +export default function PreviewSidePanel({ tabId, configuredUrls }: PreviewSidePanelProps) { + const { threadRef, visible, sendAnnotation } = usePanelHost(); + // The desktop app hosts browsers itself; other clients need an environment that runs them. const available = usePreviewAvailable(threadRef.environmentId); const canOperatePreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); if (!canOperatePreview || !available) { return ( - +

{canOperatePreview @@ -50,13 +35,13 @@ export function PreviewPanel({ } return ( - + ); diff --git a/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx b/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx new file mode 100644 index 000000000000..2331e8aec07f --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx @@ -0,0 +1,15 @@ +const BAR_WIDTHS = ["h-4 w-2/5", "h-3 w-3/5", "h-3 w-1/2"]; + +/** + * Shown while a pull request panel's code loads. It stays this small because the registry + * imports it eagerly; the panel's own ghosts take over once its code has arrived. + */ +export function PullRequestPanelPending({ label }: { label: string }) { + return ( +

+ {BAR_WIDTHS.map((width) => ( +
+ ))} +
+ ); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx new file mode 100644 index 000000000000..3a3b2c4cb8a9 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx @@ -0,0 +1,350 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ProjectId, + ThreadId, + type PullRequestDetailView, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { act, Suspense, type ComponentProps, type ReactElement, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useComposerDraftStore } from "~/composerDraftStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +const { bodyImport, capabilities, Wrapper, Trigger } = vi.hoisted(() => ({ + // Holds the body's code until a test lets it arrive, like a slow first chunk load. + bodyImport: (() => { + let resolve = () => {}; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve: () => resolve() }; + })(), + // Pull request capability per environment; null means its server config has not arrived. + capabilities: new Map(), + Wrapper: ({ children }: { children?: ReactNode }) => children, + Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( + <> + {render} + {children} + + ), +})); +const configFor = (environmentId: string) => { + const pullRequests = capabilities.get(environmentId); + return pullRequests == null + ? null + : { environment: { capabilities: { pullRequests, threadPullRequests: pullRequests } } }; +}; +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); +vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); +vi.mock("~/state/entities", () => ({ + useProjects: () => [], + useServerConfigs: () => + new Map( + [...capabilities.keys()].map((environmentId) => [environmentId, configFor(environmentId)]), + ), +})); +vi.mock("~/state/environments", () => ({ + useEnvironment: (environmentId: string) => { + const serverConfig = configFor(environmentId); + return serverConfig ? { serverConfig } : null; + }, + useEnvironments: () => ({ environments: [] }), + usePrimaryEnvironmentId: () => EnvironmentId.make("environment-new"), +})); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), + useEnvironmentSettings: () => undefined, +})); +vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} })); +vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("~/lib/sourceControlActions", () => ({ + usePreparePullRequestThreadAction: () => ({ run: vi.fn() }), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("~/state/pullRequests", async (importOriginal) => ({ + ...(await importOriginal()), + pullRequestEnvironment: { detail: () => "detail", activity: () => "activity" }, + usePullRequestTurnRefresh: () => 0, + useSharedPullRequestSummary: () => null, +})); +vi.mock("~/state/vcs", () => ({ vcsEnvironment: { listRefs: () => null } })); +vi.mock("~/state/query", () => ({ + useEnvironmentQuery: (query: string) => ({ + data: query === "detail" ? detail : null, + isPending: false, + isSuccess: true, + error: null, + refresh: vi.fn(), + }), +})); +vi.mock("~/state/usePullRequestStack", () => ({ + usePullRequestStack: () => ({ + data: { layers: [] }, + isSuccess: true, + isPending: false, + isFresh: true, + error: null, + notice: null, + refresh: vi.fn(), + }), +})); +// The stack menu's popup is chrome; its layer row is the entry point under test. +vi.mock("~/components/pullRequest/PullRequestStackMenu", () => ({ + PullRequestStackMenu: ({ + reference, + onSelect, + }: ComponentProps< + typeof import("~/components/pullRequest/PullRequestStackMenu").PullRequestStackMenu + >) => , +})); +vi.mock("~/components/ui/toast", () => ({ toastManager: { add: vi.fn(), update: vi.fn() } })); +vi.mock("~/components/ui/tooltip", () => ({ + TooltipProvider: Wrapper, + Tooltip: Wrapper, + TooltipTrigger: Trigger, + TooltipPopup: () => null, +})); +vi.mock("~/components/ui/menu", () => ({ + Menu: Wrapper, + MenuPopup: Wrapper, + MenuTrigger: Trigger, + MenuItem: "button", + MenuRadioGroup: Wrapper, + MenuRadioItem: "button", + MenuSeparator: () => null, + MenuShortcut: () => null, +})); +vi.mock("~/components/ui/alert-dialog", () => ({ + AlertDialog: () => null, + AlertDialogPopup: Wrapper, + AlertDialogHeader: Wrapper, + AlertDialogTitle: Wrapper, + AlertDialogDescription: Wrapper, + AlertDialogFooter: Wrapper, + AlertDialogClose: Wrapper, +})); +vi.mock("~/components/pullRequest/PullRequestMarkdown", () => ({ + PullRequestMarkdownContext: Wrapper, + PullRequestMarkdown: () => null, +})); +vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => vi.fn() })); +vi.mock("~/components/pullRequest/PullRequestThreadLinks", () => ({ + PullRequestThreadLinks: () => null, +})); +vi.mock("~/components/pullRequest/PullRequestSummaryTab", () => ({ + PullRequestSummaryTab: () => null, +})); +vi.mock("~/components/pullRequest/PullRequestCodeTab", () => ({ default: () => null })); +vi.mock("./PullRequestSidePanel", async (importOriginal) => { + await bodyImport.promise; + return importOriginal(); +}); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +const detail: PullRequestDetailView = { + provider: "github", + projectId: ProjectId.make("project"), + projectTitle: "Project", + workspaceRoot: "/workspace", + repository: "owner/repo", + number: 7, + title: "Test pull request", + body: "Original description", + url: "https://github.com/owner/repo/pull/7", + author: { login: "author", name: null, avatarUrl: null }, + viewer: "author", + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 0, + changedFiles: 1, + headBranch: "feature", + baseBranch: "main", + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-01T00:00:00Z", + mergedAt: null, + closedAt: null, + reviewers: [], + labels: [], + checks: [], + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + mergeCapabilities: { merge: false, squash: false, rebase: false }, + capabilities: { + diff: true, + comment: false, + search: true, + stacks: true, + actions: [], + mergeMethods: [], + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + reviewers: { request: false, listCandidates: false }, + edit: { changeRequest: true, comment: false }, + }, + viewerPermissions: { + actions: [], + comment: false, + resolve: false, + verdicts: [], + requestReviewers: false, + }, +}; + +// The same thread id on two environments is two threads. +const threadId = ThreadId.make("thread-a"); +const refOn = (environmentId: string): ScopedThreadRef => ({ + environmentId: EnvironmentId.make(environmentId), + threadId, +}); +const reference = { projectId: detail.projectId, repository: detail.repository, number: 7 }; +let renderer: ReactTestRenderer; + +// Lets the body's code arrive and settles it before rendering, so mounts finish inside one act(). +async function loadBody() { + bodyImport.resolve(); + await import("./PullRequestSidePanel"); +} + +async function renderFor(threadRef: ScopedThreadRef) { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + await act(async () => { + renderer = create( + + + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + /> + + , + ); + }); +} + +const hasText = (text: string) => + renderer.root.findAll((node) => node.children.includes(text)).length > 0; + +async function click(label: string) { + const button = renderer.root + .findAllByType("button") + .find( + (node) => + node.props["aria-label"] === label || + node.findAll((child) => child.children.includes(label)).length > 0, + ); + expect(button, label).toBeDefined(); + await act(async () => + button!.props.onClick({ + nativeEvent: new Event("click"), + preventDefault() {}, + stopPropagation() {}, + }), + ); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + capabilities.clear(); + capabilities.set("environment-old", false); + capabilities.set("environment-new", true); + capabilities.set("environment-loading", null); + useComposerDraftStore.setState({ draftsByThreadKey: {} }); + useRightPanelStore.setState({ + byThreadKey: {}, + threadPanelVisibilityByThreadKey: {}, + userActionRevisionByThreadKey: {}, + }); +}); +afterEach(() => { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +const isLoading = () => + renderer.root.findAll( + (node) => node.props.role === "status" && node.props["aria-label"] === "Loading pull request", + ).length > 0; + +describe("pull request side panel", () => { + // Runs first, while the body's code is still held back. + it("shows the pull request loading state until its code arrives", async () => { + await renderFor(refOn("environment-new")); + expect(isLoading()).toBe(true); + expect(hasText("Test pull request")).toBe(false); + + await act(loadBody); + expect(isLoading()).toBe(false); + expect(hasText("Test pull request")).toBe(true); + await click("Ask a question"); + expect( + useComposerDraftStore.getState().getComposerDraft(refOn("environment-new"))?.reviewComments, + ).toHaveLength(1); + }); + + it("follows the host environment's pull request support", async () => { + await loadBody(); + await renderFor(refOn("environment-loading")); + expect(isLoading()).toBe(true); + expect(hasText("Test pull request")).toBe(false); + expect(hasText("Pull requests unavailable")).toBe(false); + + await renderFor(refOn("environment-old")); + expect(hasText("Pull requests unavailable")).toBe(true); + expect(hasText("Update this environment's T3 Code server to browse pull requests.")).toBe(true); + expect(hasText("Test pull request")).toBe(false); + + await renderFor(refOn("environment-new")); + expect(hasText("Test pull request")).toBe(true); + }); + + it("writes a question into the host thread's composer only", async () => { + await loadBody(); + const threadRef = refOn("environment-new"); + useComposerDraftStore.getState().setPrompt(threadRef, "Keep my draft"); + await renderFor(threadRef); + await click("Ask a question"); + const draft = useComposerDraftStore.getState().getComposerDraft(threadRef); + expect(draft?.prompt).toContain("Keep my draft"); + expect(draft?.reviewComments?.length).toBeGreaterThan(0); + expect(useComposerDraftStore.getState().getComposerDraft(refOn("environment-old"))).toBeNull(); + }); + + it("opens a stack layer as a tab in the host's own thread", async () => { + await loadBody(); + const threadRef = refOn("environment-new"); + await renderFor(threadRef); + await click("Open #8"); + const { byThreadKey } = useRightPanelStore.getState(); + expect(byThreadKey[scopedThreadKey(threadRef)]?.surfaces).toMatchObject([ + { kind: "pull-request", repository: "owner/repo", number: 8 }, + ]); + expect(byThreadKey[scopedThreadKey(refOn("environment-old"))]).toBeUndefined(); + }); +}); diff --git a/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx new file mode 100644 index 000000000000..d5376e5a7cd6 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx @@ -0,0 +1,62 @@ +import type { PullRequestRef } from "@t3tools/contracts"; + +import { PullRequestDetailPanel } from "~/components/pullRequest/PullRequestDetailPanel"; +import { PullRequestDetailGhost } from "~/components/pullRequest/PullRequestGhosts"; +import { PullRequestsUnavailableState } from "~/components/pullRequest/PullRequestsUnavailableState"; +import type { ShortcutMatchContext } from "~/keybindings"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { useEnvironment } from "~/state/environments"; + +import { usePanelHost } from "../panelHost"; + +interface PullRequestSidePanelProps { + reference: PullRequestRef; + context: "page" | "thread"; + /** True only while this surface is the visible, active tab. */ + shortcutsEnabled: boolean; + getShortcutContext: () => ShortcutMatchContext; + /** Back to the thread's linked pull requests; only offered when there is more than one. */ + onBack?: (() => void) | undefined; +} + +// No onClose: the surface tab's own X owns closing here, and a second X in the header would be +// the same action twice. +export default function PullRequestSidePanel({ + reference, + context, + shortcutsEnabled, + getShortcutContext, + onBack, +}: PullRequestSidePanelProps) { + const { threadRef, composerDraftTarget } = usePanelHost(); + const serverConfig = useEnvironment(threadRef.environmentId)?.serverConfig ?? null; + if (serverConfig === null) return ; + if (serverConfig.environment.capabilities.pullRequests !== true) { + return ( + + ); + } + return ( + { + useRightPanelStore.getState().openPullRequest(threadRef, { + projectId: selected.projectId, + repository: selected.repository, + number: selected.number, + ...(selected.host ? { host: selected.host } : {}), + }); + }} + threadRef={threadRef} + reference={reference} + context={context} + composerDraftTarget={composerDraftTarget} + onBack={onBack} + /> + ); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx new file mode 100644 index 000000000000..294aa7470d15 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx @@ -0,0 +1,178 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ThreadId, + type ScopedThreadRef, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { act, Suspense, type ReactElement, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { commands, linksByThread, Wrapper, Trigger } = vi.hoisted(() => ({ + commands: [] as Array<{ command: string; request: unknown }>, + // Linked pull requests per scoped thread key. + linksByThread: new Map(), + Wrapper: ({ children }: { children?: ReactNode }) => children, + Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( + <> + {render} + {children} + + ), +})); +vi.mock("~/state/entities", () => ({ + // No projects, so rows offer no project-scoped fast actions. + useProjects: () => [], + // Only environment-new supports linked pull requests. + useServerConfigs: () => + new Map( + ["environment-new", "environment-old"].map((environmentId) => [ + environmentId, + { + environment: { + capabilities: { threadPullRequests: environmentId === "environment-new" }, + }, + }, + ]), + ), + useThreadShell: (threadRef: ScopedThreadRef) => ({ + pullRequests: linksByThread.get(scopedThreadKey(threadRef)) ?? [], + }), +})); +vi.mock("~/state/threads", () => ({ + threadEnvironment: { unlinkPullRequest: "unlink", watchPullRequest: "watch" }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: string) => (request: unknown) => { + commands.push({ command, request }); + }, +})); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => null, + useOpenPrLink: () => vi.fn(), +})); +vi.mock("~/shortcutModifierState", () => ({ + useShortcutModifierState: () => ({ + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }), +})); +vi.mock("~/components/pullRequest/LinkPullRequestDialog", () => ({ + openLinkPullRequestDialog: vi.fn(), +})); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: Wrapper, + TooltipTrigger: Trigger, + TooltipPopup: () => null, +})); +vi.mock("~/components/ui/menu", () => ({ + Menu: Wrapper, + MenuPopup: Wrapper, + MenuTrigger: Trigger, + MenuItem: "button", +})); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +// The same thread id on two environments is two threads. +const threadId = ThreadId.make("thread-a"); +const refOn = (environmentId: string): ScopedThreadRef => ({ + environmentId: EnvironmentId.make(environmentId), + threadId, +}); +const link = (repository: string, number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository, + number, + url: `https://github.com/${repository}/pull/${number}`, + source: "manual", + linkedAt: "2026-09-01T00:00:00.000Z", + snapshot: null, + stack: null, +}); +let renderer: ReactTestRenderer | undefined; + +const panelFor = (threadRef: ScopedThreadRef) => { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + return ( + + + + + + ); +}; + +// One renderer across hosts, so a host change reaches the already mounted list. +async function renderFor(threadRef: ScopedThreadRef) { + await act(async () => { + if (renderer) renderer.update(panelFor(threadRef)); + else renderer = create(panelFor(threadRef)); + }); +} + +const hasText = (text: string) => + renderer!.root.findAll((node) => node.children.includes(text)).length > 0; + +// Transform the lazy body once up front, so mounting it settles inside one act(). +beforeAll(() => import("./PullRequestsSidePanel")); +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + commands.length = 0; + linksByThread.clear(); + linksByThread.set(scopedThreadKey(refOn("environment-new")), [link("owner/alpha", 11)]); + linksByThread.set(scopedThreadKey(refOn("environment-old")), [link("owner/beta", 22)]); +}); +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("linked pull requests side panel", () => { + it("lists and unlinks the host thread's pull requests", async () => { + await renderFor(refOn("environment-new")); + expect(hasText("owner/alpha")).toBe(true); + expect(hasText("owner/beta")).toBe(false); + + const unlink = renderer!.root + .findAllByType("button") + .find( + (node) => node.findAll((child) => child.children.includes("Unlink from thread")).length, + ); + await act(async () => unlink!.props.onClick()); + expect(commands).toEqual([ + { + command: "unlink", + request: { + environmentId: "environment-new", + input: { threadId, host: "github.com", repository: "owner/alpha", number: 11 }, + }, + }, + ]); + }); + + it("follows the host to a thread whose environment cannot link pull requests", async () => { + await renderFor(refOn("environment-new")); + expect(hasText("owner/alpha")).toBe(true); + + await renderFor(refOn("environment-old")); + expect(hasText("Linked pull requests unavailable")).toBe(true); + expect(hasText("owner/alpha")).toBe(false); + expect(hasText("owner/beta")).toBe(false); + + linksByThread.set(scopedThreadKey(refOn("environment-new")), [link("owner/gamma", 33)]); + await renderFor(refOn("environment-new")); + expect(hasText("owner/gamma")).toBe(true); + }); +}); diff --git a/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx new file mode 100644 index 000000000000..b5e49f45f2fc --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx @@ -0,0 +1,7 @@ +import { ThreadPullRequestsPanel } from "~/components/pullRequest/ThreadPullRequestsPanel"; + +import { usePanelHost } from "../panelHost"; + +export default function PullRequestsSidePanel() { + return ; +} diff --git a/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx new file mode 100644 index 000000000000..45256e185872 --- /dev/null +++ b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx @@ -0,0 +1,467 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + AuthTerminalOperateScope, + AuthTerminalReadScope, + type EnvironmentId, + type ResolvedKeybindingsConfig, + type ThreadId, +} from "@t3tools/contracts"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; + +import ThreadTerminalDrawer from "~/components/ThreadTerminalDrawer"; +import { useComposerDraftStore } from "~/composerDraftStore"; +import type { TerminalContextSelection } from "~/lib/terminalContext"; +import { cn, randomUUID } from "~/lib/utils"; +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; +import { useProject, useThreadShell } from "~/state/entities"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; +import { terminalEnvironment } from "~/state/terminal"; +import { useKnownTerminalSessions } from "~/state/terminalSessions"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "~/terminalUiStateStore"; + +import type { PersistentTerminalLaunchContext } from "./TerminalSidePanel"; + +/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ +function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) { + return false; + } + if (left.length === 0) { + return true; + } + const sortedLeft = left.toSorted((a, b) => a.localeCompare(b)); + const sortedRight = right.toSorted((a, b) => a.localeCompare(b)); + for (let index = 0; index < sortedLeft.length; index += 1) { + if (sortedLeft[index] !== sortedRight[index]) { + return false; + } + } + return true; +} + +/** + * Server knows about fewer sessions than the client, but every server id still exists locally. + * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id + * and later re-add it as a separate group (no split layout). + */ +function serverTerminalIdsStrictSubsetOfClient( + serverIds: readonly string[], + clientIds: readonly string[], +): boolean { + if (serverIds.length >= clientIds.length || clientIds.length === 0) { + return false; + } + const clientSet = new Set(clientIds); + for (const id of serverIds) { + if (!clientSet.has(id)) { + return false; + } + } + return true; +} + +interface PersistentThreadTerminalDrawerProps { + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; + threadId: ThreadId; + active: boolean; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + splitShortcutLabel: string | undefined; + splitVerticalShortcutLabel: string | undefined; + newShortcutLabel: string | undefined; + closeShortcutLabel: string | undefined; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; +} + +export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ + threadRef, + threadId, + active, + launchContext, + focusRequestId, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, + keybindings, + onAddTerminalContext, +}: PersistentThreadTerminalDrawerProps) { + const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); + const hasTerminalWriteAccess = useCallback( + () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope), + [threadRef.environmentId], + ); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const serverThread = useThreadShell(threadRef); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useProject(projectRef); + const terminalUiState = useTerminalUiStateStore((state) => + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), + ); + const visible = active && terminalUiState.terminalOpen; + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId, + }); + const panelSurfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + const panelTerminalIds = useMemo( + () => + new Set( + panelSurfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [panelSurfaces], + ); + const drawerTerminalSessions = useMemo( + () => + knownTerminalSessions?.filter( + (session) => !panelTerminalIds.has(session.target.terminalId), + ) ?? [], + [knownTerminalSessions, panelTerminalIds], + ); + const terminalLabelsById = useMemo(() => { + const next = new Map(); + for (const session of drawerTerminalSessions) { + next.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return next; + }, [drawerTerminalSessions]); + const terminalLaunchLocationsById = useMemo(() => { + const next = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + if (!project) { + return next; + } + + for (const session of drawerTerminalSessions) { + const summary = session.state.summary; + if (!summary) { + continue; + } + const worktreePathForLaunch = + launchContext !== null ? launchContext.worktreePath : summary.worktreePath; + next.set(session.target.terminalId, { + cwd: launchContext?.cwd ?? summary.cwd, + worktreePath: worktreePathForLaunch, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: worktreePathForLaunch, + }), + }); + } + + return next; + }, [drawerTerminalSessions, launchContext, project]); + const serverOrderedTerminalIds = useMemo( + () => drawerTerminalSessions.map((session) => session.target.terminalId), + [drawerTerminalSessions], + ); + // Every client-side id source participates in allocation: the server list + // lags fresh opens, and panel terminals are filtered out of the drawer's + // sessions — an id collision attaches two viewports to one PTY session. + const allocatableTerminalIds = useMemo( + () => [ + ...new Set([ + ...serverOrderedTerminalIds, + ...terminalUiState.terminalIds, + ...panelTerminalIds, + ]), + ], + [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], + ); + const allocateTerminalId = useCallback( + () => + nextTerminalId( + allocatableTerminalIds, + knownTerminalSessions === null || + !readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope) + ? randomUUID() + : undefined, + ), + [allocatableTerminalIds, knownTerminalSessions, threadRef.environmentId], + ); + const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); + const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore( + (state) => state.splitTerminalVertical, + ); + const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); + const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); + const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); + const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); + + useEffect(() => { + if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { + return; + } + if ( + serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) + ) { + return; + } + reconcileTerminalIds(threadRef, serverOrderedTerminalIds); + }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); + const [localFocusRequestId, setLocalFocusRequestId] = useState(0); + const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const effectiveWorktreePath = useMemo(() => { + if (launchContext !== null) { + return launchContext.worktreePath; + } + return worktreePath; + }, [launchContext, worktreePath]); + const cwd = useMemo( + () => + launchContext?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath: effectiveWorktreePath, + }) + : null), + [effectiveWorktreePath, launchContext?.cwd, project], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: effectiveWorktreePath, + }) + : {}, + [effectiveWorktreePath, project], + ); + + const bumpFocusRequestId = useCallback(() => { + if (!visible) { + return; + } + setLocalFocusRequestId((value) => value + 1); + }, [visible]); + + const setTerminalHeight = useCallback( + (height: number) => { + storeSetTerminalHeight(threadRef, height); + }, + [storeSetTerminalHeight, threadRef], + ); + + const splitTerminal = useCallback(() => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeSplitTerminal(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + allocateTerminalId, + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + runtimeEnv, + storeSplitTerminal, + threadId, + threadRef, + openTerminal, + hasTerminalWriteAccess, + ]); + const splitTerminalVertical = useCallback(() => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeSplitTerminalVertical(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + allocateTerminalId, + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + openTerminal, + hasTerminalWriteAccess, + runtimeEnv, + storeSplitTerminalVertical, + threadId, + threadRef, + ]); + + const createNewTerminal = useCallback(() => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeNewTerminal(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + allocateTerminalId, + runtimeEnv, + storeNewTerminal, + threadId, + threadRef, + openTerminal, + hasTerminalWriteAccess, + ]); + + const activateTerminal = useCallback( + (terminalId: string) => { + storeSetActiveTerminal(threadRef, terminalId); + bumpFocusRequestId(); + }, + [bumpFocusRequestId, storeSetActiveTerminal, threadRef], + ); + + const closeTerminal = useCallback( + (terminalId: string) => { + if (!hasTerminalWriteAccess()) return; + const fallbackExitWrite = () => + writeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId, data: "exit\n" }, + }); + + void (async () => { + const closeResult = await closeTerminalMutation({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + deleteHistory: true, + }, + }); + if ( + closeResult._tag === "Failure" && + !isAtomCommandInterrupted(closeResult) && + hasTerminalWriteAccess() + ) { + await fallbackExitWrite(); + } + })(); + + storeCloseTerminal(threadRef, terminalId); + bumpFocusRequestId(); + }, + [ + bumpFocusRequestId, + storeCloseTerminal, + threadId, + threadRef, + closeTerminalMutation, + hasTerminalWriteAccess, + writeTerminal, + ], + ); + + const handleAddTerminalContext = useCallback( + (selection: TerminalContextSelection) => { + if (!visible) { + return; + } + onAddTerminalContext(selection); + }, + [onAddTerminalContext, visible], + ); + + if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { + return null; + } + + return ( +
+
+ +
+
+ ); +}); diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx new file mode 100644 index 000000000000..e0d995a5f048 --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom + +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { act, Suspense } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// Drives the registered terminal through the real RegisteredSidePanel -> +// TerminalSidePanel -> ThreadTerminalDrawer -> TerminalViewport path. Only the +// terminal transport (attach, write, resize), the WASM surface and the thread +// read model are stubbed. +const transport = vi.hoisted(() => ({ + attached: [] as unknown[], + commands: [] as Array<{ command: string; value: unknown }>, + surfaces: [] as Array<{ onData: (data: string) => void }>, +})); + +vi.mock("~/state/terminalSessions", async () => { + const { EMPTY_TERMINAL_SESSION_STATE: empty } = + await import("@t3tools/client-runtime/state/terminal"); + return { + useKnownTerminalSessions: () => [], + useAttachedTerminalSession: (input: unknown) => { + transport.attached.push(input); + return empty; + }, + }; +}); +// This client holds every scope, so the terminal accepts typing. +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("~/state/terminal", () => ({ + terminalEnvironment: { write: "write", resize: "resize", open: "open" }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => async (value: unknown) => { + if (typeof command === "string") transport.commands.push({ command, value }); + return { _tag: "Success" }; + }, +})); +vi.mock("~/terminal/ghostty/surface", async (importOriginal) => ({ + ...(await importOriginal()), + GhosttyTerminalSurface: { + create: async (_mount: HTMLElement, options: { onData: (data: string) => void }) => { + transport.surfaces.push(options); + // Every surface method is a no-op; the test only feeds keystrokes through onData. + return new Proxy({}, { get: () => () => undefined }); + }, + }, +})); +vi.mock("~/state/entities", () => { + const project = { workspaceRoot: "/repo" }; + return { + useThreadShell: (ref: ScopedThreadRef) => ({ + environmentId: ref.environmentId, + projectId: "project-a", + worktreePath: null, + }), + useProject: () => project, + }; +}); + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +const threadA: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; +const threadB: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-b"), + threadId: ThreadId.make("thread-b"), +}; +const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", +}; +const hostFor = (threadRef: ScopedThreadRef): PanelHost => ({ + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, +}); + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + // jsdom has no canvas; the terminal theme reader falls back without one. + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + transport.attached = []; + transport.commands = []; + transport.surfaces = []; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +async function renderTerminal(threadRef: ScopedThreadRef) { + await act(async () => + root.render( + + + undefined} + onSplitTerminal={() => undefined} + onSplitTerminalVertical={() => undefined} + onNewTerminal={() => undefined} + onActiveTerminalChange={() => undefined} + onCloseTerminal={() => undefined} + /> + + , + ), + ); +} + +// The registry loads the body lazily; let that import settle before asserting. +async function settle() { + await act(async () => { + await import("./TerminalSidePanel"); + }); +} + +async function type(data: string) { + await act(async () => transport.surfaces.at(-1)!.onData(data)); +} + +describe("registered terminal panel", () => { + it("attaches the host thread's terminal and sends typing to it, then follows a thread switch", async () => { + await renderTerminal(threadA); + await settle(); + expect(transport.attached.at(-1)).toMatchObject({ + environmentId: threadA.environmentId, + terminal: { threadId: threadA.threadId, terminalId: "term-1", cwd: "/repo" }, + }); + await type("ls\r"); + expect(transport.commands).toContainEqual({ + command: "write", + value: { + environmentId: threadA.environmentId, + input: { threadId: threadA.threadId, terminalId: "term-1", data: "ls\r" }, + }, + }); + + await renderTerminal(threadB); + await settle(); + expect(transport.attached.at(-1)).toMatchObject({ + environmentId: threadB.environmentId, + terminal: { threadId: threadB.threadId, terminalId: "term-1", cwd: "/repo" }, + }); + transport.commands = []; + await type("pwd\r"); + expect(transport.commands.filter((entry) => entry.command === "write")).toEqual([ + { + command: "write", + value: { + environmentId: threadB.environmentId, + input: { threadId: threadB.threadId, terminalId: "term-1", data: "pwd\r" }, + }, + }, + ]); + }); +}); diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx new file mode 100644 index 000000000000..e3e30694eb9a --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx @@ -0,0 +1,133 @@ +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const drawerRenders = vi.hoisted(() => [] as Array<{ visible: boolean; threadRef: unknown }>); +const drawerWorktreePaths = vi.hoisted(() => [] as Array); +const thread = vi.hoisted(() => ({ + environmentId: "environment-a", + projectId: "project-a", + worktreePath: null as string | null, +})); +vi.mock("~/components/ThreadTerminalDrawer", () => ({ + default: (props: { visible: boolean; threadRef: unknown; worktreePath: string | null }) => { + drawerRenders.push({ visible: props.visible, threadRef: props.threadRef }); + drawerWorktreePaths.push(props.worktreePath); + return null; + }, +})); +vi.mock("~/state/entities", () => { + const project = { workspaceRoot: "/repo" }; + return { useThreadShell: () => thread, useProject: () => project }; +}); +const terminalSessions = vi.hoisted( + () => + [] as Array<{ + target: { terminalId: string }; + state: { summary: { cwd: string; worktreePath: string | null } }; + }>, +); +vi.mock("~/state/terminalSessions", () => ({ + useKnownTerminalSessions: () => terminalSessions, +})); + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { PanelHostContext, type PanelHost } from "../panelHost"; +import TerminalSidePanel from "./TerminalSidePanel"; + +const threadRef: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; +const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", +}; +const terminalProps = { + surface, + launchContext: null, + focusRequestId: 0, + onAddTerminalContext: () => undefined, + onSplitTerminal: () => undefined, + onSplitTerminalVertical: () => undefined, + onNewTerminal: () => undefined, + onActiveTerminalChange: () => undefined, + onCloseTerminal: () => undefined, +}; + +// A host rebuilt with the same inputs, as here, must not re-render the drawer. +const hostFor = (visible: boolean): PanelHost => ({ + threadRef, + visible, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, +}); +const panelIn = (host: PanelHost) => ( + + + +); + +describe("terminal side panel", () => { + it("renders the host thread, skips rebuilt hosts with the same inputs, and follows visibility", () => { + let renderer: ReactTestRenderer | undefined; + act(() => { + renderer = create(panelIn(hostFor(true))); + }); + expect(drawerRenders).toEqual([{ visible: true, threadRef }]); + + act(() => renderer!.update(panelIn(hostFor(true)))); + expect(drawerRenders).toHaveLength(1); + + act(() => renderer!.update(panelIn(hostFor(false)))); + expect(drawerRenders).toEqual([ + { visible: true, threadRef }, + { visible: false, threadRef }, + ]); + }); + + it("keeps a local-checkout launch on the checkout after the thread gains a worktree", () => { + thread.worktreePath = "/repo/.worktrees/feature"; + drawerWorktreePaths.length = 0; + const launchedLocally = ( + + + + ); + act(() => { + create(launchedLocally); + }); + expect(drawerWorktreePaths).toEqual([null]); + + drawerWorktreePaths.length = 0; + act(() => { + create(panelIn(hostFor(true))); + }); + expect(drawerWorktreePaths).toEqual(["/repo/.worktrees/feature"]); + thread.worktreePath = null; + }); + + it("keeps a terminal the server opened on the checkout off the thread's later worktree", () => { + thread.worktreePath = "/repo/.worktrees/feature"; + terminalSessions.push({ + target: { terminalId: "term-1" }, + state: { summary: { cwd: "/repo", worktreePath: null } }, + }); + drawerWorktreePaths.length = 0; + act(() => { + create(panelIn(hostFor(true))); + }); + expect(drawerWorktreePaths).toEqual([null]); + terminalSessions.length = 0; + thread.worktreePath = null; + }); +}); diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.tsx new file mode 100644 index 000000000000..e94284b7f5e4 --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.tsx @@ -0,0 +1,225 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { ResolvedKeybindingsConfig, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { memo, useMemo } from "react"; + +import ThreadTerminalDrawer from "~/components/ThreadTerminalDrawer"; +import { useComposerDraftStore } from "~/composerDraftStore"; +import type { TerminalContextSelection } from "~/lib/terminalContext"; +import type { RightPanelSurface } from "~/rightPanelStore"; +import { useProject, useThreadShell } from "~/state/entities"; +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { useKnownTerminalSessions } from "~/state/terminalSessions"; + +import { usePanelHost } from "../panelHost"; + +export interface TerminalLaunchContext { + threadId: ThreadId; + cwd: string; + worktreePath: string | null; +} + +export type PersistentTerminalLaunchContext = Pick; + +/** + * A launch context's or summary's null worktree means the local checkout, not + * "unknown", so only fall back to the thread's worktree when neither exists. + */ +function terminalWorktreePath( + launchContext: PersistentTerminalLaunchContext | null, + summary: { readonly worktreePath: string | null } | null, + threadWorktreePath: string | null, +): string | null { + if (launchContext !== null) return launchContext.worktreePath; + if (summary !== null) return summary.worktreePath; + return threadWorktreePath; +} + +interface PersistentThreadTerminalPanelProps { + visible: boolean; + threadRef: ScopedThreadRef; + surface: Extract; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; + onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; + onNewTerminal: () => void; + onActiveTerminalChange: (terminalId: string) => void; + onCloseTerminal: (terminalId: string) => void; + splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; + newShortcutLabel?: string | undefined; + closeShortcutLabel?: string | undefined; +} + +const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ + visible, + threadRef, + surface, + launchContext, + focusRequestId, + keybindings, + onAddTerminalContext, + onSplitTerminal, + onSplitTerminalVertical, + onNewTerminal, + onActiveTerminalChange, + onCloseTerminal, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, +}: PersistentThreadTerminalPanelProps) { + const serverThread = useThreadShell(threadRef); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useProject(projectRef); + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeSummary = + knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId) + ?.state.summary ?? null; + const worktreePath = terminalWorktreePath(launchContext, activeSummary, threadWorktreePath); + const cwd = useMemo( + () => + launchContext?.cwd ?? + activeSummary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath, + }) + : null), + [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath, + }) + : {}, + [project, worktreePath], + ); + const terminalLabelsById = useMemo(() => { + const labels = new Map(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); + } + return labels; + }, [knownTerminalSessions, surface.terminalIds]); + const terminalLaunchLocationsById = useMemo(() => { + const locations = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + const worktreePathForTerminal = terminalWorktreePath( + launchContext, + summary, + threadWorktreePath, + ); + const terminalCwd = + launchContext?.cwd ?? + summary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath: worktreePathForTerminal, + }) + : null); + if (!terminalCwd || !project) continue; + locations.set(terminalId, { + cwd: terminalCwd, + worktreePath: worktreePathForTerminal, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: worktreePathForTerminal, + }), + }); + } + return locations; + }, [knownTerminalSessions, launchContext, project, surface.terminalIds, threadWorktreePath]); + + if (!project || !cwd) return null; + + return ( + undefined} + onAddTerminalContext={onAddTerminalContext} + terminalLabelsById={terminalLabelsById} + terminalLaunchLocationsById={terminalLaunchLocationsById} + keybindings={keybindings} + /> + ); +}); + +/** + * Registered right-panel body. ChatView rebuilds the host on every render, so + * the host is read here, outside the memo: renders that leave the thread, + * visibility and terminal props unchanged still skip the terminal. + */ +export default function TerminalSidePanel( + props: Omit, +) { + const { threadRef, visible } = usePanelHost(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + return ( + + ); +} diff --git a/apps/web/src/pluginActions.test.ts b/apps/web/src/pluginActions.test.ts new file mode 100644 index 000000000000..1a30b3eb144d --- /dev/null +++ b/apps/web/src/pluginActions.test.ts @@ -0,0 +1,95 @@ +import { + AuthOrchestrationOperateScope, + EnvironmentId, + PluginActionId, + 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(), + toast: vi.fn(), +})); + +// The session grant, the invoke RPC and the toast are the boundaries. +vi.mock("./state/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("./state/pluginActions", () => ({ + pluginActionEnvironment: { invoke: "invoke" }, + readPluginActions: () => [], +})); +vi.mock("./rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("./components/ui/toast", () => ({ toastManager: { add: state.toast } })); + +import { buildPluginActionItems } from "./components/CommandPalette.logic"; +import { runPluginAction } from "./pluginActions"; + +const environmentId = EnvironmentId.make("environment-plugins"); +const threadId = ThreadId.make("thread-plugins"); +const deploy: PluginAction = { + id: PluginActionId.make("plugin-deploy:deploy"), + pluginId: "plugin-deploy", + pluginName: "Deploy", + name: "deploy", + title: "Deploy this thread", + 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: null })); + state.toast.mockReset(); +}); + +describe("runPluginAction", () => { + it("reports that the plugin ran the action", async () => { + await expect(run()).resolves.toBe(true); + expect(state.invoke).toHaveBeenCalledOnce(); + expect(state.toast).toHaveBeenCalledWith(expect.objectContaining({ type: "success" })); + }); + + 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.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", description: "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(); + expect(state.toast).toHaveBeenCalledWith(expect.objectContaining({ type: "error" })); + }); + + it("rechecks the grant when a palette entry offered earlier is chosen", async () => { + const [entry] = buildPluginActionItems({ + environmentId, + actions: [deploy], + canOperate: true, + threadId, + projectId: null, + icon: null, + runAction: runPluginAction, + }); + state.canOperate = false; + + await entry?.run(); + + expect(state.invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/pluginActions.ts b/apps/web/src/pluginActions.ts new file mode 100644 index 000000000000..a94634eab0ba --- /dev/null +++ b/apps/web/src/pluginActions.ts @@ -0,0 +1,85 @@ +import { + isAtomCommandInterrupted, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { pluginActionLabels, pluginActionsAt } from "@t3tools/client-runtime/state/pluginActions"; +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + type PluginAction, + type PluginActionTarget, + type ProjectId, + type ScopedThreadRef, +} from "@t3tools/contracts"; + +import { toastManager } from "./components/ui/toast"; +import { appAtomRegistry } from "./rpc/atomRegistry"; +import { pluginActionEnvironment, readPluginActions } from "./state/pluginActions"; +import { readEnvironmentScope } from "./state/session"; + +/** The plugin entries of a thread's action menu, read when the menu opens. */ +export function threadMenuPluginActions(threadRef: ScopedThreadRef, projectId: ProjectId) { + const entries = pluginActionsAt(readPluginActions(threadRef.environmentId), "thread-menu", { + threadId: threadRef.threadId, + projectId, + }); + const labels = pluginActionLabels(entries.map((entry) => entry.action)); + return entries.map((entry, index) => ({ + ...entry, + environmentId: threadRef.environmentId, + id: `plugin-action:${entry.action.id}` as const, + label: labels[index] ?? entry.action.title, + })); +} + +/** 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 and reports the + * outcome in a toast. The environment and target are fixed when the user + * picks the action, so a later navigation cannot redirect it. 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)) { + toastManager.add({ + type: "error", + title: `${action.title} unavailable`, + description: "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") { + toastManager.add({ + type: "success", + title: action.title, + ...(result.value.message === null ? {} : { description: result.value.message }), + }); + return true; + } + if (isAtomCommandInterrupted(result)) return false; + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: `${action.title} failed`, + description: error instanceof Error ? error.message : "The plugin action failed.", + }); + return false; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 69112d6de4e5..40ebd9b42ee6 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -34,6 +34,7 @@ import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast"; import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut"; +import { PluginActionSubscriptions } from "../components/PluginActionSubscriptions"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator"; @@ -237,6 +238,7 @@ function RootRouteView() { + diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 02e0e9e26d46..f817c57fb5c4 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -272,6 +272,15 @@ const NO_LIST_TARGETS: ReadonlyArray(); +const UNAVAILABLE_SIDE_PANELS = { + preview: { available: false, onOpen: () => undefined }, + diff: { available: false, onOpen: () => undefined }, + terminal: { available: false, onOpen: () => undefined }, + device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, + files: { available: false, onOpen: () => undefined }, +}; const EMPTY_PENDING_SURFACES = new Set(); const MAX_SEARCH_LABEL_CANDIDATES = 100; @@ -2246,21 +2255,8 @@ function PullRequestsRouteView() { useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex); }} onCopyFilePath={() => undefined} - onAddBrowser={() => undefined} + panels={UNAVAILABLE_SIDE_PANELS} onAddBrowserInProfile={() => undefined} - onAddTerminal={() => undefined} - onAddDiff={() => undefined} - onAddFiles={() => undefined} - onAddPullRequest={() => undefined} - onAddPullRequests={() => undefined} - onAddDevice={() => undefined} - browserAvailable={false} - terminalAvailable={false} - diffAvailable={false} - filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} - deviceAvailable={false} pullRequestStatusSeeds={listedPullRequestTabStatuses} > { + return useAtomValue(contributionStatusEnvironment.threadStatus(environmentId, threadId)); +} diff --git a/apps/web/src/state/pluginActions.ts b/apps/web/src/state/pluginActions.ts new file mode 100644 index 000000000000..91d2b6847207 --- /dev/null +++ b/apps/web/src/state/pluginActions.ts @@ -0,0 +1,37 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createPluginActionEnvironmentAtoms } from "@t3tools/client-runtime/state/pluginActions"; +import type { EnvironmentId, PluginAction } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useEnvironmentQuery } from "./query"; + +export const pluginActionEnvironment = createPluginActionEnvironmentAtoms(connectionAtomRuntime); + +const NO_ACTIONS: ReadonlyArray = []; + +const snapshotAtom = (environmentId: EnvironmentId) => + pluginActionEnvironment.snapshot({ environmentId, input: {} }); + +/** The environment's plugin actions, kept current while the caller is mounted. */ +export function usePluginActions(environmentId: EnvironmentId | null): ReadonlyArray { + return ( + useEnvironmentQuery(environmentId === null ? null : snapshotAtom(environmentId)).data + ?.actions ?? NO_ACTIONS + ); +} + +/** Keeps one environment's list subscribed so menus built on demand read it current. */ +export function useMountPluginActions(environmentId: EnvironmentId): void { + useAtomValue(snapshotAtom(environmentId)); +} + +/** The list as last received, for menus built when they open. */ +export function readPluginActions(environmentId: EnvironmentId): ReadonlyArray { + return Option.match(AsyncResult.value(appAtomRegistry.get(snapshotAtom(environmentId))), { + onNone: () => NO_ACTIONS, + onSome: (snapshot) => snapshot.actions, + }); +} diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 149bcf57dff0..0add40b36998 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -79,6 +79,14 @@ checkpoint or diff must not extend the recorded provider duration or keep the cl provider work as active. PR discovery after completion also checks that the checkout still matches the thread's non-default branch and that a newer run is not active. +Every newly finished run that was not rolled back records exactly one of `run.finalized` or +`run.finalization-failed`. A run that never enqueues a checkpoint capture records it in the commit +that writes its terminal status, through [EventSink](../../apps/server/src/orchestration-v2/EventSink.ts), +so new terminal paths need no extra work. A run that captures records it in a later write, after +the capture and the workspace refresh; a replayed capture first checks for a recorded outcome and +stops there, so a restart either redoes the unrecorded work or honours the recorded outcome. +Neither event is `thread.settled`, which is the sidebar's parking state. + [Checkpoints](../../apps/server/src/checkpointing/CheckpointStore.ts) use hidden Git refs to capture workspace state without adding commits to the user's branch. A revert must coordinate workspace state with the provider conversation. A provider that cannot roll back its conversation diff --git a/docs/user/plugin-actions.md b/docs/user/plugin-actions.md new file mode 100644 index 000000000000..4b401aa7eb10 --- /dev/null +++ b/docs/user/plugin-actions.md @@ -0,0 +1,77 @@ +# Plugin actions + +A trusted local plugin can add actions to the command palette, the thread +menu and the composer's slash menu. Picking one runs the plugin's code right +away on the server's machine, as the OS account that runs the T3 Code server, +which is not necessarily the account on the device you picked it from. It does +not write a prompt or start an agent turn, so only enable plugins you trust. + +## Offering an action + +In the plugin's `t3-plugin.json`, add `"actions"` to `capabilities`, set +`"proposedApi": true`, and declare up to 16 actions in `actions`. Each has a +`name` (lowercase letters, digits and dashes; also its slash command), a +`title`, an optional `description`, a `target` and the `placements` where it +appears: + +```json +{ + "capabilities": ["actions"], + "proposedApi": true, + "actions": [ + { + "name": "deploy", + "title": "Deploy this branch", + "target": "thread", + "placements": ["command-palette", "thread-menu", "composer-slash"] + } + ] +} +``` + +The plugin answers by registering +`context.proposed.handle("action:", handler)`. The handler receives +`{ action, target }`, where `target` is one of: + +- `{ kind: "environment" }` +- `{ kind: "project", projectId, workspaceRoot }` +- `{ kind: "thread", threadId, projectId, cwd, branch }`, where `cwd` is the + thread's worktree or its project's folder + +Return `{ message }` to show the user a short result, or throw to report a +failure. An action that does not finish within 30 seconds fails. + +## Where actions appear + +Plugins are added, consented to and enabled from an administrative connection, +as described in [Plugin tools](./plugin-tools.md). Listing actions never +starts the plugin; the first action you pick does. + +An action appears only where its target is known: + +- **Command palette**: on web and desktop, environment actions, plus project + and thread actions for the project or thread you are in. On mobile with a + hardware keyboard, the palette shows actions for the open thread's + environment. +- **Thread menu**: thread actions for that thread, from the sidebar or the + chat header on web and desktop, and under **Plugin actions** in a thread's + long-press menu on mobile. +- **Slash menu**: type `/` at the start of a line in your message, on web, + desktop and mobile. In an open thread you see that thread's actions; in a + new, unsent thread only environment and project actions appear. Picking one + removes the typed command from your message and leaves the rest of the + message as it was. + +The result appears as a toast on web and desktop, or an alert on mobile. + +## When an action is refused + +Actions disappear as soon as their plugin is disabled, removed or no longer +able to run. If you picked an action from a list that changed in the meantime, +for example because the plugin was enabled again, it is refused instead of +running against the new version. Open the menu again and pick it from the +current list. + +If an environment's plugins declare more than 128 actions, the plugins that do +not fit are left out whole and their actions cannot run. Disable a plugin you +do not need to bring the others back. diff --git a/docs/user/plugin-settings.md b/docs/user/plugin-settings.md new file mode 100644 index 000000000000..0e715220c0c3 --- /dev/null +++ b/docs/user/plugin-settings.md @@ -0,0 +1,58 @@ +# Plugin settings and storage + +A trusted local plugin can declare settings that you fill in, including +secrets such as API tokens, and keep a small amount of its own data between +runs. Forms for filling in settings arrive in the clients later; until then, +settings are saved from an administrative connection with the +`plugins.settings.update` request. + +## Declaring settings + +In the plugin's `t3-plugin.json`, add `"settings"` to `capabilities`, set +`"proposedApi": true`, and list up to 32 fields in `settings`. Each field has +a `type` (`text`, `secret`, `boolean`, `number` or `select`), a unique `key` +and a `label`, and may have a `description`. Every type except `secret` can +have a `default`; `number` can set `min`, `max` and `integer`, and `select` +lists its `options`: + +```json +{ + "capabilities": ["settings"], + "proposedApi": true, + "settings": [ + { "type": "text", "key": "apiUrl", "label": "API URL", "default": "https://api.example.com" }, + { "type": "secret", "key": "token", "label": "API token" } + ] +} +``` + +A manifest whose settings T3 Code cannot honor, such as a default outside its +own bounds, is refused when the plugin is added. + +## Reading settings + +`context.proposed.settings.get(key)` returns the saved value if it still fits +the field, else the field's default, else `undefined`. A secret returns its saved text. Asking for +a key the manifest does not declare rejects. + +Secrets are write-only for clients: a client learns only whether a secret is +saved, never its value. T3 Code stores each secret as a plain-text file, +readable only by the server's OS account, in the server's secrets directory. It is not +encrypted. + +## Plugin storage + +`context.proposed.storage` keeps JSON values under string keys with `get`, +`set`, `delete` and `keys`. Each installation has its own storage, limited to +keys of 1 to 128 characters, values up to 64 KiB of JSON, 256 keys and 1 MiB +in total. A write past a limit rejects. + +## How long values last + +Settings, secrets and storage belong to the installation. They survive +disabling and enabling it again, server restarts and updates to the plugin's +files. When a new version stops declaring a field, or switches it between +secret and non-secret, its saved value is deleted at the next save. A saved +value that no longer fits a changed field is kept, but reads fall back to the +default until it fits again. Removing the plugin deletes everything saved for +it. diff --git a/docs/user/plugin-tools.md b/docs/user/plugin-tools.md new file mode 100644 index 000000000000..e4bb10eec96f --- /dev/null +++ b/docs/user/plugin-tools.md @@ -0,0 +1,42 @@ +# Plugin tools + +A trusted local plugin can offer tools that agents call through T3 Code's own +MCP server. Plugins are code that runs as the T3 Code server's OS account, so +only add plugins you trust. + +## Offering a tool + +In the plugin's `t3-plugin.json`, add `"tools"` to `capabilities`, set +`"proposedApi": true`, and declare each tool in `tools` with a `name`, +`description`, `inputSchema` and `sideEffect` (`read`, `write` or +`destructive`). The plugin answers a call by registering +`context.proposed.handle("t3.tool.", handler)`; the handler receives +`{ input, context: { environmentId, threadId } }`. + +`inputSchema` must stay within the JSON Schema subset T3 Code enforces. A +manifest outside it is refused when the plugin is added, with the path of the +keyword it cannot enforce. Input that does not match the schema never reaches +the plugin. + +## Making tools available + +Plugins are managed from an administrative connection with the `plugins.add`, +`plugins.consent` and `plugins.enable` requests. Consent covers the plugin's +exact files, tool declarations included. Listing tools never starts the plugin; +the first call does. + +Agents see two tools in every session that uses T3 Code's MCP server: +`plugin_tools_list` and `plugin_tool_call`. + +## Which sessions can use a plugin + +A session can use the tool plugins that were enabled when it started. A plugin +you enable later, or enable again after disabling it, shows up as not +available in this session. To use it, start a new thread. + +Disabling or removing a plugin refuses its tools at once in every session and +cancels calls in progress. T3 Code checks a plugin's files when you refresh, +consent to or enable it, when the server starts, and before a call starts the +plugin. If the files changed, the plugin is disabled until you consent to the +new version. A plugin that is already running is not checked again until one of +those points, so refresh it after editing its files. diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index f57272b4de52..6a87c2c8490d 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -30,8 +30,11 @@ Pi skills appear in the composer's `$` menu. This includes user skills and proje loads for the current workspace; selecting one uses Pi's native skill expansion. Pi loads its normal user and project extensions. Blocking `select`, `confirm`, `input`, and `editor` -dialogs work in T3 Code. Notifications appear in the work log. Pi terminal decoration such as -titles, status lines, and widgets does not have a T3 Code equivalent. +dialogs work in T3 Code. Notifications appear in the work log. In the web and desktop apps, +extension statuses (`setStatus`) appear beside the thread title; on mobile they appear in a row +under the header, where you tap one to see its details. They are hints from the running extensions +and can lag a session change, such as a resume or fork, until the extension updates them. Pi +terminal decoration such as titles and widgets does not have a T3 Code equivalent. ## Permission Modes diff --git a/knip.jsonc b/knip.jsonc index c3fed7f19200..f76384728ded 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -33,6 +33,7 @@ "scripts/update-test-shard-weights.ts", "scripts/verify-background-live.ts", "src/provider/testFixtures/*.mjs", + "src/plugins/testFixtures/**/*.mjs", ], // Keep the transitive Effect runtime pinned for standalone npm installs. // The Vite+ web build prerequisite. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index aea6ead6b943..9434f3b88729 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -175,10 +175,18 @@ "types": "./src/state/presentation.ts", "default": "./src/state/presentation.ts" }, + "./state/contribution-status": { + "types": "./src/state/contributionStatus.ts", + "default": "./src/state/contributionStatus.ts" + }, "./state/device": { "types": "./src/state/device.ts", "default": "./src/state/device.ts" }, + "./state/pluginActions": { + "types": "./src/state/pluginActions.ts", + "default": "./src/state/pluginActions.ts" + }, "./state/deviceHubAccess": { "types": "./src/state/deviceHubAccess.ts", "default": "./src/state/deviceHubAccess.ts" diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c11a49897b88..2f5dd1088143 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -5,6 +5,7 @@ import { type EnvironmentId, type ClientGuardedRpcTag, ORCHESTRATION_V2_WS_METHODS, + type ServerConfig, WS_METHODS, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -59,12 +60,14 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.scheduledTasksSubscribe | typeof WS_METHODS.serverGetStorageCleanupReport + | typeof WS_METHODS.pluginActionsSubscribe | typeof WS_METHODS.subscribeTerminalEvents | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents | typeof WS_METHODS.subscribeDiscoveredLocalServers | typeof WS_METHODS.subscribeDeviceState | typeof WS_METHODS.subscribeResourceTelemetry + | typeof WS_METHODS.subscribeContributionStatus | typeof WS_METHODS.pullRequestsSubscribeRefreshes | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.subscribeWorktreeSetup @@ -186,16 +189,10 @@ const authorizeRequest = Effect.fn("EnvironmentRpc.authorize")(function* ( yield* guard.authorize(supervisor.target.environmentId, method, input); }); -export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < +const requestOnSession = Effect.fn("EnvironmentRpc.requestOnSession")(function* < TTag extends EnvironmentUnaryRpcTag, ->(tag: TTag, input: EnvironmentRpcInput) { +>(session: RpcSession, tag: TTag, input: EnvironmentRpcInput) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; - yield* Effect.annotateCurrentSpan({ - "environment.id": supervisor.target.environmentId, - "rpc.method": tag, - }); - const session = yield* currentSession(); - yield* authorizeRequest(tag, input); const observer = yield* EnvironmentRpcRequestObserver; const method = session.client[tag] as ( input: EnvironmentRpcInput, @@ -207,6 +204,50 @@ export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < return yield* method(input).pipe(Effect.ensuring(completeObservation)); }); +export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < + TTag extends EnvironmentUnaryRpcTag, +>(tag: TTag, input: EnvironmentRpcInput) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + yield* Effect.annotateCurrentSpan({ + "environment.id": supervisor.target.environmentId, + "rpc.method": tag, + }); + const session = yield* currentSession(); + yield* authorizeRequest(tag, input); + return yield* requestOnSession(session, tag, input); +}); + +/** + * Like `request`, but only to a server whose capabilities pass `supported`. + * The check and the request use the same session, so a reconnect to an older + * server in between cannot receive the call. Otherwise fails with + * `EnvironmentRpcUnavailableError` and sends nothing. + */ +export const requestIfSupported = Effect.fn("EnvironmentRpc.requestIfSupported")(function* < + TTag extends Exclude, +>( + tag: TTag, + input: EnvironmentRpcInput, + supported: (capabilities: ServerConfig["environment"]["capabilities"]) => boolean, +) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + yield* Effect.annotateCurrentSpan({ + "environment.id": supervisor.target.environmentId, + "rpc.method": tag, + }); + const session = yield* currentSession(); + const isSupported = yield* session.initialConfig.pipe( + Effect.map((config) => supported(config.environment.capabilities)), + Effect.orElseSucceed(() => false), + ); + if (!isSupported) + return yield* new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: `${supervisor.target.label} runs a server version without this feature.`, + }); + return yield* requestOnSession(session, tag, input); +}); + export function runStreamGuarded( tag: TTag, input: EnvironmentRpcInput, diff --git a/packages/client-runtime/src/state/contributionStatus.test.ts b/packages/client-runtime/src/state/contributionStatus.test.ts new file mode 100644 index 000000000000..1dc9df8ebc6f --- /dev/null +++ b/packages/client-runtime/src/state/contributionStatus.test.ts @@ -0,0 +1,241 @@ +import { + type ContributionStatusSnapshot, + contributionStatusSourceKey, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + type ServerConfig, + ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom, AtomRegistry } from "effect/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { createContributionStatusEnvironmentAtoms } from "./contributionStatus.ts"; + +const THREAD = ThreadId.make("thread-1"); + +const OTHER_THREAD = ThreadId.make("thread-2"); + +const entry = (text: string, session = `session-${text}`, threadId = THREAD) => ({ + threadId, + source: { + kind: "provider-session" as const, + providerSessionId: ProviderSessionId.make(session), + providerInstanceId: ProviderInstanceId.make("pi"), + driver: ProviderDriverKind.make("pi"), + }, + items: [{ key: "mode", text }], +}); + +const snapshot = (text: string): ContributionStatusSnapshot => ({ entries: [entry(text)] }); + +const config = (contributionStatus: boolean) => + ({ + environment: { + serverVersion: "0.0.1", + capabilities: contributionStatus ? { contributionStatus: true } : {}, + }, + }) as ServerConfig; + +/** One environment whose server pushes status snapshots from a queue. */ +const makeEnvironment = Effect.fn("makeEnvironment")(function* (id: string, supported: boolean) { + const environmentId = EnvironmentId.make(id); + let subscriptions = 0; + const makeSession = Effect.fn("makeSession")(function* () { + const frames = yield* Queue.unbounded(); + const client = { + [WS_METHODS.subscribeContributionStatus]: () => { + subscriptions += 1; + return Stream.fromQueue(frames); + }, + } as unknown as WsRpcProtocolClient; + const session: RpcSession = { + client, + initialConfig: Effect.succeed(config(supported)), + subscribeServerConfig: () => Stream.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + return { session, push: (frame: ContributionStatusSnapshot) => Queue.offer(frames, frame) }; + }); + const first = yield* makeSession(); + const session = yield* SubscriptionRef.make(Option.some(first.session)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: new PrimaryConnectionTarget({ + environmentId, + label: id, + httpBaseUrl: `https://${id}.example.test`, + wsBaseUrl: `wss://${id}.example.test`, + }), + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: "connected", + }), + session, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + return { + environmentId, + supervisor, + config: Atom.make(config(supported)), + subscriptions: () => subscriptions, + push: first.push, + /** Swaps in a new connection, as a reconnect does, and returns its push. */ + reconnect: Effect.gen(function* () { + const next = yield* makeSession(); + yield* SubscriptionRef.set(session, Option.some(next.session)); + return next.push; + }), + }; +}); + +const makeHarness = Effect.fn("makeHarness")(function* () { + const environments = [ + yield* makeEnvironment("env-a", true), + yield* makeEnvironment("env-b", true), + yield* makeEnvironment("env-old", false), + ] as const; + const byId = new Map(environments.map((environment) => [environment.environmentId, environment])); + const supervisorFor = (environmentId: EnvironmentId) => byId.get(environmentId)!.supervisor; + const registryService = EnvironmentRegistry.EnvironmentRegistry.of({ + run: (environmentId, effect) => + Effect.provideService( + effect, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisorFor(environmentId), + ), + followStream: (environmentId, stream) => + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisorFor(environmentId), + ), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registryService), + ); + const atoms = createContributionStatusEnvironmentAtoms(runtime, { + configValueAtom: (environmentId) => byId.get(environmentId)!.config, + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const texts = (environmentId: EnvironmentId) => + registry + .get(atoms.threadStatus(environmentId, THREAD)) + .flatMap((entry) => entry.items.map((item) => item.text)); + /** Waits until a thread's rendered texts match, the client-side receipt for a frame. */ + const waitForTexts = (environmentId: EnvironmentId, expected: ReadonlyArray) => + AtomRegistry.toStream(registry, atoms.threadStatus(environmentId, THREAD)).pipe( + Stream.map((entries) => entries.flatMap((entry) => entry.items.map((item) => item.text))), + Stream.filter((actual) => actual.join("\n") === expected.join("\n")), + Stream.runHead, + ); + return { environments, atoms, registry, texts, waitForTexts }; +}); + +it.effect("keeps each environment's statuses apart and replaces them on reconnect", () => + Effect.scoped( + Effect.gen(function* () { + const { environments, atoms, registry, texts, waitForTexts } = yield* makeHarness(); + const [a, b] = environments; + const unmountA = registry.mount(atoms.threadStatus(a.environmentId, THREAD)); + const unmountB = registry.mount(atoms.threadStatus(b.environmentId, THREAD)); + + yield* a.push(snapshot("plan")); + yield* b.push(snapshot("build")); + yield* waitForTexts(a.environmentId, ["plan"]); + yield* waitForTexts(b.environmentId, ["build"]); + + // The status ended while disconnected; the new connection's first frame drops it. + const pushAfterReconnect = yield* a.reconnect; + yield* pushAfterReconnect({ entries: [] }); + yield* waitForTexts(a.environmentId, []); + assert.strictEqual(a.subscriptions(), 2); + assert.deepStrictEqual(texts(b.environmentId), ["build"]); + unmountA(); + unmountB(); + }), + ), +); + +it.effect("never subscribes to a server without the capability", () => + Effect.scoped( + Effect.gen(function* () { + const { environments, atoms, registry, waitForTexts } = yield* makeHarness(); + const [current, , old] = environments; + const unmountOld = registry.mount(atoms.threadStatus(old.environmentId, THREAD)); + const unmountCurrent = registry.mount(atoms.threadStatus(current.environmentId, THREAD)); + + // Once a supported environment has delivered, any subscription would have started. + yield* current.push(snapshot("plan")); + yield* waitForTexts(current.environmentId, ["plan"]); + assert.strictEqual(old.subscriptions(), 0); + assert.deepStrictEqual(registry.get(atoms.threadStatus(old.environmentId, THREAD)), []); + unmountOld(); + unmountCurrent(); + }), + ), +); + +it.effect("keeps each entry's source so a takeover with the same text re-keys the row", () => + Effect.scoped( + Effect.gen(function* () { + const { environments, atoms, registry } = yield* makeHarness(); + const [a] = environments; + const status = atoms.threadStatus(a.environmentId, THREAD); + const unmount = registry.mount(status); + const waitForSession = (session: string) => + AtomRegistry.toStream(registry, status).pipe( + Stream.filter((entries) => entries[0]?.source.providerSessionId === session), + Stream.runHead, + ); + + yield* a.push({ entries: [entry("plan", "session-old")] }); + yield* waitForSession("session-old"); + const before = registry.get(status); + + // Another thread changing keeps this thread's array, so its row does not re-render. + yield* a.push({ + entries: [entry("plan", "session-old"), entry("x", "session-x", OTHER_THREAD)], + }); + yield* AtomRegistry.toStream(registry, atoms.snapshot(a.environmentId)).pipe( + Stream.filter((snapshot) => snapshot.entries.length === 2), + Stream.runHead, + ); + assert.strictEqual(registry.get(status), before); + + yield* a.push({ entries: [entry("plan", "session-new")] }); + yield* waitForSession("session-new"); + const after = registry.get(status); + assert.notStrictEqual(after, before); + assert.notStrictEqual( + contributionStatusSourceKey(after[0]!.source), + contributionStatusSourceKey(before[0]!.source), + ); + unmount(); + }), + ), +); diff --git a/packages/client-runtime/src/state/contributionStatus.ts b/packages/client-runtime/src/state/contributionStatus.ts new file mode 100644 index 000000000000..e35f1d52b64e --- /dev/null +++ b/packages/client-runtime/src/state/contributionStatus.ts @@ -0,0 +1,108 @@ +import { + type ContributionStatusEntry, + type ContributionStatusSnapshot, + contributionStatusSourceKey, + type EnvironmentId, + type ServerConfig, + type ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentRpcSubscriptionAtomFamily } from "./runtime.ts"; + +const EMPTY_SNAPSHOT: ContributionStatusSnapshot = { entries: [] }; +const NO_ENTRIES: ReadonlyArray = []; + +/** Older servers lack the stream; clients show nothing for them and never subscribe. */ +function supportsContributionStatus(config: ServerConfig | null): boolean { + return config?.environment.capabilities.contributionStatus === true; +} + +/** A thread's entries, one per source, in the server's order. */ +function threadContributionStatusEntries( + snapshot: ContributionStatusSnapshot, + threadId: ThreadId, +): ReadonlyArray { + const entries = snapshot.entries.filter((entry) => entry.threadId === threadId); + return entries.length === 0 ? NO_ENTRIES : entries; +} + +/** Equal when every entry has the same source identity and the same items. */ +function sameEntries( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((entry, index) => { + const other = right[index]; + return ( + other !== undefined && + contributionStatusSourceKey(entry.source) === contributionStatusSourceKey(other.source) && + entry.items.length === other.items.length && + entry.items.every((item, itemIndex) => { + const otherItem = other.items[itemIndex]; + return ( + otherItem !== undefined && + item.key === otherItem.key && + item.text === otherItem.text && + item.tone === otherItem.tone && + item.tooltip === otherItem.tooltip + ); + }) + ); + }) + ); +} + +/** + * Live thread statuses per environment for the web and mobile renderers. + * Nothing subscribes until a renderer reads an atom, and only against servers + * that advertise the capability. Each server frame is a full replacement, so a + * reconnect's first frame drops statuses that ended while disconnected. + * + * `threadStatus` keeps each entry's source: renderers key an entry by + * `contributionStatusSourceKey(entry.source)` and an item by that plus its + * key, so a provider session taking over a thread re-keys its rows even when + * the text is unchanged. + */ +export function createContributionStatusEnvironmentAtoms( + runtime: Atom.AtomRuntime, + options: { + readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom; + }, +) { + const subscription = createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:contribution-status", + tag: WS_METHODS.subscribeContributionStatus, + }); + const snapshotAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ContributionStatusSnapshot => { + if (!supportsContributionStatus(get(options.configValueAtom(environmentId)))) { + return EMPTY_SNAPSHOT; + } + return Option.getOrElse( + AsyncResult.value(get(subscription({ environmentId, input: {} }))), + () => EMPTY_SNAPSHOT, + ); + }).pipe(Atom.withLabel(`environment-data:contribution-status:snapshot:${environmentId}`)), + ); + const threadStatusFamily = Atom.family((key: string) => { + const [environmentId, threadId] = JSON.parse(key) as [EnvironmentId, ThreadId]; + // A change on another thread keeps this thread's array, so its row does not re-render. + let previous = NO_ENTRIES; + return Atom.make((get) => { + const next = threadContributionStatusEntries(get(snapshotAtom(environmentId)), threadId); + if (!sameEntries(previous, next)) previous = next; + return previous; + }).pipe(Atom.withLabel(`environment-data:contribution-status:thread:${key}`)); + }); + return { + snapshot: snapshotAtom, + threadStatus: (environmentId: EnvironmentId, threadId: ThreadId) => + threadStatusFamily(JSON.stringify([environmentId, threadId])), + }; +} diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index 7e96a3039353..552cebede891 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -190,6 +190,10 @@ export function applyOrchestrationV2ProjectionEvent( const next = { ...base, runs: upsertEntity(base.runs, event.payload) }; return { ...next, visibleTurnItems: activeVisibleTurnItems(next) }; } + // Milestones over state the run rows already hold. + case "run.finalized": + case "run.finalization-failed": + return projection; case "run.background-work-cancelled": return { ...base, diff --git a/packages/client-runtime/src/state/pluginActions.test.ts b/packages/client-runtime/src/state/pluginActions.test.ts new file mode 100644 index 000000000000..fbbc5c180d63 --- /dev/null +++ b/packages/client-runtime/src/state/pluginActions.test.ts @@ -0,0 +1,298 @@ +import { + EnvironmentId, + PluginActionId, + ProjectId, + type PluginAction, + type PluginActionsSnapshot, + type ServerConfig, + ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type * as RpcSession from "../rpc/session.ts"; +import { + createPluginActionEnvironmentAtoms, + pluginActionLabels, + pluginActionsAt, + pluginActionsStream, +} from "./pluginActions.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const action = ( + name: string, + target: PluginAction["target"], + placements: PluginAction["placements"], +) => + ({ + id: PluginActionId.make(`installation-1:1:${name}`), + pluginId: "test.actions", + pluginName: "Actions", + name, + title: name, + target, + placements, + }) satisfies PluginAction; + +const ACTIONS = [ + action("everywhere", "environment", ["command-palette", "thread-menu"]), + action("on-project", "project", ["command-palette"]), + action("on-thread", "thread", ["command-palette", "composer-slash"]), +]; + +const ACTIONS_THEN_HOLD = Stream.succeed({ actions: ACTIONS }).pipe( + Stream.concat(Stream.never), +); + +/** A session to a server reporting `capabilities` and sending `frames`, recording each call. */ +const makeSession = ( + capabilities: ServerConfig["environment"]["capabilities"], + calls: Array, + frames: Stream.Stream = ACTIONS_THEN_HOLD, +): RpcSession.RpcSession => ({ + client: new Proxy( + {}, + { + get: (_target, method: string) => () => { + calls.push(method); + return method === WS_METHODS.pluginActionsSubscribe + ? frames + : Effect.succeed({ message: "done" }); + }, + }, + ) as WsRpcProtocolClient, + initialConfig: Effect.succeed({ environment: { capabilities } } as ServerConfig), + subscribeServerConfig: () => Stream.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, +}); + +/** + * One environment whose session reaches a server reporting `capabilities` and + * sending `frames` to the subscription, recording each call. + */ +const makeEnvironment = Effect.fn("makeEnvironment")(function* ( + capabilities: ServerConfig["environment"]["capabilities"], + frames: Stream.Stream = ACTIONS_THEN_HOLD, +) { + const calls: Array = []; + const sessionRef = yield* SubscriptionRef.make( + Option.some(makeSession(capabilities, calls, frames)), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: sessionRef, + prepared: yield* SubscriptionRef.make>(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const atoms = createPluginActionEnvironmentAtoms( + Atom.runtime( + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry.of({ + run, + followStream: (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]), + ), + ), + ); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const firstList = pluginActionsStream.pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + const invoke = Effect.promise(() => + atoms.invoke.run(registry, { + environmentId: TARGET.environmentId, + input: { actionId: ACTIONS[0]!.id, target: { _tag: "environment" } }, + }), + ); + const snapshots = AtomRegistry.toStreamResult( + registry, + atoms.snapshot({ environmentId: TARGET.environmentId, input: {} }), + ); + return { calls, firstList, invoke, sessionRef, snapshots, supervisor }; +}); + +const failureTag = (result: AsyncResult.AsyncResult) => + AsyncResult.isFailure(result) + ? Option.getOrUndefined(Cause.findErrorOption(result.cause) as Option.Option<{ _tag: string }>) + ?._tag + : undefined; + +describe("plugin actions on an older server", () => { + it.effect("offer nothing and send nothing", () => + Effect.scoped( + Effect.gen(function* () { + for (const capabilities of [ + { repositoryIdentity: true }, + { repositoryIdentity: true, plugins: true, pluginActions: false }, + ]) { + const { calls, firstList, invoke } = yield* makeEnvironment(capabilities); + expect(yield* firstList).toEqual({ actions: [] }); + expect(failureTag(yield* invoke)).toBe("EnvironmentRpcUnavailableError"); + expect(calls).toEqual([]); + } + }), + ), + ); + + it.effect("are listed and run on a server that announces them", () => + Effect.scoped( + Effect.gen(function* () { + const { calls, firstList, invoke } = yield* makeEnvironment({ + repositoryIdentity: true, + pluginActions: true, + }); + expect(yield* firstList).toEqual({ actions: ACTIONS }); + const result = yield* invoke; + expect(AsyncResult.isSuccess(result) && result.value).toEqual({ message: "done" }); + expect(calls).toEqual([WS_METHODS.pluginActionsSubscribe, WS_METHODS.pluginActionsInvoke]); + }), + ), + ); +}); + +describe("plugin actions across a reconnect", () => { + it.effect("follow the server the environment is connected to now", () => + Effect.scoped( + Effect.gen(function* () { + const { calls, invoke, sessionRef, supervisor } = yield* makeEnvironment({ + repositoryIdentity: true, + pluginActions: true, + }); + const lists = yield* Queue.unbounded(); + yield* pluginActionsStream.pipe( + Stream.runForEach((list) => Queue.offer(lists, list)), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkScoped, + ); + expect(yield* Queue.take(lists)).toEqual({ actions: ACTIONS }); + + // The environment reconnects to an older server: its actions disappear + // and a pick made from the old list is not sent there. + const oldCalls: Array = []; + yield* SubscriptionRef.set( + sessionRef, + Option.some(makeSession({ repositoryIdentity: true }, oldCalls)), + ); + expect(yield* Queue.take(lists)).toEqual({ actions: [] }); + expect(failureTag(yield* invoke)).toBe("EnvironmentRpcUnavailableError"); + expect(oldCalls).toEqual([]); + + // Back on a server with plugin actions, the list returns. + const newCalls: Array = []; + yield* SubscriptionRef.set( + sessionRef, + Option.some(makeSession({ repositoryIdentity: true, pluginActions: true }, newCalls)), + ); + expect(yield* Queue.take(lists)).toEqual({ actions: ACTIONS }); + expect(calls).toEqual([WS_METHODS.pluginActionsSubscribe]); + expect(newCalls).toEqual([WS_METHODS.pluginActionsSubscribe]); + }), + ), + ); +}); + +describe("the plugin actions snapshot", () => { + it.effect("keeps what the server's limit left out until room is freed", () => + Effect.scoped( + Effect.gen(function* () { + const frames = yield* Queue.unbounded(); + const { snapshots } = yield* makeEnvironment( + { repositoryIdentity: true, pluginActions: true }, + Stream.fromQueue(frames), + ); + const seen = yield* Queue.unbounded(); + yield* snapshots.pipe( + Stream.runForEach((snapshot) => Queue.offer(seen, snapshot)), + Effect.forkScoped, + ); + + yield* Queue.offer(frames, { actions: ACTIONS, omitted: { plugins: 1, actions: 16 } }); + expect(yield* Queue.take(seen)).toEqual({ + actions: ACTIONS, + omitted: { plugins: 1, actions: 16 }, + }); + + // Disabling a listed plugin admits the next whole; the server stops reporting omissions. + const promoted = [...ACTIONS, action("promoted", "environment", ["thread-menu"])]; + yield* Queue.offer(frames, { actions: promoted }); + const freed = yield* Queue.take(seen); + expect(freed).toEqual({ actions: promoted }); + expect(freed.omitted).toBeUndefined(); + }), + ), + ); +}); + +describe("pluginActionsAt", () => { + const names = (entries: ReturnType) => + entries.map(({ action, target }) => [action.name, target._tag]); + + it("offers an action only where it is placed and its target is known", () => { + const threadId = ThreadId.make("thread-1"); + const projectId = ProjectId.make("project-1"); + expect(names(pluginActionsAt(ACTIONS, "command-palette", { threadId, projectId }))).toEqual([ + ["everywhere", "environment"], + ["on-project", "project"], + ["on-thread", "thread"], + ]); + // A palette opened outside any thread or project. + expect( + names(pluginActionsAt(ACTIONS, "command-palette", { threadId: null, projectId: null })), + ).toEqual([["everywhere", "environment"]]); + expect(names(pluginActionsAt(ACTIONS, "thread-menu", { threadId, projectId }))).toEqual([ + ["everywhere", "environment"], + ]); + expect( + names(pluginActionsAt(ACTIONS, "composer-slash", { threadId: null, projectId })), + ).toEqual([]); + }); +}); + +describe("pluginActionLabels", () => { + it("names the plugin only when two actions share a title", () => { + const deploy = { ...action("deploy", "environment", ["thread-menu"]), title: "Deploy" }; + expect( + pluginActionLabels([ + deploy, + { ...deploy, id: PluginActionId.make("installation-2:1:deploy"), pluginName: "Other" }, + ACTIONS[0]!, + ]), + ).toEqual(["Deploy (Actions)", "Deploy (Other)", "everywhere"]); + }); +}); diff --git a/packages/client-runtime/src/state/pluginActions.ts b/packages/client-runtime/src/state/pluginActions.ts new file mode 100644 index 000000000000..43ee762800c3 --- /dev/null +++ b/packages/client-runtime/src/state/pluginActions.ts @@ -0,0 +1,125 @@ +import { + type ExecutionEnvironmentCapabilities, + type PluginAction, + type PluginActionPlacement, + type PluginActionsSnapshot, + type PluginActionTarget, + type ProjectId, + type ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type { Atom } from "effect/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import { requestIfSupported, subscribe } from "../rpc/client.ts"; +import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; + +const supportsPluginActions = ( + capabilities: Pick | null | undefined, +) => capabilities?.pluginActions === true; + +const NO_ACTIONS: PluginActionsSnapshot = { actions: [] }; + +/** + * The environment's plugin actions snapshot, following its sessions. A server + * without the capability is never subscribed to and offers none. `omitted` + * is kept so a surface can explain actions the server's limit left out. + */ +export const pluginActionsStream = Stream.unwrap( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.session).pipe( + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty, + onSome: (session) => + Stream.unwrap( + session.initialConfig.pipe( + Effect.map((config) => + supportsPluginActions(config.environment.capabilities) + ? subscribe(WS_METHODS.pluginActionsSubscribe, {}) + : Stream.succeed(NO_ACTIONS), + ), + Effect.orElseSucceed(() => Stream.empty), + ), + ), + }), + ), + ), + ), + ), +); + +export function createPluginActionEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + /** + * Every action the environment offers now, plus `omitted` counts when its + * limit left plugins out; no actions on servers without plugin actions. + */ + snapshot: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:plugin-actions", + subscribe: (_input: Record) => pluginActionsStream, + }), + /** Runs on the session it checked, so an older server never receives it. */ + invoke: createEnvironmentRpcCommand(runtime, { + label: "environment-data:plugin-actions:invoke", + tag: WS_METHODS.pluginActionsInvoke, + execute: (input) => + requestIfSupported(WS_METHODS.pluginActionsInvoke, input, supportsPluginActions), + }), + }; +} + +/** What a surface knows about where the user is. */ +export interface PluginActionContext { + readonly threadId: ThreadId | null; + readonly projectId: ProjectId | null; +} + +/** The target `action` runs on from `context`, or null when the surface cannot supply it. */ +function pluginActionTarget( + action: Pick, + context: PluginActionContext, +): PluginActionTarget | null { + switch (action.target) { + case "environment": + return { _tag: "environment" }; + case "project": + return context.projectId === null ? null : { _tag: "project", projectId: context.projectId }; + case "thread": + return context.threadId === null ? null : { _tag: "thread", threadId: context.threadId }; + } +} + +/** The actions a surface offers at `placement`, each with the target it would run on. */ +export function pluginActionsAt( + actions: ReadonlyArray, + placement: PluginActionPlacement, + context: PluginActionContext, +): ReadonlyArray<{ readonly action: PluginAction; readonly target: PluginActionTarget }> { + return actions.flatMap((action) => { + if (!action.placements.includes(placement)) return []; + const target = pluginActionTarget(action, context); + return target === null ? [] : [{ action, target }]; + }); +} + +/** Menu labels that stay distinct when two plugins use the same title. */ +export function pluginActionLabels(actions: ReadonlyArray): ReadonlyArray { + const titleCounts = new Map(); + for (const action of actions) { + titleCounts.set(action.title, (titleCounts.get(action.title) ?? 0) + 1); + } + return actions.map((action) => + (titleCounts.get(action.title) ?? 0) > 1 + ? `${action.title} (${action.pluginName})` + : action.title, + ); +} diff --git a/packages/contracts/src/contributionStatus.test.ts b/packages/contracts/src/contributionStatus.test.ts new file mode 100644 index 000000000000..ba18aaed1e38 --- /dev/null +++ b/packages/contracts/src/contributionStatus.test.ts @@ -0,0 +1,47 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { ContributionStatusSnapshot } from "./contributionStatus.ts"; + +const decodeSnapshot = Schema.decodeUnknownSync(ContributionStatusSnapshot); + +const piEntry = (items: ReadonlyArray) => ({ + threadId: "thread-1", + source: { + kind: "provider-session", + providerSessionId: "session-1", + providerInstanceId: "pi", + driver: "pi", + }, + items, +}); + +describe("ContributionStatusSnapshot", () => { + it("decodes a tone from a newer server as neutral instead of dropping the item", () => { + const snapshot = decodeSnapshot({ + entries: [piEntry([{ key: "build", text: "Building", tone: "celebrate" }])], + }); + expect(snapshot.entries[0]?.items).toEqual([ + { key: "build", text: "Building", tone: "neutral" }, + ]); + }); + + it("drops entries an older client cannot decode and keeps the rest", () => { + const snapshot = decodeSnapshot({ + entries: [ + { + ...piEntry([{ key: "a", text: "From a plugin" }]), + source: { kind: "plugin", pluginId: "x" }, + }, + piEntry([{ key: "a", text: "x".repeat(81) }]), + piEntry([{ key: "mode", text: "plan" }]), + ], + }); + expect(snapshot.entries.map((entry) => entry.items)).toEqual([[{ key: "mode", text: "plan" }]]); + }); + + it("rejects an entry with more items than one source may set", () => { + const items = Array.from({ length: 9 }, (_, index) => ({ key: `k${index}`, text: "on" })); + expect(decodeSnapshot({ entries: [piEntry(items)] }).entries).toEqual([]); + }); +}); diff --git a/packages/contracts/src/contributionStatus.ts b/packages/contracts/src/contributionStatus.ts new file mode 100644 index 000000000000..7f0ef1b73a4f --- /dev/null +++ b/packages/contracts/src/contributionStatus.ts @@ -0,0 +1,104 @@ +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; + +import { ForwardCompatibleArray, ProviderSessionId, ThreadId } from "./baseSchemas.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; + +/** + * Short status text a producer attaches to a thread, such as a Pi + * extension's `ctx.ui.setStatus(key, text)`. The server owns these in memory + * only: they are never persisted and disappear with their producer. + */ +export const CONTRIBUTION_STATUS_KEY_MAX_LENGTH = 64; +export const CONTRIBUTION_STATUS_TEXT_MAX_LENGTH = 80; +export const CONTRIBUTION_STATUS_TOOLTIP_MAX_LENGTH = 240; +export const CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE = 8; +/** + * Server-owned limits on a whole snapshot. They bound counts, not bytes: frame + * size also depends on identifier lengths and on how JSON encodes the text. + * Clients never enforce them; raising one needs a new capability. + */ +export const CONTRIBUTION_STATUS_MAX_SOURCES_PER_THREAD = 4; +export const CONTRIBUTION_STATUS_MAX_THREADS = 64; +export const CONTRIBUTION_STATUS_MAX_ITEMS = 128; + +const CONTRIBUTION_STATUS_TONES = ["neutral", "info", "success", "warning", "error"] as const; +const ContributionStatusToneLiteral = Schema.Literals(CONTRIBUTION_STATUS_TONES); +const isContributionStatusTone = Schema.is(ContributionStatusToneLiteral); + +/** Tones a newer server adds decode as `neutral` instead of dropping the item. */ +export const ContributionStatusTone = Schema.String.pipe( + Schema.decodeTo( + ContributionStatusToneLiteral, + SchemaTransformation.transform({ + decode: (tone) => (isContributionStatusTone(tone) ? tone : "neutral"), + encode: (tone) => tone, + }), + ), +); +export type ContributionStatusTone = typeof ContributionStatusTone.Type; + +export const ContributionStatusItem = Schema.Struct({ + /** Stable per source; setting an existing key replaces its item. */ + key: Schema.String.check( + Schema.isNonEmpty(), + Schema.isMaxLength(CONTRIBUTION_STATUS_KEY_MAX_LENGTH), + ), + /** Single line of plain text. Producers strip control characters and ANSI styling. */ + text: Schema.String.check( + Schema.isNonEmpty(), + Schema.isMaxLength(CONTRIBUTION_STATUS_TEXT_MAX_LENGTH), + ), + /** Absent means neutral. */ + tone: Schema.optionalKey(ContributionStatusTone), + tooltip: Schema.optionalKey( + Schema.String.check(Schema.isMaxLength(CONTRIBUTION_STATUS_TOOLTIP_MAX_LENGTH)), + ), +}); +export type ContributionStatusItem = typeof ContributionStatusItem.Type; + +/** + * Who set an entry's items. A thread has at most one provider-session source: + * a new provider session on the thread takes over the previous one's entry. + * Plugin sources are reserved for a later `kind`; each plugin will own its own + * entry beside the provider's. + */ +export const ContributionStatusSource = Schema.Struct({ + kind: Schema.Literal("provider-session"), + providerSessionId: ProviderSessionId, + providerInstanceId: ProviderInstanceId, + driver: ProviderDriverKind, +}); +export type ContributionStatusSource = typeof ContributionStatusSource.Type; + +/** + * Identity of a source, stable for its lifetime and distinct across a takeover. + * Renderers key an entry by it and an item by it plus the item key. + */ +export const contributionStatusSourceKey = (source: ContributionStatusSource): string => + JSON.stringify([source.kind, source.providerInstanceId, source.providerSessionId]); + +/** One source's items on one thread. Entry identity is the thread plus the source. */ +export const ContributionStatusEntry = Schema.Struct({ + threadId: ThreadId, + source: ContributionStatusSource, + /** Sorted by key, at most CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE, never empty. */ + items: Schema.Array(ContributionStatusItem).check( + Schema.isMinLength(1), + Schema.isMaxLength(CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE), + ), +}); +export type ContributionStatusEntry = typeof ContributionStatusEntry.Type; + +/** + * Every live status in one environment, ordered by thread id, then provider + * sources before any other kind, then source key. `subscribeContributionStatus` + * sends one on subscribe and a full replacement after each change, so a + * client replaces its copy and never merges. Entries an older client cannot + * decode, such as a future source kind, are dropped rather than failing the + * stream. + */ +export const ContributionStatusSnapshot = Schema.Struct({ + entries: ForwardCompatibleArray(ContributionStatusEntry), +}); +export type ContributionStatusSnapshot = typeof ContributionStatusSnapshot.Type; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 2a61480a907b..91d9d28902bf 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -213,6 +213,18 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ "server"`) and streams them over `/api/preview-stream`. Clients without a local browser runtime open server tabs here. */ serverBrowser: Schema.optionalKey(Schema.Boolean), + /** Server serves subscribeContributionStatus. Absent on older servers, which + lack the method, so clients must not subscribe and show no statuses. */ + contributionStatus: Schema.optionalKey(Schema.Boolean), + /** Server exposes the trusted local plugin catalogue (`plugins.*`). Absent on + older servers, so clients must not call or subscribe to those methods. */ + plugins: Schema.optionalKey(Schema.Boolean), + /** Server stores plugin setting values and secrets (`plugins.settings.*`). Absent on + older servers, so clients must not call or subscribe to those methods. */ + pluginSettings: Schema.optionalKey(Schema.Boolean), + /** Server lists and runs plugin actions (`pluginActions.*`). Absent on older + servers, so clients must not call or subscribe to those methods. */ + pluginActions: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d738ef79909e..3485ce846f66 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -53,9 +53,17 @@ export * from "./mcpApps.ts"; export * from "./browserImport.ts"; export * from "./browserProfile.ts"; export * from "./device.ts"; +export * from "./plugin.ts"; +export * from "./pluginCatalog.ts"; +export * from "./pluginEvents.ts"; +export * from "./pluginTools.ts"; +export * from "./pluginSettingFields.ts"; +export * from "./pluginSettings.ts"; +export * from "./pluginActions.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; +export * from "./contributionStatus.ts"; export * from "./usage.ts"; export * from "./scheduledTask.ts"; export * from "./worktreeMcp.ts"; diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index cd185f908d5c..c60689f5de79 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -348,6 +348,32 @@ describe("orchestration V2 contracts", () => { ).toThrow(); }); + it("decodes run finalization records as known thread events", () => { + const decodeWireItems = Schema.decodeUnknownSync( + Schema.toCodecJson(Schema.Array(OrchestrationV2RpcSchemas.subscribeThread.output)), + ); + const event = (sequence: number, type: string, payload: unknown) => ({ + kind: "event", + sequence, + event: { + id: `event:run-finalized:run-${sequence}`, + type, + threadId: "thread-1", + runId: `run-${sequence}`, + occurredAt: DateTime.formatIso(DateTime.makeUnsafe(0)), + payload, + }, + }); + const items = decodeWireItems([ + event(1, "run.finalized", { runId: "run-1", outcome: "interrupted", checkpointId: null }), + event(2, "run.finalization-failed", { runId: "run-2", operation: "refresh-workspace" }), + ]); + expect(items.map((item) => (item.kind === "event" ? item.event.payload : item.kind))).toEqual([ + { runId: "run-1", outcome: "interrupted", checkpointId: null }, + { runId: "run-2", operation: "refresh-workspace" }, + ]); + }); + it("negotiates bounded socket snapshots as an optional capability", () => { expect( decodeOrchestrationV2SubscribeThreadInput({ diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 7aa8c74fd80a..569dd089a5d0 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -540,6 +540,50 @@ export type OrchestrationV2ThreadLaunchWorkspaceStrategy = /** Failure code on the error item a failed workspace preparation leaves. */ export const ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE = "workspace_preparation_failed"; +/** + * How a finalized run ended. A rolled-back run is discarded, not finalized. + */ +export const OrchestrationV2RunFinalizedOutcome = Schema.Literals([ + "completed", + "failed", + "interrupted", + "cancelled", +]); +export type OrchestrationV2RunFinalizedOutcome = typeof OrchestrationV2RunFinalizedOutcome.Type; + +/** + * A run and its follow-up work are done: checkpoint capture and workspace + * refresh succeeded for runs that capture; the terminal write is the + * finalization for runs that never enqueue a capture. A run records this or + * `run.finalization-failed`, never both, at most once. The event's + * `occurredAt` is the finalization time. + */ +export const OrchestrationV2RunFinalized = Schema.Struct({ + runId: RunId, + outcome: OrchestrationV2RunFinalizedOutcome, + checkpointId: Schema.NullOr(CheckpointId), +}); +export type OrchestrationV2RunFinalized = typeof OrchestrationV2RunFinalized.Type; + +/** The finalization step that failed. */ +export const OrchestrationV2RunFinalizationOperation = Schema.Literals([ + "capture-checkpoint", + "refresh-workspace", + "record-finalized", +]); +export type OrchestrationV2RunFinalizationOperation = + typeof OrchestrationV2RunFinalizationOperation.Type; + +/** + * A run's finalization gave up after its last attempt, so the run will not + * record `run.finalized`. The run row keeps whatever status it reached. + */ +export const OrchestrationV2RunFinalizationFailed = Schema.Struct({ + runId: RunId, + operation: OrchestrationV2RunFinalizationOperation, +}); +export type OrchestrationV2RunFinalizationFailed = typeof OrchestrationV2RunFinalizationFailed.Type; + export const OrchestrationV2Run = Schema.Struct({ id: RunId, threadId: ThreadId, @@ -1695,6 +1739,16 @@ export const OrchestrationV2DomainEvent = Schema.Union([ type: Schema.Literal("run.background-work-cancelled"), payload: OrchestrationV2RunBackgroundWorkCancelled, }), + Schema.Struct({ + ...OrchestrationV2EventBase.fields, + type: Schema.Literal("run.finalized"), + payload: OrchestrationV2RunFinalized, + }), + Schema.Struct({ + ...OrchestrationV2EventBase.fields, + type: Schema.Literal("run.finalization-failed"), + payload: OrchestrationV2RunFinalizationFailed, + }), Schema.Struct({ ...OrchestrationV2EventBase.fields, type: Schema.Literal("run-attempt.created"), @@ -2515,6 +2569,16 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ type: Schema.Literal("run.background-work-cancelled"), payload: OrchestrationV2RunBackgroundWorkCancelled, }), + Schema.Struct({ + ...OrchestrationV2JsonEventBaseFields, + type: Schema.Literal("run.finalized"), + payload: OrchestrationV2RunFinalized, + }), + Schema.Struct({ + ...OrchestrationV2JsonEventBaseFields, + type: Schema.Literal("run.finalization-failed"), + payload: OrchestrationV2RunFinalizationFailed, + }), Schema.Struct({ ...OrchestrationV2JsonEventBaseFields, type: Schema.Literal("run-attempt.created"), diff --git a/packages/contracts/src/plugin.test.ts b/packages/contracts/src/plugin.test.ts new file mode 100644 index 000000000000..df8a3cf1c145 --- /dev/null +++ b/packages/contracts/src/plugin.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { ForwardCompatibleOptional } from "./baseSchemas.ts"; +import { PluginHostState, PluginManifest } from "./plugin.ts"; + +const decode = Schema.decodeUnknownExit(PluginManifest); + +const minimal = { + id: "acme.notifier", + name: "Notifier", + version: "1.2.3", + apiVersion: 1, + entry: "dist/main.mjs", +}; + +describe("PluginManifest", () => { + it("defaults optional fields and ignores keys from newer manifests", () => { + const decoded = decode({ ...minimal, contributes: { views: [] } }); + expect(Exit.isSuccess(decoded) && decoded.value).toEqual({ + ...minimal, + capabilities: [], + proposedApi: false, + }); + }); + + it("keeps an unknown API version decodable so the loader can explain it", () => { + expect(Exit.isSuccess(decode({ ...minimal, apiVersion: 2 }))).toBe(true); + }); + + it.each([ + ["an unqualified id", { id: "notifier" }], + ["an uppercase id", { id: "Acme.Notifier" }], + ["an absolute entry", { entry: "/tmp/main.mjs" }], + ["a parent entry", { entry: "lib/../../main.mjs" }], + ["a TypeScript entry", { entry: "main.ts" }], + ["a Windows entry", { entry: "C:\\main.mjs" }], + ["a malformed capability", { capabilities: ["Tools!"] }], + ])("rejects %s", (_label, override) => { + expect(Exit.isFailure(decode({ ...minimal, ...override }))).toBe(true); + }); +}); + +describe("PluginHostState on the client wire", () => { + const decodeRow = Schema.decodeUnknownExit( + Schema.Struct({ id: Schema.String, state: ForwardCompatibleOptional(PluginHostState) }), + ); + + it("keeps a row whose state comes from a newer server, with the state unknown", () => { + const decoded = decodeRow({ id: "acme.notifier", state: { _tag: "hibernating", since: 1 } }); + expect(Exit.isSuccess(decoded) && decoded.value).toEqual({ id: "acme.notifier" }); + }); + + it("decodes the states this build knows", () => { + const state = { _tag: "incompatible", reason: "uses top-level await" }; + const decoded = decodeRow({ id: "acme.notifier", state }); + expect(Exit.isSuccess(decoded) && decoded.value).toEqual({ id: "acme.notifier", state }); + }); +}); diff --git a/packages/contracts/src/plugin.ts b/packages/contracts/src/plugin.ts new file mode 100644 index 000000000000..18d1245a7520 --- /dev/null +++ b/packages/contracts/src/plugin.ts @@ -0,0 +1,110 @@ +/** + * Plugin - Schemas for trusted local plugins run by the environment server. + * + * A plugin is a directory with a `t3-plugin.json` manifest and one JavaScript + * entry module. The server runs each enabled plugin in its own supervised + * child process, started lazily on first use. Plugins are trusted OS-user + * code: the child process is an availability boundary, not a sandbox. + * + * @module Plugin + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { IsoDateTime, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PLUGIN_TOOL_LIMITS, PluginToolDeclaration } from "./pluginTools.ts"; +import { PluginSettingsDeclaration } from "./pluginSettingFields.ts"; +import { PLUGIN_ACTIONS_MAX_PER_PLUGIN, PluginActionDeclaration } from "./pluginActions.ts"; + +/** + * The one plugin API version this server implements. A manifest names the + * version it was written against; any other value is not loaded. Additive + * changes keep the version, removals and meaning changes bump it. New APIs + * ship under the manifest's `proposedApi` opt-in first. + */ +export const PLUGIN_API_VERSION = 1; + +export const PLUGIN_MANIFEST_FILE = "t3-plugin.json"; + +/** Owner-qualified id such as `acme.notifier`: two or more lowercase dot segments. */ +export const PluginId = TrimmedNonEmptyString.check( + Schema.isMaxLength(128), + Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/), +).pipe(Schema.brand("PluginId")); +export type PluginId = typeof PluginId.Type; + +/** + * A capability the plugin asks the host for. The host refuses to load a plugin + * that declares a capability it does not implement, so a plugin never runs + * with a silently missing feature. + */ +const PluginCapabilityName = Schema.String.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/), +); +export type PluginCapabilityName = typeof PluginCapabilityName.Type; + +/** Relative `.js` or `.mjs` path inside the plugin directory, without `..` segments. */ +const PluginEntryPath = Schema.String.check( + Schema.isMaxLength(256), + Schema.isPattern(/^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[^\\:\0]+\.m?js$/), +); + +export const PluginManifest = Schema.Struct({ + id: PluginId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + /** Display identity of the installed bytes; the host does not order versions. */ + version: TrimmedNonEmptyString.check(Schema.isMaxLength(64)), + description: Schema.optional(Schema.String.check(Schema.isMaxLength(500))), + apiVersion: Schema.Int, + entry: PluginEntryPath, + capabilities: Schema.Array(PluginCapabilityName) + .check(Schema.isMaxLength(32)) + .pipe(Schema.withDecodingDefault(Effect.succeed([]))), + /** Opts into APIs that may change or disappear without an API version bump. */ + proposedApi: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** Tools for agents; needs the `tools` capability (see PluginTools). */ + tools: Schema.optionalKey( + Schema.Array(PluginToolDeclaration).check( + Schema.isMaxLength(PLUGIN_TOOL_LIMITS.maxToolsPerPlugin), + ), + ), + /** Fields users can set for this plugin; needs the `settings` capability. */ + settings: Schema.optionalKey(PluginSettingsDeclaration), + /** Commands for clients to offer; needs the `actions` capability (see PluginActions). */ + actions: Schema.optionalKey( + Schema.Array(PluginActionDeclaration).check(Schema.isMaxLength(PLUGIN_ACTIONS_MAX_PER_PLUGIN)), + ), +}); +export type PluginManifest = typeof PluginManifest.Type; + +const PluginFailureReason = Schema.String.check(Schema.isMaxLength(1000)); + +/** + * Runtime state of one enabled plugin's child process. `idle` means no child + * runs: either it has not been needed yet or it stopped cleanly. After a + * crash the plugin waits in `backoff` and becomes `idle` again at `retryAt`; + * too many consecutive failures park it in `quarantined` until someone resumes + * it explicitly. `incompatible` means the plugin's code cannot run on any + * server of this version (for example it uses top-level await); it does not + * count as a failure and also waits for an explicit resume. + * + * Clients must decode this through `ForwardCompatibleOptional` and treat an + * absent value as unknown: never as idle, disabled, or safe to enable. + */ +export const PluginHostState = Schema.Union([ + Schema.TaggedStruct("idle", {}), + Schema.TaggedStruct("starting", {}), + Schema.TaggedStruct("running", {}), + Schema.TaggedStruct("backoff", { + failures: NonNegativeInt, + reason: PluginFailureReason, + retryAt: IsoDateTime, + }), + Schema.TaggedStruct("quarantined", { + failures: NonNegativeInt, + reason: PluginFailureReason, + }), + Schema.TaggedStruct("incompatible", { reason: PluginFailureReason }), +]); +export type PluginHostState = typeof PluginHostState.Type; diff --git a/packages/contracts/src/pluginActions.test.ts b/packages/contracts/src/pluginActions.test.ts new file mode 100644 index 000000000000..0bc3a21147ee --- /dev/null +++ b/packages/contracts/src/pluginActions.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { PluginManifest } from "./plugin.ts"; +import { PluginActionsSnapshot } from "./pluginActions.ts"; + +const action = { + id: "installation-1:1:open-dashboard", + pluginId: "acme.dashboard", + pluginName: "Dashboard", + name: "open-dashboard", + title: "Open dashboard", + target: "thread", + placements: ["command-palette", "thread-menu"], +}; +const decodeSnapshot = Schema.decodeUnknownExit(PluginActionsSnapshot); +const decodeManifest = Schema.decodeUnknownExit(PluginManifest); + +describe("PluginActionsSnapshot from a newer server", () => { + it("drops placements and actions this client cannot offer, keeping the rest", () => { + const decoded = decodeSnapshot({ + actions: [ + { ...action, placements: ["toolbar", "thread-menu"], icon: "rocket" }, + // A target kind this client cannot supply: the action is not offered at all. + { ...action, id: "installation-1:1:deploy", name: "deploy", target: "selection" }, + ], + }); + expect(Exit.isSuccess(decoded)).toBe(true); + if (!Exit.isSuccess(decoded)) return; + expect(decoded.value.actions).toEqual([{ ...action, placements: ["thread-menu"] }]); + }); + + it("carries what the environment bound left out, and older frames without it", () => { + const omitted = { plugins: 2, actions: 20 }; + const decoded = decodeSnapshot({ actions: [action], omitted }); + expect(Exit.isSuccess(decoded) && decoded.value.omitted).toEqual(omitted); + const older = decodeSnapshot({ actions: [action] }); + expect(Exit.isSuccess(older) && older.value).toEqual({ actions: [action] }); + }); +}); + +describe("PluginManifest actions", () => { + const manifest = { + id: "acme.dashboard", + name: "Dashboard", + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + capabilities: ["actions"], + proposedApi: true, + }; + + it("refuses a malformed or unbounded declaration", () => { + const declaration = { + name: "open", + title: "Open", + target: "thread", + placements: ["thread-menu"], + }; + expect(Exit.isSuccess(decodeManifest({ ...manifest, actions: [declaration] }))).toBe(true); + for (const actions of [ + [{ ...declaration, name: "Open Dashboard" }], + [{ ...declaration, title: "x".repeat(61) }], + [{ ...declaration, placements: [] }], + [{ ...declaration, target: "selection" }], + Array.from({ length: 17 }, (_, index) => ({ ...declaration, name: `open-${index}` })), + ]) { + expect(Exit.isFailure(decodeManifest({ ...manifest, actions }))).toBe(true); + } + }); +}); diff --git a/packages/contracts/src/pluginActions.ts b/packages/contracts/src/pluginActions.ts new file mode 100644 index 000000000000..270cb183e65e --- /dev/null +++ b/packages/contracts/src/pluginActions.ts @@ -0,0 +1,148 @@ +/** + * PluginActions - Commands that trusted local plugins add to the command + * palette, the thread menu, and the composer's slash menu. + * + * A plugin declares its actions in `t3-plugin.json`, so they are covered by + * the digest the user consented to and listing them never starts the plugin. + * The server lists the actions of every enabled installation under opaque, + * server-issued ids that name the installation's current registration. A + * click runs the plugin's `action:` handler against one target: the + * environment, a project, or a thread. A stale id (the plugin was disabled, + * removed, changed, or enabled again since the list was sent) is refused; it + * never reaches a newer registration. + * + * Gated by `ExecutionEnvironmentCapabilities.pluginActions`. + * + * @module PluginActions + */ +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; + +import { + ForwardCompatibleArray, + NonNegativeInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; + +export const PLUGIN_ACTIONS_MAX_PER_PLUGIN = 16; +/** The most actions one environment offers at once, across all its plugins. */ +export const PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT = 128; +/** The most bytes a snapshot's UTF-8 JSON encoding takes. */ +export const PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES = 128 * 1024; +const PLUGIN_ACTION_NAME_MAX_LENGTH = 48; +const PLUGIN_ACTION_TITLE_MAX_LENGTH = 60; +const PLUGIN_ACTION_DESCRIPTION_MAX_LENGTH = 240; +export const PLUGIN_ACTION_MESSAGE_MAX_LENGTH = 500; + +/** Unique within one plugin; also the slash command, `/`. */ +const PluginActionName = Schema.String.check( + Schema.isMaxLength(PLUGIN_ACTION_NAME_MAX_LENGTH), + Schema.isPattern(/^[a-z][a-z0-9-]*$/), +); + +/** What the action runs against. The client supplies the matching target when it invokes. */ +const PluginActionTargetKind = Schema.Literals(["environment", "project", "thread"]); + +/** Where clients offer the action. Clients ignore placements they do not know. */ +const PluginActionPlacement = Schema.Literals(["command-palette", "thread-menu", "composer-slash"]); +export type PluginActionPlacement = typeof PluginActionPlacement.Type; +const isPluginActionPlacement = Schema.is(PluginActionPlacement); + +/** + * Placements as sent to clients; a placement this client does not know is + * dropped by itself. `ForwardCompatibleArray` cannot do this here: nested in + * the actions array, dropping one placement would drop the whole action. + */ +const OfferedPluginActionPlacements = Schema.Array(Schema.String).pipe( + Schema.decodeTo( + Schema.Array(PluginActionPlacement), + SchemaTransformation.transform, ReadonlyArray>({ + decode: (placements) => placements.filter(isPluginActionPlacement), + encode: (placements) => placements, + }), + ), +); + +/** One entry of the manifest's `actions` array. */ +export const PluginActionDeclaration = Schema.Struct({ + name: PluginActionName, + title: TrimmedNonEmptyString.check(Schema.isMaxLength(PLUGIN_ACTION_TITLE_MAX_LENGTH)), + description: Schema.optionalKey( + Schema.String.check(Schema.isMaxLength(PLUGIN_ACTION_DESCRIPTION_MAX_LENGTH)), + ), + target: PluginActionTargetKind, + placements: Schema.Array(PluginActionPlacement).check( + Schema.isMinLength(1), + Schema.isMaxLength(PluginActionPlacement.literals.length), + ), +}); + +/** Opaque and server-issued. Clients pass it back unchanged and never parse it. */ +export const PluginActionId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)).pipe( + Schema.brand("PluginActionId"), +); +export type PluginActionId = typeof PluginActionId.Type; + +const PluginAction = Schema.Struct({ + id: PluginActionId, + /** Display only: the manifest's plugin id and name. */ + pluginId: Schema.String, + pluginName: Schema.String, + name: Schema.String, + title: Schema.String, + description: Schema.optionalKey(Schema.String), + target: PluginActionTargetKind, + placements: OfferedPluginActionPlacements, +}); +export type PluginAction = typeof PluginAction.Type; + +/** + * Every action the environment offers now. Each frame replaces the previous one. + * + * Plugins are offered whole, in catalogue order, until the next one would pass + * `PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT` or `PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES`. + * That plugin and every later one are left out and counted in `omitted`, and + * their actions cannot be run. + */ +export const PluginActionsSnapshot = Schema.Struct({ + actions: ForwardCompatibleArray(PluginAction), + /** Present only when plugins were left out. */ + omitted: Schema.optionalKey(Schema.Struct({ plugins: NonNegativeInt, actions: NonNegativeInt })), +}); +export type PluginActionsSnapshot = typeof PluginActionsSnapshot.Type; + +const PluginActionTarget = Schema.Union([ + Schema.TaggedStruct("environment", {}), + Schema.TaggedStruct("project", { projectId: ProjectId }), + Schema.TaggedStruct("thread", { threadId: ThreadId }), +]); +export type PluginActionTarget = typeof PluginActionTarget.Type; + +export const PluginActionInvokeInput = Schema.Struct({ + actionId: PluginActionId, + /** Must be the kind the action declares. */ + target: PluginActionTarget, +}); +export type PluginActionInvokeInput = typeof PluginActionInvokeInput.Type; + +export const PluginActionInvokeResult = Schema.Struct({ + /** What the plugin wants the user to read, one bounded string; null when it said nothing. */ + message: Schema.NullOr(Schema.String), +}); +export type PluginActionInvokeResult = typeof PluginActionInvokeResult.Type; + +/** + * `reason` is an open set so newer servers can add cases: `not-found` (no + * such action now), `stale` (the plugin was enabled again since the list was + * sent), `target-mismatch`, `target-not-found`, `unavailable`, `busy`, + * `timeout`, `stopped`, `failed` (the plugin threw; `message` is its own). + */ +export class PluginActionError extends Schema.TaggedError()( + "PluginActionError", + { + reason: Schema.String, + message: Schema.String, + }, +) {} diff --git a/packages/contracts/src/pluginCatalog.test.ts b/packages/contracts/src/pluginCatalog.test.ts new file mode 100644 index 000000000000..9fc07ac14df8 --- /dev/null +++ b/packages/contracts/src/pluginCatalog.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { PluginCatalogSnapshot, pluginInstallationStatus } from "./pluginCatalog.ts"; + +const digest = `sha256:${"a".repeat(64)}`; +const installation = { + installationId: "installation-1", + generation: 1, + directory: "/srv/plugins/notifier", + manifest: { + id: "acme.notifier", + name: "Notifier", + version: "1.0.0", + capabilities: [], + proposedApi: false, + }, + source: { digest, files: 2, bytes: 120 }, + problem: null, + inspectedAt: "2026-10-04T00:00:00.000Z", + consent: { digest, capabilities: [], grantedAt: "2026-10-04T00:00:00.000Z" }, + enabled: true, + hostState: { _tag: "running" }, + addedAt: "2026-10-04T00:00:00.000Z", +}; +const decodeSnapshot = Schema.decodeUnknownExit(PluginCatalogSnapshot); + +describe("PluginCatalogSnapshot from a newer server", () => { + it("keeps an installation whose state or fields this client does not know", () => { + const decoded = decodeSnapshot({ + installations: [ + { ...installation, hostState: { _tag: "hibernating" }, publisher: "acme" }, + { + ...installation, + installationId: "installation-2", + // Relaxed id rules and new capability names on a newer server. + manifest: { ...installation.manifest, id: "notifier", capabilities: ["tools"] }, + }, + ], + }); + expect(Exit.isSuccess(decoded)).toBe(true); + if (!Exit.isSuccess(decoded)) return; + const [unknownState, relaxed] = decoded.value.installations; + expect(unknownState?.hostState).toBeUndefined(); + expect(unknownState && pluginInstallationStatus(unknownState)).toBe("enabled"); + expect(relaxed?.manifest?.capabilities).toEqual(["tools"]); + expect(relaxed?.hostState).toEqual({ _tag: "running" }); + }); +}); + +describe("PluginInstallation.eventDelivery", () => { + it("decodes known delivery states and keeps the row for unknown or missing ones", () => { + const quarantined = { _tag: "quarantined", failures: 5, reason: "onEvent failed" }; + const decoded = decodeSnapshot({ + installations: [ + { ...installation, eventDelivery: quarantined }, + { ...installation, installationId: "installation-2", eventDelivery: { _tag: "paused" } }, + // An older server sends no delivery state at all. + { ...installation, installationId: "installation-3" }, + ], + }); + expect(Exit.isSuccess(decoded)).toBe(true); + if (!Exit.isSuccess(decoded)) return; + const [known, unknown, older] = decoded.value.installations; + expect(known?.eventDelivery).toEqual(quarantined); + expect(unknown?.eventDelivery).toBeUndefined(); + expect(unknown?.hostState).toEqual({ _tag: "running" }); + expect(older?.eventDelivery).toBeUndefined(); + }); +}); + +describe("pluginInstallationStatus", () => { + it("asks for consent again when the bytes differ from what was approved", () => { + expect(pluginInstallationStatus(installation)).toBe("enabled"); + expect(pluginInstallationStatus({ ...installation, enabled: false })).toBe("disabled"); + expect(pluginInstallationStatus({ ...installation, consent: null, enabled: false })).toBe( + "needs-consent", + ); + const changed = { ...installation.source, digest: `sha256:${"b".repeat(64)}` }; + expect(pluginInstallationStatus({ ...installation, source: changed, enabled: false })).toBe( + "needs-consent", + ); + expect( + pluginInstallationStatus({ ...installation, source: null, problem: "gone", enabled: false }), + ).toBe("unavailable"); + }); +}); diff --git a/packages/contracts/src/pluginCatalog.ts b/packages/contracts/src/pluginCatalog.ts new file mode 100644 index 000000000000..88d9cb02a872 --- /dev/null +++ b/packages/contracts/src/pluginCatalog.ts @@ -0,0 +1,171 @@ +/** + * PluginCatalog - Wire schemas for managing the trusted local plugins an + * environment runs. + * + * A plugin is installed by adding a directory on the server's machine. The + * server reads its manifest and computes a digest of the directory's exact + * bytes; nothing in the directory runs until someone consents to that digest + * and enables the installation. Any later byte change moves the installation + * back to needing consent and stops it. Disable and remove are always allowed. + * + * Installations are environment-local: the same plugin on two environments is + * two records. `installationId` is the identity; `generation` counts how many + * times the server has registered the installation to run, so a consumer can + * tell a replacement from the registration it was talking to. + * + * @module PluginCatalog + */ +import * as Schema from "effect/Schema"; + +import { + ForwardCompatibleArray, + ForwardCompatibleOptional, + IsoDateTime, + NonNegativeInt, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +import { PluginHostState } from "./plugin.ts"; +import { PluginEventDeliveryState } from "./pluginEvents.ts"; +import { PluginToolDeclaration } from "./pluginTools.ts"; +import { PluginSettingsFieldList } from "./pluginSettingFields.ts"; +import { PluginActionDeclaration } from "./pluginActions.ts"; + +export const PluginInstallationId = TrimmedNonEmptyString.check(Schema.isMaxLength(64)).pipe( + Schema.brand("PluginInstallationId"), +); +export type PluginInstallationId = typeof PluginInstallationId.Type; + +/** `sha256:` and the hex digest of every file in the plugin directory. */ +const PluginSourceDigest = Schema.String.check(Schema.isPattern(/^sha256:[0-9a-f]{64}$/)); + +/** + * What the manifest said when the server last read it. Ids and capability + * names are plain strings here so an older client keeps rows whose values a + * newer server accepts. + */ +const PluginInstallationManifest = Schema.Struct({ + id: TrimmedNonEmptyString.pipe(Schema.brand("PluginId")), + name: Schema.String, + /** Display metadata only; never proof of the installed bytes. */ + version: Schema.String, + description: Schema.optionalKey(Schema.String), + capabilities: Schema.Array(Schema.String), + proposedApi: Schema.Boolean, + /** The declared tools, when there are any. Unknown shapes from a newer server are dropped. */ + tools: Schema.optionalKey(ForwardCompatibleArray(PluginToolDeclaration)), + /** Declared settings fields (`settings` capability); absent when the plugin has none. */ + settings: Schema.optionalKey(PluginSettingsFieldList), + /** The declared actions, when there are any. Unknown shapes from a newer server are dropped. */ + actions: Schema.optionalKey(ForwardCompatibleArray(PluginActionDeclaration)), +}); +export type PluginInstallationManifest = typeof PluginInstallationManifest.Type; + +/** The exact bytes the server found at its last inspection. */ +const PluginSource = Schema.Struct({ + digest: PluginSourceDigest, + files: NonNegativeInt, + bytes: NonNegativeInt, +}); +export type PluginSource = typeof PluginSource.Type; + +/** Consent to run one exact source with the capabilities its manifest declared. */ +const PluginConsent = Schema.Struct({ + digest: PluginSourceDigest, + capabilities: Schema.Array(Schema.String), + grantedAt: IsoDateTime, +}); + +export const PluginInstallation = Schema.Struct({ + installationId: PluginInstallationId, + generation: NonNegativeInt, + /** Absolute path on the server's machine. */ + directory: Schema.String, + /** From the last inspection that could read the manifest. */ + manifest: Schema.NullOr(PluginInstallationManifest), + /** Null when the last inspection failed; `problem` says why. */ + source: Schema.NullOr(PluginSource), + problem: Schema.NullOr(Schema.String), + /** When the current manifest, source, and problem were found; a check that finds the same keeps it. */ + inspectedAt: IsoDateTime, + consent: Schema.NullOr(PluginConsent), + /** Registered to run. Only true while `consent.digest` matches `source.digest`. */ + enabled: Schema.Boolean, + /** + * The plugin process's state while enabled. Absent when disabled, and also + * when this client does not know the state a newer server sent: treat + * absence on an enabled installation as unknown, never as idle or stopped. + */ + hostState: ForwardCompatibleOptional(PluginHostState), + /** + * Event delivery while enabled, for a plugin that declares `events`. Absent + * otherwise, from a server without it, and when this client does not know + * the state a newer server sent: treat absence as unknown, never as healthy. + */ + eventDelivery: ForwardCompatibleOptional(PluginEventDeliveryState), + addedAt: IsoDateTime, +}); +export type PluginInstallation = typeof PluginInstallation.Type; + +type PluginInstallationStatus = "unavailable" | "needs-consent" | "enabled" | "disabled"; + +/** What the user can do next with an installation. */ +export const pluginInstallationStatus = ( + installation: Pick, +): PluginInstallationStatus => { + if (installation.problem !== null || installation.source === null) return "unavailable"; + if (installation.consent?.digest !== installation.source.digest) return "needs-consent"; + return installation.enabled ? "enabled" : "disabled"; +}; + +export const PluginCatalogSnapshot = Schema.Struct({ + installations: Schema.Array(PluginInstallation), +}); +export type PluginCatalogSnapshot = typeof PluginCatalogSnapshot.Type; + +export const PluginAddInput = Schema.Struct({ + /** Absolute path of the plugin directory on the server's machine. */ + directory: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), +}); +export type PluginAddInput = typeof PluginAddInput.Type; + +export const PluginInstallationInput = Schema.Struct({ + installationId: PluginInstallationId, +}); +export type PluginInstallationInput = typeof PluginInstallationInput.Type; + +export const PluginRefreshInput = Schema.Struct({ + /** Omit to inspect every installation. */ + installationId: Schema.optionalKey(PluginInstallationId), +}); +export type PluginRefreshInput = typeof PluginRefreshInput.Type; + +export const PluginConsentInput = Schema.Struct({ + installationId: PluginInstallationId, + /** The digest the user was shown. Consent fails if the bytes have changed since. */ + digest: PluginSourceDigest, +}); +export type PluginConsentInput = typeof PluginConsentInput.Type; + +export const PluginInstallationResult = Schema.Struct({ + installation: PluginInstallation, +}); +export type PluginInstallationResult = typeof PluginInstallationResult.Type; + +export const PluginRemoveResult = Schema.Struct({ + installationId: PluginInstallationId, +}); +export type PluginRemoveResult = typeof PluginRemoveResult.Type; + +/** + * `reason` is an open set so newer servers can add cases: `not-found`, + * `invalid-directory`, `already-added`, `source-changed`, `consent-required`, + * `plugin-id-conflict`, `unavailable`, `storage`, `generation-changed`. + */ +export class PluginCatalogError extends Schema.TaggedError()( + "PluginCatalogError", + { + reason: Schema.String, + message: Schema.String, + installationId: Schema.optionalKey(PluginInstallationId), + }, +) {} diff --git a/packages/contracts/src/pluginEvents.ts b/packages/contracts/src/pluginEvents.ts new file mode 100644 index 000000000000..04a6618b39a6 --- /dev/null +++ b/packages/contracts/src/pluginEvents.ts @@ -0,0 +1,131 @@ +/** + * PluginEvents - The curated event projection delivered to plugins that + * declare the `events` capability. + * + * The server reads its durable event log for each such plugin from a + * persisted, per-installation cursor and hands the plugin small, ordered + * pages. A page is acknowledged only when the plugin's handlers return for + * every event in it; the cursor then moves past the page. Delivery is + * at-least-once: a crash or failure after a side effect and before the + * acknowledgement delivers the same events again, so handlers deduplicate by + * `deliveryId`. + * + * The projection carries identifiers, outcomes and the thread title, never + * message bodies. New event types and new optional fields are additive: + * handlers must ignore types they do not know. + * + * @module PluginEvents + */ +import * as Schema from "effect/Schema"; + +import { + EnvironmentId, + EventId, + IsoDateTime, + NonNegativeInt, + ProjectId, + RunId, + ThreadId, +} from "./baseSchemas.ts"; +import { + OrchestrationV2RunFinalizationOperation, + OrchestrationV2RunFinalizedOutcome, +} from "./orchestrationV2.ts"; + +/** The manifest capability a plugin declares to receive events. */ +export const PLUGIN_EVENTS_CAPABILITY = "events"; + +/** Most events in one delivered page. */ +const PLUGIN_EVENT_PAGE_MAX_EVENTS = 256; + +/** Longest thread title in an event, in UTF-16 code units. Longer titles are cut. */ +export const PLUGIN_EVENT_THREAD_TITLE_MAX_LENGTH = 200; + +/** + * The thread an event belongs to, as it is when the event is delivered (not + * when it happened). `null` when the thread has been deleted since. + */ +const PluginEventThread = Schema.Struct({ + projectId: ProjectId, + title: Schema.String.check(Schema.isMaxLength(PLUGIN_EVENT_THREAD_TITLE_MAX_LENGTH)), +}); + +const PluginEventBase = Schema.Struct({ + /** + * Stable identity for deduplication: the environment-local id of the stored + * event. Redelivery repeats it. `run.finalized` and `run.finalization-failed` + * for one run share it, since a run records exactly one of them. Combine it + * with `environmentId` when events from several environments meet. + */ + deliveryId: EventId, + /** Position in the environment's event log; increases within and across pages. */ + sequence: NonNegativeInt, + occurredAt: IsoDateTime, + environmentId: EnvironmentId, + threadId: ThreadId, + runId: RunId, + thread: Schema.NullOr(PluginEventThread), +}); + +/** + * A turn finished: the run ended and its finalization (checkpoint capture and + * workspace refresh, when the run had them) completed. + */ +export const PluginRunFinalizedEvent = Schema.Struct({ + ...PluginEventBase.fields, + type: Schema.Literal("run.finalized"), + outcome: OrchestrationV2RunFinalizedOutcome, +}); +export type PluginRunFinalizedEvent = typeof PluginRunFinalizedEvent.Type; + +/** A run ended but its finalization was abandoned at `operation`. */ +export const PluginRunFinalizationFailedEvent = Schema.Struct({ + ...PluginEventBase.fields, + type: Schema.Literal("run.finalization-failed"), + operation: OrchestrationV2RunFinalizationOperation, +}); +export type PluginRunFinalizationFailedEvent = typeof PluginRunFinalizationFailedEvent.Type; + +export const PluginEvent = Schema.Union([ + PluginRunFinalizedEvent, + PluginRunFinalizationFailedEvent, +]); +export type PluginEvent = typeof PluginEvent.Type; + +export const PLUGIN_EVENT_TYPES = [ + "run.finalized", + "run.finalization-failed", +] as const satisfies ReadonlyArray; + +/** One delivery: events in log order, acknowledged together. */ +export const PluginEventPage = Schema.Struct({ + events: Schema.Array(PluginEvent).check(Schema.isMaxLength(PLUGIN_EVENT_PAGE_MAX_EVENTS)), +}); +export type PluginEventPage = typeof PluginEventPage.Type; + +const PluginEventDeliveryReason = Schema.String.check(Schema.isMaxLength(1000)); + +/** + * How delivery to one enabled installation that declares `events` is going. + * It is separate from the plugin process's state: a running plugin can have + * quarantined delivery. `plugins.resume` clears both. + * + * - `active`: delivering, or waiting for the next event. + * - `retrying`: a page failed; the same page is tried again at `retryAt`. + * - `quarantined`: stopped in front of a page after `failures` consecutive + * failures, or in front of an event the server cannot read (`failures` 0). + * Nothing is delivered until resume, a re-enable, or a server restart. + */ +export const PluginEventDeliveryState = Schema.Union([ + Schema.TaggedStruct("active", {}), + Schema.TaggedStruct("retrying", { + failures: NonNegativeInt, + reason: PluginEventDeliveryReason, + retryAt: IsoDateTime, + }), + Schema.TaggedStruct("quarantined", { + failures: NonNegativeInt, + reason: PluginEventDeliveryReason, + }), +]); +export type PluginEventDeliveryState = typeof PluginEventDeliveryState.Type; diff --git a/packages/contracts/src/pluginSettingFields.ts b/packages/contracts/src/pluginSettingFields.ts new file mode 100644 index 000000000000..7e030c25c593 --- /dev/null +++ b/packages/contracts/src/pluginSettingFields.ts @@ -0,0 +1,183 @@ +/** + * PluginSettingFields - Typed settings a plugin declares in its manifest. + * + * A plugin that declares the `settings` capability lists its fields under + * `settings` in `t3-plugin.json`. Values belong to the installation: they + * survive disable, re-enable, restarts and source changes, and are deleted + * when the installation is removed. Every field applies to the whole + * environment. + * + * Secret values are write-only. Clients can set, replace or clear a secret + * and learn whether one is saved, but the server never sends a secret back. + * Only the plugin itself reads it. + * + * @module PluginSettingFields + */ +import * as Schema from "effect/Schema"; + +import { ForwardCompatibleArray, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** The manifest capability that grants settings, secrets and plugin storage. */ +export const PLUGIN_SETTINGS_CAPABILITY = "settings"; + +export const PLUGIN_SETTINGS_MAX_FIELDS = 32; +const PLUGIN_SETTING_MAX_OPTIONS = 32; +const PLUGIN_SETTING_TEXT_MAX_LENGTH = 2000; +const PLUGIN_SETTING_SECRET_MAX_LENGTH = 8192; + +/** A setting's identity within its installation, such as `apiUrl`. */ +export const PluginSettingKey = Schema.String.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[A-Za-z][A-Za-z0-9_.-]*$/), +); +export type PluginSettingKey = typeof PluginSettingKey.Type; + +const PluginSettingLabel = TrimmedNonEmptyString.check(Schema.isMaxLength(60)); +const PluginSettingDescription = Schema.String.check(Schema.isMaxLength(240)); +const PluginSettingText = Schema.String.check(Schema.isMaxLength(PLUGIN_SETTING_TEXT_MAX_LENGTH)); + +/** A select option. `value` is what is saved and must stay stable; `label` is display text. */ +const PluginSettingOption = Schema.Struct({ + value: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(64)), + label: PluginSettingLabel, +}); + +const fieldBase = { + key: PluginSettingKey, + label: PluginSettingLabel, + description: Schema.optionalKey(PluginSettingDescription), +}; + +const PluginTextSettingField = Schema.Struct({ + type: Schema.Literal("text"), + ...fieldBase, + default: Schema.optionalKey(PluginSettingText), +}); + +/** Never has a default, and its value never leaves the server. */ +const PluginSecretSettingField = Schema.Struct({ + type: Schema.Literal("secret"), + ...fieldBase, +}); + +const PluginBooleanSettingField = Schema.Struct({ + type: Schema.Literal("boolean"), + ...fieldBase, + default: Schema.optionalKey(Schema.Boolean), +}); + +const PluginNumberSettingField = Schema.Struct({ + type: Schema.Literal("number"), + ...fieldBase, + default: Schema.optionalKey(Schema.Finite), + min: Schema.optionalKey(Schema.Finite), + max: Schema.optionalKey(Schema.Finite), + integer: Schema.optionalKey(Schema.Boolean), +}); + +const PluginSelectSettingField = Schema.Struct({ + type: Schema.Literal("select"), + ...fieldBase, + options: Schema.Array(PluginSettingOption).check( + Schema.isMinLength(1), + Schema.isMaxLength(PLUGIN_SETTING_MAX_OPTIONS), + ), + default: Schema.optionalKey(Schema.String), +}); + +/** A saved setting value; which one a field takes depends on its `type`. */ +export const PluginSettingValue = Schema.Union([Schema.String, Schema.Finite, Schema.Boolean]); +export type PluginSettingValue = typeof PluginSettingValue.Type; + +const PluginSettingFieldShape = Schema.Union([ + PluginTextSettingField, + PluginSecretSettingField, + PluginBooleanSettingField, + PluginNumberSettingField, + PluginSelectSettingField, +]); +type PluginSettingFieldShape = typeof PluginSettingFieldShape.Type; + +/** + * Why `value` cannot be saved for `field`, or undefined when it can. The + * message never repeats the value, so it is safe for secrets. + */ +export const pluginSettingValueProblem = ( + field: PluginSettingFieldShape, + value: PluginSettingValue, +): string | undefined => { + switch (field.type) { + case "text": + if (typeof value !== "string") return `${field.label} must be text.`; + if (value.length > PLUGIN_SETTING_TEXT_MAX_LENGTH) + return `${field.label} must be at most ${PLUGIN_SETTING_TEXT_MAX_LENGTH} characters.`; + return undefined; + case "secret": + if (typeof value !== "string" || value.length === 0) + return `${field.label} must be non-empty text.`; + if (value.length > PLUGIN_SETTING_SECRET_MAX_LENGTH) + return `${field.label} must be at most ${PLUGIN_SETTING_SECRET_MAX_LENGTH} characters.`; + return undefined; + case "boolean": + return typeof value === "boolean" ? undefined : `${field.label} must be on or off.`; + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) + return `${field.label} must be a number.`; + if (field.integer === true && !Number.isInteger(value)) + return `${field.label} must be a whole number.`; + if (field.min !== undefined && value < field.min) + return `${field.label} must be at least ${field.min}.`; + if (field.max !== undefined && value > field.max) + return `${field.label} must be at most ${field.max}.`; + return undefined; + case "select": + return typeof value === "string" && field.options.some((option) => option.value === value) + ? undefined + : `${field.label} must be one of its options.`; + } +}; + +const fieldDeclarationProblem = (field: PluginSettingFieldShape): string | undefined => { + if (field.type === "number" && field.min !== undefined && field.max !== undefined) + if (field.min > field.max) return `${field.key}: min is greater than max.`; + if (field.type === "select") { + const values = new Set(field.options.map((option) => option.value)); + if (values.size !== field.options.length) return `${field.key}: option values repeat.`; + } + if (field.type !== "secret" && field.default !== undefined) { + const problem = pluginSettingValueProblem(field, field.default); + if (problem !== undefined) return `${field.key}: the default is invalid. ${problem}`; + } + return undefined; +}; + +/** One declared setting. */ +export const PluginSettingField = PluginSettingFieldShape.check( + Schema.makeFilter((field) => fieldDeclarationProblem(field) ?? true), +); +export type PluginSettingField = typeof PluginSettingField.Type; + +/** The `settings` list of a manifest: unique keys, at most 32 fields. */ +export const PluginSettingsDeclaration = Schema.Array(PluginSettingField).check( + Schema.isMaxLength(PLUGIN_SETTINGS_MAX_FIELDS), + Schema.makeFilter((fields) => { + const keys = new Set(fields.map((field) => field.key)); + return keys.size === fields.length || "settings: keys repeat."; + }), +); + +/** + * The declared fields as clients read them from the catalogue. A field type a + * newer server knows and this client does not is dropped, not the whole row. + */ +export const PluginSettingsFieldList = ForwardCompatibleArray(PluginSettingField); + +/** The value a plugin reads for a non-secret field: what was saved if it still fits, else the default. */ +export const resolvePluginSettingValue = ( + field: PluginSettingField, + saved: PluginSettingValue | undefined, +): PluginSettingValue | undefined => { + if (field.type === "secret") return undefined; + if (saved !== undefined && pluginSettingValueProblem(field, saved) === undefined) return saved; + return field.default; +}; diff --git a/packages/contracts/src/pluginSettings.test.ts b/packages/contracts/src/pluginSettings.test.ts new file mode 100644 index 000000000000..1867fd8eb9c8 --- /dev/null +++ b/packages/contracts/src/pluginSettings.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { PluginManifest } from "./plugin.ts"; +import { PluginCatalogSnapshot } from "./pluginCatalog.ts"; +import { + PluginSettingsDeclaration, + pluginSettingValueProblem, + resolvePluginSettingValue, + type PluginSettingField, +} from "./pluginSettingFields.ts"; +import { PluginSettingsValues } from "./pluginSettings.ts"; + +const decodeDeclaration = Schema.decodeUnknownExit(PluginSettingsDeclaration); +const decodeManifest = Schema.decodeUnknownSync(PluginManifest); +const decodeSnapshot = Schema.decodeUnknownSync(PluginCatalogSnapshot); +const decodeValues = Schema.decodeUnknownSync(PluginSettingsValues); +const select = { + type: "select", + key: "mode", + label: "Mode", + options: [ + { value: "safe", label: "Safe" }, + { value: "fast", label: "Fast" }, + ], + default: "safe", +} as const; + +describe("PluginSettingsDeclaration", () => { + it("accepts each field type and refuses inconsistent declarations", () => { + const declaration = [ + { type: "text", key: "apiUrl", label: "API URL", default: "https://example.com" }, + // A default on a secret is not part of its shape and is dropped. + { type: "secret", key: "token", label: "Token", default: "leaked" }, + { type: "boolean", key: "verbose", label: "Verbose" }, + { type: "number", key: "retries", label: "Retries", min: 0, max: 5, integer: true }, + select, + ]; + const decoded = decodeDeclaration(declaration); + expect(Exit.isSuccess(decoded)).toBe(true); + if (Exit.isSuccess(decoded)) expect(decoded.value[1]).not.toHaveProperty("default"); + + for (const invalid of [ + [select, select], + [{ ...select, default: "turbo" }], + [{ ...select, options: [select.options[0], select.options[0]] }], + [{ ...select, options: [] }], + [{ type: "number", key: "n", label: "N", min: 3, max: 1 }], + [{ type: "number", key: "n", label: "N", integer: true, default: 1.5 }], + [{ type: "text", key: "1st", label: "First" }], + [{ type: "color", key: "c", label: "C" }], + ]) + expect(Exit.isFailure(decodeDeclaration(invalid))).toBe(true); + }); + + it("is optional in the manifest", () => { + const manifest = decodeManifest({ + id: "acme.notifier", + name: "Notifier", + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + }); + expect(manifest.settings).toBeUndefined(); + }); +}); + +describe("setting values", () => { + const retries: PluginSettingField = { + type: "number", + key: "retries", + label: "Retries", + min: 0, + max: 5, + integer: true, + default: 2, + }; + + it("checks a value against its field", () => { + expect(pluginSettingValueProblem(retries, 3)).toBeUndefined(); + expect(pluginSettingValueProblem(retries, 6)).toBe("Retries must be at most 5."); + expect(pluginSettingValueProblem(retries, "3")).toBe("Retries must be a number."); + expect(pluginSettingValueProblem(select, "fast")).toBeUndefined(); + expect(pluginSettingValueProblem(select, "Fast")).toBe("Mode must be one of its options."); + }); + + it("falls back to the default when a saved value no longer fits its field", () => { + expect(resolvePluginSettingValue(retries, 4)).toBe(4); + // For example after an update narrowed the range. + expect(resolvePluginSettingValue(retries, 9)).toBe(2); + expect(resolvePluginSettingValue(retries, undefined)).toBe(2); + expect( + resolvePluginSettingValue({ type: "secret", key: "token", label: "Token" }, "x"), + ).toBeUndefined(); + }); +}); + +describe("from a newer server", () => { + it("drops field types and values this client does not know, not the installation", () => { + const digest = `sha256:${"a".repeat(64)}`; + const snapshot = decodeSnapshot({ + installations: [ + { + installationId: "installation-1", + generation: 1, + directory: "/srv/plugins/notifier", + manifest: { + id: "acme.notifier", + name: "Notifier", + version: "1.0.0", + capabilities: ["settings"], + proposedApi: true, + settings: [{ type: "color", key: "accent", label: "Accent" }, select], + }, + source: { digest, files: 2, bytes: 120 }, + problem: null, + inspectedAt: "2026-10-04T00:00:00.000Z", + consent: null, + enabled: false, + addedAt: "2026-10-04T00:00:00.000Z", + }, + ], + }); + expect(snapshot.installations[0]?.manifest?.settings?.map((field) => field.key)).toEqual([ + "mode", + ]); + + const values = decodeValues({ + installationId: "installation-1", + values: [ + { key: "mode", value: "fast" }, + { key: "tags", value: ["a", "b"] }, + ], + secrets: ["token"], + }); + expect(values.values).toEqual([{ key: "mode", value: "fast" }]); + }); +}); diff --git a/packages/contracts/src/pluginSettings.ts b/packages/contracts/src/pluginSettings.ts new file mode 100644 index 000000000000..59e91a54be8f --- /dev/null +++ b/packages/contracts/src/pluginSettings.ts @@ -0,0 +1,51 @@ +/** + * PluginSettings - Wire schemas for reading and saving an installation's + * setting values (`plugins.settings.*`, gated on the `pluginSettings` + * environment capability). + * + * Values belong to the installation, not to one registration: they survive + * disable, re-enable, restarts and source changes, and are deleted when the + * installation is removed. A write is checked against the fields the + * installation's manifest declares when the write arrives. + * + * @module PluginSettings + */ +import * as Schema from "effect/Schema"; + +import { ForwardCompatibleArray } from "./baseSchemas.ts"; +import { PluginInstallationId } from "./pluginCatalog.ts"; +import { + PLUGIN_SETTINGS_MAX_FIELDS, + PluginSettingKey, + PluginSettingValue, +} from "./pluginSettingFields.ts"; + +/** What clients see of an installation's saved settings. */ +export const PluginSettingsValues = Schema.Struct({ + installationId: PluginInstallationId, + /** Saved non-secret values. A field without one uses its default. */ + values: ForwardCompatibleArray(Schema.Struct({ key: Schema.String, value: PluginSettingValue })), + /** Keys of secret fields that have a saved value. The values themselves are never sent. */ + secrets: Schema.Array(Schema.String), +}); +export type PluginSettingsValues = typeof PluginSettingsValues.Type; + +export const PluginSettingsInput = Schema.Struct({ + installationId: PluginInstallationId, +}); +export type PluginSettingsInput = typeof PluginSettingsInput.Type; + +const PluginSettingChange = Schema.Struct({ + key: PluginSettingKey, + /** `null` clears the saved value: a field returns to its default, a secret is deleted. */ + value: Schema.NullOr(PluginSettingValue), +}); + +export const PluginSettingsUpdateInput = Schema.Struct({ + installationId: PluginInstallationId, + changes: Schema.Array(PluginSettingChange).check( + Schema.isMinLength(1), + Schema.isMaxLength(PLUGIN_SETTINGS_MAX_FIELDS), + ), +}); +export type PluginSettingsUpdateInput = typeof PluginSettingsUpdateInput.Type; diff --git a/packages/contracts/src/pluginTools.ts b/packages/contracts/src/pluginTools.ts new file mode 100644 index 000000000000..d0af749de9b0 --- /dev/null +++ b/packages/contracts/src/pluginTools.ts @@ -0,0 +1,193 @@ +/** + * PluginTools - Tools a trusted local plugin offers to agents. + * + * A plugin declares its tools in `t3-plugin.json`, next to the `tools` + * capability and `proposedApi: true`, so they are covered by the digest the + * user consented to and listing them never starts the plugin: + * + * ```json + * "capabilities": ["tools"], + * "proposedApi": true, + * "tools": [{ + * "name": "word_count", + * "description": "Count the words in a text.", + * "inputSchema": { + * "type": "object", + * "properties": { "text": { "type": "string" } }, + * "required": ["text"], + * "additionalProperties": false + * }, + * "sideEffect": "read" + * }] + * ``` + * + * Each tool runs the handler the plugin registers as `t3.tool.`: + * + * ```js + * context.proposed.handle("t3.tool.word_count", ({ input }) => ({ + * words: input.text.split(/\s+/).length, + * })); + * ``` + * + * Agents never see one MCP tool per plugin tool. Every provider session gets + * the same two fixed tools: `plugin_tools_list` shows the declarations below, + * qualified by plugin id, and `plugin_tool_call` calls one by that name. The + * host checks every call against the declared input schema before the plugin + * sees it. `sideEffect` and `openWorld` are metadata for the agent, not + * enforcement: the plugin is trusted local code. + * + * Input schemas use a JSON Schema (draft 2020-12) subset, and the host + * enforces each keyword it accepts with JSON Schema's own meaning. A manifest + * using anything else is refused when the plugin is added, naming the keyword: + * + * - `type`: one of, or an array of, `object`, `array`, `string`, `number`, + * `integer`, `boolean`, `null`. The root is `"type": "object"`. + * - objects: `properties`, `required` (names from `properties`), + * `additionalProperties` as `true` or `false` (omitted means `true`). + * - arrays: `items`, `minItems`, `maxItems`. + * - strings: `minLength`, `maxLength`, counted in Unicode code points. + * - numbers and integers: `minimum`, `maximum`, `exclusiveMinimum`, + * `exclusiveMaximum`. + * - `enum` and `const` with string, number, boolean, or null values. + * - `anyOf`. + * - `$defs` at the root and `"$ref": "#/$defs/"`. A reference cycle + * must pass through `properties` or `items`. + * - Annotations, shown but not enforced: `title`, `description`, `default`, + * `examples`, `deprecated`, `readOnly`, `writeOnly`, `format`, `$comment`. + * The host never fills in a `default`. + * + * Type-specific keywords need their `type`; `$ref` and `anyOf` take only + * annotations beside them. Every keyword must have its JSON Schema shape (a + * `null` is refused, never read as absent), and every `$defs` entry is held to + * the subset whether or not it is referenced. A plugin written with Effect Schema can generate the + * schema at build time: + * + * ```ts + * const { schema, definitions } = Schema.toJsonSchemaDocument(Input, { onExcessProperty: "error" }); + * const inputSchema = Object.keys(definitions).length === 0 ? schema : { ...schema, $defs: definitions }; + * ``` + * + * @module PluginTools + */ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** The manifest capability a plugin declares to offer tools. */ +export const PLUGIN_TOOLS_CAPABILITY = "tools"; + +/** Handler names starting with this run declared tools; the prefix is reserved. */ +export const PLUGIN_TOOL_HANDLER_PREFIX = "t3.tool."; + +/** + * Handler that runs one tool. It receives `{ input, context: { environmentId, threadId } }`: + * `input` already matched the tool's schema, and `context` comes from the calling + * session's credential, never from the agent. + */ +export const pluginToolHandlerName = (name: PluginToolName) => + `${PLUGIN_TOOL_HANDLER_PREFIX}${name}`; + +export const PLUGIN_TOOL_LIMITS = { + maxToolsPerPlugin: 32, + /** Serialized `inputSchema` of one tool. */ + maxInputSchemaBytes: 16 * 1024, + /** Nesting of schemas inside one `inputSchema`. */ + maxInputSchemaDepth: 32, + /** One plugin's tools as `plugin_tools_list` shows them, so every plugin fits on a page. */ + maxPluginListingBytes: 48 * 1024, + /** A whole serialized `plugin_tools_list` result. */ + maxListBytes: 64 * 1024, + /** Serialized result of one tool call. */ + maxResultBytes: 64 * 1024, + defaultTimeoutSeconds: 60, + maxTimeoutSeconds: 600, +} as const; + +/** Unique within its plugin; agents address it as `/`. */ +export const PluginToolName = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9_]{0,63}$/)); +export type PluginToolName = typeof PluginToolName.Type; + +/** + * What a tool may do: `read` only observes, `write` changes state, and + * `destructive` may lose data. Shown to the agent; not enforced. + */ +export const PluginToolSideEffect = Schema.Literals(["read", "write", "destructive"]); +export type PluginToolSideEffect = typeof PluginToolSideEffect.Type; + +const PluginToolTitle = Schema.String.check(Schema.isMaxLength(100)); +const PluginToolDescriptionText = TrimmedNonEmptyString.check(Schema.isMaxLength(2000)); + +/** A JSON Schema object in the subset above; the root describes an object. */ +const JsonSchemaObject = Schema.Record(Schema.String, Schema.Json); + +/** One entry of the manifest's `tools` array. */ +export const PluginToolDeclaration = Schema.Struct({ + name: PluginToolName, + title: Schema.optionalKey(PluginToolTitle), + description: PluginToolDescriptionText, + inputSchema: JsonSchemaObject, + sideEffect: PluginToolSideEffect, + /** True when the tool reaches outside this machine (network, external services). */ + openWorld: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** Deadline of the tool's handler once its plugin runs; default 60. */ + timeoutSeconds: Schema.optionalKey( + Schema.Int.check( + Schema.isBetween({ minimum: 1, maximum: PLUGIN_TOOL_LIMITS.maxTimeoutSeconds }), + ), + ), +}); +export type PluginToolDeclaration = typeof PluginToolDeclaration.Type; + +/** `/`, the name agents call a tool by. */ +export const qualifyPluginToolName = (pluginId: string, name: PluginToolName) => + `${pluginId}/${name}`; + +const decodeToolName = Schema.decodeUnknownOption(PluginToolName); + +/** Splits `/` at the first `/`; plugin ids never contain one. */ +export const parseQualifiedPluginToolName = (tool: string) => { + const slash = tool.indexOf("/"); + if (slash <= 0) return Option.none(); + return decodeToolName(tool.slice(slash + 1)).pipe( + Option.map((name) => ({ pluginId: tool.slice(0, slash), name })), + ); +}; + +const PluginToolPlugin = Schema.Struct({ id: Schema.String, name: Schema.String }); + +/** One tool as `plugin_tools_list` shows it. */ +export const PluginToolListing = Schema.Struct({ + tool: Schema.String, + plugin: PluginToolPlugin, + title: Schema.optionalKey(Schema.String), + description: Schema.String, + /** Exactly the declared schema; calls are checked against it. */ + inputSchema: JsonSchemaObject, + sideEffect: PluginToolSideEffect, + openWorld: Schema.Boolean, +}); +export type PluginToolListing = typeof PluginToolListing.Type; + +/** + * One page of tools, ordered by plugin id. A plugin's tools are never split + * across pages. Pass `nextCursor` back as `cursor` for the next page. + */ +export const PluginToolsListResult = Schema.Struct({ + tools: Schema.Array(PluginToolListing), + /** Tool plugins enabled after this session started; a new session can use them. */ + notInThisSession: Schema.Array(PluginToolPlugin), + nextCursor: Schema.optionalKey(Schema.String), +}); +export type PluginToolsListResult = typeof PluginToolsListResult.Type; + +/** + * Why a plugin tool call or listing failed. `reason` is an open string; in + * use: `not-granted`, `unavailable`, `unknown-tool`, `invalid-input`, + * `timeout`, `failed`, `result-too-large`. + */ +export class PluginToolError extends Schema.TaggedError()("PluginToolError", { + reason: Schema.String, + message: Schema.String, +}) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 36afee2f9efa..7311c429e733 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -26,6 +26,7 @@ import * as Rpc from "effect/rpc/Rpc"; import * as RpcGroup from "effect/rpc/RpcGroup"; import * as RpcMiddleware from "effect/rpc/RpcMiddleware"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ContributionStatusSnapshot } from "./contributionStatus.ts"; import { CodexAuthCallbackInput, CodexAuthCallbackState, @@ -341,6 +342,27 @@ import { ScheduledTaskMutationResult, } from "./scheduledTask.ts"; import { SecretRequestAnswerInput, SecretRequestError } from "./secretRequest.ts"; +import { + PluginAddInput, + PluginCatalogError, + PluginCatalogSnapshot, + PluginConsentInput, + PluginInstallationInput, + PluginInstallationResult, + PluginRefreshInput, + PluginRemoveResult, +} from "./pluginCatalog.ts"; +import { + PluginSettingsInput, + PluginSettingsUpdateInput, + PluginSettingsValues, +} from "./pluginSettings.ts"; +import { + PluginActionError, + PluginActionInvokeInput, + PluginActionInvokeResult, + PluginActionsSnapshot, +} from "./pluginActions.ts"; import { ProjectCloneActionInput, ProjectCloneActionResult, @@ -514,6 +536,24 @@ export const WS_METHODS = { scheduledTasksListWebhookDeliveries: "scheduledTasks.listWebhookDeliveries", scheduledTasksGetWebhookDelivery: "scheduledTasks.getWebhookDelivery", + // Trusted local plugins (gated on the `plugins` environment capability) + pluginsList: "plugins.list", + pluginsSubscribe: "plugins.subscribe", + pluginsAdd: "plugins.add", + pluginsRefresh: "plugins.refresh", + pluginsConsent: "plugins.consent", + pluginsEnable: "plugins.enable", + pluginsDisable: "plugins.disable", + pluginsRemove: "plugins.remove", + pluginsResume: "plugins.resume", + // Plugin setting values (gated on the `pluginSettings` environment capability) + pluginsSettingsSubscribe: "plugins.settings.subscribe", + pluginsSettingsUpdate: "plugins.settings.update", + + // Plugin actions (gated on the `pluginActions` environment capability) + pluginActionsSubscribe: "pluginActions.subscribe", + pluginActionsInvoke: "pluginActions.invoke", + // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -572,6 +612,7 @@ export const WS_METHODS = { subscribeAuthAccess: "subscribeAuthAccess", subscribeBackgroundPolicy: "subscribeBackgroundPolicy", subscribeResourceTelemetry: "subscribeResourceTelemetry", + subscribeContributionStatus: "subscribeContributionStatus", } as const; const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -1795,6 +1836,102 @@ const WsScheduledTasksGetWebhookDeliveryRpc = Rpc.make( error: Schema.Union([ScheduledTaskError, EnvironmentAuthorizationError]), }, ); +const pluginRpcError = Schema.Union([PluginCatalogError, EnvironmentAuthorizationError]); + +const WsPluginsListRpc = Rpc.make(WS_METHODS.pluginsList, { + payload: Schema.Struct({}), + success: PluginCatalogSnapshot, + error: pluginRpcError, +}); + +/** One snapshot on subscribe, then a fresh one after every catalogue or plugin state change. */ +const WsPluginsSubscribeRpc = Rpc.make(WS_METHODS.pluginsSubscribe, { + payload: Schema.Struct({}), + success: PluginCatalogSnapshot, + error: pluginRpcError, + stream: true, +}); + +/** Reads the manifest and digests the directory; runs nothing. */ +const WsPluginsAddRpc = Rpc.make(WS_METHODS.pluginsAdd, { + payload: PluginAddInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +/** Inspects the bytes again; an enabled installation whose bytes changed is stopped. */ +const WsPluginsRefreshRpc = Rpc.make(WS_METHODS.pluginsRefresh, { + payload: PluginRefreshInput, + success: PluginCatalogSnapshot, + error: pluginRpcError, +}); + +const WsPluginsConsentRpc = Rpc.make(WS_METHODS.pluginsConsent, { + payload: PluginConsentInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +const WsPluginsEnableRpc = Rpc.make(WS_METHODS.pluginsEnable, { + payload: PluginInstallationInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +const WsPluginsDisableRpc = Rpc.make(WS_METHODS.pluginsDisable, { + payload: PluginInstallationInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +/** + * Disables and forgets the installation and deletes its saved settings and + * storage. The directory is left untouched. + */ +const WsPluginsRemoveRpc = Rpc.make(WS_METHODS.pluginsRemove, { + payload: PluginInstallationInput, + success: PluginRemoveResult, + error: pluginRpcError, +}); + +/** Clears backoff, quarantine, or incompatibility; the next use starts a fresh process. */ +const WsPluginsResumeRpc = Rpc.make(WS_METHODS.pluginsResume, { + payload: PluginInstallationInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +/** The installation's saved values now, then after every change; fails `not-found` once it is removed. */ +const WsPluginsSettingsSubscribeRpc = Rpc.make(WS_METHODS.pluginsSettingsSubscribe, { + payload: PluginSettingsInput, + success: PluginSettingsValues, + error: pluginRpcError, + stream: true, +}); + +/** Saves or clears values; all changes are checked before any is saved. Never echoes a secret. */ +const WsPluginsSettingsUpdateRpc = Rpc.make(WS_METHODS.pluginsSettingsUpdate, { + payload: PluginSettingsUpdateInput, + success: PluginSettingsValues, + error: pluginRpcError, +}); + +const pluginActionRpcError = Schema.Union([PluginActionError, EnvironmentAuthorizationError]); + +/** The actions of every enabled plugin now, then a fresh list after every change. */ +const WsPluginActionsSubscribeRpc = Rpc.make(WS_METHODS.pluginActionsSubscribe, { + payload: Schema.Struct({}), + success: PluginActionsSnapshot, + error: pluginActionRpcError, + stream: true, +}); + +/** Runs one listed action against its target; starts the plugin if it is not running. */ +const WsPluginActionsInvokeRpc = Rpc.make(WS_METHODS.pluginActionsInvoke, { + payload: PluginActionInvokeInput, + success: PluginActionInvokeResult, + error: pluginActionRpcError, +}); const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { payload: Schema.Struct({}), @@ -1817,6 +1954,14 @@ const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTel stream: true, }); +/** Streams every live thread status in the environment: one snapshot on subscribe, then a full replacement after each change. Gated by the `contributionStatus` capability. */ +const WsSubscribeContributionStatusRpc = Rpc.make(WS_METHODS.subscribeContributionStatus, { + payload: Schema.Struct({}), + success: ContributionStatusSnapshot, + error: EnvironmentAuthorizationError, + stream: true, +}); + /** * Checks the connection's scopes against the scope each RPC declares, before * the handler runs. Every RPC in `WsRpcGroup` carries it, so a handler cannot @@ -1887,6 +2032,19 @@ export const WsRpcGroup = RpcGroup.make( WsSecretsAnswerRequestRpc, WsScheduledTasksListWebhookDeliveriesRpc, WsScheduledTasksGetWebhookDeliveryRpc, + WsPluginsListRpc, + WsPluginsSubscribeRpc, + WsPluginsAddRpc, + WsPluginsRefreshRpc, + WsPluginsConsentRpc, + WsPluginsEnableRpc, + WsPluginsDisableRpc, + WsPluginsRemoveRpc, + WsPluginsResumeRpc, + WsPluginsSettingsSubscribeRpc, + WsPluginsSettingsUpdateRpc, + WsPluginActionsSubscribeRpc, + WsPluginActionsInvokeRpc, WsServerReportClientActivityRpc, WsServerReportHostPowerStateRpc, WsServerGetBackgroundPolicyRpc, @@ -2001,6 +2159,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeAuthAccessRpc, WsSubscribeBackgroundPolicyRpc, WsSubscribeResourceTelemetryRpc, + WsSubscribeContributionStatusRpc, WsOrchestrationV2DispatchCommandRpc, WsOrchestrationV2GetWorkflowScriptRpc, WsOrchestrationV2GetTurnItemRpc, diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index c45de9a9b149..5586621a8613 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -23,6 +23,10 @@ "types": "./src/server/collectStreamText.ts", "import": "./src/server/collectStreamText.ts" }, + "./server/ContributionStatusStore": { + "types": "./src/server/ContributionStatusStore.ts", + "import": "./src/server/ContributionStatusStore.ts" + }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" diff --git a/packages/provider-core/src/server/ContributionStatusStore.test.ts b/packages/provider-core/src/server/ContributionStatusStore.test.ts new file mode 100644 index 000000000000..16ef272350bc --- /dev/null +++ b/packages/provider-core/src/server/ContributionStatusStore.test.ts @@ -0,0 +1,280 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + CONTRIBUTION_STATUS_MAX_ITEMS, + CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE, + CONTRIBUTION_STATUS_MAX_THREADS, + type ContributionStatusSource, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as ContributionStatusStore from "./ContributionStatusStore.ts"; + +const THREAD_A = ThreadId.make("thread-a"); +const THREAD_B = ThreadId.make("thread-b"); + +const source = (session: string): ContributionStatusSource => ({ + kind: "provider-session", + providerSessionId: ProviderSessionId.make(session), + providerInstanceId: ProviderInstanceId.make("pi"), + driver: ProviderDriverKind.make("pi"), +}); + +/** Opens a handle in its own scope so a test can end that producer on demand. */ +const openHandle = Effect.fn("openHandle")(function* ( + store: ContributionStatusStore.ContributionStatusStoreShape, + session: string, + threadId: ThreadId | null, +) { + const scope = yield* Scope.make(); + const handle = yield* store.openSource(source(session)).pipe(Scope.provide(scope)); + yield* handle.bindThread(threadId); + return { handle, close: Scope.close(scope, Exit.void) }; +}); + +const itemsByThread = (store: ContributionStatusStore.ContributionStatusStoreShape) => + Effect.map(store.snapshot, (snapshot) => + Object.fromEntries( + snapshot.entries.map((entry) => [ + entry.threadId, + entry.items.map((item) => `${item.key}=${item.text}`), + ]), + ), + ); + +describe("ContributionStatusStore", () => { + it.effect("sets, replaces, and clears statuses as plain single-line text", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + + yield* handle.set({ key: "mode", text: "\u001b[32m● plan\u001b[39m\nmode" }); + yield* handle.set({ key: "branch", text: "main" }); + assert.deepStrictEqual(yield* itemsByThread(store), { + [THREAD_A]: ["branch=main", "mode=● plan mode"], + }); + + yield* handle.set({ key: "mode", text: "build" }); + yield* handle.clear("branch"); + assert.deepStrictEqual(yield* itemsByThread(store), { [THREAD_A]: ["mode=build"] }); + + // A lone surrogate would encode as a six-byte JSON escape; it becomes U+FFFD. + yield* handle.set({ key: "lone", text: "a\uD800b", tooltip: "\uDC00" }); + const lone = (yield* store.snapshot).entries[0]?.items.find((item) => item.key === "lone"); + assert.deepStrictEqual(lone, { key: "lone", text: "a\uFFFDb", tooltip: "\uFFFD" }); + + yield* handle.set({ key: "mode", text: " \t " }); + yield* handle.clear("lone"); + assert.deepStrictEqual(yield* itemsByThread(store), {}); + }), + ); + + it.effect("bounds keys, text, and items per source", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + + for (let index = 0; index < 9; index += 1) { + yield* handle.set({ key: `k${index}`, text: "on" }); + } + yield* handle.set({ key: "k0", text: `${"a".repeat(78)}😀tail` }); + + const [entry] = (yield* store.snapshot).entries; + assert.strictEqual(entry?.items.length, 8); + assert.isFalse(entry?.items.some((item) => item.key === "k8")); + // The emoji would straddle the limit, so it is dropped rather than split. + assert.strictEqual(entry?.items[0]?.text, `${"a".repeat(78)}…`); + }), + ); + + it.effect("treats keys as identities that never collide", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + const prefix = "x".repeat(63); + + yield* handle.set({ key: `${prefix}A`, text: "first" }); + yield* handle.set({ key: `${prefix}B`, text: "second" }); + yield* handle.set({ key: "a b", text: "spaced" }); + yield* handle.set({ key: "a b", text: "double spaced" }); + // Overlong or control-character keys are rejected, never rewritten into another key. + yield* handle.set({ key: `${prefix}AB`, text: "overlong" }); + yield* handle.set({ key: "a\nb", text: "control" }); + yield* handle.set({ key: "a\uD800", text: "lone surrogate" }); + assert.deepStrictEqual(yield* itemsByThread(store), { + [THREAD_A]: ["a b=double spaced", "a b=spaced", `${prefix}A=first`, `${prefix}B=second`], + }); + + // Clearing applies the same rule, so it cannot remove a neighbouring key. + yield* handle.clear(`${prefix}AB`); + yield* handle.clear("a\tb"); + yield* handle.clear("a\uD800"); + yield* handle.clear(`${prefix}A`); + yield* handle.clear("a b"); + assert.deepStrictEqual(yield* itemsByThread(store), { + [THREAD_A]: ["a b=double spaced", `${prefix}B=second`], + }); + }), + ); + + it.effect("clears a producer's statuses when its scope closes and ignores it afterwards", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle, close } = yield* openHandle(store, "session-1", THREAD_A); + yield* handle.set({ key: "mode", text: "plan" }); + + yield* close; + yield* handle.set({ key: "mode", text: "late" }); + yield* handle.bindThread(THREAD_B); + + assert.deepStrictEqual(yield* itemsByThread(store), {}); + }), + ); + + it.effect("moves a producer between threads and keeps items when rebinding its own thread", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + yield* handle.set({ key: "mode", text: "plan" }); + + yield* handle.bindThread(THREAD_A); + assert.deepStrictEqual(yield* itemsByThread(store), { [THREAD_A]: ["mode=plan"] }); + + yield* handle.bindThread(THREAD_B); + yield* handle.set({ key: "mode", text: "fork" }); + assert.deepStrictEqual(yield* itemsByThread(store), { [THREAD_B]: ["mode=fork"] }); + }), + ); + + it.effect("rejects a replaced producer so late updates cannot touch the replacement", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const old = yield* openHandle(store, "session-old", THREAD_A); + yield* old.handle.set({ key: "mode", text: "old" }); + + const replacement = yield* openHandle(store, "session-new", THREAD_A); + assert.deepStrictEqual(yield* itemsByThread(store), {}); + yield* replacement.handle.set({ key: "mode", text: "new" }); + + yield* old.handle.set({ key: "mode", text: "stale" }); + yield* old.handle.clear("mode"); + yield* old.handle.clearAll; + yield* old.close; + + const snapshot = yield* store.snapshot; + assert.deepStrictEqual(yield* itemsByThread(store), { [THREAD_A]: ["mode=new"] }); + assert.strictEqual( + snapshot.entries[0]?.source.providerSessionId, + ProviderSessionId.make("session-new"), + ); + }), + ); + + it.effect("gives each subscriber the current state, then only the latest change", () => + Effect.scoped( + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + yield* handle.set({ key: "mode", text: "plan" }); + + const subscription = yield* store.subscribe; + assert.deepStrictEqual( + subscription.latest.entries.map((entry) => entry.threadId), + [THREAD_A], + ); + + yield* handle.set({ key: "mode", text: "build" }); + yield* handle.set({ key: "mode", text: "review" }); + yield* handle.set({ key: "turn", text: "3" }); + const next = yield* Stream.runHead(subscription.changes); + assert.deepStrictEqual( + next._tag === "Some" ? next.value.entries[0]?.items.map((item) => item.text) : [], + ["review", "3"], + ); + + // A reconnect is a fresh subscription; its first frame is the current state. + const reconnect = yield* store.subscribe; + assert.deepStrictEqual(reconnect.latest, yield* store.snapshot); + }), + ), + ); + + it.effect("caps threads showing a status without counting silent producers", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const max = CONTRIBUTION_STATUS_MAX_THREADS; + // More bound producers than the cap that never set anything take no capacity. + for (let index = 0; index <= max; index += 1) { + yield* openHandle(store, `silent-${index}`, ThreadId.make(`silent-${index}`)); + } + const visible = []; + for (let index = 0; index < max; index += 1) { + const opened = yield* openHandle(store, `visible-${index}`, ThreadId.make(`t-${index}`)); + yield* opened.handle.set({ key: "mode", text: "on" }); + visible.push(opened.handle); + } + assert.strictEqual((yield* store.snapshot).entries.length, max); + + const late = yield* openHandle(store, "late", ThreadId.make("late")); + yield* late.handle.set({ key: "mode", text: "rejected" }); + assert.isUndefined( + (yield* store.snapshot).entries.find((entry) => entry.threadId === "late"), + ); + + // Clearing a thread's last item returns its capacity, and the rejected producer's next set lands. + yield* visible[0]!.clear("mode"); + yield* late.handle.set({ key: "mode", text: "admitted" }); + const snapshot = yield* store.snapshot; + assert.strictEqual(snapshot.entries.length, max); + assert.deepStrictEqual(snapshot.entries.find((entry) => entry.threadId === "late")?.items, [ + { key: "mode", text: "admitted" }, + ]); + // A thread already showing a status can still replace it at the cap. + yield* visible[1]!.set({ key: "mode", text: "replaced" }); + assert.strictEqual( + (yield* store.snapshot).entries.find((entry) => entry.threadId === "t-1")?.items[0]?.text, + "replaced", + ); + }), + ); + + it.effect("orders entries by thread and caps the items in one snapshot", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const fullThreads = CONTRIBUTION_STATUS_MAX_ITEMS / CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE; + const handles = []; + // Bind in reverse so the snapshot order cannot come from insertion order. + for (let index = fullThreads; index >= 0; index -= 1) { + const opened = yield* openHandle(store, `s-${index}`, ThreadId.make(`t-${index}`)); + handles[index] = opened.handle; + } + for (let index = 0; index <= fullThreads; index += 1) { + for (let item = 0; item < CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE; item += 1) { + yield* handles[index]!.set({ key: `k${item}`, text: "on" }); + } + } + const snapshot = yield* store.snapshot; + const threadIds = snapshot.entries.map((entry) => entry.threadId); + assert.deepStrictEqual(threadIds, threadIds.toSorted()); + assert.strictEqual(threadIds.length, fullThreads); + assert.notInclude(threadIds, ThreadId.make(`t-${fullThreads}`)); + assert.strictEqual( + snapshot.entries.reduce((total, entry) => total + entry.items.length, 0), + CONTRIBUTION_STATUS_MAX_ITEMS, + ); + + yield* handles[0]!.clear("k0"); + yield* handles[fullThreads]!.set({ key: "k0", text: "admitted" }); + assert.include( + (yield* store.snapshot).entries.map((entry) => entry.threadId), + ThreadId.make(`t-${fullThreads}`), + ); + }), + ); +}); diff --git a/packages/provider-core/src/server/ContributionStatusStore.ts b/packages/provider-core/src/server/ContributionStatusStore.ts new file mode 100644 index 000000000000..8730f2f6b388 --- /dev/null +++ b/packages/provider-core/src/server/ContributionStatusStore.ts @@ -0,0 +1,347 @@ +import * as NodeUtil from "node:util"; + +import { + CONTRIBUTION_STATUS_KEY_MAX_LENGTH, + CONTRIBUTION_STATUS_MAX_ITEMS, + CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE, + CONTRIBUTION_STATUS_MAX_SOURCES_PER_THREAD, + CONTRIBUTION_STATUS_MAX_THREADS, + CONTRIBUTION_STATUS_TEXT_MAX_LENGTH, + CONTRIBUTION_STATUS_TOOLTIP_MAX_LENGTH, + type ContributionStatusItem, + type ContributionStatusSnapshot, + type ContributionStatusSource, + type ContributionStatusTone, + contributionStatusSourceKey, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import type * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +/** + * One producer's statuses. A handle writes to at most one thread at a time and + * is retired when the scope that opened it closes, clearing what it set. After + * another handle with the same kind of source binds the same thread, this one + * is stale: its writes are ignored until it binds again, so a late update from + * a replaced provider session never reaches the replacement's statuses. + */ +export interface ContributionStatusSourceHandle { + /** + * Moves this handle to `threadId`, clearing what it set on its previous + * thread. Rebinding the thread it still owns keeps its items; binding a + * thread where another provider session owns the provider entry takes that + * entry over and clears its items. + */ + readonly bindThread: (threadId: ThreadId | null) => Effect.Effect; + /** Sets or replaces `key`. Text that is empty after normalization clears the key. */ + readonly set: (input: { + readonly key: string; + readonly text: string; + readonly tone?: ContributionStatusTone; + readonly tooltip?: string; + }) => Effect.Effect; + readonly clear: (key: string) => Effect.Effect; + readonly clearAll: Effect.Effect; + /** What this handle currently shows, as stored: admitted, normalized, and capped. */ + readonly items: Effect.Effect>; +} + +export interface ContributionStatusStoreShape { + readonly openSource: ( + source: ContributionStatusSource, + ) => Effect.Effect; + readonly snapshot: Effect.Effect; + /** Latest snapshot plus later full replacements; a slow subscriber only skips intermediate ones. */ + readonly subscribe: Effect.Effect< + { + readonly latest: ContributionStatusSnapshot; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; +} + +const emptySnapshot: ContributionStatusSnapshot = { entries: [] }; + +const noopHandle: ContributionStatusSourceHandle = { + bindThread: () => Effect.void, + set: () => Effect.void, + clear: () => Effect.void, + clearAll: Effect.void, + items: Effect.succeed([]), +}; + +/** + * Thread statuses produced by live provider sessions. The default drops every + * update so adapters construct without it in tests; the live layer must be the + * same reference the WebSocket server reads, so layer memoization shares one + * store between producers and subscribers. + */ +export class ContributionStatusStore extends Context.Reference( + "@t3tools/provider-core/server/ContributionStatusStore", + { + defaultValue: () => ({ + openSource: () => Effect.succeed(noopHandle), + snapshot: Effect.succeed(emptySnapshot), + subscribe: Effect.succeed({ latest: emptySnapshot, changes: Stream.empty }), + }), + }, +) {} + +// Line breaks, tabs, other C0/C1 controls, DEL, and Unicode line/paragraph separators. +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g; +// eslint-disable-next-line no-control-regex -- matches the control characters on purpose, as above +const HAS_CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; + +/** + * Keys are identities, not display text, so they are never rewritten: two + * different keys must never collide. A key that is empty, longer than the + * contract allows, or contains a control character or lone UTF-16 surrogate + * is rejected on set and clear alike. + */ +const isValidKey = (key: string) => + key.length > 0 && + key.length <= CONTRIBUTION_STATUS_KEY_MAX_LENGTH && + key.isWellFormed() && + !HAS_CONTROL_CHARACTER.test(key); + +/** + * Producer text as one plain line: terminal styling removed, lone surrogates + * replaced with U+FFFD, controls turned into spaces, whitespace collapsed, and + * at most `maxLength` UTF-16 units without splitting a surrogate pair. A + * truncated value ends in `…`. + */ +function normalizeContributionStatusText(raw: string, maxLength: number): string { + const text = NodeUtil.stripVTControlCharacters(raw.toWellFormed()) + .replace(CONTROL_CHARACTERS, " ") + .replace(/\s+/g, " ") + .trim(); + if (text.length <= maxLength) return text; + let truncated = ""; + for (const codePoint of text) { + if (truncated.length + codePoint.length > maxLength - 1) break; + truncated += codePoint; + } + return `${truncated.trimEnd()}…`; +} + +interface SourceSlot { + readonly generation: number; + readonly source: ContributionStatusSource; + readonly items: Map; +} + +/** + * Which handles compete for one entry on a thread. A thread hosts one provider + * session at a time, so every provider-session source shares a slot and a new + * session takes the old one over; plugins will each get their own slot. + */ +const slotKey = (source: ContributionStatusSource) => source.kind; + +const sameItem = (left: ContributionStatusItem | undefined, right: ContributionStatusItem) => + left !== undefined && + left.text === right.text && + left.tone === right.tone && + left.tooltip === right.tooltip; + +const compareStrings = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); + +const sourceRank = (source: ContributionStatusSource) => + source.kind === "provider-session" ? 0 : 1; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.fn("contributions.status.make")(function* () { + const threads = new Map>(); + const changes = yield* PubSub.sliding(1); + const mutex = yield* Semaphore.make(1); + let nextGeneration = 0; + + /** + * Binding a thread only records ownership; capacity is taken by the first + * visible item and returned by the last clear, so silent producers never + * crowd out ones that show something, and a rejected producer's later set + * is admitted once capacity frees up. + */ + const admitsNewItem = (threadId: ThreadId, slot: SourceSlot) => { + if (slot.items.size >= CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE) return false; + let items = 0; + let visibleThreads = 0; + for (const slots of threads.values()) { + let visible = false; + for (const other of slots.values()) { + items += other.items.size; + visible ||= other.items.size > 0; + } + if (visible) visibleThreads += 1; + } + if (items >= CONTRIBUTION_STATUS_MAX_ITEMS) return false; + if (slot.items.size > 0) return true; + let visibleSources = 0; + for (const other of threads.get(threadId)?.values() ?? []) { + if (other.items.size > 0) visibleSources += 1; + } + if (visibleSources >= CONTRIBUTION_STATUS_MAX_SOURCES_PER_THREAD) return false; + return visibleSources > 0 || visibleThreads < CONTRIBUTION_STATUS_MAX_THREADS; + }; + + const currentSnapshot = (): ContributionStatusSnapshot => ({ + entries: Array.from(threads.keys()) + .toSorted(compareStrings) + .flatMap((threadId) => + Array.from(threads.get(threadId)?.values() ?? []) + .filter((slot) => slot.items.size > 0) + .toSorted( + (left, right) => + sourceRank(left.source) - sourceRank(right.source) || + compareStrings( + contributionStatusSourceKey(left.source), + contributionStatusSourceKey(right.source), + ), + ) + .map((slot) => ({ + threadId, + source: slot.source, + items: Array.from(slot.items.values()).toSorted((left, right) => + compareStrings(left.key, right.key), + ), + })), + ), + }); + + /** Runs `mutate` under the store lock and publishes once when it reports a visible change. */ + const update = (mutate: () => boolean) => + mutex.withPermits(1)( + Effect.suspend(() => + mutate() ? PubSub.publish(changes, currentSnapshot()).pipe(Effect.asVoid) : Effect.void, + ), + ); + + const openSource: ContributionStatusStoreShape["openSource"] = (source) => + Effect.gen(function* () { + let threadId: ThreadId | null = null; + let generation = 0; + let retired = false; + + const slotId = slotKey(source); + + /** The slot this handle still owns, or undefined when it is unbound, replaced, or retired. */ + const ownedSlot = () => { + if (retired || threadId === null) return undefined; + const slot = threads.get(threadId)?.get(slotId); + return slot?.generation === generation ? slot : undefined; + }; + + const release = () => { + const slot = ownedSlot(); + if (slot === undefined || threadId === null) return false; + const slots = threads.get(threadId); + slots?.delete(slotId); + if (slots?.size === 0) threads.delete(threadId); + return slot.items.size > 0; + }; + + const handle: ContributionStatusSourceHandle = { + bindThread: (nextThreadId) => + update(() => { + if (retired) return false; + if (nextThreadId !== null && nextThreadId === threadId && ownedSlot() !== undefined) { + return false; + } + const released = release(); + threadId = nextThreadId; + if (nextThreadId === null) return released; + let slots = threads.get(nextThreadId); + if (slots === undefined) { + slots = new Map(); + threads.set(nextThreadId, slots); + } + const replaced = slots.get(slotId); + generation = ++nextGeneration; + slots.set(slotId, { generation, source, items: new Map() }); + return released || (replaced !== undefined && replaced.items.size > 0); + }), + set: (input) => + update(() => { + const slot = ownedSlot(); + const key = input.key; + if (slot === undefined || threadId === null || !isValidKey(key)) return false; + const text = normalizeContributionStatusText( + input.text, + CONTRIBUTION_STATUS_TEXT_MAX_LENGTH, + ); + if (text.length === 0) return slot.items.delete(key); + const previous = slot.items.get(key); + if (previous === undefined && !admitsNewItem(threadId, slot)) return false; + const tooltip = + input.tooltip === undefined + ? "" + : normalizeContributionStatusText( + input.tooltip, + CONTRIBUTION_STATUS_TOOLTIP_MAX_LENGTH, + ); + const item: ContributionStatusItem = { + key, + text, + ...(input.tone === undefined || input.tone === "neutral" ? {} : { tone: input.tone }), + ...(tooltip.length === 0 ? {} : { tooltip }), + }; + if (sameItem(previous, item)) return false; + slot.items.set(key, item); + return true; + }), + clear: (key) => + update(() => { + const slot = ownedSlot(); + if (slot === undefined || !isValidKey(key)) return false; + return slot.items.delete(key); + }), + clearAll: update(() => { + const slot = ownedSlot(); + if (slot === undefined || slot.items.size === 0) return false; + slot.items.clear(); + return true; + }), + items: mutex.withPermits(1)( + Effect.sync(() => Array.from(ownedSlot()?.items.values() ?? [])), + ), + }; + + yield* Effect.addFinalizer(() => + update(() => { + const released = release(); + retired = true; + return released; + }), + ); + return handle; + }); + + return ContributionStatusStore.of({ + openSource, + snapshot: mutex.withPermits(1)(Effect.sync(currentSnapshot)), + // Subscribes under the lock so no change lands between the snapshot and the stream. + subscribe: mutex.withPermits(1)( + Effect.map(PubSub.subscribe(changes), (subscription) => ({ + latest: currentSnapshot(), + changes: Stream.fromSubscription(subscription), + })), + ), + }); +}); + +export const layer = Layer.effect(ContributionStatusStore, make()); + +/** The `subscribeContributionStatus` stream: the current snapshot, then each replacement. */ +export const subscriptionStream = (store: ContributionStatusStoreShape) => + Stream.unwrap( + Effect.map(store.subscribe, ({ latest, changes }) => + Stream.concat(Stream.make(latest), changes), + ), + ); diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index e13f8213077f..bc0603a507b6 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -12,6 +12,7 @@ import { RunId, ThreadId, type ChatAttachment, + type ContributionStatusSnapshot, type ModelSelection, type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, @@ -19,13 +20,17 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -39,6 +44,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import { makePiAdapterV2, PiAdapterV2Driver, @@ -96,8 +102,14 @@ interface FakePi { readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; /** Hold a model-select extension hook until its UI request is answered. */ readonly deferNextModelSelection: () => void; + /** Send the held lifecycle request's normal response. */ + readonly resolveDeferredLifecycle: Effect.Effect; readonly queueModels: (models: ReadonlyArray) => void; readonly vetoNextNewSession: () => void; + /** Make the next `fork` ack report an extension veto. */ + readonly vetoNextFork: () => void; + /** Make the next `fork` fail with `success: false`, leaving Pi on its session. */ + readonly rejectNextFork: () => void; /** Every request received by the fake process. */ readonly allRequests: () => ReadonlyArray; /** Data returned by the next `get_session_stats` acks, consumed in order. */ @@ -147,7 +159,10 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let failState = false; let vetoSwitch = false; let vetoNewSession = false; + let vetoFork = false; + let rejectFork = false; let deferredLifecycle: string | undefined; + let deferredLifecycleRequest: PiRpcRecord | undefined; let sessionFile = FAKE_SESSION_FILE; let sessionGeneration = 0; let models: ReadonlyArray = []; @@ -194,8 +209,15 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { return { ...base, data: messagesQueue.shift() ?? { messages: [] } }; case "get_session_stats": return { ...base, data: statsQueue.shift() ?? {} }; - case "fork": - return { ...base, data: { text: "Hello pi", cancelled: false } }; + case "fork": { + if (rejectFork) { + rejectFork = false; + return { ...base, success: false, error: "fork refused" }; + } + const cancelled = vetoFork; + vetoFork = false; + return { ...base, data: { text: "Hello pi", cancelled } }; + } default: return base; } @@ -220,6 +242,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { } if (record["type"] === deferredLifecycle) { deferredLifecycle = undefined; + deferredLifecycleRequest = record; continue; } const response = respondTo(record); @@ -294,12 +317,25 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { deferNextModelSelection: () => { deferredLifecycle = "set_model"; }, + resolveDeferredLifecycle: Effect.suspend(() => { + const record = deferredLifecycleRequest; + assert.isDefined(record); + deferredLifecycleRequest = undefined; + const response = respondTo(record!); + return response === null ? Effect.void : emit(response); + }), queueModels: (value) => { models = value; }, vetoNextNewSession: () => { vetoNewSession = true; }, + rejectNextFork: () => { + rejectFork = true; + }, + vetoNextFork: () => { + vetoFork = true; + }, allRequests: () => allRequests, vetoNextSwitch: () => { vetoSwitch = true; @@ -380,6 +416,95 @@ const openRuntime = Effect.fnUntraced(function* ( return { runtime, takeEvent }; }); +const statusMap = (snapshot: ContributionStatusSnapshot) => + Object.fromEntries( + snapshot.entries.map((entry) => [ + entry.threadId, + entry.items.map((item) => `${item.key}=${item.text}`), + ]), + ); + +/** Pi's fire-and-forget `ctx.ui.setStatus`; a missing text clears the key. */ +const emitStatus = (fake: FakePi, statusKey: string, statusText?: string) => + fake.emit({ + type: "extension_ui_request", + id: `status-${statusKey}-${statusText ?? "clear"}`, + method: "setStatus", + statusKey, + ...(statusText === undefined ? {} : { statusText }), + }); + +/** Waits for the store to publish a snapshot matching `predicate`, the receipt for status events. */ +const waitForStatuses = ( + subscription: { readonly changes: Stream.Stream }, + predicate: (statuses: Record>) => boolean, +) => + subscription.changes.pipe( + Stream.map(statusMap), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +/** + * A status store whose "gate" status holds the session event pump until + * released. The pump handles records in order, so `pumpHeld` is also the + * receipt that every earlier record has been handled. + */ +const makeGatedStatusStore = Effect.fnUntraced(function* () { + const store = yield* ContributionStatusStore.make(); + const pumpHeld = yield* Deferred.make(); + const releasePump = yield* Deferred.make(); + const gated: ContributionStatusStore.ContributionStatusStoreShape = { + ...store, + openSource: (source) => + Effect.map(store.openSource(source), (handle) => ({ + ...handle, + set: (input) => + input.key === "gate" + ? Deferred.succeed(pumpHeld, undefined).pipe( + Effect.andThen(Deferred.await(releasePump)), + Effect.andThen(handle.set(input)), + ) + : handle.set(input), + })), + }; + return { + store, + provide: Effect.provideService(ContributionStatusStore.ContributionStatusStore, gated), + holdPump: (fake: FakePi) => + emitStatus(fake, "gate", "held").pipe(Effect.andThen(Deferred.await(pumpHeld))), + releasePump: Deferred.succeed(releasePump, undefined), + }; +}); + +/** Rolls `providerThread` back to its start, which forks Pi's session before the first turn. */ +const rollbackToThreadStart = ( + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, + providerThread: OrchestrationV2ProviderThread, +) => + runtime.rollbackThread({ + providerThread, + providerThreadTurns: [ + { + id: ProviderTurnId.make("turn-1"), + providerThreadId: providerThread.id, + nodeId: NodeId.make("node-1"), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: "u1", strength: "strong" }, + ordinal: 1, + status: "completed", + startedAt: null, + completedAt: null, + }, + ], + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint-pi-rollback"), + appRunOrdinal: 0, + }, + }); + const makeAppThread = Effect.fnUntraced(function* (model: string, threadId = THREAD_ID) { const now = yield* DateTime.now; return { @@ -1311,6 +1436,447 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect( + "shows extension statuses on the session's thread until Pi rebinds or the session ends", + () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const subscription = yield* store.subscribe; + // Each status change publishes one snapshot; waiting on it is the receipt. + const nextStatuses = Stream.runHead(subscription.changes).pipe( + Effect.map((snapshot) => + Option.match(snapshot, { + onNone: () => ({}), + onSome: (value) => + Object.fromEntries( + value.entries.map((entry) => [ + entry.threadId, + entry.items.map((item) => `${item.key}=${item.text}`), + ]), + ), + }), + ), + ); + const setStatus = (statusKey: string, statusText?: string) => + fake.emit({ + type: "extension_ui_request", + id: `status-${statusKey}-${statusText ?? "clear"}`, + method: "setStatus", + statusKey, + ...(statusText === undefined ? {} : { statusText }), + }); + + const sessionScope = yield* Scope.make(); + const { runtime } = yield* openRuntime(fake).pipe(Scope.provide(sessionScope)); + + // Extensions set statuses from session_start, before T3 registers a thread. + yield* setStatus("plan", "\u001b[33m⏸ plan\u001b[39m"); + assert.deepStrictEqual(yield* nextStatuses, { [THREAD_ID]: ["plan=⏸ plan"] }); + + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* setStatus("mode", "build"); + assert.deepStrictEqual(yield* nextStatuses, { [THREAD_ID]: ["mode=build", "plan=⏸ plan"] }); + yield* setStatus("plan"); + assert.deepStrictEqual(yield* nextStatuses, { [THREAD_ID]: ["mode=build"] }); + + yield* runtime.resumeThread({ providerThread }); + assert.deepStrictEqual(yield* nextStatuses, {}); + yield* setStatus("mode", "resumed"); + assert.deepStrictEqual(yield* nextStatuses, { [THREAD_ID]: ["mode=resumed"] }); + + yield* Scope.close(sessionScope, Exit.void); + assert.deepStrictEqual(yield* nextStatuses, {}); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + + it.effect("lands a fork's startup statuses on the target thread before Pi answers", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const forkFake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const statuses = yield* store.subscribe; + const { runtime } = yield* openRuntime(fake, "default", THREAD_ID, SESSION_ID, forkFake); + const source = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "legacy", "old"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.[0] === "legacy=old"); + + const forkFile = "/fake/forked.jsonl"; + forkFake.queueState({ sessionFile: forkFile }); + fake.queueState({ sessionFile: forkFile }); + fake.deferNextLifecycle("switch_session"); + const target = ThreadId.make("fork-target"); + const forked = yield* runtime + .forkThread({ sourceProviderThread: source, targetThreadId: target }) + .pipe(Effect.forkChild); + yield* fake.takeRequest("switch_session"); + // Pi rebinds the extensions, which set their statuses before the switch response. + yield* emitStatus(fake, "mode", "forked"); + yield* waitForStatuses(statuses, (current) => current[target]?.[0] === "mode=forked"); + yield* fake.resolveDeferredLifecycle; + yield* Fiber.join(forked); + + assert.deepStrictEqual(statusMap(yield* store.snapshot), { [target]: ["mode=forked"] }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + + it.effect("drops statuses the old session queued before a switch the new one never resets", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const gate = yield* makeGatedStatusStore(); + const statuses = yield* gate.store.subscribe; + const { runtime } = yield* openRuntime(fake).pipe(gate.provide); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "legacy", "old"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.[0] === "legacy=old"); + + // Hold the event pump so the stale update below queues behind it. + yield* gate.holdPump(fake); + yield* emitStatus(fake, "legacy", "stale"); + // Responses resolve in stdout order, so once this answer arrives the stale update is queued. + yield* runtime.readThreadSnapshot({ providerThread }); + + fake.deferNextLifecycle("switch_session"); + const resumed = yield* runtime.resumeThread({ providerThread }).pipe(Effect.forkChild); + yield* fake.takeRequest("switch_session"); + yield* emitStatus(fake, "mode", "new"); + yield* fake.resolveDeferredLifecycle; + yield* Fiber.join(resumed); + yield* gate.releasePump; + + // "mode=new" is the last status event, so the first snapshot showing it is final. + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("mode=new"), + ); + assert.deepStrictEqual(settled, { [THREAD_ID]: ["mode=new"] }); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("keeps a vetoed switch's later statuses off every thread until one registers", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const gate = yield* makeGatedStatusStore(); + const statuses = yield* gate.store.subscribe; + const { runtime } = yield* openRuntime(fake).pipe(gate.provide); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "legacy", "old"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.[0] === "legacy=old"); + + fake.vetoNextSwitch(); + yield* runtime.resumeThread({ providerThread }).pipe(Effect.flip); + // Pi stays on a session no thread is bound to; its updates go nowhere. + yield* emitStatus(fake, "orphan", "dropped"); + yield* gate.holdPump(fake); + yield* gate.releasePump; + + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "mode", "fresh"); + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("mode=fresh"), + ); + assert.deepStrictEqual(settled, { [THREAD_ID]: ["mode=fresh"] }); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect.each([ + { failure: "a rejected get_state", sessionFile: true }, + { failure: "a missing sessionFile", sessionFile: false }, + ])("keeps statuses off the thread after a rollback fork with $failure", ({ sessionFile }) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const gate = yield* makeGatedStatusStore(); + const statuses = yield* gate.store.subscribe; + const { runtime } = yield* openRuntime(fake).pipe(gate.provide); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "legacy", "old"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.[0] === "legacy=old"); + + if (sessionFile) fake.failNextState(); + else fake.queueState({ sessionFile: undefined }); + const turn: OrchestrationV2ProviderTurn = { + id: ProviderTurnId.make("turn-1"), + providerThreadId: providerThread.id, + nodeId: NodeId.make("node-1"), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: "u1", strength: "strong" }, + ordinal: 1, + status: "completed", + startedAt: null, + completedAt: null, + }; + const error = yield* runtime + .rollbackThread({ + providerThread, + providerThreadTurns: [turn], + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint-pi-rollback"), + appRunOrdinal: 0, + }, + }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "ProviderAdapterRollbackThreadError"); + // The forked session's updates go nowhere until a thread registers. + yield* emitStatus(fake, "orphan", "after-failed-rollback"); + yield* gate.holdPump(fake); + yield* gate.releasePump; + assert.deepStrictEqual(statusMap(yield* gate.store.snapshot), {}); + + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "mode", "fresh"); + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("mode=fresh"), + ); + assert.deepStrictEqual(settled, { [THREAD_ID]: ["mode=fresh"] }); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("puts back the session's statuses when an extension cancels a rollback", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const statuses = yield* store.subscribe; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "mode", "build"); + yield* emitStatus(fake, "plan", "on"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.length === 2); + + fake.vetoNextFork(); + fake.deferNextLifecycle("fork"); + const turn: OrchestrationV2ProviderTurn = { + id: ProviderTurnId.make("turn-1"), + providerThreadId: providerThread.id, + nodeId: NodeId.make("node-1"), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: "u1", strength: "strong" }, + ordinal: 1, + status: "completed", + startedAt: null, + completedAt: null, + }; + const rollback = yield* runtime + .rollbackThread({ + providerThread, + providerThreadTurns: [turn], + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint-pi-rollback"), + appRunOrdinal: 0, + }, + }) + .pipe(Effect.flip, Effect.forkChild); + yield* fake.takeRequest("fork"); + // The session Pi stays on keeps writing while its hook decides. + yield* emitStatus(fake, "plan", "off"); + yield* fake.resolveDeferredLifecycle; + const error = yield* Fiber.join(rollback); + assert.strictEqual(error._tag, "ProviderAdapterRollbackThreadError"); + yield* emitStatus(fake, "zz", "receipt"); + + // "zz" is the last status event, so the first snapshot showing it is final. + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("zz=receipt"), + ); + assert.deepStrictEqual(settled, { [THREAD_ID]: ["mode=build", "plan=off", "zz=receipt"] }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + + it.effect("puts back the session's statuses when Pi refuses a rollback fork", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const statuses = yield* store.subscribe; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "mode", "build"); + yield* emitStatus(fake, "plan", "on"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.length === 2); + + fake.rejectNextFork(); + fake.deferNextLifecycle("fork"); + const turn: OrchestrationV2ProviderTurn = { + id: ProviderTurnId.make("turn-1"), + providerThreadId: providerThread.id, + nodeId: NodeId.make("node-1"), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: "u1", strength: "strong" }, + ordinal: 1, + status: "completed", + startedAt: null, + completedAt: null, + }; + const rollback = yield* runtime + .rollbackThread({ + providerThread, + providerThreadTurns: [turn], + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint-pi-rollback"), + appRunOrdinal: 0, + }, + }) + .pipe(Effect.flip, Effect.forkChild); + yield* fake.takeRequest("fork"); + // The session Pi stays on keeps writing while its hook decides. + yield* emitStatus(fake, "plan", "off"); + yield* fake.resolveDeferredLifecycle; + const error = yield* Fiber.join(rollback); + assert.strictEqual(error._tag, "ProviderAdapterRollbackThreadError"); + yield* emitStatus(fake, "zz", "receipt"); + + // "zz" is the last status event, so the first snapshot showing it is final. + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("zz=receipt"), + ); + assert.deepStrictEqual(settled, { [THREAD_ID]: ["mode=build", "plan=off", "zz=receipt"] }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + + it.effect("restores only the statuses the store held when an extension cancels a rollback", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const statuses = yield* store.subscribe; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + // The source holds at most eight items, so k9 is rejected; clearing k1 frees a slot. + for (let index = 1; index <= 9; index++) yield* emitStatus(fake, `k${index}`, `v${index}`); + yield* emitStatus(fake, "k1"); + const before = yield* waitForStatuses( + statuses, + (current) => current[THREAD_ID]?.length === 7 && !current[THREAD_ID].includes("k1=v1"), + ); + + assert.deepStrictEqual(before, { + [THREAD_ID]: ["k2=v2", "k3=v3", "k4=v4", "k5=v5", "k6=v6", "k7=v7", "k8=v8"], + }); + + fake.vetoNextFork(); + const error = yield* rollbackToThreadStart(runtime, providerThread).pipe(Effect.flip); + assert.strictEqual(error._tag, "ProviderAdapterRollbackThreadError"); + yield* emitStatus(fake, "k8", "receipt"); + + // "k8=receipt" is the last status event, so the first snapshot showing it is final. + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("k8=receipt"), + ); + assert.deepStrictEqual(settled, { + [THREAD_ID]: ["k2=v2", "k3=v3", "k4=v4", "k5=v5", "k6=v6", "k7=v7", "k8=receipt"], + }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + + it.effect("drops a successful rollback's kept statuses and restores stored text", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const statuses = yield* store.subscribe; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "mode", "before"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.[0] === "mode=before"); + + // A successful fork clears the session's statuses and keeps nothing for later. + yield* rollbackToThreadStart(runtime, providerThread); + for (let index = 0; index < 50; index++) { + yield* emitStatus(fake, `churn${index}`, "on"); + yield* emitStatus(fake, `churn${index}`); + } + const longText = `a\nb ${"x".repeat(200)}`; + yield* emitStatus(fake, "mode", longText); + const stored = `a b ${"x".repeat(75)}…`; + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.[0] === `mode=${stored}`); + + fake.vetoNextFork(); + yield* rollbackToThreadStart(runtime, providerThread).pipe(Effect.flip); + yield* emitStatus(fake, "zz", "receipt"); + + // "zz" is the last status event, so the first snapshot showing it is final. + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("zz=receipt"), + ); + assert.deepStrictEqual(settled, { [THREAD_ID]: [`mode=${stored}`, "zz=receipt"] }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + + // Known residual, not a fix: Pi runs the old session's session_shutdown + // handlers after it reads switch_session and reports no rebind on stdout, so + // those writes are indistinguishable from the new session's and persist. + // Flip this test if Pi ever marks the native-session boundary. + it.effect("keeps an old session's shutdown status written after the switch marker", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const statuses = yield* store.subscribe; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + + fake.deferNextLifecycle("switch_session"); + const resumed = yield* runtime.resumeThread({ providerThread }).pipe(Effect.forkChild); + yield* fake.takeRequest("switch_session"); + yield* emitStatus(fake, "legacy", "old-shutdown-write"); + yield* emitStatus(fake, "mode", "new-startup"); + yield* fake.resolveDeferredLifecycle; + yield* Fiber.join(resumed); + + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("mode=new-startup"), + ); + assert.deepStrictEqual(settled, { + [THREAD_ID]: ["legacy=old-shutdown-write", "mode=new-startup"], + }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + it.effect("rejects a resume while a turn is active", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 7fd91ebc7609..157fe33fc4af 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -20,8 +20,20 @@ * Dialog methods become v2 runtime requests (`confirm` → approval_request, * `select`/`input`/`editor` → user_input_request); answers travel back as * `extension_ui_response`. `notify` becomes a completed activity item. - * Terminal-only decoration such as status, widget, title, and editor-text - * updates has no matching T3 surface and is ignored. + * `setStatus` feeds the thread's contribution status, an advisory channel + * owned by this Pi process for the T3 provider session's lifetime. Before a + * T3-initiated switch, new session, or fork, a queued marker clears the + * statuses read so far and sends later ones to the target thread; a failed + * registration or rollback sends them nowhere until a thread registers. A + * rollback's marker keeps the statuses the store held for this session; if an + * extension cancels the fork, the keys the session has not written since come + * back, and once Pi answers either way the kept copy is dropped. Pi's + * RPC stdout marks no native-session boundary, so old-session writes after + * the marker (such as session_shutdown handlers) look like the new session's + * and may persist, and extension-initiated switches or reloads are not + * tracked. Closing this session clears its statuses. + * Other terminal decoration (widget, title, editor text) has no matching T3 + * surface and is ignored. */ import * as HostProcess from "@t3tools/shared/HostProcess"; import { AgentScope } from "@t3tools/shared/AgentScope"; @@ -30,6 +42,7 @@ import { defaultInstanceIdForDriver, ProviderDriverKind, type ChatAttachment, + type ContributionStatusItem, type ModelSelection, type OrchestrationV2ExecutionNode, type OrchestrationV2ProviderCapabilities, @@ -53,6 +66,7 @@ import * as Deferred from "effect/Deferred"; import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; 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 Queue from "effect/Queue"; @@ -69,6 +83,7 @@ import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, @@ -393,6 +408,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const idAllocator = yield* IdAllocator.IdAllocatorV2; const host = yield* ProviderHost.ProviderHost; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const statusStore = yield* ContributionStatusStore.ContributionStatusStore; const { continuationRequests } = options; const protocolError = (detail: string, payload?: unknown) => @@ -489,6 +505,37 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // serialize the two paths to stop `turn.terminal` from overtaking the // dialog's own resolution updates. const sessionEventPermit = yield* Semaphore.make(1); + // Extensions set statuses from session_start, before the first thread + // registration, so the source starts on the thread this session opened for. + const statusSource = yield* statusStore.openSource({ + kind: "provider-session", + providerSessionId: input.providerSessionId, + providerInstanceId: options.instanceId, + driver: PI_PROVIDER, + }); + yield* statusSource.bindThread(input.threadId); + // The statuses a pending rollback's marker cleared, as the store held + // them, minus every key the session has written since. At most one + // source's capped items, dropped once the rollback's fork is answered. + let rollbackStatuses: Map | null = null; + // Targets of `t3.status_generation` markers still queued, oldest first. + const statusGenerationTargets: Array = []; + /** + * Starts a new status generation on `threadId` in event order: statuses + * read before the marker are cleared, later ones land on `threadId`, or + * nowhere when it is null. A rollback's marker keeps the cleared statuses + * until its `t3.status_settle`. + */ + const queueStatusGeneration = ( + threadId: OrchestrationV2ProviderThread["appThreadId"], + rollback = false, + ) => + Effect.sync(() => statusGenerationTargets.push(threadId)).pipe( + Effect.andThen( + Queue.offer(connection.events, { type: "t3.status_generation", rollback }), + ), + Effect.asVoid, + ); let threadState: PiThreadState | null = null; let registrationAttempted = false; let lastNativeThreadId: string | null = null; @@ -620,22 +667,36 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return null; }; - const lifecycleRequest = (record: PiRpcRecord) => - request(record, PI_SESSION_TIMEOUT_MS).pipe( - // A local timeout does not cancel Pi's lifecycle hook. Retire the - // process before fallback can race its eventual switch/new-session. - Effect.tapError((error) => - Effect.logWarning("Pi session lifecycle request failed", { - providerSessionId: input.providerSessionId, - operation: record["type"], - errorTag: error._tag, - }), + /** + * Switches Pi to another native session whose statuses belong to + * `statusThreadId`. Pi rebinds every extension for the new session, and + * their startup statuses can arrive before the response, so the status + * generation starts before the request is written. + */ + const lifecycleRequest = ( + record: PiRpcRecord, + statusThreadId: OrchestrationV2ProviderThread["appThreadId"], + rollback = false, + ) => + queueStatusGeneration(statusThreadId, rollback).pipe( + Effect.andThen( + request(record, PI_SESSION_TIMEOUT_MS).pipe( + // A local timeout does not cancel Pi's lifecycle hook. Retire the + // process before fallback can race its eventual switch/new-session. + Effect.tapError((error) => + Effect.logWarning("Pi session lifecycle request failed", { + providerSessionId: input.providerSessionId, + operation: record["type"], + errorTag: error._tag, + }), + ), + Effect.catchTags({ + PiRpcTimeoutError: (error) => + connection.terminate.pipe(Effect.andThen(Effect.fail(error))), + }), + Effect.onInterrupt(() => connection.terminate), + ), ), - Effect.catchTags({ - PiRpcTimeoutError: (error) => - connection.terminate.pipe(Effect.andThen(Effect.fail(error))), - }), - Effect.onInterrupt(() => connection.terminate), ); const tokenUsageFromStats = ( @@ -1239,6 +1300,16 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }); return; } + if (method === "setStatus") { + // Pi serializes a cleared status as a missing `statusText`. + const key = recordString(event, "statusKey"); + if (key === undefined) return; + const text = recordString(event, "statusText"); + // The session has replaced or cleared this key since the rollback marker. + rollbackStatuses?.delete(key); + yield* text === undefined ? statusSource.clear(key) : statusSource.set({ key, text }); + return; + } if ( method !== "select" && method !== "confirm" && @@ -2028,6 +2099,28 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( } return; } + case "t3.status_generation": { + // Statuses queued before this marker came from the session Pi is + // leaving. Its shutdown writes can still follow; see the header. + rollbackStatuses = + event["rollback"] === true + ? new Map((yield* statusSource.items).map((item) => [item.key, item])) + : null; + yield* statusSource.clearAll; + yield* statusSource.bindThread(statusGenerationTargets.shift() ?? null); + return; + } + case "t3.status_settle": { + // Queued once Pi answers a rollback's fork, behind every status it + // wrote before answering. A cancelled fork kept Pi on its session + // and the marker's target was the same thread, so put back what + // the marker kept; otherwise just drop it. + const kept = rollbackStatuses; + rollbackStatuses = null; + if (kept === null || event["restore"] !== true) return; + for (const item of kept.values()) yield* statusSource.set(item); + return; + } case "t3.flush_extension_errors": { // Startup extension failures are informational and do not block // Pi, so attach them to the next real turn instead of creating a @@ -2150,7 +2243,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // ── session runtime ─────────────────────────────────── - const registerThread = Effect.fnUntraced(function* ( + const registerThreadUnguarded = Effect.fnUntraced(function* ( threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, publish = true, ) { @@ -2177,8 +2270,9 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const existing = threadInput.existingProviderThread; const resumeId = existing?.nativeThreadRef?.nativeId; const needsNewSession = resumeId == null && registrationAttempted; + const switchesSession = resumeId != null || needsNewSession; registrationAttempted = true; - if (resumeId != null || needsNewSession) { + if (switchesSession) { lastNativeThreadId = resumeId ?? lastNativeThreadId; // Even a failed lifecycle operation can change Pi's native session. // Never leave the old app binding or model defaults usable afterward. @@ -2193,6 +2287,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( resumeId != null ? { type: "switch_session", sessionPath: resumeId } : { type: "new_session" }, + existing === undefined ? threadInput.threadId : existing.appThreadId, ); if (recordField(result, "cancelled") === true) { return yield* protocolError("A Pi extension cancelled the session switch"); @@ -2266,6 +2361,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( updatedAt: createdAt, }; threadState = { providerThread, activeTurn: null }; + // A session switch moved the statuses in event order already. + if (!switchesSession) yield* statusSource.bindThread(providerThread.appThreadId); // Baseline the session-tree leaf so the first turn's user entry can // be located with a `since` cursor instead of a full entry scan. const baselineEntries = yield* request({ type: "get_entries" }).pipe( @@ -2285,6 +2382,16 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return providerThread; }); + // A failed registration leaves no thread bound, so no thread shows this + // session's statuses until a later registration succeeds. + const registerThread = ( + threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, + publish = true, + ) => + registerThreadUnguarded(threadInput, publish).pipe( + Effect.onError(() => (threadState === null ? queueStatusGeneration(null) : Effect.void)), + ); + const applySelection = Effect.fnUntraced(function* (modelSelection: ModelSelection) { const thinking = getModelSelectionStringOptionValue(modelSelection, "thinking"); if (modelSelection.model === PI_INHERIT_MODEL_SLUG) { @@ -2907,19 +3014,44 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ), (barrier) => Effect.gen(function* () { - const forkData = yield* lifecycleRequest({ type: "fork", entryId: forkEntryId }); + const forkData = yield* lifecycleRequest( + { type: "fork", entryId: forkEntryId }, + state.providerThread.appThreadId, + true, + ).pipe( + Effect.onExit((exit) => + Queue.offer(connection.events, { + type: "t3.status_settle", + // Pi stays on its session when an extension cancels the fork or + // Pi refuses it; a timeout, interruption or dead transport says nothing. + restore: Exit.isSuccess(exit) + ? recordField(exit.value, "cancelled") === true + : !Cause.hasInterrupts(exit.cause) && + Option.exists( + Cause.findErrorOption(exit.cause), + (error) => + error._tag === "PiRpcError" && + (error.operation === "fork" || error.operation === "request"), + ), + }), + ), + ); if (recordField(forkData, "cancelled") === true) return yield* protocolError("A Pi extension cancelled the session fork"); + // Without the fork's identity the thread is unusable, so, like a + // failed registration, no thread shows this session's statuses + // until one registers. An interrupted read leaves the identity + // just as unknown as a failed one. + const invalidateThread = Effect.suspend(() => { + threadState = null; + return queueStatusGeneration(null); + }); const forkState = yield* request({ type: "get_state" }).pipe( - Effect.onError(() => - Effect.sync(() => { - threadState = null; - }), - ), + Effect.onError(() => invalidateThread), ); const forkSessionFile = recordString(forkState, "sessionFile"); if (forkSessionFile === undefined) { - threadState = null; + yield* invalidateThread; return yield* protocolError("Pi fork did not return a persisted session file"); } const entriesData = yield* request({ type: "get_entries" }).pipe(