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/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/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 511fd77cd147..651d56fe1f6a 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,17 @@ 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, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, @@ -167,6 +179,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..8e49c6be3551 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -251,6 +251,8 @@ export const make = Effect.gen(function* () { serverResolvedCommandContext: true, environmentIcon: true, projectCloneTracking: true, + contributionStatus: true, + plugins: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), // V2 restart recovery uses the environment-owned opt-in. The old diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 2ad903ff792a..f7c3be9b3b76 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -88,6 +88,15 @@ 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.scheduledTasksRotateWebhookToken]: "scheduledTasks", [WS_METHODS.scheduledTasksListWebhookDeliveries]: "scheduledTasks", [WS_METHODS.scheduledTasksGetWebhookDelivery]: "scheduledTasks", @@ -154,6 +163,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/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/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..cb6f1e554b49 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -74,6 +74,8 @@ 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"; /** * Migration loader with all migrations defined inline. @@ -148,6 +150,8 @@ export const migrationEntries = [ [58, "WebhookRelayDeliveries", Migration0058], [59, "McpAppModelContext", Migration0059], [60, "ThreadSnapshotWindowIndexes", Migration0060], + [61, "PluginInstallations", Migration0061], + [62, "PluginEventCursors", Migration0062], ] 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..26248f1dc421 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: 62 }, (_, index) => index + 1), ); }), ); @@ -32,6 +32,8 @@ layer("055_OrchestrationV2", (it) => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "PluginInstallations"], + [62, "PluginEventCursors"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -58,6 +60,8 @@ 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" }, ]); 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/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 675d5f5db021..e44eca130b50 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -41,6 +41,8 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "PluginInstallations"], + [62, "PluginEventCursors"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -124,6 +126,8 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "PluginInstallations"], + [62, "PluginEventCursors"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/plugins/PluginCatalog.test.ts b/apps/server/src/plugins/PluginCatalog.test.ts new file mode 100644 index 000000000000..fd60468b4cd8 --- /dev/null +++ b/apps/server/src/plugins/PluginCatalog.test.ts @@ -0,0 +1,783 @@ +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), + }); + 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..f8ac872ca268 --- /dev/null +++ b/apps/server/src/plugins/PluginCatalog.ts @@ -0,0 +1,745 @@ +/** + * 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, +}); + +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; + /** 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) + .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, + 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..d9170c097384 --- /dev/null +++ b/apps/server/src/plugins/PluginIpc.ts @@ -0,0 +1,60 @@ +/** + * 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. + */ +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)); + +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, + maxMessageBytes: Schema.Int, + }, + Invoke: { requestId: RequestId, handler: PluginHandlerName, input: Schema.Json }, + Cancel: { requestId: RequestId }, + Deactivate: {}, +}); +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: {}, +}); +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..94aed5bef84e --- /dev/null +++ b/apps/server/src/plugins/PluginManifestLoader.ts @@ -0,0 +1,100 @@ +import { + PLUGIN_API_VERSION, + PLUGIN_EVENTS_CAPABILITY, + PLUGIN_MANIFEST_FILE, + PluginManifest, + type PluginCapabilityName, +} 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"; + +/** Capabilities this server implements. A plugin declaring any other is not loaded. */ +const SUPPORTED_PLUGIN_CAPABILITIES: ReadonlySet = new Set([ + PLUGIN_EVENTS_CAPABILITY, +]); + +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; +} + +/** + * 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 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/PluginSupervisor.test.ts b/apps/server/src/plugins/PluginSupervisor.test.ts new file mode 100644 index 000000000000..33b0d4e7de0c --- /dev/null +++ b/apps/server/src/plugins/PluginSupervisor.test.ts @@ -0,0 +1,727 @@ +// @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: ["tools"] })).toContain("does not support tools"); + 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"); + }), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginSupervisor.ts b/apps/server/src/plugins/PluginSupervisor.ts new file mode 100644 index 000000000000..9eee4a75e8e6 --- /dev/null +++ b/apps/server/src/plugins/PluginSupervisor.ts @@ -0,0 +1,872 @@ +// @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. + * + * 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 { + decodePluginChildMessage, + encodePluginHostMessage, + PluginHandlerName, + type PluginHostMessage, + type PluginLogLevel, +} from "./PluginIpc.ts"; +import { + DEFAULT_PLUGIN_IPC_MAX_BYTES, + PLUGIN_IPC_FD, + PLUGIN_IPC_MAX_BYTES_LIMIT, + 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; + +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; +} + +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; +// 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 + >; + } +>()("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(); + /** 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 }), + ); + }); + + 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; + } + }); + + const handleExit = Effect.fnUntraced(function* ( + entry: Entry, + child: Child, + code: number | null, + signal: string | null, + ) { + // 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, + }; + // 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, + 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 || Deferred.isDoneUnsafe(child.exited)) return; + revokeChild(entry, 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), + }); +}); + +export const layer = (overrides: Partial = {}) => + Layer.effect(PluginSupervisor, make(overrides)); diff --git a/apps/server/src/plugins/pluginApi.ts b/apps/server/src/plugins/pluginApi.ts new file mode 100644 index 000000000000..bb7d1df2f961 --- /dev/null +++ b/apps/server/src/plugins/pluginApi.ts @@ -0,0 +1,85 @@ +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. + */ +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; + +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; +} + +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..01d22b745c22 --- /dev/null +++ b/apps/server/src/plugins/pluginHostChild.ts @@ -0,0 +1,254 @@ +// @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 { + PluginContext, + PluginEvent, + PluginEventHandler, + PluginHandler, + PluginJson, + PluginModule, +} 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, + makeLineDecoder, +} from "./pluginIpcFraming.ts"; + +// 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 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); + }; + + const activate = async (message: Extract) => { + maxBytes = message.maxMessageBytes; + const controller = new AbortController(); + const proposed = message.proposedApi + ? { + handle(name: string, handler: PluginHandler) { + if (name.startsWith("t3.")) + 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) }; + }, + } + : 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; + } + }; + + 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..7aae17bf370d --- /dev/null +++ b/apps/server/src/plugins/pluginIpcFraming.ts @@ -0,0 +1,107 @@ +// 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; + +/** + * 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/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/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/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..f4e0139c8703 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,10 @@ 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 PluginSupervisor from "./plugins/PluginSupervisor.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as ServerBrowser from "./preview/ServerBrowser.ts"; @@ -420,6 +425,13 @@ const layerDevice = DeviceService.layer.pipe( Layer.provide(NetService.layer), ); +// Zero enabled plugins means zero plugin processes; each starts on first use. +const layerPlugin = PluginCatalog.layer().pipe( + 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 +587,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ProviderUsageLimitsIngestion.layer, layerProviderInstallationRefresh, ReplayMarkers.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 +602,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..c46c311dea80 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,7 @@ 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 { archivedShellStreamItemFromThreadShell, buildActiveShellSnapshot, @@ -212,6 +213,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 +1225,7 @@ const layerWsRpc = ( const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const secretRequests = yield* SecretRequests.SecretRequests; + const pluginCatalog = yield* PluginCatalog.PluginCatalog; const pullRequests = yield* PullRequestService.PullRequestService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const deviceService = yield* DeviceService.DeviceService; @@ -1322,6 +1325,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 +2055,15 @@ 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.scheduledTasksRunNow]: (input) => Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( Effect.andThen(scheduledTasks.runNow(input)), @@ -3113,6 +3126,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/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/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/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/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/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/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/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/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..865aa3c1ba75 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -175,6 +175,10 @@ "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" diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c11a49897b88..d5c2fe2cfd51 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -65,6 +65,7 @@ export type EnvironmentSubscriptionRpcTag = | 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 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/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..737218a1cbec 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -213,6 +213,12 @@ 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), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d738ef79909e..49234c73c09a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -53,9 +53,13 @@ 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 "./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..8d379922213d --- /dev/null +++ b/packages/contracts/src/plugin.ts @@ -0,0 +1,95 @@ +/** + * 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"; + +/** + * 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))), +}); +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/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..6b5f64c0106d --- /dev/null +++ b/packages/contracts/src/pluginCatalog.ts @@ -0,0 +1,161 @@ +/** + * 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 { + ForwardCompatibleOptional, + IsoDateTime, + NonNegativeInt, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +import { PluginHostState } from "./plugin.ts"; +import { PluginEventDeliveryState } from "./pluginEvents.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, +}); +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/rpc.ts b/packages/contracts/src/rpc.ts index 36afee2f9efa..c1f889054cac 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,16 @@ import { ScheduledTaskMutationResult, } from "./scheduledTask.ts"; import { SecretRequestAnswerInput, SecretRequestError } from "./secretRequest.ts"; +import { + PluginAddInput, + PluginCatalogError, + PluginCatalogSnapshot, + PluginConsentInput, + PluginInstallationInput, + PluginInstallationResult, + PluginRefreshInput, + PluginRemoveResult, +} from "./pluginCatalog.ts"; import { ProjectCloneActionInput, ProjectCloneActionResult, @@ -514,6 +525,17 @@ 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", + // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -572,6 +594,7 @@ export const WS_METHODS = { subscribeAuthAccess: "subscribeAuthAccess", subscribeBackgroundPolicy: "subscribeBackgroundPolicy", subscribeResourceTelemetry: "subscribeResourceTelemetry", + subscribeContributionStatus: "subscribeContributionStatus", } as const; const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -1795,6 +1818,67 @@ 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; 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, +}); const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { payload: Schema.Struct({}), @@ -1817,6 +1901,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 +1979,15 @@ export const WsRpcGroup = RpcGroup.make( WsSecretsAnswerRequestRpc, WsScheduledTasksListWebhookDeliveriesRpc, WsScheduledTasksGetWebhookDeliveryRpc, + WsPluginsListRpc, + WsPluginsSubscribeRpc, + WsPluginsAddRpc, + WsPluginsRefreshRpc, + WsPluginsConsentRpc, + WsPluginsEnableRpc, + WsPluginsDisableRpc, + WsPluginsRemoveRpc, + WsPluginsResumeRpc, WsServerReportClientActivityRpc, WsServerReportHostPowerStateRpc, WsServerGetBackgroundPolicyRpc, @@ -2001,6 +2102,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(