diff --git a/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx b/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx new file mode 100644 index 0000000000000..24c82f79874d4 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx @@ -0,0 +1,112 @@ +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/ui/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; +} + +// The strip overlays the feed; useThreadContributionStatusStripInset reserves its band. +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 870ab31f00003..219bfbbd31803 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -173,6 +173,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, @@ -1141,6 +1145,12 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { 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, ); @@ -1407,6 +1417,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { contentInsetEndAdjustment={combinedContentInsetEndAdjustment} keyboardOffset={composerKeyboardOffset} contentTopInset={0} + topOverlayInset={statusStripInset} contentBottomInset={ estimatedOverlayHeight + (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0) @@ -1434,11 +1445,32 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { threadId={props.selectedThread.id} tabs={browserTabs.tabs} loaded={browserTabs.loaded} - top={navigationHeaderHeight + 8} + top={ + navigationHeaderHeight + + (props.usesAutomaticContentInsets === true && Platform.OS === "ios" + ? statusStripInset + : 0) + + 8 + } onOpen={openBrowserPreview} /> ) : null} + {showContent ? ( + + ) : null} + {showContent ? ( ; readonly keyboardOffset: number; 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; @@ -2750,6 +2753,11 @@ export const ThreadFeed = memo(function ThreadFeed({ 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"]; @@ -3353,7 +3361,7 @@ export const ThreadFeed = memo(function ThreadFeed({ presentedFeed, props.anchorMessageId, (entry) => (entry.type === "message" ? entry.id : null), - { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, + { anchorOffset: feedTop.anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, ); const failedRunIds = failedFeedRunIds(props.feed, props.latestRun); const terminalAssistantMessageIds = (() => { @@ -3760,7 +3768,9 @@ export const ThreadFeed = memo(function ThreadFeed({ 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 0000000000000..3f09c12308fb8 --- /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 0000000000000..647edaff83718 --- /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 0eaed72c1979e..26b18d83ac08b 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -4,6 +4,7 @@ import { alignListPaneToHinge, deriveLayout, deriveThreadFeedInitialContentInset, + deriveThreadFeedTopGeometry, deriveThreadArrangementSizing, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, @@ -200,3 +201,42 @@ describe("alignListPaneToHinge", () => { expect(alignListPaneToHinge(narrowDetail)).toBe(layout); }); }); + +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 }); + }); +}); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 4155d834ba24d..d0167ca845609 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -98,6 +98,18 @@ export function deriveThreadFeedInitialContentInset(input: { return { bottom: Math.max(0, input.bottomContentInset) }; } +// Overlay height offsets both the feed spacer and message anchor below the header. +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 0000000000000..8a83addc96ab1 --- /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 6049121b41a5d..c05fe2d2b0c15 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, @@ -115,6 +116,21 @@ 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 requires access:write, + // which current standard pairings also receive for managing access. + [WS_METHODS.pluginsList]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsAdd]: AuthAccessWriteScope, + [WS_METHODS.pluginsRefresh]: AuthAccessWriteScope, + [WS_METHODS.pluginsConsent]: AuthAccessWriteScope, + [WS_METHODS.pluginsEnable]: AuthAccessWriteScope, + [WS_METHODS.pluginsDisable]: AuthAccessWriteScope, + [WS_METHODS.pluginsRemove]: AuthAccessWriteScope, + [WS_METHODS.pluginsResume]: AuthAccessWriteScope, + // Saved setting values are readable like the catalogue; secrets are never sent. Saving one + // configures code that runs as the server's OS user, so it takes the administrative scope. + [WS_METHODS.pluginsSettingsSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSettingsUpdate]: AuthAccessWriteScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, @@ -169,6 +185,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/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index b3dd615c8d4d7..870f6ab892e67 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -1,9 +1,12 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The child fixture proves writer liveness and crash cleanup across processes. +import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -148,6 +151,204 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { }).pipe(Effect.provide(layerConcurrentCreateSecretStore())), ); + it.effect.each([false, true])( + "cleans crash temporaries at startup with a final secret: %s", + (hasFinal) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(config.secretsDir, { recursive: true }); + const final = `${config.secretsDir}/plugin-setting-test.bin`; + const temporary = `${final}.00000000-0000-4000-8000-000000000001.tmp`; + const reusedPidTemporary = `${final}.${process.pid}-00000000.00000000-0000-4000-8000-000000000002.tmp`; + const legacyPidTemporary = `${final}.${process.pid}.00000000-0000-4000-8000-000000000003.tmp`; + const unrelated = `${config.secretsDir}/notes.tmp`; + if (hasFinal) yield* fs.writeFileString(final, "old-secret"); + yield* fs.writeFileString(temporary, "new-secret"); + yield* fs.writeFileString(reusedPidTemporary, "old-instance-secret"); + yield* fs.writeFileString(legacyPidTemporary, "legacy-instance-secret"); + yield* fs.writeFileString(unrelated, "keep"); + const store = yield* ServerSecretStore.make; + assert.isFalse(yield* fs.exists(temporary)); + assert.isFalse(yield* fs.exists(reusedPidTemporary)); + assert.isFalse(yield* fs.exists(legacyPidTemporary)); + assert.isTrue(yield* fs.exists(unrelated)); + assert.equal(Option.isSome(yield* store.get("plugin-setting-test")), hasFinal); + if (hasFinal) assert.equal(yield* fs.readFileString(final), "old-secret"); + }).pipe(Effect.provide(layerServerConfig())), + ); + + it.effect("removes orphan temporaries when the final secret is missing", () => + Effect.gen(function* () { + const store = yield* ServerSecretStore.ServerSecretStore; + const fs = yield* FileSystem.FileSystem; + const temporary = `${store.directory}/plugin-setting-test.bin.00000000-0000-4000-8000-000000000001.tmp`; + const other = `${store.directory}/other.bin.00000000-0000-4000-8000-000000000001.tmp`; + yield* fs.writeFileString(temporary, "secret"); + yield* fs.writeFileString(other, "other-secret"); + yield* store.remove("plugin-setting-test"); + assert.isFalse(yield* fs.exists(temporary)); + assert.isTrue(yield* fs.exists(other)); + }).pipe(Effect.provide(layerServerSecretStore())), + ); + + it.effect("a second store preserves an active replacement during startup and removal", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const written = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const writer = yield* ServerSecretStore.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + rename: (from, to) => + Deferred.succeed(written, undefined).pipe( + Effect.andThen(Deferred.await(finish)), + Effect.andThen(fs.rename(from, to)), + ), + }), + ); + yield* Effect.addFinalizer(() => Deferred.succeed(finish, undefined)); + const writing = yield* writer + .set("shared-secret", Uint8Array.from([1, 2, 3])) + .pipe(Effect.forkChild); + yield* Deferred.await(written); + const replacement = (yield* fs.readDirectory(config.secretsDir)).find((name) => + name.endsWith(".tmp"), + )!; + const second = yield* ServerSecretStore.make; + yield* second.remove("shared-secret"); + assert.isTrue(yield* fs.exists(`${config.secretsDir}/${replacement}`)); + yield* Deferred.succeed(finish, undefined); + yield* Fiber.join(writing); + assert.deepEqual( + Array.from(Option.getOrThrow(yield* second.get("shared-secret"))), + [1, 2, 3], + ); + assert.isFalse(yield* fs.exists(`${config.secretsDir}/${replacement}`)); + }).pipe(Effect.provide(layerServerConfig())), + ); + + it.effect("a second store preserves an active create during startup and removal", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const written = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const writer = yield* ServerSecretStore.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + link: (from, to) => + Deferred.succeed(written, undefined).pipe( + Effect.andThen(Deferred.await(finish)), + Effect.andThen(fs.link(from, to)), + ), + }), + ); + yield* Effect.addFinalizer(() => Deferred.succeed(finish, undefined)); + const writing = yield* writer + .create("shared-secret", Uint8Array.from([1, 2, 3])) + .pipe(Effect.forkChild); + yield* Deferred.await(written); + const replacement = (yield* fs.readDirectory(config.secretsDir)).find((name) => + name.endsWith(".tmp"), + )!; + const second = yield* ServerSecretStore.make; + yield* second.remove("shared-secret"); + assert.isTrue(yield* fs.exists(`${config.secretsDir}/${replacement}`)); + yield* Deferred.succeed(finish, undefined); + yield* Fiber.join(writing); + assert.deepEqual( + Array.from(Option.getOrThrow(yield* second.get("shared-secret"))), + [1, 2, 3], + ); + assert.isFalse(yield* fs.exists(`${config.secretsDir}/${replacement}`)); + }).pipe(Effect.provide(layerServerConfig())), + ); + + it.effect("cleans its temporary when a replacement is interrupted", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const written = yield* Deferred.make(); + const writer = yield* ServerSecretStore.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + rename: () => Deferred.succeed(written, undefined).pipe(Effect.andThen(Effect.never)), + }), + ); + const writing = yield* writer + .set("interrupted-secret", Uint8Array.from([1])) + .pipe(Effect.forkChild); + yield* Deferred.await(written); + yield* Fiber.interrupt(writing); + assert.deepEqual(yield* fs.readDirectory(config.secretsDir), []); + }).pipe(Effect.provide(layerServerConfig())), + ); + + it.effect("retains a foreign live writer's temporary and sweeps it after exit", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + yield* fs.makeDirectory(config.secretsDir, { recursive: true }); + const child = yield* Effect.acquireRelease( + Effect.sync(() => + NodeChildProcess.spawn( + process.execPath, + [ + "-e", + ` + const fs = require('node:fs'); + const path = require('node:path'); + const temporary = path.join(process.argv[1], 'foreign.bin.' + process.pid + '.00000000-0000-4000-8000-000000000001.tmp'); + fs.writeFileSync(temporary, 'crash-secret', { mode: 0o600 }); + process.on('message', () => process.exit(0)); + process.send(temporary); + `, + config.secretsDir, + ], + { stdio: ["ignore", "ignore", "ignore", "ipc"] }, + ), + ), + (process) => + Effect.promise( + () => + new Promise((resolve) => { + if (process.exitCode !== null || process.signalCode !== null) return resolve(); + process.once("exit", () => resolve()); + process.kill(); + }), + ), + ); + const temporary = yield* Effect.tryPromise( + () => + new Promise((resolve, reject) => { + child.once("message", (message) => + typeof message === "string" + ? resolve(message) + : reject(new Error("Unexpected child fixture message")), + ); + child.once("error", reject); + child.once("exit", () => + reject(new Error("Child exited before publishing its temporary")), + ); + }), + ); + const second = yield* ServerSecretStore.make; + yield* second.remove("foreign"); + assert.isTrue(yield* fs.exists(temporary)); + yield* Effect.promise( + () => + new Promise((resolve) => { + child.once("exit", () => resolve()); + child.send("exit"); + }), + ); + yield* ServerSecretStore.make; + assert.isFalse(yield* fs.exists(temporary)); + }).pipe(Effect.provide(layerServerConfig())), + ); + it.effect("propagates read failures other than missing-file errors", () => Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 100e0e19ca2ae..50dea6adcfa63 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -150,9 +150,23 @@ export class ServerSecretStore extends Context.Service< } >()("t3/auth/ServerSecretStore") {} +let processInstance: string | undefined; + +// A restarted container often reuses its own PID; its old temporaries belong to a different instance. +const isWriterAlive = (pid: number, instance: string | undefined) => { + if (pid === process.pid) return instance === processInstance; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !(error instanceof Error && "code" in error && error.code === "ESRCH"); + } +}; + /** @public */ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + processInstance ??= (crypto.nextIntUnsafe() >>> 0).toString(16).padStart(8, "0"); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; @@ -170,6 +184,30 @@ export const make = Effect.gen(function* () { const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); + const removeTemporaryFiles = Effect.fnUntraced(function* (name?: string) { + for (const file of yield* fileSystem.readDirectory(serverConfig.secretsDir)) { + const match = + /^(.*\.bin)\.(?:(\d+)(?:-([0-9a-f]{8}))?\.)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/i.exec( + file, + ); + if ( + match !== null && + (name === undefined || match[1] === `${name}.bin`) && + (match[2] === undefined || !isWriterAlive(Number(match[2]), match[3])) + ) { + yield* fileSystem.remove(path.join(serverConfig.secretsDir, file), { force: true }); + } + } + }); + + // New temporaries name their writer so another process cannot sweep an active replacement. + // UUID-only files predate writer ownership and remain eligible for crash recovery. + yield* removeTemporaryFiles().pipe( + Effect.mapError( + (cause) => new SecretStoreRemoveError({ resource: "temporary secret files", cause }), + ), + ); + const get: ServerSecretStore["Service"]["get"] = (name) => fileSystem.readFile(resolveSecretPath(name)).pipe( Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), @@ -197,26 +235,17 @@ export const make = Effect.gen(function* () { }), ), Effect.flatMap((uuid) => { - const tempPath = `${secretPath}.${uuid}.tmp`; + const tempPath = `${secretPath}.${process.pid}-${processInstance}.${uuid}.tmp`; return Effect.gen(function* () { yield* fileSystem.writeFile(tempPath, value); yield* fileSystem.chmod(tempPath, 0o600); yield* fileSystem.rename(tempPath, secretPath); yield* fileSystem.chmod(secretPath, 0o600); }).pipe( - Effect.catch((cause) => - fileSystem.remove(tempPath).pipe( - Effect.ignore, - Effect.flatMap(() => - Effect.fail( - new SecretStorePersistError({ - resource: `secret ${name}`, - cause, - }), - ), - ), - ), + Effect.mapError( + (cause) => new SecretStorePersistError({ resource: `secret ${name}`, cause }), ), + Effect.ensuring(fileSystem.remove(tempPath, { force: true }).pipe(Effect.ignore)), ); }), Effect.withSpan("ServerSecretStore.set"), @@ -236,7 +265,7 @@ export const make = Effect.gen(function* () { }), ), Effect.flatMap((uuid) => { - const tempPath = `${secretPath}.${uuid}.tmp`; + const tempPath = `${secretPath}.${process.pid}-${processInstance}.${uuid}.tmp`; return Effect.gen(function* () { yield* Effect.scoped( Effect.gen(function* () { @@ -304,7 +333,8 @@ export const make = Effect.gen(function* () { ); const remove: ServerSecretStore["Service"]["remove"] = (name) => - fileSystem.remove(resolveSecretPath(name)).pipe( + removeTemporaryFiles(name).pipe( + Effect.andThen(fileSystem.remove(resolveSecretPath(name))), Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.void diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 936c79943fd74..93380095f2f50 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -1,7 +1,7 @@ /** * 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. - * They and Codex's managed-token helper dispatch before the full CLI module graph (seconds + * They, plugin children and Codex's managed-token helper dispatch before the full CLI module graph (seconds * of evaluation) loads; everything else defers to the real CLI in ./binCli.ts. */ import { isEntrypoint } from "./entrypoint.ts"; @@ -35,6 +35,10 @@ if ( } else 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 { enableCliCompileCache(); // Windows desktop launches without stdin; attach before reading, except on fast paths using pipes. diff --git a/apps/server/src/contributions/ContributionStatusRpc.test.ts b/apps/server/src/contributions/ContributionStatusRpc.test.ts new file mode 100644 index 0000000000000..73a7ba435b35c --- /dev/null +++ b/apps/server/src/contributions/ContributionStatusRpc.test.ts @@ -0,0 +1,208 @@ +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 PortScanner from "../preview/PortScanner.ts"; +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.mergeAll( + NodeServices.layer, + PortScanner.layerTest, + 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 d542b4ee55bb4..b18b9f28db000 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -264,6 +264,9 @@ export const make = Effect.gen(function* () { ...(hostPlatform === "win32" ? {} : { localServerStop: true }), environmentIcon: true, projectCloneTracking: true, + contributionStatus: true, + plugins: true, + pluginSettings: true, projectOrganization: true, ignoredFileListing: true, triage: true, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index e684316f48d9a..0cf303ab4061c 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -69,6 +69,8 @@ import { WorktreeToolkit } from "./toolkits/worktree/tools.ts"; import * as WorktreeMcpService from "./WorktreeMcpService.ts"; import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts"; import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; +import * as PluginToolsHandlers from "./toolkits/pluginTools/handlers.ts"; +import { PluginToolsToolkit } from "./toolkits/pluginTools/tools.ts"; import * as DeviceHandlers from "./toolkits/device/handlers.ts"; import { DeviceScreenshotTool, @@ -1125,6 +1127,11 @@ export const layerPullRequestsToolkit = toolkitRegistration( PullRequestsHandlers.layer, ); +export const layerPluginToolsToolkit = toolkitRegistration( + PluginToolsToolkit, + PluginToolsHandlers.layer, +); + const layerDeviceStandardToolkitRegistration = toolkitRegistration( DeviceStandardToolkit, DeviceHandlers.layerStandard, @@ -1161,6 +1168,7 @@ export const layerTools = Layer.mergeAll( layerPullRequestsToolkit, layerDeviceToolkit, layerHtmlToolkit, + layerPluginToolsToolkit, ); export const layer = layerTools.pipe(Layer.provideMerge(layerMcpTransport)); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 5ce9669bc4d83..706e321ca05c9 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -14,6 +14,8 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; +import type { PluginToolGrant } from "../plugins/PluginTools.ts"; + const ALL_MCP_CAPABILITIES = [ "preview", "orchestration", @@ -104,6 +106,8 @@ export interface McpInvocationScope { readonly requestNamespace: string; readonly thread: McpThreadCaller | undefined; readonly client: McpClientCaller | undefined; + /** Tool plugins enabled when the session was prepared; see PluginTools.ts. */ + readonly pluginToolGrants?: ReadonlyArray; /** False while the Linked environments beta is off here; `/mcp` reads it per request. */ readonly linkedEnvironments?: boolean; } diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 282c3dffd6d64..998e58ccf8c89 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -1,6 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + PluginInstallationId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/http"; import * as NetAddress from "effect/net/NetAddress"; @@ -159,6 +164,32 @@ it.effect("does not keep credentials of other threads alive", () => }), ); +it.effect("keeps a credential's plugin tool grants and replaces them without rotating it", () => + Effect.gen(function* () { + const registry = yield* makeRegistry(() => 1_000); + const grant = (generation: number) => ({ + installationId: PluginInstallationId.make("installation-1"), + generation, + }); + const issued = yield* registry.issue({ + threadId: ThreadId.make("thread-plugin-tools"), + providerInstanceId: ProviderInstanceId.make("codex"), + pluginToolGrants: [grant(1)], + }); + const other = yield* registry.issue({ + threadId: ThreadId.make("thread-other"), + providerInstanceId: ProviderInstanceId.make("codex"), + }); + const tokenOf = (credential: typeof issued) => + credential.config.authorizationHeader.replace(/^Bearer\s+/, ""); + expect((yield* registry.resolve(tokenOf(issued)))?.pluginToolGrants).toEqual([grant(1)]); + expect((yield* registry.resolve(tokenOf(other)))?.pluginToolGrants).toBeUndefined(); + + yield* registry.setPluginToolGrants(issued.config.providerSessionId, [grant(2)]); + expect((yield* registry.resolve(tokenOf(issued)))?.pluginToolGrants).toEqual([grant(2)]); + expect((yield* registry.resolve(tokenOf(other)))?.pluginToolGrants).toBeUndefined(); + }), +); it.effect("only a trusted touch restores an expired token with its original scope", () => Effect.gen(function* () { let timestamp = 1_000; diff --git a/apps/server/src/mcp/McpSessionRegistry.testkit.ts b/apps/server/src/mcp/McpSessionRegistry.testkit.ts index 84676e7ccb1b4..61a271c5b52e0 100644 --- a/apps/server/src/mcp/McpSessionRegistry.testkit.ts +++ b/apps/server/src/mcp/McpSessionRegistry.testkit.ts @@ -22,6 +22,7 @@ export const layer = Layer.succeed( // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. resolve: () => Effect.succeed(undefined), touch: () => Effect.void, + setPluginToolGrants: () => Effect.void, revokeProviderSession: () => Effect.void, revokeThread: () => Effect.void, revokeAll: Effect.void, diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 962beb1aed6c7..8f354e31ad4ee 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -9,6 +9,7 @@ import { HttpServer } from "effect/http"; import * as NetAddress from "effect/net/NetAddress"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import type { PluginToolGrant } from "../plugins/PluginTools.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; @@ -18,6 +19,8 @@ export interface McpCredentialRequest { /** False omits the preview capability wherever the token is honored. Defaults to full access. */ readonly browserToolsAvailable?: boolean; readonly capabilities?: ReadonlySet; + /** Tool plugins the session may use; none when omitted. */ + readonly pluginToolGrants?: ReadonlyArray; readonly subagent?: boolean; } @@ -31,6 +34,14 @@ export interface McpSessionRegistryShape { rawToken: string, ) => Effect.Effect; readonly touch: (threadId: ThreadId) => Effect.Effect; + /** + * Replaces the tool plugin grants of a live credential without rotating it, + * for a session prepared again on a credential its provider still holds. + */ + readonly setPluginToolGrants: ( + providerSessionId: string, + grants: ReadonlyArray, + ) => Effect.Effect; readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect; readonly revokeThread: (threadId: ThreadId) => Effect.Effect; readonly revokeAll: Effect.Effect; @@ -116,6 +127,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( ...(request.capabilities ?? (browserToolsAvailable ? (["preview"] as const) : [])), ]), issuedAt, + ...(request.pluginToolGrants === undefined + ? {} + : { pluginToolGrants: request.pluginToolGrants }), }; yield* SynchronizedRef.update(state, ({ records }) => { const next = new Map(records); @@ -178,6 +192,22 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( issue, resolve, touch, + setPluginToolGrants: Effect.fn("McpSessionRegistry.setPluginToolGrants")( + function* (providerSessionId, grants) { + yield* SynchronizedRef.update(state, ({ records }) => { + const next = new Map(records); + for (const [tokenHash, record] of records) { + if (record.scope.thread.providerSessionId === providerSessionId) { + next.set(tokenHash, { + ...record, + scope: { ...record.scope, pluginToolGrants: grants }, + }); + } + } + return { records: next }; + }); + }, + ), revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( function* (providerSessionId) { yield* revokeWhere((record) => record.scope.thread.providerSessionId === providerSessionId); diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index b82d9bb5ae59c..29ac5a8b1491c 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -127,12 +127,14 @@ export const reads = (handle: (params: P) => Effect.Effect) export const readsAsCaller = (handle: (params: P) => Effect.Effect) => declare((params: P) => requireThreadCaller.pipe(Effect.flatMap(() => handle(params))), undefined); -export const actsAsCaller = (handle: (params: P) => Effect.Effect) => +export const actsAsCaller = ( + handle: (params: P, caller: Caller) => Effect.Effect, +) => declare( (params: P) => requireThreadCaller.pipe( Effect.andThen(writingCaller), - Effect.flatMap(() => handle(params)), + Effect.flatMap((caller) => handle(params, caller)), ), undefined, ); diff --git a/apps/server/src/mcp/toolkits/pluginTools/handlers.test.ts b/apps/server/src/mcp/toolkits/pluginTools/handlers.test.ts new file mode 100644 index 0000000000000..42d5b60e9cf92 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pluginTools/handlers.test.ts @@ -0,0 +1,187 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + PluginInstallationId, + PluginToolError, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as PluginTools from "../../../plugins/PluginTools.ts"; +import * as McpHttpServer from "../../McpHttpServer.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; + +const grants = [{ installationId: PluginInstallationId.make("installation-1"), generation: 3 }]; +const invocation = { + environmentId: EnvironmentId.make("environment-plugin-tools"), + capabilities: new Set(), + issuedAt: 1, + requestNamespace: "thread:thread-plugin-tools", + thread: { + threadId: ThreadId.make("thread-plugin-tools"), + providerSessionId: "provider-session-plugin-tools", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + pluginToolGrants: grants, +} satisfies McpInvocationContext.McpInvocationScope; +// An MCP OAuth client signed in from outside T3 Code: no thread and no grants. +const outsideClient = { + environmentId: invocation.environmentId, + capabilities: new Set(), + issuedAt: 1, + requestNamespace: "client:session-outside", + thread: undefined, + client: { sessionId: "session-outside", label: "outside", access: "full-access" }, +} satisfies McpInvocationContext.McpInvocationScope; +const client = McpSchema.McpServerClient.of({ + clientId: 1, + clientCapabilities: {}, + clientInfo: { name: "mcp-test", version: "1.0.0" }, + protocolVersion: "2025-06-18", + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mcp-test", version: "1.0.0" }, + }, + getClient: Effect.die("unused"), +}); + +it.effect( + "takes grants and thread from the credential and marks the call tool conservatively", + () => { + const seen: Array = []; + let shell = liveThreadShell(invocation.thread.threadId); + const threads = Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => Effect.sync(() => shell), + }); + const tools = Layer.mock(PluginTools.PluginTools)({ + list: (granted, options) => + Effect.sync(() => { + seen.push({ granted, options }); + return { tools: [], notInThisSession: [] }; + }), + call: (granted, request) => + Effect.suspend(() => { + seen.push({ granted, request }); + return request.tool === "acme.search/lookup" + ? Effect.succeed({ hits: 1 }) + : Effect.fail( + new PluginToolError({ reason: "unknown-tool", message: "No such tool." }), + ); + }), + }); + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const registered = Object.fromEntries(server.tools.map(({ tool }) => [tool.name, tool])); + expect(registered.plugin_tools_list?.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + }); + expect(registered.plugin_tool_call?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }); + const callTool = ( + name: string, + args: Record, + scope: McpInvocationContext.McpInvocationScope = invocation, + ) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + const listed = yield* callTool("plugin_tools_list", { plugin: "acme.search", cursor: "a" }); + expect(listed.isError).toBe(false); + // A context the agent passes is only input; the plugin gets the credential's. + const called = yield* callTool("plugin_tool_call", { + tool: "acme.search/lookup", + input: { q: "x", context: { threadId: "spoofed" } }, + }); + expect(called).toMatchObject({ isError: false, structuredContent: { result: { hits: 1 } } }); + const failed = yield* callTool("plugin_tool_call", { tool: "acme.search/nope" }); + expect(failed.isError).toBe(true); + expect(failed.content).toEqual([{ type: "text", text: "No such tool." }]); + + expect(seen).toEqual([ + { granted: grants, options: { plugin: "acme.search", cursor: "a" } }, + { + granted: grants, + request: { + tool: "acme.search/lookup", + input: { q: "x", context: { threadId: "spoofed" } }, + context: { + environmentId: invocation.environmentId, + threadId: invocation.thread.threadId, + }, + }, + }, + { + granted: grants, + request: { + tool: "acme.search/nope", + input: {}, + context: { + environmentId: invocation.environmentId, + threadId: invocation.thread.threadId, + }, + }, + }, + ]); + + // Outside clients see no plugin tools and cannot call one; the plugin is never reached. + seen.length = 0; + const outsideList = yield* callTool("plugin_tools_list", {}, outsideClient); + expect(outsideList.isError).toBe(false); + const outsideCall = yield* callTool( + "plugin_tool_call", + { tool: "acme.search/lookup" }, + outsideClient, + ); + expect(outsideCall.isError).toBe(true); + expect(outsideCall.content).toEqual([ + { + type: "text", + text: "This tool acts as the calling T3 thread, so it needs an agent running inside T3 Code. This MCP client signed in from outside a thread.", + }, + ]); + expect(outsideList.structuredContent).toEqual({ tools: [], notInThisSession: [] }); + expect(seen).toEqual([]); + for (const modes of [ + { runtimeMode: "approval-required", interactionMode: "default" }, + { runtimeMode: "auto-accept-edits", interactionMode: "default" }, + { runtimeMode: "auto", interactionMode: "default" }, + { runtimeMode: "full-access", interactionMode: "plan" }, + ] as const) { + shell = liveThreadShell(invocation.thread.threadId, modes); + const refused = yield* callTool("plugin_tool_call", { tool: "acme.search/lookup" }); + expect(refused.isError).toBe(true); + expect(refused.content).toEqual([ + { + type: "text", + text: "Calling a trusted plugin needs a full-access thread in default mode.", + }, + ]); + expect(seen).toEqual([]); + } + }).pipe( + Effect.scoped, + Effect.provide( + McpHttpServer.layerPluginToolsToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(tools), + Layer.provide(threads), + ), + ), + ); + }, +); diff --git a/apps/server/src/mcp/toolkits/pluginTools/handlers.ts b/apps/server/src/mcp/toolkits/pluginTools/handlers.ts new file mode 100644 index 0000000000000..c512ef8802770 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pluginTools/handlers.ts @@ -0,0 +1,50 @@ +import * as Effect from "effect/Effect"; + +import * as PluginTools from "../../../plugins/PluginTools.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { PluginToolsToolkit } from "./tools.ts"; +import { assertFullAccess } from "../../threadAccess.ts"; + +const make = Effect.gen(function* () { + const tools = yield* PluginTools.PluginTools; + return { + // Scope and grants come from the session's credential, never from the agent. + // A caller without a thread can call no plugin tool, so it learns of no plugins either. + plugin_tools_list: McpToolAccess.reads((input) => + McpInvocationContext.McpInvocationContext.pipe( + Effect.flatMap((scope) => + scope.thread === undefined + ? Effect.succeed({ tools: [], notInThisSession: [] }) + : tools.list(scope.pluginToolGrants ?? [], { + ...(input.plugin === undefined ? {} : { plugin: input.plugin }), + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + }), + ), + ), + ), + // A plugin tool runs on behalf of the calling thread while its run is live, so a client + // signed in from outside T3 Code (no thread, no grants) cannot call one. + plugin_tool_call: McpToolAccess.actsAsCaller((input, caller) => + assertFullAccess( + caller, + "Calling a trusted plugin needs a full-access thread in default mode.", + ).pipe( + Effect.andThen(McpInvocationContext.McpInvocationContext), + Effect.flatMap((scope) => + McpInvocationContext.requireThreadScope(scope, "plugin_tool_call"), + ), + Effect.flatMap((scope) => + tools.call(scope.pluginToolGrants ?? [], { + tool: input.tool, + input: input.input ?? {}, + context: { environmentId: scope.environmentId, threadId: scope.thread.threadId }, + }), + ), + Effect.map((result) => ({ result })), + ), + ), + } satisfies McpToolAccess.Handlers; +}); + +export const layer = McpToolAccess.toLayer(PluginToolsToolkit, make); diff --git a/apps/server/src/mcp/toolkits/pluginTools/tools.ts b/apps/server/src/mcp/toolkits/pluginTools/tools.ts new file mode 100644 index 0000000000000..8cfbdc395c09c --- /dev/null +++ b/apps/server/src/mcp/toolkits/pluginTools/tools.ts @@ -0,0 +1,64 @@ +import { OrchestratorMcpFailure, PluginToolError, PluginToolsListResult } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Tool from "effect/ai/Tool"; +import * as Toolkit from "effect/ai/Toolkit"; + +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const dependencies = [McpInvocationContext.McpInvocationContext]; + +const PluginToolsListTool = Tool.make("plugin_tools_list", { + description: + "List the tools offered by the user's trusted local T3 Code plugins that this session may use. Each entry has the tool name to pass to plugin_tool_call, its inputSchema, and its sideEffect (read, write or destructive). Listing starts no plugin. Results come in pages ordered by plugin id: when nextCursor is present, pass it as cursor for more. Pass plugin to list one plugin's tools. Plugins enabled after this session started appear under notInThisSession and need a new session.", + parameters: Schema.Struct({ + plugin: Schema.optionalKey( + Schema.String.check(Schema.isMaxLength(128)).annotate({ + description: "A plugin id, such as acme.search, to list only that plugin's tools.", + }), + ), + cursor: Schema.optionalKey( + Schema.String.check(Schema.isMaxLength(128)).annotate({ + description: "The nextCursor of the previous page.", + }), + ), + }), + success: PluginToolsListResult, + failure: PluginToolError, + dependencies, +}) + .annotate(Tool.Title, "List plugin tools") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +/** + * One fixed tool calls every plugin tool, so its hints describe the most a + * plugin tool may do. Each tool's own sideEffect is in plugin_tools_list. + */ +const PluginToolCallTool = Tool.make("plugin_tool_call", { + description: + "Call a tool from plugin_tools_list. Pass its tool name and an input object that matches its inputSchema. Check the tool's sideEffect first: write and destructive tools change things, so follow the user's instructions about such changes.", + parameters: Schema.Struct({ + tool: Schema.String.check(Schema.isMaxLength(200)).annotate({ + description: "The tool value from plugin_tools_list, such as acme.search/lookup.", + }), + input: Schema.optionalKey( + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: "Arguments matching the tool's inputSchema. Defaults to {}.", + }), + ), + }), + success: Schema.Struct({ result: Schema.Unknown }), + // The caller check refuses an outside client or a thread whose run has ended. + failure: Schema.Union([PluginToolError, OrchestratorMcpFailure]), + dependencies: [...dependencies, ThreadManagementService.ThreadManagementService], +}) + .annotate(Tool.Title, "Call plugin tool") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, true); + +export const PluginToolsToolkit = Toolkit.make(PluginToolsListTool, PluginToolCallTool); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 1e3388e4136ad..d94aeafac4839 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -92,6 +92,17 @@ const RPC_AGGREGATES = { [WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks", [WS_METHODS.scheduledTasksDelete]: "scheduledTasks", [WS_METHODS.scheduledTasksRunNow]: "scheduledTasks", + [WS_METHODS.pluginsList]: "plugins", + [WS_METHODS.pluginsSubscribe]: "plugins", + [WS_METHODS.pluginsAdd]: "plugins", + [WS_METHODS.pluginsRefresh]: "plugins", + [WS_METHODS.pluginsConsent]: "plugins", + [WS_METHODS.pluginsEnable]: "plugins", + [WS_METHODS.pluginsDisable]: "plugins", + [WS_METHODS.pluginsRemove]: "plugins", + [WS_METHODS.pluginsResume]: "plugins", + [WS_METHODS.pluginsSettingsSubscribe]: "plugins", + [WS_METHODS.pluginsSettingsUpdate]: "plugins", [WS_METHODS.scheduledTasksRotateWebhookToken]: "scheduledTasks", [WS_METHODS.scheduledTasksListWebhookDeliveries]: "scheduledTasks", [WS_METHODS.scheduledTasksGetWebhookDelivery]: "scheduledTasks", @@ -172,6 +183,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 92e1e01ad43f2..f353c7ccf8730 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -207,6 +207,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 2329f2aa20cf0..7ed64568f0e06 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -135,7 +135,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 534043aeb4eb8..2cb554d15e04c 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -61,6 +61,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< @@ -573,6 +585,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, + }), + ), + ); + }, }); }), ); @@ -655,9 +697,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 @@ -666,7 +711,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) => @@ -814,22 +859,32 @@ 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 }); + // Terminal interrupt races succeed; recorded failures remain recoverable until their write commits. + // Backoff avoids repeating a failed recording immediately; interruption replays the effect. 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 b210d3c9840ae..603c79f8620bd 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -33,6 +33,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"; export class EventSinkWriteError extends Schema.TaggedError()( @@ -157,6 +158,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; @@ -329,7 +349,105 @@ 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)); + + // Capture runs finalize through RunFinalizationService; uncaptured or abandoned runs finalize with their terminal write. + // Stored state prevents duplicate milestones; the enclosing EventSink operation owns tracing. + const withRunFinalizedEvents = Effect.fnUntraced(function* ( + events: ReadonlyArray, + effects: ReadonlyArray, + ) { + 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.succeedSome("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]; + }); + + // The enclosing EventSink operation already traces this persistence work. + const normalizeEvents = Effect.fnUntraced(function* ( + input: ReadonlyArray, + effects: ReadonlyArray = [], + ) { + 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" @@ -441,6 +559,7 @@ const layerBase: Layer.Layer< input.guardPendingUserInputCancellations === true ? yield* guardUserInputCancellations(events) : events, + input.effects, ); const committed = yield* eventStore.append({ ...(input.commandId === undefined ? {} : { commandId: input.commandId }), @@ -496,10 +615,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 }), @@ -632,6 +754,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. + // The enclosing EventSink operation already traces this persistence work. + const recordAbandonedCaptures = Effect.fnUntraced(function* ( + commandId: CommandId, + cancelledEffectIds: ReadonlyArray, + occurredAt: DateTime.Utc, + ) { + 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 }); + const outcomes = yield* applyStoredEvents(storedEvents); + return { committed: true as const, storedEvents, outcomes }; + }), + (result) => + result.committed + ? effectOutbox + .notifyAvailable() + .pipe(Effect.andThen(publishStoredEvents(result.storedEvents, result.outcomes))) + : Effect.void, + ); + }); + const commitCommandEffect = Effect.fn("orchestrationV2.EventSink.commitCommand")(function* ( input: Parameters[0], ) { @@ -656,14 +851,15 @@ const layerBase: Layer.Layer< runId === undefined ? input.events : yield* updateDelegatedCompletionOnly(input.events, (id) => id === runId), + input.effects, ); - const storedEvents = yield* eventStore.append({ + const appended = yield* eventStore.append({ commandId: input.commandId, events: normalized, }); // A Stop that changes nothing can still queue the stop of its delegated tasks. const sequence = - storedEvents.at(-1)?.sequence ?? + appended.at(-1)?.sequence ?? (input.effects.length === 0 ? undefined : yield* eventStore.latestSequence({ threadId: input.threadId })); @@ -672,25 +868,37 @@ const layerBase: Layer.Layer< new Error(`Command ${input.commandId} produced no orchestration events.`), ); } - const outcomes = yield* applyStoredEvents(storedEvents); + const outcomes = 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 ?? sequence, status: "accepted", error: null, }; yield* commandReceipts.upsert(receipt); - const cancelledEffectIds = - input.cancelUnsettledEffects === undefined - ? [] - : yield* effectOutbox.cancelUnsettled({ - threadId: input.threadId, - ...input.cancelUnsettledEffects, - }); return { receipt, storedEvents, outcomes, committed: true as const, cancelledEffectIds }; }), (result) => @@ -939,6 +1147,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 95c0f7a4bae53..55ceb83c029f3 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -832,6 +832,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, @@ -2001,6 +2005,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( @@ -2717,6 +2724,8 @@ export const layer: Layer.Layer = event.type !== "thread.interaction-mode-updated" && event.type !== "thread.model-selection-updated" && event.type !== "thread.provider-switched" && + event.type !== "run.finalized" && + event.type !== "run.finalization-failed" && event.type !== "thread.guest-share-updated" ) { const updatedAt = yield* encodeThreadUpdatedAt(event.occurredAt); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 5bbb3926e2b66..fdd1beed5b2b2 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -10,6 +10,7 @@ import { type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, + PluginInstallationId, type Project, ProjectId, ProviderDriverKind, @@ -34,6 +35,7 @@ import { HttpServer } from "effect/http"; import { ProviderWorkspaceMissingError } from "../provider/Errors.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as PluginTools from "../plugins/PluginTools.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -476,11 +478,9 @@ function layerTest(input: { readonly failReleaseEventWrites?: boolean; readonly flakyReleaseWrites?: FlakyReleaseWrites; readonly pauseAttachWrite?: Parameters[0]; + /** Once armed, the next call of that registry step hangs until interrupted or crashes. */ + readonly pauseMcpRegistry?: PauseMcpRegistry; readonly beforeUnchangedWrite?: Ref.Ref>; - readonly pauseResolve?: { - readonly armed: Ref.Ref; - readonly paused: Deferred.Deferred; - }; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hasPendingBackgroundWorkForThread?: Effect.Effect; readonly backgroundTasksEndWithProcess?: boolean; @@ -492,6 +492,7 @@ function layerTest(input: { readonly scopeCloseReached?: Deferred.Deferred; readonly serverSettingsLayer?: ReturnType; readonly projectServiceLayer?: Layer.Layer; + readonly pluginToolsLayer?: Layer.Layer; }) { const layerConfiguredEventSink = input.flakyReleaseWrites !== undefined @@ -531,9 +532,9 @@ function layerTest(input: { }).pipe(Effect.map(ProviderAdapterRegistry.layerSingle)), ); const layerConfiguredMcpRegistry = - input.pauseResolve === undefined + input.pauseMcpRegistry === undefined ? layerTestMcpRegistry - : layerPausingMcpRegistry(input.pauseResolve); + : layerPausingMcpRegistry(input.pauseMcpRegistry); const layerProviderEventIngestorTest = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( @@ -574,6 +575,7 @@ function layerTest(input: { layerTestStores, ...(input.serverSettingsLayer === undefined ? [] : [input.serverSettingsLayer]), ...(input.projectServiceLayer === undefined ? [] : [input.projectServiceLayer]), + ...(input.pluginToolsLayer === undefined ? [] : [input.pluginToolsLayer]), ), ), ), @@ -599,23 +601,41 @@ const layerTestMcpRegistry = Layer.effect( Layer.provide(NodeServices.layer), ); -const layerPausingMcpRegistry = (pause: { +interface PauseMcpRegistry { + readonly step: "resolve" | "setPluginToolGrants"; + readonly outcome: "hang" | "crash"; readonly armed: Ref.Ref; readonly paused: Deferred.Deferred; -}) => +} + +const layerPausingMcpRegistry = (pause: PauseMcpRegistry) => Layer.effect( McpSessionRegistry.McpSessionRegistry, Effect.gen(function* () { const delegate = yield* McpSessionRegistry.McpSessionRegistry; + const holdIfArmed = (step: PauseMcpRegistry["step"], run: Effect.Effect) => + step !== pause.step + ? run + : Ref.getAndSet(pause.armed, false).pipe( + Effect.flatMap((armed) => + armed + ? Deferred.succeed(pause.paused, undefined).pipe( + Effect.andThen( + pause.outcome === "hang" + ? Effect.never + : Effect.die(new Error(`${step} crashed`)), + ), + ) + : run, + ), + ); return McpSessionRegistry.McpSessionRegistry.of({ ...delegate, - resolve: (rawToken) => - Ref.getAndSet(pause.armed, false).pipe( - Effect.flatMap((armed) => - armed - ? Deferred.succeed(pause.paused, undefined).pipe(Effect.andThen(Effect.never)) - : delegate.resolve(rawToken), - ), + resolve: (rawToken) => holdIfArmed("resolve", delegate.resolve(rawToken)), + setPluginToolGrants: (providerSessionId, grants) => + holdIfArmed( + "setPluginToolGrants", + delegate.setPluginToolGrants(providerSessionId, grants), ), }); }), @@ -1845,6 +1865,49 @@ it.effect( }), ); +it.effect("ProviderSessionManagerV2 snapshots the enabled tool plugins into the credential", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const enabled = yield* Ref.make([ + { installationId: PluginInstallationId.make("installation-1"), generation: 1 }, + ]); + const pluginToolsLayer = Layer.mock(PluginTools.PluginTools)({ grants: Ref.get(enabled) }); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-plugin-tools"); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const openAndResolve = Effect.gen(function* () { + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const config = (yield* Ref.get(mcpConfigs)).at(-1); + const token = config!.authorizationHeader.replace(/^Bearer\s+/, ""); + const resolved = yield* registry.resolve(token); + yield* manager.close(providerSessionId); + return resolved?.pluginToolGrants; + }); + + assert.deepEqual(yield* openAndResolve, yield* Ref.get(enabled)); + // A plugin enabled later reaches the next session, not the one already prepared. + yield* Ref.set(enabled, []); + assert.deepEqual(yield* openAndResolve, []); + }).pipe( + Effect.provide(layerTest({ state, idleTimeoutMs: 1_000, mcpConfigs, pluginToolsLayer })), + ); + }), +); + it.effect("ProviderSessionManagerV2 honors a project browser-access opt-out", () => Effect.gen(function* () { const captured = yield* runBrowserAccessScenario({ @@ -2075,10 +2138,14 @@ it.effect( }), ); -// regression: #15571 an interrupted session start did not clean up -it.effect( - "ProviderSessionManagerV2 revokes a reused credential after a resume stopped while checking it", - () => +it.effect.each([ + ["stopped while checking it", "resolve", "hang"], + ["stopped while updating its plugin tool grants", "setPluginToolGrants", "hang"], + ["crashed while checking it", "resolve", "crash"], + ["crashed while updating its plugin tool grants", "setPluginToolGrants", "crash"], +] as const)( + "ProviderSessionManagerV2 revokes a reused credential after a resume %s", + ([, step, outcome]) => Effect.gen(function* () { const state = yield* Ref.make(emptyState); const armed = yield* Ref.make(false); @@ -2090,8 +2157,8 @@ it.effect( const registry = yield* McpSessionRegistry.McpSessionRegistry; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const now = yield* DateTime.now; - const owner = ThreadId.make("thread-provider-session-manager-resolve-stop-owner"); - const threadId = ThreadId.make("thread-provider-session-manager-resolve-stop"); + const owner = ThreadId.make(`thread-provider-session-manager-${step}-${outcome}-owner`); + const threadId = ThreadId.make(`thread-provider-session-manager-${step}-${outcome}`); const providerSessionId = idAllocator.derive.providerSession({ providerInstanceId: modelSelection.instanceId, }); @@ -2114,7 +2181,7 @@ it.effect( threadId, providerSessionId, now, - nativeThreadId: "native-resolve-stop", + nativeThreadId: `native-${step}-${outcome}`, }), }); yield* resume; @@ -2123,16 +2190,22 @@ it.effect( assert.isDefined(config); const token = config!.authorizationHeader.replace(/^Bearer\s+/, ""); + // A re-attach is stopped, or crashes, while it reuses that credential. yield* Ref.set(armed, true); const stopped = yield* resume.pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(paused); - yield* Fiber.interrupt(stopped); + // A crashed attach is logged and rolled back rather than failing the resume. + yield* outcome === "hang" ? Fiber.interrupt(stopped) : Fiber.await(stopped); yield* manager.release({ providerSessionId, reason: "manual_shutdown" }); assert.isUndefined(yield* registry.resolve(token)); }).pipe( Effect.provide( - layerTest({ state, idleTimeoutMs: 60_000, pauseResolve: { armed, paused } }), + layerTest({ + state, + idleTimeoutMs: 60_000, + pauseMcpRegistry: { step, outcome, armed, paused }, + }), ), ); }), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 03a7278774a64..d029d7df2079a 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -45,6 +45,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ServerSettings from "../serverSettings.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; +import * as PluginTools from "../plugins/PluginTools.ts"; import * as EventSink from "./EventSink.ts"; import * as CommandOutputHub from "./CommandOutputHub.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -340,6 +341,8 @@ export const layerWithOptions = ( /** Unreadable settings must preserve explicit browser-access opt-outs. */ const serverSettings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); const projectService = yield* Effect.serviceOption(ProjectService.ProjectService); + // Optional for the same reason; without it a session gets no plugin tools. + const pluginTools = yield* Effect.serviceOption(PluginTools.PluginTools); const eventSink = yield* EventSink.EventSinkV2; const commandOutput = yield* CommandOutputHub.CommandOutputHub; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -457,6 +460,10 @@ export const layerWithOptions = ( >(["orchestration", "worktree", "pull-requests"]); if (browserToolsAvailable) capabilities.add("preview"); if (deviceToolsAvailable) capabilities.add("device"); + // Taken at every preparation; each call still checks the plugin is enabled now. + const pluginToolGrants = Option.isSome(pluginTools) + ? yield* pluginTools.value.grants + : []; const subagent = yield* projectionStore.getThread(threadId).pipe( Effect.map((thread) => thread.lineage.relationshipToParent === "subagent"), Effect.orElseSucceed(() => false), @@ -466,25 +473,37 @@ export const layerWithOptions = ( // Reserve before async validation so release cannot revoke between validation and reservation. reserveMcpCredential(threadId, existing.providerSessionId); const rawToken = existing.authorizationHeader.replace(/^Bearer\s+/, ""); - // Interrupted resolution must release the reservation before the caller learns of it. - const resolved = yield* mcpSessionRegistry - .resolve(rawToken) - .pipe( - Effect.onInterrupt(() => - Effect.sync(() => - dropMcpCredentialReservation(threadId, existing.providerSessionId), - ), - ), + // The caller only learns of the reservation once this returns, + // so a stop or crash while resolving or updating grants must drop it here. + const reused = yield* Effect.gen(function* () { + const resolved = yield* mcpSessionRegistry.resolve(rawToken); + if ( + resolved === undefined || + resolved.thread.threadId !== threadId || + resolved.thread.providerInstanceId !== providerInstanceId || + // A flipped browser-access setting must not survive through + // credential reuse: rotate so the new scope reflects it. + resolved.capabilities.has("preview") !== browserToolsAvailable || + resolved.capabilities.has("device") !== deviceToolsAvailable || + (resolved.thread.subagent === true) !== subagent + ) { + return false; + } + // The provider keeps this credential, and the plugin tools are fixed meta-tools, + // so new grants apply to it without rotating the token. + yield* mcpSessionRegistry.setPluginToolGrants( + existing.providerSessionId, + pluginToolGrants, ); - if ( - resolved !== undefined && - resolved.thread.threadId === threadId && - resolved.thread.providerInstanceId === providerInstanceId && - resolved.capabilities.has("preview") === browserToolsAvailable && - resolved.capabilities.has("device") === deviceToolsAvailable && - // A credential issued while the lineage read failed is replaced once it works. - (resolved.thread.subagent === true) === subagent - ) { + return true; + }).pipe( + Effect.onError(() => + Effect.sync(() => + dropMcpCredentialReservation(threadId, existing.providerSessionId), + ), + ), + ); + if (reused) { return { mcpCredentialId: existing.providerSessionId, issued: false }; } dropMcpCredentialReservation(threadId, existing.providerSessionId); @@ -495,6 +514,7 @@ export const layerWithOptions = ( providerInstanceId, browserToolsAvailable, capabilities, + pluginToolGrants, subagent, }); yield* mcpSessions.set(credential.config); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index b9b2b09bd227e..f690bfea04678 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -53,6 +53,7 @@ import { makeProviderFailureTurnItem, } from "@t3tools/provider-core/server/failure"; import * as RunFinalizationService from "./RunFinalizationService.ts"; +import { checkpointCaptureEffectId } from "./RunFinalized.ts"; import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; import { userFacingDatabaseErrorMessage } from "./UserFacingErrors.ts"; @@ -742,7 +743,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/RunFinalizationQueue.test.ts b/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts index c2b9e0aabcd5b..fa1589c9c52a3 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts @@ -11,6 +11,7 @@ import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; import * as CheckpointCapture from "./CheckpointCaptureService.ts"; import * as CheckpointRollback from "./CheckpointRollbackService.ts"; +import * as EventSink from "./EventSink.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -37,6 +38,15 @@ it.effect.each([ cause: "checkpoint storage unavailable", }); const projections = Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getCheckpointCaptureContext: () => + Effect.succeed({ + run: undefined, + rootNode: undefined, + scope: undefined, + providerThread: undefined, + readyCheckpointOrdinals: [], + sideChat: false, + }), getCheckpointContext: () => Effect.succeed({ runs: [], @@ -70,6 +80,9 @@ it.effect.each([ Layer.mergeAll( projections, observer, + Layer.mock(EventSink.EventSinkV2)({ + hasRunFinalization: () => Effect.succeed(false), + }), Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ execute: () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index 38a18192531a5..286b09ae26f81 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 } from "@effect/vitest"; +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,9 +14,68 @@ 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, then records finalization", () => { + const threadId = ThreadId.make("thread_finalize"); + const runId = RunId.make("run_finalize"); + const scopeId = CheckpointScopeId.make("scope_finalize"); + 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" }], + checkpoints: [], + }; + const layer = RunFinalization.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ execute: capture }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + 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: [], + sideChat: false, + }), + }), + Layer.mock(EventSink.EventSinkV2)({ + write, + hasRunFinalization: () => Effect.succeed(false), + }), + Layer.succeed(RunFinalization.RunFinalizationObserver, { + refresh, + refreshAfterTurn: () => Effect.void, + }), + ), + ), + ); + return Effect.gen(function* () { + const service = yield* RunFinalization.RunFinalizationService; + 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)); +}); + it.effect("completes finalization when the post-checkpoint workspace refresh fails", () => { const threadId = ThreadId.make("thread_refresh_failure"); const runId = RunId.make("run_refresh_failure"); @@ -23,6 +84,10 @@ it.effect("completes finalization when the post-checkpoint workspace refresh fai const layer = RunFinalization.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(EventSink.EventSinkV2)({ + hasRunFinalization: () => Effect.succeed(false), + write: () => Effect.sync(() => (captured.push("finalized"), [])), + }), Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ execute: () => Effect.sync(() => { @@ -36,6 +101,22 @@ it.effect("completes finalization when the post-checkpoint workspace refresh fai checkpointScopes: [{ id: scopeId, runId, kind: "root_run" as const, cwd: "/repo" }], checkpoints: [], }), + 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: [], + sideChat: false, + }), }), Layer.succeed(RunFinalization.RunFinalizationObserver, { refresh: () => @@ -54,7 +135,81 @@ it.effect("completes finalization when the post-checkpoint workspace refresh fai return Effect.gen(function* () { const service = yield* RunFinalization.RunFinalizationService; yield* service.finalize({ threadId, runId, scopeId }); - assert.deepEqual(captured, ["checkpoint", "refresh"]); + assert.deepEqual(captured, ["checkpoint", "refresh", "finalized"]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("preserves checkpoint failures without refreshing the workspace", () => { + const threadId = ThreadId.make("thread_capture_failure"); + const runId = RunId.make("run_capture_failure"); + const scopeId = CheckpointScopeId.make("scope_capture_failure"); + const captureError = new CheckpointCapture.CheckpointCaptureExecutionError({ + threadId, + runId, + scopeId, + cause: new Error("Checkpoint capture failed"), + }); + const refresh = vi.fn(() => Effect.void); + const layer = RunFinalization.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(EventSink.EventSinkV2)({ + hasRunFinalization: () => Effect.succeed(false), + }), + Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ + execute: () => Effect.fail(captureError), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getCheckpointContext: () => Effect.die("capture failed before context lookup"), + }), + Layer.succeed(RunFinalization.RunFinalizationObserver, { + refresh, + refreshAfterTurn: () => Effect.void, + }), + ), + ), + ); + return Effect.gen(function* () { + const service = yield* RunFinalization.RunFinalizationService; + const error = yield* service.finalize({ threadId, runId, scopeId }).pipe(Effect.flip); + assert.equal(error.operation, "capture-checkpoint"); + assert.strictEqual(error.cause, captureError); + assert.equal(refresh.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); +}); + +it.effect("preserves checkpoint context read failures without refreshing the workspace", () => { + const threadId = ThreadId.make("thread_context_failure"); + const runId = RunId.make("run_context_failure"); + const scopeId = CheckpointScopeId.make("scope_context_failure"); + const contextError = new ProjectionStore.ProjectionStoreReadError({ + threadId, + cause: new Error("Checkpoint context read failed"), + }); + const refresh = vi.fn(() => Effect.void); + const layer = RunFinalization.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(EventSink.EventSinkV2)({ + hasRunFinalization: () => Effect.succeed(false), + }), + Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ execute: () => Effect.void }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getCheckpointContext: () => Effect.fail(contextError), + }), + Layer.succeed(RunFinalization.RunFinalizationObserver, { + refresh, + refreshAfterTurn: () => Effect.void, + }), + ), + ), + ); + return Effect.gen(function* () { + const service = yield* RunFinalization.RunFinalizationService; + const error = yield* service.finalize({ threadId, runId, scopeId }).pipe(Effect.flip); + assert.equal(error.operation, "refresh-workspace"); + assert.strictEqual(error.cause, contextError); + assert.equal(refresh.mock.calls.length, 0); }).pipe(Effect.provide(layer)); }); diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index 40882033ba527..34cde32358965 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,51 +53,134 @@ 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 outside the best-effort display refresh 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( + 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) { + // A display refresh failure must not retry an already durable checkpoint. + yield* observer.refresh({ cwd, threadId: input.threadId, runId: input.runId }).pipe( + Effect.catchTags({ + RunFinalizationRefreshError: (cause) => + Effect.logWarning("Failed to refresh workspace after checkpoint capture", { + ...input, + cause, + }), + }), + ); + } + }).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"))); + }); + + const abandon: RunFinalizationService["Service"]["abandon"] = Effect.fn( + "RunFinalizationService.abandon", + )( + function* (input) { + 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; + }, + (effect, input) => + effect.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) { - // Retrying a display refresh after the checkpoint is durable blocks the next turn. - yield* observer.refresh({ cwd, threadId: input.threadId, runId: input.runId }).pipe( - Effect.catchTags({ - RunFinalizationRefreshError: (cause) => - Effect.logWarning("Failed to refresh workspace after checkpoint capture", { - ...input, + (cause) => + new RunFinalizationError({ + threadId: input.threadId, + runId: input.runId, + scopeId: input.scopeId, + operation: input.operation, cause, }), - }), - ); - } - }); - return RunFinalizationService.of({ finalize }); + ), + ), + ); + + 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 0000000000000..40b352ff625a5 --- /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" }); + 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 failed display refresh finalizes the durable checkpoint across restart", () => + 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("succeeded")); + assert.equal(yield* runStatus, "completed"); + const finalized = [ + { + type: "run.finalized" as const, + payload: { runId, outcome: "completed" as const, checkpointId }, + }, + ]; + assert.deepEqual(yield* finalizationRecords, finalized); + // Neither a restart nor a later write duplicates the recorded finalization. + 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, finalized); + }).pipe(Effect.provide(makeLayer(commitCapture("completed"), failRefresh))), +); + +it.effect("a failed display refresh is not retried after 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, 1); + 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" }); + 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 0000000000000..c63292032030b --- /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/RunMetrics.test.ts b/apps/server/src/orchestration-v2/RunMetrics.test.ts index fead1c29a969e..aa07165f7c5bf 100644 --- a/apps/server/src/orchestration-v2/RunMetrics.test.ts +++ b/apps/server/src/orchestration-v2/RunMetrics.test.ts @@ -271,8 +271,20 @@ describe("EventSink run outcomes", () => { yield* Fiber.join(terminal), yield* Fiber.join(unrelated), ]; - assert.strictEqual(terminalEvents.length, 1); - assert.strictEqual(unrelatedEvents.length, 1); + assert.deepEqual( + terminalEvents.map((stored) => stored.event.type), + ["run.updated", "run.finalized"], + ); + assert.deepEqual(terminalEvents[0]?.event, runEvent("run.updated", "completed")); + assert.deepEqual(terminalEvents[1]?.event.payload, { + runId, + outcome: "completed", + checkpointId: null, + }); + assert.deepEqual( + unrelatedEvents.map((stored) => stored.event.type), + ["thread.created"], + ); const counter = (yield* runSnapshots("p-sink")).find( (snapshot) => snapshot.id === "t3_runs_total", ); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 8e1ca75fcac98..da5ebe62ff3d9 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -20,6 +20,7 @@ import { type OrchestrationV2Run, type OrchestrationV2RuntimeRequest, ProjectId, + PluginInstallationId, type PullRequestDetail, type PullRequestComment, PullRequestOperationError, @@ -40,6 +41,8 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Exit from "effect/Exit"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; @@ -53,6 +56,8 @@ import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.t import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; +import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; +import * as PluginTools from "../plugins/PluginTools.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -273,25 +278,30 @@ const moveProject = (projectId: ProjectId, workspaceRoot: string, updatedAt: str }), ); -const layerTest = Layer.mergeAll( - RuntimeLayer.layer, - RuntimeLayer.layerEventSink, - ProjectStore.layer, - ProjectionStore.layer, - EffectOutbox.layer, - ThreadCommandExecutor.layer, -).pipe( - Layer.provide(McpSessionRegistryTestkit.layer), - Layer.provide(McpProviderSessions.layer), - Layer.provide(SqlitePersistence.layerMemory), - Layer.provide(layerCheckpointStoreTest), - Layer.provide(layerServerConfig), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(layerTestProviderInstanceRegistry), - Layer.provide(layerGitWorkflowTest), - Layer.provide(layerProjectServiceTest), - Layer.provide(layerPlatformTest), -); +const layerTestWithMcpRegistry = ( + registryLayer: Layer.Layer, +) => + Layer.mergeAll( + RuntimeLayer.layer, + RuntimeLayer.layerEventSink, + ProjectStore.layer, + ProjectionStore.layer, + EffectOutbox.layer, + ThreadCommandExecutor.layer, + ).pipe( + Layer.provide(registryLayer), + Layer.provide(McpProviderSessions.layer), + Layer.provide(SqlitePersistence.layerMemory), + Layer.provide(layerCheckpointStoreTest), + Layer.provide(layerServerConfig), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerTestProviderInstanceRegistry), + Layer.provide(layerGitWorkflowTest), + Layer.provide(layerProjectServiceTest), + Layer.provide(layerPlatformTest), + ); + +const layerTest = layerTestWithMcpRegistry(McpSessionRegistryTestkit.layer); const layerLegacyImportTest = RuntimeLayer.layer.pipe( Layer.provide(McpSessionRegistryTestkit.layer), @@ -8501,3 +8511,70 @@ it.layer(layerQueueResumeDisabledInstanceTest)( ); }, ); + +it.effect("runtime layers snapshot outer plugin tool grants into session credentials", () => + Effect.gen(function* () { + const requests = yield* Ref.make>([]); + const grants = [ + { installationId: PluginInstallationId.make("runtime-tool-plugin"), generation: 3 }, + ]; + const registryLayer = Layer.effect( + McpSessionRegistry.McpSessionRegistry, + Effect.gen(function* () { + const delegate = yield* McpSessionRegistry.McpSessionRegistry; + return McpSessionRegistry.McpSessionRegistry.of({ + ...delegate, + issue: (request) => + delegate + .issue(request) + .pipe(Effect.tap(() => Ref.update(requests, (current) => [...current, request]))), + }); + }), + ).pipe(Layer.provide(McpSessionRegistryTestkit.layer)); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const threadId = ThreadId.make("runtime-plugin-grants-thread"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-plugin-grants-create"), + threadId, + projectId: ProjectId.make("runtime-plugin-grants-project"), + title: "Plugin grants", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + // The lifecycle adapter deliberately refuses to open; credentials are issued before that boundary. + const opened = yield* manager + .open({ + threadId, + providerSessionId: ProviderSessionId.make("runtime-plugin-grants-session"), + modelSelection, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }, + }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(opened)); + assert.deepEqual( + (yield* Ref.get(requests)).map((request) => request.pluginToolGrants), + [grants], + ); + }).pipe( + Effect.provide( + layerTestWithMcpRegistry(registryLayer).pipe( + Layer.provideMerge( + Layer.mock(PluginTools.PluginTools)({ grants: Effect.succeed(grants) }), + ), + ), + ), + ); + }), +); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 1b54627595372..803d8b13aa674 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -207,7 +207,9 @@ const layerCheckpointCaptureServiceProvided = CheckpointCaptureService.layer.pip ), ); const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe( - Layer.provide(Layer.merge(layerCheckpointCaptureServiceProvided, ProjectionStore.layer)), + Layer.provide( + Layer.mergeAll(layerCheckpointCaptureServiceProvided, layerEventSink, 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 77e8bae8f085f..8a3b6a7f08df4 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -210,8 +210,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 afd11a9ab7ecb..6402c10d4a983 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -444,7 +444,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 e13c2b449d9e7..a16b2973b8fa3 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -1,6 +1,7 @@ import * as Migrator from "effect/sql/Migrator"; import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; +import { reconcilePluginPreviewMigrations } from "./reconcilePluginPreviewMigrations.ts"; import { reconcileV2PreviewMigration } from "./reconcileV2PreviewMigration.ts"; import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; @@ -79,6 +80,9 @@ import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts"; import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; import Migration0076 from "./Migrations/076_ProjectionProjectsPinArchive.ts"; +import Migration0077 from "./Migrations/077_PluginInstallations.ts"; +import Migration0078 from "./Migrations/078_PluginEventCursors.ts"; +import Migration0079 from "./Migrations/079_PluginSettings.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -158,6 +162,9 @@ const migrationEntries = [ [74, "ExcludeAttachmentPlaceholdersFromSearch", Migration0074], [75, "SubagentDeliveryState", Migration0075], [76, "ProjectionProjectsPinArchive", Migration0076], + [77, "PluginInstallations", Migration0077], + [78, "PluginEventCursors", Migration0078], + [79, "PluginSettings", Migration0079], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); @@ -218,11 +225,15 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ const includesV2 = toMigrationInclusive === undefined || toMigrationInclusive >= FIRST_V2_MIGRATION_ID; const previewMigrations = includesV2 ? yield* reconcileV2PreviewMigration() : []; + const pluginMigrations = includesV2 + ? yield* reconcilePluginPreviewMigrations(migrationManifest, toMigrationInclusive) + : []; if (includesV2) { yield* assertV2MigrationsCanRun(toMigrationInclusive); } const executedMigrations = [ ...previewMigrations, + ...pluginMigrations, ...(yield* run({ loader: makeMigrationLoader(toMigrationInclusive) })), ]; const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`); diff --git a/apps/server/src/persistence/Migrations/077_PluginInstallations.ts b/apps/server/src/persistence/Migrations/077_PluginInstallations.ts new file mode 100644 index 0000000000000..fc8e31c2cd87e --- /dev/null +++ b/apps/server/src/persistence/Migrations/077_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/078_PluginEventCursors.ts b/apps/server/src/persistence/Migrations/078_PluginEventCursors.ts new file mode 100644 index 0000000000000..cf24e4b054559 --- /dev/null +++ b/apps/server/src/persistence/Migrations/078_PluginEventCursors.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // One row per plugin installation that receives events: the event log sequence it has + // acknowledged through. Removing the installation removes its row. + yield* sql` + CREATE TABLE IF NOT EXISTS plugin_event_cursors ( + installation_id TEXT PRIMARY KEY, + acknowledged_sequence INTEGER NOT NULL, + updated_at TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Migrations/079_PluginSettings.ts b/apps/server/src/persistence/Migrations/079_PluginSettings.ts new file mode 100644 index 0000000000000..0dde99c4fb122 --- /dev/null +++ b/apps/server/src/persistence/Migrations/079_PluginSettings.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Saved plugin setting values per installation, secrets excepted. + yield* sql` + CREATE TABLE IF NOT EXISTS plugin_settings ( + installation_id TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (installation_id, key) + ) + `; + // Secrets whose value may be in the server secret store. A row is written before its file and + // deleted after it, so cleanup can always find the file; `saved` is 1 once the value is complete + // and 0 while it is written or deleted. + yield* sql` + CREATE TABLE IF NOT EXISTS plugin_setting_secrets ( + installation_id TEXT NOT NULL, + key TEXT NOT NULL, + saved INTEGER NOT NULL, + PRIMARY KEY (installation_id, key) + ) + `; + // Each installation's private key-value storage; `bytes` is the value's encoded size for quotas. + yield* sql` + CREATE TABLE IF NOT EXISTS plugin_storage ( + installation_id TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + bytes INTEGER NOT NULL, + PRIMARY KEY (installation_id, key) + ) + `; +}); diff --git a/apps/server/src/persistence/reconcilePluginPreviewMigrations.test.ts b/apps/server/src/persistence/reconcilePluginPreviewMigrations.test.ts new file mode 100644 index 0000000000000..c2544749db0b5 --- /dev/null +++ b/apps/server/src/persistence/reconcilePluginPreviewMigrations.test.ts @@ -0,0 +1,182 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Migrator from "effect/sql/Migrator"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { migrationManifest, runMigrations } from "./Migrations.ts"; +import PluginInstallations from "./Migrations/077_PluginInstallations.ts"; +import PluginEventCursors from "./Migrations/078_PluginEventCursors.ts"; +import PluginSettings from "./Migrations/079_PluginSettings.ts"; + +const previews = [ + [67, "PluginInstallations", PluginInstallations], + [68, "PluginEventCursors", PluginEventCursors], + [69, "PluginSettings", PluginSettings], +] as const; +const seed = (through: number, start = 67) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: start - 1 }); + yield* Migrator.make({})({ + loader: Migrator.fromRecord( + Object.fromEntries( + previews + .filter(([id]) => start + id - 67 <= through) + .map(([id, name, migration]) => [`${start + id - 67}_${name}`, migration]), + ), + ), + }); + yield* sql`INSERT INTO plugin_installations VALUES ('sentinel', '/plugins/sentinel', '{"consent":"retained"}')`; + if (through >= start + 1) + yield* sql`INSERT INTO plugin_event_cursors VALUES ('sentinel', 321, 'retained-time')`; + if (through >= start + 2) { + yield* sql`INSERT INTO plugin_settings VALUES ('sentinel', 'setting', '{"retained":true}')`; + yield* sql`INSERT INTO plugin_setting_secrets VALUES ('sentinel', 'secret', 1)`; + yield* sql`INSERT INTO plugin_storage VALUES ('sentinel', 'key', '{"retained":true}', 17)`; + } + yield* sql`UPDATE effect_sql_migrations SET created_at = '2026-10-01 00:00:00' WHERE migration_id >= ${start}`; + }); +const snapshot = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const schema = yield* sql<{ + readonly name: string; + readonly type: string; + readonly sql: string | null; + }>`SELECT name, type, sql FROM sqlite_master ORDER BY name`; + const data = []; + for (const row of schema) { + if ( + row.type === "table" && + (row.name.startsWith("plugin_") || row.name === "effect_sql_migrations") + ) + data.push([row.name, yield* sql.unsafe(`SELECT * FROM "${row.name}" ORDER BY 1`)]); + } + return { schema, data }; +}); +const assertCurrent = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + assert.deepStrictEqual( + (yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>`SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id`).map( + (row) => [row.migration_id, row.name] as const, + ), + migrationManifest, + ); + assert.strictEqual( + (yield* sql`PRAGMA table_info(scheduled_tasks)`).some( + (row) => row.name === "last_run_webhook_delivery_id", + ), + true, + ); + assert.strictEqual( + (yield* sql`SELECT name FROM sqlite_master WHERE name = 'orchestration_v2_projection_turn_items_stop_request_idx'`) + .length, + 1, + ); +}); +const memory = (test: Effect.Effect) => + test.pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))); + +describe("plugin preview upgrade", () => { + it.effect.each([0, 68, 75, 76])("upgrades fresh/main %s with canonical migration ids", (main) => + memory( + Effect.gen(function* () { + if (main) yield* runMigrations({ toMigrationInclusive: main }); + yield* runMigrations(); + yield* assertCurrent; + const before = yield* snapshot; + assert.deepStrictEqual(yield* runMigrations(), []); + assert.deepStrictEqual(yield* snapshot, before); + }), + ), + ); + it.effect.each([ + [67, 67], + [68, 67], + [69, 69], + [70, 69], + [69, 67], + [71, 69], + [76, 76], + [77, 76], + [78, 76], + ] as const)("preserves preview %s data and timestamps and is reentrant", ([through, start]) => + memory( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* seed(through, start); + const before = (yield* snapshot).data.filter(([name]) => name !== "effect_sql_migrations"); + yield* runMigrations(); + yield* assertCurrent; + const after = yield* snapshot; + // A later canonical build can add tables, but existing rows stay byte-for-byte identical. + for (const table of before) + assert.deepStrictEqual( + after.data.find(([name]) => name === table[0]), + table, + ); + assert.deepStrictEqual( + yield* sql`SELECT created_at FROM effect_sql_migrations WHERE migration_id BETWEEN 77 AND ${77 + through - start}`, + Array.from({ length: through - start + 1 }, () => ({ + created_at: "2026-10-01 00:00:00", + })), + ); + assert.deepStrictEqual(yield* runMigrations(), []); + assert.deepStrictEqual(yield* snapshot, after); + }), + ), + ); + it.effect.each([ + "unknown-history", + "unknown-schema", + "partial-upstream", + "partial-search", + "partial-project", + "partial-target", + ])("rejects %s unchanged", (variant) => + memory( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* seed(67); + if (variant === "unknown-history") + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (68, 'UnknownFork')`; + if (variant === "unknown-schema") + yield* sql`ALTER TABLE plugin_installations ADD COLUMN unknown TEXT`; + if (variant === "partial-upstream") + yield* sql`ALTER TABLE scheduled_tasks ADD COLUMN last_run_webhook_delivery_id TEXT`; + if (variant === "partial-search") + yield* sql`CREATE TABLE orchestration_v2_message_search (text TEXT)`; + if (variant === "partial-project") + yield* sql`ALTER TABLE projection_projects ADD COLUMN pinned_at TEXT`; + const before = yield* snapshot; + assert.ok( + Exit.isFailure( + yield* Effect.exit( + runMigrations(variant === "partial-target" ? { toMigrationInclusive: 75 } : {}), + ), + ), + ); + assert.deepStrictEqual(yield* snapshot, before); + }), + ), + ); + it.effect("rolls back ledger moves and upstream schema on SQL failure, then retries", () => + memory( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* seed(67); + yield* sql`CREATE TRIGGER refuse_plugin_upgrade BEFORE INSERT ON effect_sql_migrations WHEN NEW.name = 'ShellStopRequestIndex' BEGIN SELECT RAISE(ABORT, 'injected failure'); END`; + const before = yield* snapshot; + assert.ok(Exit.isFailure(yield* Effect.exit(runMigrations()))); + assert.deepStrictEqual(yield* snapshot, before); + yield* sql`DROP TRIGGER refuse_plugin_upgrade`; + yield* runMigrations(); + yield* assertCurrent; + }), + ), + ); +}); diff --git a/apps/server/src/persistence/reconcilePluginPreviewMigrations.ts b/apps/server/src/persistence/reconcilePluginPreviewMigrations.ts new file mode 100644 index 0000000000000..9c0cfe1ff08de --- /dev/null +++ b/apps/server/src/persistence/reconcilePluginPreviewMigrations.ts @@ -0,0 +1,179 @@ +import * as Effect from "effect/Effect"; +import * as Migrator from "effect/sql/Migrator"; +import * as SqlClient from "effect/sql/SqlClient"; + +import WebhookDedupe from "./Migrations/067_WebhookDedupe.ts"; +import ShellStopRequestIndex from "./Migrations/068_ShellStopRequestIndex.ts"; +import Migration69 from "./Migrations/069_ThreadMessageSearch.ts"; +import Migration70 from "./Migrations/070_PeerLinks.ts"; +import Migration71 from "./Migrations/071_PeerLinkFence.ts"; +import Migration72 from "./Migrations/072_WaitingLinkRequestIndex.ts"; +import Migration73 from "./Migrations/073_ActiveTurnItemIndexes.ts"; +import Migration74 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts"; +import Migration75 from "./Migrations/075_SubagentDeliveryState.ts"; +import Migration76 from "./Migrations/076_ProjectionProjectsPinArchive.ts"; + +// These unmerged preview builds used main's next slots. Recognize their exact +// histories and schemas; never reinterpret an unknown build's migration ledger. +const legacyNames = ["PluginInstallations", "PluginEventCursors", "PluginSettings"] as const; +const legacySchemas = [ + [ + [ + "plugin_installations", + "CREATE TABLE plugin_installations ( installation_id TEXT PRIMARY KEY, directory TEXT NOT NULL UNIQUE, record_json TEXT NOT NULL )", + ], + ], + [ + [ + "plugin_event_cursors", + "CREATE TABLE plugin_event_cursors ( installation_id TEXT PRIMARY KEY, acknowledged_sequence INTEGER NOT NULL, updated_at TEXT NOT NULL )", + ], + ], + [ + [ + "plugin_settings", + "CREATE TABLE plugin_settings ( installation_id TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT NOT NULL, PRIMARY KEY (installation_id, key) )", + ], + [ + "plugin_setting_secrets", + "CREATE TABLE plugin_setting_secrets ( installation_id TEXT NOT NULL, key TEXT NOT NULL, saved INTEGER NOT NULL, PRIMARY KEY (installation_id, key) )", + ], + [ + "plugin_storage", + "CREATE TABLE plugin_storage ( installation_id TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT NOT NULL, bytes INTEGER NOT NULL, PRIMARY KEY (installation_id, key) )", + ], + ], +] as const; +const upstream = [ + [67, "WebhookDedupe", WebhookDedupe], + [68, "ShellStopRequestIndex", ShellStopRequestIndex], + [69, "ThreadMessageSearch", Migration69], + [70, "PeerLinks", Migration70], + [71, "PeerLinkFence", Migration71], + [72, "WaitingLinkRequestIndex", Migration72], + [73, "ActiveTurnItemIndexes", Migration73], + [74, "ExcludeAttachmentPlaceholdersFromSearch", Migration74], + [75, "SubagentDeliveryState", Migration75], + [76, "ProjectionProjectsPinArchive", Migration76], +] as const; +const normalize = (sql: string) => sql.trim().replace(/\s+/g, " ").toLowerCase(); +const badState = () => + new Migrator.MigrationError({ + kind: "BadState", + message: "Cannot upgrade an unknown or ambiguous plugin preview migration history/schema.", + }); + +export const reconcilePluginPreviewMigrations = Effect.fn("reconcilePluginPreviewMigrations")( + function* (manifest: ReadonlyArray, throughId?: number) { + const sql = yield* SqlClient.SqlClient; + return yield* sql.withTransaction( + Effect.gen(function* () { + const exists = + yield* sql`SELECT name FROM sqlite_master WHERE name = 'effect_sql_migrations'`; + if (exists.length === 0) return []; + const history = yield* sql<{ readonly migration_id: number; readonly name: string }>` + SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id + `; + const preview = history.find( + (row) => row.name === legacyNames[0] && [67, 69, 76].includes(row.migration_id), + ); + if (!preview) return []; + const start = preview.migration_id; + const canonical = manifest.find(([, name]) => name === legacyNames[0])?.[0]; + if (canonical === undefined) return yield* badState(); + const tail = history.filter((row) => row.migration_id >= start); + const available = legacyNames.filter((name, offset) => + manifest.some(([id, found]) => id === canonical + offset && found === name), + ); + if ( + tail.length === 0 || + tail.length > available.length || + tail.some( + (row, offset) => row.migration_id !== start + offset || row.name !== available[offset], + ) || + (throughId !== undefined && throughId < canonical + tail.length - 1) + ) + return yield* badState(); + const earlier = history.filter((row) => row.migration_id < start); + const expected = manifest.filter(([id]) => id < start); + if ( + earlier.length !== expected.length || + earlier.some( + (row, offset) => + row.migration_id !== expected[offset]?.[0] || row.name !== expected[offset]?.[1], + ) + ) + return yield* badState(); + const schema = yield* sql<{ + readonly name: string; + readonly tbl_name: string; + readonly sql: string | null; + }>`SELECT name, tbl_name, sql FROM sqlite_master`; + for (const [offset, tables] of legacySchemas.entries()) { + for (const [name, definition] of tables) { + const objects = schema.filter((row) => row.tbl_name === name && row.sql !== null); + if (offset < tail.length) { + if ( + objects.length !== 1 || + objects[0]?.name !== name || + normalize(objects[0].sql!) !== normalize(definition) + ) + return yield* badState(); + } else if (objects.length !== 0) return yield* badState(); + } + } + if ( + start < 76 && + schema.some((row) => + [ + "orchestration_v2_message_search", + "peer_environment_links", + "orchestration_v2_projection_turn_items_waiting_link_idx", + "orchestration_v2_projection_turn_items_active_ordinal_idx", + "orchestration_v2_projection_turn_items_active_run_idx", + "orchestration_v2_projection_subagents_settled_idx", + ].includes(row.name), + ) + ) + return yield* badState(); + const subagentColumns = yield* sql<{ + readonly name: string; + }>`PRAGMA table_info(orchestration_v2_projection_subagents)`; + if (start < 76 && subagentColumns.some((row) => row.name === "completion_delivery_state")) + return yield* badState(); + const projectColumns = yield* sql<{ + readonly name: string; + }>`PRAGMA table_info(projection_projects)`; + if (projectColumns.some((row) => row.name === "pinned_at" || row.name === "archived_at")) + return yield* badState(); + const taskColumns = yield* sql<{ + readonly name: string; + }>`PRAGMA table_info(scheduled_tasks)`; + if ( + start === 67 && + (taskColumns.some((row) => row.name === "last_run_webhook_delivery_id") || + schema.some((row) => + [ + "scheduled_task_webhook_replay_keys", + "scheduled_task_webhook_pending", + "orchestration_v2_projection_turn_items_stop_request_idx", + ].includes(row.name), + )) + ) + return yield* badState(); + // Move the preview ledger before filling main's slots; the transaction preserves both on failure. + for (let offset = tail.length - 1; offset >= 0; offset--) { + yield* sql`UPDATE effect_sql_migrations SET migration_id = ${canonical + offset} WHERE migration_id = ${start + offset} AND name = ${legacyNames[offset]}`; + } + const executed: Array = []; + for (const [id, name, migration] of upstream) { + if (id < start) continue; + yield* migration; + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (${id}, ${name})`; + executed.push([id, name]); + } + return executed; + }), + ); + }, +); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 08c5094bf49c8..95c1b1059e9b0 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -56,6 +56,9 @@ describe("V2 preview upgrade", () => { [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], + [77, "PluginInstallations"], + [78, "PluginEventCursors"], + [79, "PluginSettings"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -155,6 +158,9 @@ describe("V2 preview upgrade", () => { [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], + [77, "PluginInstallations"], + [78, "PluginEventCursors"], + [79, "PluginSettings"], ]); }).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 0000000000000..8db207dc74b92 --- /dev/null +++ b/apps/server/src/plugins/PluginCatalog.test.ts @@ -0,0 +1,884 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + ThreadId, + 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"; +import * as PluginTools from "./PluginTools.ts"; + +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; + +type Catalog = PluginCatalog.PluginCatalog["Service"]; + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +/** Starts a catalogue and its supervisor in `scope`, as one server start would. */ +const startCatalog = Effect.fn("startCatalog")(function* (scope: Scope.Scope) { + const supervisor = yield* PluginSupervisor.make({ + heapLimitMb: 64, + activationTimeout: "10 seconds", + stopGrace: "1 second", + }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + return yield* PluginCatalog.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(Scope.Scope, scope), + ); +}); + +/** + * Writes a plugin whose activation leaves a marker outside its own directory: + * a plugin writing into its directory changes its own digest. + */ +const preparePlugin = Effect.fn("preparePlugin")(function* (id: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-catalog-" }); + const directory = path.join(root, "plugin"); + yield* fs.makeDirectory(directory); + const marker = path.join(root, "activated"); + const entry = path.join(directory, "main.mjs"); + yield* fs.writeFileString( + entry, + [ + `import * as NodeFS from "node:fs";`, + `export function activate(context) {`, + ` NodeFS.writeFileSync(${toJson(marker)}, String(process.pid));`, + ` context.proposed.handle("ping", (input) => ({ pid: process.pid, input }));`, + `}`, + ``, + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id, + name: id, + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + proposedApi: true, + }), + ); + const edit = (line: string) => + fs + .readFileString(entry) + .pipe(Effect.flatMap((content) => fs.writeFileString(entry, `${content}// ${line}\n`))); + return { directory, marker, edit }; +}); + +/** Waits, through the subscription, for a snapshot that satisfies `predicate`. */ +const awaitSnapshot = ( + catalog: Catalog, + predicate: (installations: ReadonlyArray) => boolean, +) => + catalog.subscribe.pipe( + Stream.filter((snapshot) => predicate(snapshot.installations)), + Stream.runHead, + Effect.map((snapshot) => Option.getOrThrow(snapshot).installations), + ); + +/** A step a test can stop at: `reached` completes when it is entered, `release` lets it go on. */ +interface Hold { + readonly reached: Deferred.Deferred; + readonly release: Deferred.Deferred; +} + +const makeHold = Effect.gen(function* () { + const hold: Hold = { + reached: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + return hold; +}); + +const passHold = (hold: Hold | undefined) => + hold === undefined + ? Effect.void + : Deferred.succeed(hold.reached, undefined).pipe(Effect.andThen(Deferred.await(hold.release))); + +/** + * A supervisor without processes that behaves like the real one at its + * boundary: registration by plugin id, revocation at the start of `disable`, + * and `invoke` answering with the registered directory. Tests can hold + * `enable` and `disable` open. + */ +const makeStubSupervisor = Effect.gen(function* () { + const registrations = new Map(); + const invoked: Array = []; + const holds: { enable?: Hold; disable?: Hold } = {}; + const events = yield* PubSub.unbounded(); + const service = PluginSupervisor.PluginSupervisor.of({ + enable: (registration) => + Effect.suspend(() => { + const pluginId = registration.manifest.id; + if (registrations.has(pluginId)) + return Effect.fail(new PluginSupervisor.PluginAlreadyEnabledError({ pluginId })); + registrations.set(pluginId, registration); + return passHold(holds.enable); + }), + disable: (pluginId) => + Effect.suspend(() => { + registrations.delete(pluginId); + return passHold(holds.disable); + }), + resume: () => Effect.void, + invoke: (pluginId) => + Effect.suspend(() => { + const registration = registrations.get(pluginId); + if (registration === undefined) + return Effect.fail(new PluginSupervisor.PluginNotEnabledError({ pluginId })); + invoked.push(registration.directory); + return Effect.succeed(registration.directory); + }), + state: (pluginId) => + Effect.sync(() => + registrations.has(pluginId) ? Option.some({ _tag: "idle" as const }) : Option.none(), + ), + subscribe: PubSub.subscribe(events), + serveHostMethod: () => Effect.void, + }); + return { service, registrations, invoked, holds }; +}); + +/** The real file system, except that the next read of a held directory waits for its hold. */ +const makeHoldingFileSystem = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + let held: { readonly directory: string; readonly hold: Hold } | undefined; + const fileSystem: FileSystem.FileSystem = { + ...fs, + realPath: (target) => + Effect.suspend(() => { + if (held === undefined || !target.startsWith(held.directory)) return fs.realPath(target); + const { hold } = held; + held = undefined; + return passHold(hold).pipe(Effect.andThen(fs.realPath(target))); + }), + }; + const holdDirectory = Effect.fn("holdDirectory")(function* (directory: string) { + const hold = yield* makeHold; + held = { directory, hold }; + return hold; + }); + return { fileSystem, holdDirectory }; +}); + +/** A catalogue over the stub supervisor, reading files through `fileSystem`. */ +const startStubCatalog = Effect.fn("startStubCatalog")(function* ( + scope: Scope.Scope, + supervisor: PluginSupervisor.PluginSupervisor["Service"], + fileSystem?: FileSystem.FileSystem, +) { + return yield* PluginCatalog.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(FileSystem.FileSystem, fileSystem ?? (yield* FileSystem.FileSystem)), + Effect.provideService(Scope.Scope, scope), + ); +}); + +const pidOf = (value: unknown) => (value as { readonly pid: number }).pid; + +const isProcessAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +// Each test gets its own database; the restart test shares one between two starts. +const withDatabase = (effect: Effect.Effect) => + effect.pipe(Effect.provide(SqlitePersistence.layerMemory)); + +it.layer(NodeServices.layer)("PluginCatalog", (it) => { + it.effect("revokes changed code even when persisting its disabled record fails", () => + withDatabase( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const stub = yield* makeStubSupervisor; + const catalog = yield* startStubCatalog(yield* Scope.Scope, stub.service); + const plugin = yield* preparePlugin("test.failed-revocation"); + 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* plugin.edit("changed after consent"); + yield* sql`CREATE TRIGGER refuse_plugin_save BEFORE UPDATE ON plugin_installations BEGIN SELECT RAISE(FAIL, 'test write failure'); END`; + const failed = yield* catalog.refresh({ installationId }).pipe(Effect.flip); + expect(failed.reason).toBe("storage"); + expect(stub.registrations.size).toBe(0); + const listed = (yield* catalog.list).installations[0]!; + expect(listed.enabled).toBe(false); + const published = yield* awaitSnapshot(catalog, ([row]) => row?.enabled === false); + expect(published[0]?.enabled).toBe(false); + const invoked = yield* catalog.invoke(installationId, "ping", null).pipe(Effect.flip); + expect(invoked._tag).toBe("PluginCatalogError"); + expect(stub.invoked).toEqual([]); + yield* sql`DROP TRIGGER refuse_plugin_save`; + yield* catalog.refresh({ installationId }); + expect((yield* catalog.list).installations[0]?.enabled).toBe(false); + const saved = yield* sql<{ + readonly record_json: string; + }>`SELECT record_json FROM plugin_installations WHERE installation_id = ${installationId}`; + expect(JSON.parse(saved[0]!.record_json).enabled).toBe(false); + }), + ), + ); + + 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("prepares tool grants after startup restores registration generations", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const before = yield* Scope.make(); + const first = yield* startStubCatalog(before, (yield* makeStubSupervisor).service); + const { directory } = yield* preparePlugin("test.startup-tools"); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id: "test.startup-tools", + name: "Startup tools", + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + proposedApi: true, + capabilities: ["tools"], + tools: [ + { + name: "ping", + description: "Ping", + inputSchema: { type: "object" }, + sideEffect: "read", + }, + ], + }), + ); + const { installation } = yield* first.add({ directory }); + const installationId = installation.installationId; + yield* first.consent({ installationId, digest: installation.source!.digest }); + yield* first.enable({ installationId }); + yield* Scope.close(before, Exit.void); + + const stub = yield* makeStubSupervisor; + const registering = yield* makeHold; + stub.holds.enable = registering; + const catalog = yield* startStubCatalog(yield* Scope.Scope, stub.service); + yield* Effect.addFinalizer(() => Deferred.succeed(registering.release, undefined)); + yield* Deferred.await(registering.reached); + const tools = yield* PluginTools.make.pipe( + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + ); + const preparing = yield* tools.grants.pipe(Effect.forkChild({ startImmediately: true })); + expect(preparing.pollUnsafe()).toBeUndefined(); + yield* Deferred.succeed(registering.release, undefined); + const grants = yield* Fiber.join(preparing); + expect(grants).toEqual([{ installationId, generation: 2 }]); + expect((yield* tools.list(grants)).tools).toHaveLength(1); + expect( + yield* tools.call(grants, { + tool: "test.startup-tools/ping", + input: {}, + context: { + environmentId: EnvironmentId.make("test"), + threadId: ThreadId.make("test"), + }, + }), + ).toBe(directory); + }), + ), + ); + + 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 0000000000000..4f74f38e01bbf --- /dev/null +++ b/apps/server/src/plugins/PluginCatalog.ts @@ -0,0 +1,742 @@ +// Consent binds to exact directory bytes; edits revoke registration and require fresh consent. +// The digest cannot constrain its OS-user owner between checks or turn trusted plugins into a sandbox. +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; + /** Revocation is visible even when storage fails; retry that write on refresh. */ + unsaved?: boolean; + /** The registration this installation runs under, while enabled. */ + registered: Registration | undefined; + /** Waits for the process of the last registration revoked here to exit. */ + stopping: Fiber.Fiber | undefined; +} + +type Inspection = + | { + readonly _tag: "ok"; + readonly registration: PluginRegistration; + readonly source: PluginSource; + } + | { readonly _tag: "failed"; readonly reason: string }; + +/** + * How long a call waits for startup to re-register enabled plugins before it + * proceeds without them, matching the supervisor's default activation timeout. + */ +const STARTUP_RESTORE_WAIT = Duration.seconds(10); + +/** What one admission attempt of `invoke` found. */ +type Admission = + | { readonly _tag: "called"; readonly value: Schema.Json } + | { readonly _tag: "revoked" } + /** A fresh process would start: the bytes must be checked against `consented` first. */ + | { readonly _tag: "check"; readonly consented: string }; + +const summarize = (manifest: PluginManifest): PluginInstallationManifest => ({ + id: manifest.id, + name: manifest.name, + version: manifest.version, + ...(manifest.description === undefined ? {} : { description: manifest.description }), + capabilities: manifest.capabilities, + proposedApi: manifest.proposedApi, + ...(manifest.tools === undefined || manifest.tools.length === 0 ? {} : { tools: manifest.tools }), + ...(manifest.settings === undefined ? {} : { settings: manifest.settings }), +}); + +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; + /** Completes after enabled installations have their startup registration generations. */ + readonly ready: Effect.Effect; + /** Changes whenever `list` could show different installation records, so a reader can cache what it derives from them. */ + readonly revision: Effect.Effect; + /** One snapshot now, then a fresh one after every catalogue or plugin state change. */ + readonly subscribe: Stream.Stream; + readonly add: ( + input: PluginAddInput, + ) => Effect.Effect; + readonly refresh: ( + input: PluginRefreshInput, + ) => Effect.Effect; + readonly consent: ( + input: PluginConsentInput, + ) => Effect.Effect; + readonly enable: ( + input: PluginInstallationInput, + ) => Effect.Effect; + readonly disable: ( + input: PluginInstallationInput, + ) => Effect.Effect; + readonly remove: ( + input: PluginInstallationInput, + ) => Effect.Effect; + /** Clears the process's backoff, quarantine, or incompatibility, and restarts stopped event delivery. */ + readonly resume: ( + input: PluginInstallationInput, + ) => Effect.Effect; + // Checks consent before cold starts; generation pins the registration so revocation or replacement never redirects a call. + 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; + installation.unsaved = false; + 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"; + + // Revocation starts immediately; retries share the original stop fiber and cannot stop a replacement registration. + 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; + }); + + /** + * Revokes the registration, saves `record` (normally with `enabled: false`), + * and waits for the process to exit. 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 leaves the registration revoked, so changed bytes cannot run. + */ + const revoke = (installation: Installation, record: PluginInstallationRecord) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const stopping = yield* unregister(installation); + if (record !== installation.record || installation.unsaved) + yield* commit(installation, record).pipe( + Effect.tapError(() => + Effect.sync(() => { + installation.record = record; + installation.unsaved = true; + revision++; + }), + ), + ); + 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, + }) + ) { + if (installation.unsaved) yield* commit(installation, current); + return inspection; + } + const record: PluginInstallationRecord = { ...current, ...found, inspectedAt: yield* now }; + if (record.enabled && !isReady(record)) { + yield* Effect.logWarning("Plugin source changed; disabling until consent is renewed", { + installationId: record.installationId, + directory: record.directory, + }); + yield* revoke(installation, { ...record, enabled: false }); + } else { + yield* commit(installation, record); + } + return inspection; + }); + + /** + * Registers a ready installation with the supervisor under a new generation + * and saves it as enabled. Both happen or neither, even if the caller is + * interrupted. + */ + const register = Effect.fnUntraced(function* ( + installation: Installation, + registration: PluginRegistration, + ) { + const pluginId = registration.manifest.id; + const holder = [...installations.values()].find( + (other) => other !== installation && other.registered?.pluginId === pluginId, + ); + if (holder) + return yield* catalogError( + "plugin-id-conflict", + `Another enabled plugin already uses the id ${pluginId} (${holder.record.directory}). Disable it first.`, + installation.record.installationId, + ); + yield* supervisor + .enable({ ...registration, installationId: installation.record.installationId }) + .pipe( + Effect.mapError(() => + catalogError( + "plugin-id-conflict", + `A plugin with the id ${pluginId} is already running.`, + installation.record.installationId, + ), + ), + ); + const record = { + ...installation.record, + enabled: true, + generation: installation.record.generation + 1, + }; + yield* saveEnabled(record, registration.manifest.capabilities).pipe( + Effect.tapError(() => supervisor.disable(pluginId)), + ); + // Together, so an invoke never sees the new registration with the old generation. + installation.record = record; + installation.registered = { pluginId, generation: record.generation }; + revision++; + }, Effect.uninterruptible); + + const find = (installationId: PluginInstallationId) => + Effect.suspend(() => { + const installation = installations.get(installationId); + return installation + ? Effect.succeed(installation) + : Effect.fail( + catalogError("not-found", "That plugin is not installed here.", installationId), + ); + }); + + const toWire = Effect.fnUntraced(function* (installation: Installation) { + const registered = installation.registered; + if (registered === undefined) return installation.record; + const hostState = yield* supervisor.state(registered.pluginId); + const eventDelivery = Option.isSome(eventDeliveries) + ? yield* eventDeliveries.value.state( + installation.record.installationId, + registered.generation, + ) + : Option.none(); + const wire: PluginInstallation = { + ...installation.record, + ...(Option.isSome(hostState) ? { hostState: hostState.value } : {}), + ...(Option.isSome(eventDelivery) ? { eventDelivery: eventDelivery.value } : {}), + }; + return wire; + }); + + const list = Effect.suspend(() => + Effect.forEach( + [...installations.values()].sort( + (a, b) => + a.record.addedAt.localeCompare(b.record.addedAt) || + a.record.installationId.localeCompare(b.record.installationId), + ), + toWire, + ), + ).pipe(Effect.map((installations) => ({ installations }))); + + const result = (installation: Installation) => + toWire(installation).pipe(Effect.map((wire) => ({ installation: wire }))); + + /** Runs a management step under the lock and tells subscribers if it changed anything, even when it then failed. */ + const managed = (effect: Effect.Effect) => + lock.withPermit( + Effect.suspend(() => { + const before = revision; + return effect.pipe( + Effect.ensuring(Effect.suspend(() => (revision === before ? Effect.void : notify))), + ); + }), + ); + + const add = Effect.fn("PluginCatalog.add")(function* (input: PluginAddInput) { + if (!path.isAbsolute(input.directory)) + return yield* catalogError( + "invalid-directory", + "Enter the plugin directory's absolute path on the server's machine.", + ); + const inspection = yield* inspect(input.directory); + if (inspection._tag === "failed") + return yield* catalogError("invalid-directory", inspection.reason); + const directory = inspection.registration.directory; + const existing = [...installations.values()].find( + (installation) => installation.record.directory === directory, + ); + if (existing) + return yield* catalogError( + "already-added", + `${directory} is already installed.`, + existing.record.installationId, + ); + const installationId = PluginInstallationId.make( + yield* crypto.randomUUIDv4().pipe(Effect.orDie), + ); + const at = yield* now; + const record: PluginInstallationRecord = { + installationId, + generation: 0, + directory, + manifest: summarize(inspection.registration.manifest), + source: inspection.source, + problem: null, + inspectedAt: at, + consent: null, + enabled: false, + addedAt: at, + }; + const installation: Installation = { record, registered: undefined, stopping: undefined }; + yield* save(record).pipe( + Effect.andThen( + Effect.sync(() => { + installations.set(installationId, installation); + revision++; + }), + ), + Effect.uninterruptible, + ); + return yield* result(installation); + }, managed); + + 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; + }, managed); + + 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); + }, managed); + + 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); + }, managed); + + 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); + }, managed); + + 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 }; + }, managed); + + 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); + }, managed); + + /** + * 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, + ready: Deferred.await(restored), + revision: Effect.sync(() => revision), + subscribe: Stream.unwrap( + // Subscribe before the first snapshot so a change in between is not lost. + PubSub.subscribe(changes).pipe( + Effect.map((subscription) => + Stream.concat( + Stream.fromEffect(list), + Stream.fromSubscription(subscription).pipe(Stream.mapEffect(() => list)), + ).pipe( + // A plugin state event and the step that caused it can describe the same snapshot. + Stream.changes, + ), + ), + ), + ), + add, + refresh, + consent, + enable, + disable, + remove, + resume, + 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 0000000000000..9cd64fc3d4301 --- /dev/null +++ b/apps/server/src/plugins/PluginCatalogRpc.test.ts @@ -0,0 +1,147 @@ +import { + AuthAccessWriteScope, + 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 read-only pairing read the catalogue but not change what runs", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient([AuthOrchestrationReadScope], 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 a standard pairing manage plugins", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient(AuthStandardClientScopes, 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 0000000000000..b5f6a580b4f02 --- /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, + { + // Starts at the event-log end in the enabling transaction; re-enables and restarts retain the 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: Effect.fn("PluginEventDelivery.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: Effect.fn("PluginEventDelivery.state")((installationId, generation) => + Effect.sync(() => { + const current = states.get(installationId); + return current?.generation === generation ? Option.some(current.state) : Option.none(); + }), + ), + report: Effect.fn("PluginEventDelivery.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: Effect.fn("PluginEventDelivery.resume")((installationId) => + Effect.suspend(() => resume?.(installationId) ?? Effect.void), + ), + handleResume: Effect.fn("PluginEventDelivery.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 0000000000000..0e723eb110957 --- /dev/null +++ b/apps/server/src/plugins/PluginEventFeed.test.ts @@ -0,0 +1,965 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + EventId, + PluginCatalogError, + type PluginCatalogSnapshot, + type PluginEventDeliveryState, + 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; + readonly sql?: (sql: SqlClient.SqlClient) => SqlClient.SqlClient; + } = {}, +) { + 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( + SqlClient.SqlClient, + feedSees.sql?.(yield* SqlClient.SqlClient) ?? (yield* SqlClient.SqlClient), + ), + 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( + "keeps a newly enabled cursor when reconciliation sees an older catalogue snapshot", + () => + withStores( + Effect.gen(function* () { + const captured = yield* Deferred.make(); + const release = yield* Deferred.make(); + const reconciled = yield* Deferred.make(); + const scope = yield* Scope.Scope; + const { catalog } = yield* startServer( + scope, + {}, + { + catalog: (catalog) => ({ + ...catalog, + subscribe: Stream.fromEffect(catalog.list).pipe( + Stream.tap(() => + Deferred.succeed(captured, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ), + ), + Stream.concat( + Stream.fromEffect(Deferred.succeed(reconciled, undefined)).pipe(Stream.drain), + ), + Stream.concat(catalog.subscribe), + ), + }), + }, + ); + yield* Deferred.await(captured); + const plugin = yield* preparePlugin("test.concurrent-enable"); + const installationId = yield* install(catalog, plugin.directory); + const cursor = yield* storedCursor(installationId); + expect(cursor).toBeDefined(); + yield* seedThread; + yield* finalizeRun("after-enable-before-reconcile"); + yield* Deferred.succeed(release, undefined); + yield* Deferred.await(reconciled); + expect(yield* storedCursor(installationId)).toBe(cursor); + }), + ), + ); + + it.effect("recovers from a reconciliation SQL failure using a fresh catalogue snapshot", () => + withStores( + Effect.gen(function* () { + const failed = yield* Deferred.make(); + let fail = true; + let reconciliations = 0; + const { catalog, receipts } = yield* startServer( + yield* Scope.Scope, + { maxRetryBackoff: "1 second" }, + { + sql: (sql) => + new Proxy(sql, { + apply: (target, self, args) => { + const parts = args[0] as ReadonlyArray; + if (parts.join(" ").includes("FROM plugin_event_cursors")) reconciliations++; + if (fail && parts.join(" ").includes("FROM plugin_event_cursors")) { + fail = false; + return Deferred.succeed(failed, undefined).pipe( + Effect.andThen(sql`SELECT * FROM missing_reconciliation_table`), + ); + } + return Reflect.apply(target, self, args); + }, + }), + }, + ); + yield* Deferred.await(failed); + const plugin = yield* preparePlugin("test.reconciliation-recovery"); + const installationId = yield* install(catalog, plugin.directory); + yield* TestClock.adjust("1 second"); + expect(reconciliations).toBeGreaterThan(1); + expect(yield* next(receipts, "Started", installationId)).toMatchObject({ + installationId, + }); + yield* catalog.disable({ installationId }); + yield* catalog.enable({ installationId }); + expect(yield* next(receipts, "Started", installationId)).toMatchObject({ + installationId, + }); + }), + ), + ); + + it.effect("forgets a persisted cursor absent from the first catalog snapshot", () => + withStores( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const installationId = PluginInstallationId.make("removed-before-startup"); + yield* sql`INSERT INTO plugin_event_cursors (installation_id, acknowledged_sequence, updated_at) VALUES (${installationId}, 0, '2026-10-20T00:00:00Z')`; + const scope = yield* Scope.make(); + const observed = yield* Deferred.make(); + const { receipts } = yield* startServer(scope, {}, { catalog: observedAfter(observed) }); + yield* Deferred.succeed(observed, undefined); + yield* next(receipts, "Forgotten", installationId); + expect(yield* storedCursor(installationId)).toBeUndefined(); + yield* Scope.close(scope, Exit.void); + }), + ), + ); + + 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 0000000000000..a631784fb8e54 --- /dev/null +++ b/apps/server/src/plugins/PluginEventFeed.ts @@ -0,0 +1,619 @@ +// Delivery is at-least-once: acknowledge a page only after every handler returns; quarantine leaves its cursor unchanged. +// Enable sets the initial cursor transactionally; disable and restarts retain it, while removal forgets it. +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 = Effect.fn("PluginEventFeed.quarantine")(function* ( + attempts: number, + reason: string, + ) { + 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); + }); + + 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. + // Catalogue notifications can lag a concurrent enable. Decide from the durable + // installation rows in the same statement that removes their cursors. + const forgotten = yield* sql<{ readonly installation_id: PluginInstallationId }>` + DELETE FROM plugin_event_cursors + WHERE NOT EXISTS ( + SELECT 1 FROM plugin_installations + WHERE plugin_installations.installation_id = plugin_event_cursors.installation_id + ) + RETURNING installation_id + `; + for (const { installation_id: installationId } of forgotten) { + yield* publish({ _tag: "Forgotten", installationId }); + } + }), + ); + + yield* catalog.subscribe.pipe( + Stream.runForEach((snapshot) => reconcile(snapshot.installations)), + Effect.tapError((error) => + Effect.logWarning("Plugin event reconciliation failed; retrying", { error }), + ), + Effect.retry(Schedule.spaced(maxRetryBackoff)), + 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: Effect.fn("PluginEventFeed.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 0000000000000..021bcb7ff451c --- /dev/null +++ b/apps/server/src/plugins/PluginIpc.ts @@ -0,0 +1,60 @@ +// Both IPC endpoints ship together; only the public plugin API is versioned. +// Every Invoke must receive one result, including after Cancel, so cancellation can release its slot. +import { PluginId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const RequestId = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); + +/** Name a plugin registers a handler under, and the server invokes it by. */ +export const PluginHandlerName = Schema.String.check( + Schema.isMaxLength(128), + Schema.isPattern(/^[A-Za-z][A-Za-z0-9_.:-]*$/), +); + +const PluginErrorMessage = Schema.String.check(Schema.isMaxLength(2000)); + +/** A server method a capability serves to plugins, such as `settings.get`. */ +const PluginHostMethodName = Schema.String.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$/), +); + +const PluginLogLevel = Schema.Literals(["debug", "info", "warn", "error"]); +export type PluginLogLevel = typeof PluginLogLevel.Type; + +const PluginHostMessage = Schema.TaggedUnion({ + Activate: { + pluginId: PluginId, + version: Schema.String, + apiVersion: Schema.Int, + entryPath: Schema.String, + proposedApi: Schema.Boolean, + /** The manifest's capabilities, so the child offers only the APIs they grant. */ + capabilities: Schema.Array(Schema.String), + maxMessageBytes: Schema.Int, + }, + Invoke: { requestId: RequestId, handler: PluginHandlerName, input: Schema.Json }, + Cancel: { requestId: RequestId }, + Deactivate: {}, + HostCallSucceeded: { requestId: RequestId, value: Schema.Json }, + HostCallFailed: { requestId: RequestId, message: PluginErrorMessage }, +}); +export type PluginHostMessage = typeof PluginHostMessage.Type; + +const PluginChildMessage = Schema.TaggedUnion({ + Ready: {}, + ActivationFailed: { message: PluginErrorMessage }, + /** The entry cannot load on any runtime this server ships on; retrying cannot help. */ + Incompatible: { message: PluginErrorMessage }, + Succeeded: { requestId: RequestId, value: Schema.Json }, + Failed: { requestId: RequestId, message: PluginErrorMessage }, + Log: { level: PluginLogLevel, message: Schema.String.check(Schema.isMaxLength(4000)) }, + Deactivated: {}, + HostCall: { requestId: RequestId, method: PluginHostMethodName, input: Schema.Json }, +}); +export type PluginChildMessage = typeof PluginChildMessage.Type; + +export const decodePluginChildMessage = Schema.decodeUnknownExit( + Schema.fromJsonString(PluginChildMessage), +); +export const encodePluginHostMessage = Schema.encodeExit(Schema.fromJsonString(PluginHostMessage)); diff --git a/apps/server/src/plugins/PluginManifestLoader.ts b/apps/server/src/plugins/PluginManifestLoader.ts new file mode 100644 index 0000000000000..9c9c0487e733e --- /dev/null +++ b/apps/server/src/plugins/PluginManifestLoader.ts @@ -0,0 +1,130 @@ +import { + PLUGIN_API_VERSION, + PLUGIN_EVENTS_CAPABILITY, + PLUGIN_MANIFEST_FILE, + PLUGIN_SETTINGS_CAPABILITY, + PLUGIN_TOOLS_CAPABILITY, + PluginManifest, + type PluginCapabilityName, + type PluginInstallationId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { preparePluginTools } from "./pluginToolDeclarations.ts"; + +/** Capabilities this server implements. A plugin declaring any other is not loaded. */ +const SUPPORTED_PLUGIN_CAPABILITIES: ReadonlySet = new Set([ + PLUGIN_EVENTS_CAPABILITY, + PLUGIN_SETTINGS_CAPABILITY, + PLUGIN_TOOLS_CAPABILITY, +]); + +const MAX_MANIFEST_BYTES = 64 * 1024; + +const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); + +class PluginManifestError extends Schema.TaggedError()("PluginManifestError", { + directory: Schema.String, + reason: Schema.String, +}) { + override get message(): string { + return `Cannot load the plugin in ${this.directory}: ${this.reason}`; + } +} + +/** A validated plugin directory: what the supervisor needs to run it. */ +export interface PluginRegistration { + readonly manifest: PluginManifest; + /** Real path of the plugin directory, used as the child's working directory. */ + readonly directory: string; + /** Real path of the entry module, inside `directory`. */ + readonly entryPath: string; + /** The catalogue installation this registration runs, set when the catalogue enables it. */ + readonly installationId?: PluginInstallationId; +} + +/** Declared tools need the capability and the proposed `handle` API, and must compile. */ +const checkTools = (manifest: PluginManifest): string | undefined => { + const tools = manifest.tools ?? []; + if (tools.length === 0) return undefined; + if (!manifest.capabilities.includes(PLUGIN_TOOLS_CAPABILITY)) + return "it declares tools without the tools capability."; + if (!manifest.proposedApi) return "it declares tools, which need proposedApi: true."; + const prepared = preparePluginTools(manifest, tools); + return "problem" in prepared ? prepared.problem : undefined; +}; + +/** + * Reads and validates `t3-plugin.json` in `directory`. The entry must resolve, + * after symlinks, to a file inside the directory, and the manifest must target + * this server's plugin API version with only supported capabilities. + */ +export const loadPluginDirectory = Effect.fn("PluginManifestLoader.loadPluginDirectory")(function* ( + directory: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fail = (reason: string) => new PluginManifestError({ directory, reason }); + + const realDirectory = yield* fs + .realPath(directory) + .pipe(Effect.mapError(() => fail("the directory does not exist."))); + const manifestPath = path.join(realDirectory, PLUGIN_MANIFEST_FILE); + const info = yield* fs + .stat(manifestPath) + .pipe(Effect.mapError(() => fail(`${PLUGIN_MANIFEST_FILE} is missing.`))); + if (info.type !== "File" || Number(info.size) > MAX_MANIFEST_BYTES) + return yield* fail(`${PLUGIN_MANIFEST_FILE} must be a file of at most 64 KiB.`); + const raw = yield* fs + .readFileString(manifestPath) + .pipe(Effect.mapError(() => fail(`${PLUGIN_MANIFEST_FILE} is not readable.`))); + const manifest = yield* decodeManifest(raw).pipe( + Effect.mapError((error) => fail(`${PLUGIN_MANIFEST_FILE} is invalid: ${error.message}`)), + ); + + if (manifest.apiVersion !== PLUGIN_API_VERSION) + return yield* fail( + `it targets plugin API version ${manifest.apiVersion}; this server implements version ${PLUGIN_API_VERSION}.`, + ); + const unsupported = manifest.capabilities.filter( + (capability) => !SUPPORTED_PLUGIN_CAPABILITIES.has(capability), + ); + if (unsupported.length > 0) + return yield* fail(`this server does not support ${unsupported.join(", ")}.`); + // Events arrive through `context.proposed.onEvent`, which only exists with the opt-in. + if (manifest.capabilities.includes(PLUGIN_EVENTS_CAPABILITY) && !manifest.proposedApi) + return yield* fail(`the "${PLUGIN_EVENTS_CAPABILITY}" capability needs "proposedApi": true.`); + const toolProblem = checkTools(manifest); + if (toolProblem !== undefined) return yield* fail(toolProblem); + const hasSettings = manifest.capabilities.includes(PLUGIN_SETTINGS_CAPABILITY); + if (manifest.settings !== undefined && !hasSettings) + return yield* fail( + `it declares settings without the "${PLUGIN_SETTINGS_CAPABILITY}" capability.`, + ); + // The settings API is still proposed, so it only exists with the opt-in. + if (hasSettings && !manifest.proposedApi) + return yield* fail(`the "${PLUGIN_SETTINGS_CAPABILITY}" capability needs "proposedApi": true.`); + + const entryPath = yield* fs + .realPath(path.resolve(realDirectory, manifest.entry)) + .pipe(Effect.mapError(() => fail(`the entry ${manifest.entry} does not exist.`))); + const relative = path.relative(realDirectory, entryPath); + if ( + relative === "" || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) + return yield* fail(`the entry ${manifest.entry} resolves outside the plugin directory.`); + if (!/\.m?js$/.test(entryPath)) + return yield* fail(`the entry ${manifest.entry} must be a .js or .mjs file.`); + const entryInfo = yield* fs + .stat(entryPath) + .pipe(Effect.mapError(() => fail(`the entry ${manifest.entry} is not readable.`))); + if (entryInfo.type !== "File") return yield* fail(`the entry ${manifest.entry} is not a file.`); + + return { manifest, directory: realDirectory, entryPath } satisfies PluginRegistration; +}); diff --git a/apps/server/src/plugins/PluginSettings.test.ts b/apps/server/src/plugins/PluginSettings.test.ts new file mode 100644 index 0000000000000..8632273011a43 --- /dev/null +++ b/apps/server/src/plugins/PluginSettings.test.ts @@ -0,0 +1,1281 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import type { PluginInstallationId, PluginSettingsValues } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { + SecretStorePersistError, + SecretStoreRemoveError, + ServerSecretStore, +} from "../auth/ServerSecretStore.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as PluginCatalog from "./PluginCatalog.ts"; +import { loadPluginDirectory } from "./PluginManifestLoader.ts"; +import * as PluginSettings from "./PluginSettings.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; + +const FIXTURE_DIR = `${import.meta.dirname}/testFixtures/settingsPlugin`; +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; +// Or a stand-in that speaks the IPC directly, as a plugin writing raw lines to fd 3 could. +const RAW_CHILD_PATH = `${import.meta.dirname}/testFixtures/rawHostCallChild.mjs`; +const SECRET = "s3cret-token-value"; + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const parseManifest = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +/** + * A secret store in memory, so a test can see exactly what was saved and deleted. `faults` + * makes the next writes fail after saving (as an interrupted save would) or deletes fail, and + * `beforeSet` and `beforeGet` hold writes and reads. + */ +const makeSecretStore = () => { + const entries = new Map(); + const faults = { set: false, remove: false }; + const hooks: { beforeSet: Effect.Effect; beforeGet: Effect.Effect } = { + beforeSet: Effect.void, + beforeGet: Effect.void, + }; + const service = ServerSecretStore.of({ + get: (name) => + Effect.suspend(() => hooks.beforeGet).pipe( + Effect.andThen(Effect.sync(() => Option.fromUndefinedOr(entries.get(name)))), + ), + set: (name, value) => + Effect.suspend(() => hooks.beforeSet).pipe( + Effect.andThen( + Effect.suspend(() => { + entries.set(name, value); + return faults.set + ? Effect.fail(new SecretStorePersistError({ resource: name, cause: "fault" })) + : Effect.void; + }), + ), + ), + create: (name, value) => Effect.sync(() => void entries.set(name, value)), + getOrCreateRandom: (name, bytes) => + Effect.sync(() => { + const value = entries.get(name) ?? new Uint8Array(bytes); + entries.set(name, value); + return value; + }), + remove: (name) => + Effect.suspend(() => + faults.remove + ? Effect.fail(new SecretStoreRemoveError({ resource: name, cause: "fault" })) + : Effect.sync(() => void entries.delete(name)), + ), + }); + return { entries, faults, hooks, service }; +}; + +/** Starts a supervisor, catalogue and settings in `scope`, as one server start would. */ +const startPlugins = Effect.fn("startPlugins")(function* ( + scope: Scope.Scope, + secretStore: ServerSecretStore["Service"], + limits?: PluginSettings.PluginStorageLimits, + /** Runs as each settings host call starts, so a test can see that one arrived. */ + onHostCall: (method: string) => Effect.Effect = () => Effect.void, +) { + const supervisor = yield* PluginSupervisor.make({ + heapLimitMb: 64, + activationTimeout: "10 seconds", + stopGrace: "1 second", + }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + const catalog = yield* PluginCatalog.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(Scope.Scope, scope), + ); + const settings = yield* PluginSettings.make(limits).pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, { + ...supervisor, + serveHostMethod: (method, handler) => + supervisor.serveHostMethod(method, (call) => + onHostCall(method).pipe(Effect.andThen(handler(call))), + ), + }), + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + Effect.provideService(ServerSecretStore, secretStore), + Effect.provideService(Scope.Scope, scope), + ); + return { supervisor, catalog, settings }; +}); + +/** Writes the fixture's manifest into `directory`, with `manifest` overriding its keys. */ +const writeManifest = Effect.fn("writeManifest")(function* ( + directory: string, + manifest: Record = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = parseManifest(yield* fs.readFileString(path.join(FIXTURE_DIR, "t3-plugin.json"))); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ ...fixture, ...manifest }), + ); +}); + +/** Copies the fixture into a scoped temp directory, optionally under a changed manifest. */ +const preparePlugin = Effect.fn("preparePlugin")(function* ( + manifest: Record = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-settings-" }); + yield* fs.copyFile(path.join(FIXTURE_DIR, "main.mjs"), path.join(directory, "main.mjs")); + yield* writeManifest(directory, manifest); + return directory; +}); + +/** A plugin directory for the raw IPC child, which reads `config` from `raw-child.json`. */ +const prepareRawPlugin = Effect.fn("prepareRawPlugin")(function* ( + id: string, + config: Record, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* preparePlugin({ id }); + yield* fs.writeFileString(path.join(directory, "raw-child.json"), toJson(config)); + return { directory, registration: yield* loadPluginDirectory(directory) }; +}); + +/** A supervisor whose children run `childPath`. */ +const makeSupervisor = ( + scope: Scope.Scope, + childPath: string, + options: Partial = {}, +) => + PluginSupervisor.make({ heapLimitMb: 64, stopGrace: "1 second", ...options }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, childPath]), + Effect.provideService(Scope.Scope, scope), + ); + +/** Starts waiting for the first log line of `pluginId` that satisfies `predicate`. */ +const awaitLog = ( + supervisor: PluginSupervisor.PluginSupervisor["Service"], + pluginId: string, + predicate: (message: string) => boolean, +) => + supervisor.subscribe.pipe( + Effect.flatMap((subscription) => + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event._tag === "Log" && event.pluginId === pluginId), + Stream.map((event) => (event._tag === "Log" ? event.message : "")), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild({ startImmediately: true }), + ), + ), + ); + +/** Closes `scope` and starts the plugin services again on the same database, as a restart would. */ +const restart = Effect.fn("restart")(function* ( + scope: Scope.Closeable, + secretStore: ServerSecretStore["Service"], +) { + yield* Scope.close(scope, Exit.void); + const next = yield* Scope.make(); + return { scope: next, ...(yield* startPlugins(next, secretStore)) }; +}); + +/** Adds, approves and enables the plugin in `directory`. */ +const install = Effect.fn("install")(function* ( + catalog: PluginCatalog.PluginCatalog["Service"], + directory: string, +) { + const { installation } = yield* catalog.add({ directory }); + const installationId = installation.installationId; + yield* catalog.consent({ installationId, digest: installation.source!.digest }); + yield* catalog.enable({ installationId }); + return installationId; +}); + +/** Waits, through the subscription, for values that satisfy `predicate`. */ +const awaitValues = ( + settings: PluginSettings.PluginSettings["Service"], + installationId: PluginInstallationId, + predicate: (values: PluginSettingsValues) => boolean, +) => + settings + .subscribe(installationId) + .pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)); + +const parseRefusals = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +const valueOf = (values: PluginSettingsValues, key: string) => + values.values.find((entry) => entry.key === key)?.value; + +const countRows = Effect.fn("countRows")(function* (installationId: PluginInstallationId) { + const sql = yield* SqlClient.SqlClient; + const settings = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM plugin_settings WHERE installation_id = ${installationId} + `; + const secrets = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM plugin_setting_secrets WHERE installation_id = ${installationId} + `; + const storage = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM plugin_storage WHERE installation_id = ${installationId} + `; + return { settings: settings[0]!.count, secrets: secrets[0]!.count, storage: storage[0]!.count }; +}); + +// Each test gets its own database. +const withDatabase = (effect: Effect.Effect) => + effect.pipe(Effect.provide(SqlitePersistence.layerMemory)); + +it.layer(NodeServices.layer)("PluginSettings", (it) => { + describe("values", () => { + it.effect("ends a removed subscription after unrelated settings overflow", () => + withDatabase( + Effect.gen(function* () { + const { catalog, settings } = yield* startPlugins( + yield* Scope.Scope, + makeSecretStore().service, + ); + const { installation } = yield* catalog.add({ directory: yield* preparePlugin() }); + const other = (yield* catalog.add({ + directory: yield* preparePlugin({ id: "test.other" }), + })).installation; + const received = yield* Deferred.make(); + const release = yield* Deferred.make(); + const stopped = yield* settings.subscribe(installation.installationId).pipe( + Stream.runForEach(() => + Deferred.succeed(received, undefined).pipe(Effect.andThen(Deferred.await(release))), + ), + Effect.flip, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(received); + yield* catalog.remove({ installationId: installation.installationId }); + for (let index = 0; index < 300; index++) { + yield* settings.update({ + installationId: other.installationId, + changes: [{ key: "verbose", value: index % 2 === 0 }], + }); + } + yield* Deferred.succeed(release, undefined); + expect((yield* Fiber.join(stopped)).reason).toBe("not-found"); + }), + ), + ); + + it.effect("saves what the plugin reads and never sends a secret back", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const installationId = yield* install(catalog, yield* preparePlugin()); + + // The catalogue carries the declared fields for clients to render. + const listed = (yield* catalog.list).installations[0]!; + expect(listed.manifest?.settings?.map((field) => field.key)).toEqual([ + "apiUrl", + "token", + "verbose", + "retries", + "mode", + ]); + + const initial = yield* awaitValues(settings, installationId, () => true); + expect(initial).toEqual({ installationId, values: [], secrets: [] }); + // Unsaved fields read as their defaults; a secret without a value reads as unset. + expect(yield* catalog.invoke(installationId, "activationMode", null)).toBe("safe"); + expect(yield* catalog.invoke(installationId, "read", { key: "retries" })).toEqual({ + value: 2, + }); + expect(yield* catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + unset: true, + }); + + const watching = yield* awaitValues( + settings, + installationId, + (values) => values.secrets.length > 0, + ).pipe(Effect.forkChild({ startImmediately: true })); + const saved = yield* settings.update({ + installationId, + changes: [ + { key: "apiUrl", value: "https://other.example.com" }, + { key: "token", value: SECRET }, + { key: "verbose", value: true }, + { key: "retries", value: 4 }, + { key: "mode", value: "fast" }, + ], + }); + expect(saved.secrets).toEqual(["token"]); + expect(valueOf(saved, "retries")).toBe(4); + expect(valueOf(saved, "token")).toBeUndefined(); + expect(toJson(saved)).not.toContain(SECRET); + // Every subscriber hears about the change, still without the secret. + const heard = yield* Fiber.join(watching); + expect(heard).toEqual(saved); + + for (const [key, value] of [ + ["apiUrl", "https://other.example.com"], + ["token", SECRET], + ["verbose", true], + ["retries", 4], + ["mode", "fast"], + ] as const) + expect(yield* catalog.invoke(installationId, "read", { key })).toEqual({ value }); + const undeclared = yield* catalog.invoke(installationId, "attempt", { + method: "get", + key: "missing", + }); + expect(undeclared).toEqual({ + ok: false, + message: '"missing" is not a declared setting.', + }); + + // Clearing returns a field to its default and deletes a secret. + const cleared = yield* settings.update({ + installationId, + changes: [ + { key: "token", value: null }, + { key: "retries", value: null }, + { key: "verbose", value: null }, + ], + }); + expect(cleared.secrets).toEqual([]); + expect(valueOf(cleared, "retries")).toBeUndefined(); + // A reset boolean keeps no override, so the plugin reads whatever its default is. + expect(valueOf(cleared, "verbose")).toBeUndefined(); + expect(yield* catalog.invoke(installationId, "read", { key: "verbose" })).toEqual({ + value: false, + }); + expect(secrets.entries.size).toBe(0); + expect(yield* catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + unset: true, + }); + expect(yield* catalog.invoke(installationId, "read", { key: "retries" })).toEqual({ + value: 2, + }); + }), + ), + ); + + it.effect("checks every change before saving any, without repeating the value", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const installationId = yield* install(catalog, yield* preparePlugin()); + const reject = (changes: Parameters[0]["changes"]) => + settings.update({ installationId, changes }).pipe(Effect.flip); + + for (const [changes, message] of [ + [[{ key: "nope", value: "x" }], 'This plugin has no setting "nope".'], + [[{ key: "verbose", value: "yes" }], "Verbose logging must be on or off."], + [[{ key: "retries", value: 9 }], "Retries must be at most 5."], + [[{ key: "retries", value: 1.5 }], "Retries must be a whole number."], + [[{ key: "mode", value: "turbo" }], "Mode must be one of its options."], + [[{ key: "token", value: "" }], "API token must be non-empty text."], + [ + [ + { key: "mode", value: "fast" }, + { key: "mode", value: "safe" }, + ], + '"mode" is changed twice.', + ], + // A valid change next to an invalid one is not saved either. + [ + [ + { key: "token", value: SECRET }, + { key: "retries", value: -1 }, + ], + "Retries must be at least 0.", + ], + ] as const) { + const error = yield* reject(changes); + expect(error).toMatchObject({ reason: "invalid-setting", message }); + } + const tooLong = yield* reject([{ key: "token", value: SECRET.repeat(1000) }]); + expect(tooLong.message).not.toContain(SECRET); + expect(yield* countRows(installationId)).toEqual({ settings: 0, secrets: 0, storage: 0 }); + expect(secrets.entries.size).toBe(0); + + const unknown = yield* settings + .update({ + installationId: "missing" as PluginInstallationId, + changes: [{ key: "mode", value: "fast" }], + }) + .pipe(Effect.flip); + expect(unknown.reason).toBe("not-found"); + }), + ), + ); + + it.effect("refuses manifests whose settings it cannot honor", () => + withDatabase( + Effect.gen(function* () { + const { catalog } = yield* startPlugins(yield* Scope.Scope, makeSecretStore().service); + const field = { type: "text", key: "a", label: "A" }; + for (const [manifest, problem] of [ + [{ capabilities: [] }, 'declares settings without the "settings" capability'], + [{ proposedApi: false }, 'needs "proposedApi": true'], + [{ settings: [field, field] }, "settings: keys repeat."], + [ + { + settings: [ + { ...field, type: "select", options: [{ value: "x", label: "X" }], default: "y" }, + ], + }, + "a: the default is invalid.", + ], + [{ settings: [{ ...field, type: "number", min: 2, max: 1 }] }, "min is greater"], + ] as const) { + const error = yield* catalog + .add({ directory: yield* preparePlugin(manifest) }) + .pipe(Effect.flip); + expect(error.reason).toBe("invalid-directory"); + expect(error.message).toContain(problem); + } + }), + ), + ); + }); + + describe("storage", () => { + it.effect("keeps a bounded private store per installation", () => + withDatabase( + Effect.gen(function* () { + const { catalog } = yield* startPlugins(yield* Scope.Scope, makeSecretStore().service, { + maxKeyLength: 8, + maxValueBytes: 100, + maxKeys: 2, + maxTotalBytes: 150, + }); + const first = yield* install(catalog, yield* preparePlugin()); + const second = yield* install( + catalog, + yield* preparePlugin({ id: "test.settings-other" }), + ); + const attempt = (installationId: PluginInstallationId, key: string, value: unknown) => + catalog.invoke(installationId, "attempt", { + method: "set", + key, + value: value as Schema.Json, + }); + + expect(yield* attempt(first, "a", { n: 1 })).toEqual({ ok: true, result: null }); + expect(yield* catalog.invoke(first, "load", { key: "a" })).toEqual({ + value: { n: 1 }, + }); + // Another installation sees nothing of it. + expect(yield* catalog.invoke(second, "load", { key: "a" })).toEqual({ missing: true }); + + expect(yield* attempt(first, "big", "x".repeat(200))).toMatchObject({ + ok: false, + message: "The value is 202 bytes; the limit is 100.", + }); + expect(yield* attempt(first, "much-too-long", 1)).toMatchObject({ ok: false }); + expect(yield* attempt(first, "bad\nkey", 1)).toMatchObject({ ok: false }); + expect(yield* attempt(first, "b", "y".repeat(60))).toEqual({ ok: true, result: null }); + expect(yield* attempt(first, "c", 1)).toEqual({ + ok: false, + message: "The plugin already stores 2 keys.", + }); + // Replacing a key counts its new size, not both. + expect(yield* attempt(first, "a", "z".repeat(90))).toMatchObject({ + ok: false, + message: "Saving this would store more than 150 bytes for the plugin.", + }); + expect(yield* attempt(first, "a", "z".repeat(40))).toEqual({ ok: true, result: null }); + expect(yield* catalog.invoke(first, "keys", null)).toEqual(["a", "b"]); + + yield* catalog.invoke(first, "drop", { key: "a" }); + expect(yield* catalog.invoke(first, "load", { key: "a" })).toEqual({ missing: true }); + expect(yield* catalog.invoke(first, "keys", null)).toEqual(["b"]); + }), + ), + ); + + it.effect("lets a plugin wait for at most 16 host calls at a time", () => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const supervisor = yield* PluginSupervisor.make({ heapLimitMb: 64 }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + const release = yield* Deferred.make(); + let inFlight = 0; + yield* supervisor + .serveHostMethod("storage.get", () => + Effect.sync(() => inFlight++).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ found: false, value: null }), + ), + ) + .pipe(Effect.provideService(Scope.Scope, scope)); + yield* supervisor + .serveHostMethod("settings.get", () => Effect.succeed({ value: null })) + .pipe(Effect.provideService(Scope.Scope, scope)); + const registration = yield* loadPluginDirectory(yield* preparePlugin()); + yield* supervisor.enable(registration); + const refused = yield* supervisor.subscribe.pipe( + Effect.map((subscription) => + Stream.fromSubscription(subscription).pipe( + Stream.filter( + (event) => event._tag === "Log" && event.message === "host-call-refused", + ), + Stream.runHead, + ), + ), + ); + const waitingForRefusal = yield* refused.pipe(Effect.forkChild({ startImmediately: true })); + + const burst = yield* supervisor + .invoke(registration.manifest.id, "burst", { count: 17 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.join(waitingForRefusal); + yield* Deferred.succeed(release, undefined); + const results = (yield* Fiber.join(burst)) as ReadonlyArray; + expect(results.filter((result) => result === "ok")).toHaveLength(16); + expect(results.filter((result) => result !== "ok")).toEqual([ + "16 calls to the server are already in flight.", + ]); + expect(inFlight).toBe(16); + }), + ); + }); + + describe("lifetime", () => { + it.effect("keeps values across disable and re-enable, and deletes them on remove", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const installationId = yield* install(catalog, yield* preparePlugin()); + yield* settings.update({ + installationId, + changes: [ + { key: "token", value: SECRET }, + { key: "mode", value: "fast" }, + ], + }); + yield* catalog.invoke(installationId, "store", { key: "cursor", value: 42 }); + + yield* catalog.disable({ installationId }); + // Disabled plugins keep their values and can still be configured. + yield* settings.update({ installationId, changes: [{ key: "verbose", value: true }] }); + yield* catalog.enable({ installationId }); + expect(yield* catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + value: SECRET, + }); + expect(yield* catalog.invoke(installationId, "load", { key: "cursor" })).toEqual({ + value: 42, + }); + expect(yield* countRows(installationId)).toEqual({ settings: 2, secrets: 1, storage: 1 }); + expect(secrets.entries.size).toBe(1); + + // The settings stream ends once the cleanup after the removal has run. + const ended = yield* settings + .subscribe(installationId) + .pipe(Stream.runDrain, Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* catalog.remove({ installationId }); + expect((yield* Fiber.join(ended)).reason).toBe("not-found"); + expect(yield* countRows(installationId)).toEqual({ settings: 0, secrets: 0, storage: 0 }); + expect(secrets.entries.size).toBe(0); + const late = yield* settings + .update({ installationId, changes: [{ key: "mode", value: "safe" }] }) + .pipe(Effect.flip); + expect(late.reason).toBe("not-found"); + expect(yield* countRows(installationId)).toEqual({ settings: 0, secrets: 0, storage: 0 }); + }), + ), + ); + + it.effect("deletes what an earlier run left for removed installations at start", () => + withDatabase( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const secrets = makeSecretStore(); + const gone = "gone-installation" as PluginInstallationId; + yield* sql` + INSERT INTO plugin_settings (installation_id, key, value_json) + VALUES (${gone}, 'mode', '"fast"') + `; + yield* sql` + INSERT INTO plugin_setting_secrets (installation_id, key, saved) + VALUES (${gone}, 'token', 1) + `; + yield* sql` + INSERT INTO plugin_storage (installation_id, key, value_json, bytes) + VALUES (${gone}, 'cursor', '1', 1) + `; + const secretName = `plugin-setting-${Buffer.from(gone).toString("base64url")}-${Buffer.from("token").toString("base64url")}`; + secrets.entries.set(secretName, new TextEncoder().encode(SECRET)); + + yield* startPlugins(yield* Scope.Scope, secrets.service); + expect(yield* countRows(gone)).toEqual({ settings: 0, secrets: 0, storage: 0 }); + expect(secrets.entries.size).toBe(0); + }), + ), + ); + + it.effect("refuses settings and storage to a registration without the capability", () => + Effect.gen(function* () { + const methods = new Map(); + const supervisor = PluginSupervisor.PluginSupervisor.of({ + enable: () => Effect.void, + disable: () => Effect.void, + resume: () => Effect.void, + invoke: () => Effect.succeed(null), + state: () => Effect.succeedNone, + subscribe: Effect.die("unused"), + serveHostMethod: (method, handler) => + Effect.sync(() => void methods.set(method, handler)), + }); + const catalog = PluginCatalog.PluginCatalog.of({ + ready: Effect.void, + list: Effect.succeed({ installations: [] }), + revision: Effect.succeed(0), + subscribe: Stream.empty, + add: () => Effect.die("unused"), + refresh: () => Effect.die("unused"), + consent: () => Effect.die("unused"), + enable: () => Effect.die("unused"), + disable: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + resume: () => Effect.die("unused"), + invoke: () => Effect.die("unused"), + }); + yield* PluginSettings.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + Effect.provideService(ServerSecretStore, makeSecretStore().service), + Effect.provide(SqlitePersistence.layerMemory), + ); + const registration = yield* loadPluginDirectory( + yield* preparePlugin({ capabilities: [], settings: undefined }), + ); + for (const method of ["settings.get", "storage.get", "storage.set", "storage.keys"]) { + const error = yield* methods.get(method)!({ + registration: { ...registration, installationId: "x" as PluginInstallationId }, + input: { key: "a", value: 1 }, + admitted: Effect.void, + }).pipe(Effect.flip); + expect(error.message).toBe('The plugin did not declare the "settings" capability.'); + } + }), + ); + }); + + describe("host calls", () => { + /** Serves `storage.get` with a call held until `release`, recording how it ended. */ + const holdStorageGet = Effect.fn("holdStorageGet")(function* ( + supervisor: PluginSupervisor.PluginSupervisor["Service"], + ) { + const scope = yield* Scope.Scope; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const ended = yield* Deferred.make>(); + const counts = { reads: 0, writes: 0 }; + yield* supervisor + .serveHostMethod("settings.get", () => + Effect.sync(() => { + counts.reads++; + return { value: null }; + }), + ) + .pipe(Effect.provideService(Scope.Scope, scope)); + yield* supervisor + .serveHostMethod("storage.get", () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + // Stands for a side effect after an awaited host operation. + Effect.andThen(Effect.sync(() => counts.writes++)), + Effect.as({ found: false, value: null }), + Effect.onExit((exit) => Deferred.succeed(ended, exit)), + ), + ) + .pipe(Effect.provideService(Scope.Scope, scope)); + return { started, release, ended, counts }; + }); + + it.effect("ends a disabled generation's host work and refuses its later calls", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(yield* Scope.Scope, BIN_PATH); + const held = yield* holdStorageGet(supervisor); + const registration = yield* loadPluginDirectory(yield* preparePlugin()); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const load = yield* supervisor + .invoke(pluginId, "load", { key: "a" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(held.started); + const deactivated = yield* awaitLog(supervisor, pluginId, (message) => + message.startsWith("deactivate:"), + ); + const readsBefore = held.counts.reads; + + yield* supervisor.disable(pluginId); + // The held call ended with its generation, before disable returned. + expect(yield* Deferred.isDone(held.ended)).toBe(true); + expect(Exit.hasInterrupts(yield* Deferred.await(held.ended))).toBe(true); + expect((yield* Fiber.join(load))._tag).toBe("PluginStoppedError"); + // deactivate() asked for a setting after the revocation: refused, never served. + expect(yield* Fiber.join(deactivated)).toBe("deactivate: The plugin was stopped."); + expect(held.counts.reads).toBe(readsBefore); + yield* Deferred.succeed(held.release, undefined); + expect(held.counts.writes).toBe(0); + }), + ); + + it.effect("ends host work of a process that exits", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(yield* Scope.Scope, BIN_PATH); + const held = yield* holdStorageGet(supervisor); + const registration = yield* loadPluginDirectory(yield* preparePlugin()); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const load = yield* supervisor + .invoke(pluginId, "load", { key: "a" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(held.started); + + const crashed = yield* supervisor.invoke(pluginId, "exit", null).pipe(Effect.flip); + expect(crashed._tag).toBe("PluginCrashedError"); + expect(Exit.hasInterrupts(yield* Deferred.await(held.ended))).toBe(true); + expect((yield* Fiber.join(load))._tag).toBe("PluginCrashedError"); + yield* Deferred.succeed(held.release, undefined); + expect(held.counts.writes).toBe(0); + }), + ); + + it.effect("waits for host work still ending when disabling a process that exited", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(yield* Scope.Scope, BIN_PATH); + const started = yield* Deferred.make(); + const ending = yield* Deferred.make(); + const release = yield* Deferred.make(); + const order: Array = []; + yield* supervisor.serveHostMethod("settings.get", () => Effect.succeed({ value: null })); + yield* supervisor.serveHostMethod("storage.get", () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + // Stands for cleanup that outlasts the process, such as releasing a lock. + Effect.onInterrupt(() => + Deferred.succeed(ending, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.sync(() => order.push("host work ended"))), + ), + ), + ), + ); + const registration = yield* loadPluginDirectory(yield* preparePlugin()); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + yield* supervisor + .invoke(pluginId, "load", { key: "a" }) + .pipe(Effect.ignore, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + yield* supervisor + .invoke(pluginId, "exit", null) + .pipe(Effect.ignore, Effect.forkChild({ startImmediately: true })); + // The process has exited and its host work is ending. + yield* Deferred.await(ending); + + const disabling = yield* supervisor + .disable(pluginId) + .pipe( + Effect.andThen(Effect.sync(() => order.push("disabled"))), + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(disabling); + expect(order).toEqual(["host work ended", "disabled"]); + }), + ); + + it.effect("drops a write that waited for the settings lock past its generation", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const storeArrived = yield* Deferred.make(); + const readArrived = yield* Deferred.make(); + const { catalog, settings } = yield* startPlugins( + yield* Scope.Scope, + secrets.service, + undefined, + (method) => + method === "storage.set" + ? Deferred.succeed(storeArrived, undefined) + : Deferred.isDone(storeArrived).pipe( + Effect.flatMap((stored) => + stored ? Deferred.succeed(readArrived, undefined) : Effect.void, + ), + ), + ); + const installationId = yield* install(catalog, yield* preparePlugin()); + expect(yield* catalog.invoke(installationId, "read", { key: "mode" })).toEqual({ + value: "safe", + }); + + // A client save holds the settings lock while its secret is written. + const writing = yield* Deferred.make(); + const finishWrite = yield* Deferred.make(); + secrets.hooks.beforeSet = Deferred.succeed(writing, undefined).pipe( + Effect.andThen(Deferred.await(finishWrite)), + ); + const saving = yield* settings + .update({ installationId, changes: [{ key: "token", value: SECRET }] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(writing); + const storing = yield* catalog + .invoke(installationId, "store", { key: "cursor", value: 1 }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(storeArrived); + // So does its read of the secret being saved. + const reading = yield* catalog + .invoke(installationId, "read", { key: "token" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(readArrived); + + // The plugin's write waits for the lock while the plugin is disabled and enabled again. + yield* catalog.disable({ installationId }); + // Disabling ended the waiting read instead of waiting for the lock itself. + expect((yield* Fiber.join(reading))._tag).toBe("PluginStoppedError"); + yield* catalog.enable({ installationId }); + yield* Deferred.succeed(finishWrite, undefined); + expect((yield* Fiber.join(saving)).secrets).toEqual(["token"]); + yield* Fiber.join(storing); + expect((yield* countRows(installationId)).storage).toBe(0); + + // The new generation saves as usual. + yield* catalog.invoke(installationId, "store", { key: "cursor", value: 2 }); + expect(yield* catalog.invoke(installationId, "load", { key: "cursor" })).toEqual({ + value: 2, + }); + }), + ), + ); + + // Below the channel's 64 KiB buffer a full bound can come without a write that waits for drain. + it.effect.each([ + { maxMessageBytes: 128 * 1024, answerBytes: 60_000 }, + { maxMessageBytes: 32 * 1024, answerBytes: 20_000 }, + ])( + "stops reading a plugin that does not read its answers, and loses none ($maxMessageBytes bytes)", + ({ maxMessageBytes, answerBytes }) => { + const backedUp = Deferred.makeUnsafe(); + const logger = Logger.make(({ message }) => { + if (String(message).includes("not reading the server's answers")) + Deferred.doneUnsafe(backedUp, Exit.void); + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supervisor = yield* makeSupervisor(yield* Scope.Scope, RAW_CHILD_PATH, { + maxMessageBytes, + }); + let served = 0; + yield* supervisor + .serveHostMethod("flood.get", () => + Effect.sync(() => { + served++; + return { data: "x".repeat(answerBytes) }; + }), + ) + .pipe(Effect.provideService(Scope.Scope, yield* Scope.Scope)); + const requests = 400; + const flood = yield* prepareRawPlugin("test.flood", { + mode: "flood", + requests, + method: "flood.get", + }); + const echo = yield* prepareRawPlugin("test.echo", { mode: "echo" }); + yield* supervisor.enable(flood.registration); + yield* supervisor.enable(echo.registration); + const summary = yield* awaitLog(supervisor, "test.flood", (message) => + message.startsWith("answered"), + ); + const first = yield* supervisor + .invoke(flood.registration.manifest.id, "ping", 1) + .pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(backedUp); + // Other plugins keep working while this one is not read. + expect(yield* supervisor.invoke(echo.registration.manifest.id, "ping", 2)).toBe(2); + // Without backpressure all 400 answers would sit in the server's write buffer. + expect(served).toBeGreaterThan(0); + expect(served).toBeLessThan(40); + + // Once the plugin reads again, every request gets exactly one answer. + const pid = Number(yield* fs.readFileString(path.join(flood.directory, "raw-child.pid"))); + process.kill(pid, "SIGUSR2"); + const answered = yield* Fiber.join(summary); + const [, total, refused, messages] = /^answered (\d+), refused (\d+): (.*)$/.exec( + answered, + )!; + expect(Number(total)).toBe(requests); + expect(served + Number(refused)).toBe(requests); + // A refusal can only be the cap, while answers wait for the plugin to read. + for (const message of parseRefusals(messages!)) + expect(message).toBe("16 calls to the server are already in flight."); + expect(yield* Fiber.join(first)).toBe(1); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }, + ); + + it.effect("refuses past 16 host calls from a plugin that bypasses the API", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(yield* Scope.Scope, RAW_CHILD_PATH); + const release = yield* Deferred.make(); + let inFlight = 0; + yield* supervisor + .serveHostMethod("burst.get", () => + Effect.sync(() => inFlight++).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(null), + ), + ) + .pipe(Effect.provideService(Scope.Scope, yield* Scope.Scope)); + const burst = yield* prepareRawPlugin("test.burst", { + mode: "burst", + requests: 17, + method: "burst.get", + }); + const pluginId = burst.registration.manifest.id; + yield* supervisor.enable(burst.registration); + const refused = yield* awaitLog(supervisor, pluginId, (message) => + message.startsWith("refused"), + ); + const summary = yield* awaitLog(supervisor, pluginId, (message) => + message.startsWith("answered"), + ); + expect(yield* supervisor.invoke(pluginId, "ping", 1)).toBe(1); + expect(yield* Fiber.join(refused)).toBe( + "refused: 16 calls to the server are already in flight.", + ); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(summary)).toBe( + 'answered 17, refused 1: ["16 calls to the server are already in flight."]', + ); + expect(inFlight).toBe(16); + }), + ); + }); + + describe("secrets", () => { + it.effect("keeps a secret's row until its file is deleted, so cleanup always finishes", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + let run = yield* restart(yield* Scope.make(), secrets.service); + const installationId = yield* install(run.catalog, yield* preparePlugin()); + const saveToken = run.settings.update({ + installationId, + changes: [{ key: "token", value: SECRET }], + }); + yield* saveToken; + + // A clear whose file deletion fails reads as cleared, and keeps the row that finds it. + secrets.faults.remove = true; + const clearing = yield* run.settings + .update({ installationId, changes: [{ key: "token", value: null }] }) + .pipe(Effect.flip); + expect(clearing.reason).toBe("storage"); + expect((yield* awaitValues(run.settings, installationId, () => true)).secrets).toEqual( + [], + ); + expect(yield* run.catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + unset: true, + }); + expect(secrets.entries.size).toBe(1); + secrets.faults.remove = false; + run = yield* restart(run.scope, secrets.service); + expect(secrets.entries.size).toBe(0); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 0, + storage: 0, + }); + + // A save interrupted after its file was written is found the same way. + secrets.faults.set = true; + const saving = yield* run.settings + .update({ installationId, changes: [{ key: "token", value: SECRET }] }) + .pipe(Effect.flip); + expect(saving.reason).toBe("storage"); + expect(secrets.entries.size).toBe(1); + expect((yield* awaitValues(run.settings, installationId, () => true)).secrets).toEqual( + [], + ); + secrets.faults.set = false; + run = yield* restart(run.scope, secrets.service); + expect(secrets.entries.size).toBe(0); + expect((yield* countRows(installationId)).secrets).toBe(0); + + // A removal whose file deletion fails finishes at the next start. + yield* run.settings.update({ + installationId, + changes: [{ key: "token", value: SECRET }], + }); + secrets.faults.remove = true; + const ended = yield* run.settings + .subscribe(installationId) + .pipe(Stream.runDrain, Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* run.catalog.remove({ installationId }); + expect((yield* Fiber.join(ended)).reason).toBe("not-found"); + expect(secrets.entries.size).toBe(1); + expect((yield* countRows(installationId)).secrets).toBe(1); + secrets.faults.remove = false; + run = yield* restart(run.scope, secrets.service); + expect(secrets.entries.size).toBe(0); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 0, + storage: 0, + }); + yield* Scope.close(run.scope, Exit.void); + }), + ), + ); + + it.effect("never lets a plugin read a secret whose save did not finish", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const installationId = yield* install(catalog, yield* preparePlugin()); + yield* settings.update({ + installationId, + changes: [{ key: "token", value: "old-valid" }], + }); + + // The plugin's read has found the saved secret and is about to read its file. + const reading = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + secrets.hooks.beforeGet = Deferred.succeed(reading, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + ); + const read = yield* catalog + .invoke(installationId, "read", { key: "token" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(reading); + + // Meanwhile a client clears it, then a new save fails after writing its file. + const changing = yield* Effect.gen(function* () { + yield* settings.update({ installationId, changes: [{ key: "token", value: null }] }); + secrets.faults.set = true; + return yield* settings + .update({ installationId, changes: [{ key: "token", value: "new-failed" }] }) + .pipe(Effect.flip); + }).pipe(Effect.forkChild({ startImmediately: true })); + + secrets.hooks.beforeGet = Effect.void; + yield* Deferred.succeed(finishRead, undefined); + // The read sees the secret saved when it started, never the unfinished one. + expect(yield* Fiber.join(read)).toEqual({ value: "old-valid" }); + expect((yield* Fiber.join(changing)).reason).toBe("storage"); + expect(yield* catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + unset: true, + }); + }), + ), + ); + + it.effect("keeps only the fields the manifest declares now", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const directory = yield* preparePlugin(); + const installationId = yield* install(catalog, directory); + const redeclare = (fields: ReadonlyArray>) => + writeManifest(directory, { settings: fields }).pipe( + Effect.andThen(catalog.refresh({ installationId })), + ); + yield* settings.update({ + installationId, + changes: [ + { key: "token", value: SECRET }, + { key: "mode", value: "fast" }, + ], + }); + + // A secret field that becomes text loses its secret before the text is saved. + yield* redeclare([{ type: "text", key: "token", label: "API token" }]); + const retyped = yield* settings.update({ + installationId, + changes: [{ key: "token", value: "plain" }], + }); + expect(retyped).toEqual({ + installationId, + values: [{ key: "token", value: "plain" }], + secrets: [], + }); + expect(secrets.entries.size).toBe(0); + expect(yield* countRows(installationId)).toEqual({ + settings: 1, + secrets: 0, + storage: 0, + }); + + // Manifests that keep renaming 32 fields keep 32 values, and frames stay one size. + const sizes = []; + for (const generation of [0, 1, 2]) { + const keys = Array.from( + { length: 32 }, + (_, index) => `g${generation}k${String(index).padStart(2, "0")}`, + ); + yield* redeclare(keys.map((key) => ({ type: "text", key, label: key }))); + const saved = yield* settings.update({ + installationId, + changes: keys.map((key) => ({ key, value: "v".repeat(2000) })), + }); + expect(saved.values.map((entry) => entry.key)).toEqual(keys); + expect(yield* countRows(installationId)).toEqual({ + settings: 32, + secrets: 0, + storage: 0, + }); + sizes.push(toJson(saved).length); + } + expect(new Set(sizes).size).toBe(1); + + // Values of fields no longer declared are not sent, even before the next save. + yield* redeclare([{ type: "boolean", key: "verbose", label: "Verbose" }]); + expect(yield* awaitValues(settings, installationId, () => true)).toEqual({ + installationId, + values: [], + secrets: [], + }); + }), + ), + ); + + it.effect("sends open subscriptions the new fields when the declaration changes", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const directory = yield* preparePlugin(); + const installationId = yield* install(catalog, directory); + yield* settings.update({ + installationId, + changes: [ + { key: "token", value: SECRET }, + { key: "mode", value: "fast" }, + ], + }); + const first = yield* Deferred.make(); + const snapshots = yield* settings.subscribe(installationId).pipe( + Stream.tap(() => Deferred.succeed(first, undefined)), + Stream.take(2), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(first); + + yield* writeManifest(directory, { + settings: [{ type: "boolean", key: "verbose", label: "Verbose" }], + }); + yield* catalog.refresh({ installationId }); + expect(yield* Fiber.join(snapshots)).toEqual([ + { installationId, values: [{ key: "mode", value: "fast" }], secrets: ["token"] }, + { installationId, values: [], secrets: [] }, + ]); + }), + ), + ); + + it.effect("saves nothing new while a retired secret cannot be deleted", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const directory = yield* preparePlugin(); + const installationId = yield* install(catalog, directory); + const declare = (generation: number) => + Effect.gen(function* () { + const keys = Array.from( + { length: 32 }, + (_, index) => `g${generation}k${String(index).padStart(2, "0")}`, + ); + yield* writeManifest(directory, { + settings: keys.map((key) => ({ type: "secret", key, label: key })), + }); + yield* catalog.refresh({ installationId }); + return keys.map((key) => ({ key, value: SECRET })); + }); + yield* settings.update({ installationId, changes: yield* declare(0) }); + expect(secrets.entries.size).toBe(32); + + // Renaming every field while deletes fail refuses each save and stores nothing more. + secrets.faults.remove = true; + for (const generation of [1, 2, 3]) { + const refused = yield* settings + .update({ installationId, changes: yield* declare(generation) }) + .pipe(Effect.flip); + expect(refused.reason).toBe("storage"); + expect(secrets.entries.size).toBe(32); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 32, + storage: 0, + }); + } + // A secret field retyped as text keeps its secret and gets no value beside it. + yield* writeManifest(directory, { + settings: [{ type: "text", key: "g0k00", label: "g0k00" }], + }); + yield* catalog.refresh({ installationId }); + const retyped = yield* settings + .update({ installationId, changes: [{ key: "g0k00", value: "plain" }] }) + .pipe(Effect.flip); + expect(retyped.reason).toBe("storage"); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 32, + storage: 0, + }); + + // Once deletes work, the next save retires the old secrets first. + secrets.faults.remove = false; + const changes = yield* declare(3); + const saved = yield* settings.update({ installationId, changes }); + expect(saved.secrets).toEqual(changes.map((change) => change.key)); + expect(secrets.entries.size).toBe(32); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 32, + storage: 0, + }); + + const ended = yield* settings + .subscribe(installationId) + .pipe(Stream.runDrain, Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* catalog.remove({ installationId }); + expect((yield* Fiber.join(ended)).reason).toBe("not-found"); + expect(secrets.entries.size).toBe(0); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 0, + storage: 0, + }); + }), + ), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginSettings.ts b/apps/server/src/plugins/PluginSettings.ts new file mode 100644 index 0000000000000..ff593b30dc98b --- /dev/null +++ b/apps/server/src/plugins/PluginSettings.ts @@ -0,0 +1,529 @@ +// Secret rows precede file writes and outlive failed deletion, so startup or removal can finish cleanup. +// Writes and removal serialize; retired fields are removed before saving, and failed secret cleanup prevents new writes. +// Clients see secret presence only; installation values survive registration changes and end with removal. +import { + PLUGIN_SETTINGS_CAPABILITY, + PluginCatalogError, + PluginSettingValue, + pluginSettingValueProblem, + resolvePluginSettingValue, + type PluginCatalogSnapshot, + type PluginInstallationId, + type PluginSettingField, + type PluginSettingsUpdateInput, + type PluginSettingsValues, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import type { PluginRegistration } from "./PluginManifestLoader.ts"; +import { PluginCatalog } from "./PluginCatalog.ts"; +import { + PluginHostCallError, + PluginSupervisor, + type PluginHostMethod, +} from "./PluginSupervisor.ts"; + +type HostCall = Parameters[0]; + +/** Bounds of one installation's private storage. */ +export interface PluginStorageLimits { + readonly maxKeyLength: number; + readonly maxValueBytes: number; + readonly maxKeys: number; + readonly maxTotalBytes: number; +} + +const defaultPluginStorageLimits: PluginStorageLimits = { + maxKeyLength: 128, + maxValueBytes: 64 * 1024, + maxKeys: 256, + maxTotalBytes: 1024 * 1024, +}; + +const encodeName = (text: string) => Buffer.from(text, "utf8").toString("base64url"); +const secretName = (installationId: PluginInstallationId, key: string) => + `plugin-setting-${encodeName(installationId)}-${encodeName(key)}`; + +const SavedValueJson = Schema.fromJsonString(PluginSettingValue); +const decodeSavedValue = Schema.decodeUnknownOption(SavedValueJson); +const encodeSavedValue = Schema.encodeSync(SavedValueJson); +const StoredJson = Schema.fromJsonString(Schema.Json); +const decodeStoredJson = Schema.decodeUnknownOption(StoredJson); +const encodeStoredJson = Schema.encodeSync(StoredJson); + +const decodeKeyInput = Schema.decodeUnknownEffect(Schema.Struct({ key: Schema.String })); +const decodeSetInput = Schema.decodeUnknownEffect( + Schema.Struct({ key: Schema.String, value: Schema.Json }), +); + +const hasControlCharacter = (text: string) => { + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index); + if (code < 0x20 || code === 0x7f) return true; + } + return false; +}; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const catalogError = (reason: string, message: string, installationId: PluginInstallationId) => + new PluginCatalogError({ reason, message, installationId }); + +const hostError = (message: string) => new PluginHostCallError({ message }); + +export class PluginSettings extends Context.Service< + PluginSettings, + { + /** The saved values now, then after every change; fails `not-found` once the installation is removed. */ + readonly subscribe: ( + installationId: PluginInstallationId, + ) => Stream.Stream; + /** Checks every change against the declared fields, then saves them. Never returns a secret. */ + readonly update: ( + input: PluginSettingsUpdateInput, + ) => Effect.Effect; + } +>()("t3/plugins/PluginSettings") {} + +export const make = Effect.fn("PluginSettings.make")(function* ( + limits: PluginStorageLimits = defaultPluginStorageLimits, +) { + const sql = yield* SqlClient.SqlClient; + const secrets = yield* ServerSecretStore; + const catalog = yield* PluginCatalog; + const supervisor = yield* PluginSupervisor; + + // Writes, removal cleanup and secret reads run one at a time, so none sees another half done. + const lock = yield* Semaphore.make(1); + // Notifications coalesce; every subscriber re-reads retained catalogue and settings state. + const events = yield* PubSub.sliding(1); + + const findInstallation = (installationId: PluginInstallationId) => + catalog.list.pipe( + Effect.map((snapshot) => + snapshot.installations.find( + (installation) => installation.installationId === installationId, + ), + ), + ); + + const notFound = (installationId: PluginInstallationId) => + catalogError("not-found", "That plugin is not installed here.", installationId); + + const storageFailed = (installationId: PluginInstallationId) => (cause: unknown) => + Effect.logWarning("Plugin settings storage failed", { installationId, cause }).pipe( + Effect.andThen( + Effect.fail( + catalogError("storage", "Could not save the plugin's settings.", installationId), + ), + ), + ); + + const savedRows = (installationId: PluginInstallationId) => + sql<{ readonly key: string; readonly value_json: string }>` + SELECT key, value_json FROM plugin_settings + WHERE installation_id = ${installationId} + ORDER BY key + `; + + /** Secrets that may have a file; `saved` is 0 while one is written or being deleted. */ + const secretRows = (installationId: PluginInstallationId) => + sql<{ readonly key: string; readonly saved: number }>` + SELECT key, saved FROM plugin_setting_secrets + WHERE installation_id = ${installationId} + ORDER BY key + `; + + const isSecret = (fields: ReadonlyArray, key: string) => + fields.some((field) => field.key === key && field.type === "secret"); + const isValue = (fields: ReadonlyArray, key: string) => + fields.some((field) => field.key === key && field.type !== "secret"); + + /** The saved values of the fields declared now. */ + const readValues = ( + installationId: PluginInstallationId, + fields: ReadonlyArray, + ) => + Effect.all([savedRows(installationId), secretRows(installationId)]).pipe( + Effect.map(([rows, secretKeys]): PluginSettingsValues => { + const values: Array<{ key: string; value: PluginSettingValue }> = []; + for (const row of rows) { + if (!isValue(fields, row.key)) continue; + const value = decodeSavedValue(row.value_json); + if (Option.isSome(value)) values.push({ key: row.key, value: value.value }); + } + return { + installationId, + values, + secrets: secretKeys + .filter((row) => row.saved === 1 && isSecret(fields, row.key)) + .map((row) => row.key), + }; + }), + Effect.catch(storageFailed(installationId)), + ); + + const current = Effect.fnUntraced(function* (installationId: PluginInstallationId) { + const installation = yield* findInstallation(installationId); + if (installation === undefined) return yield* notFound(installationId); + return yield* readValues(installationId, installation.manifest?.settings ?? []); + }); + + /** Saves a secret: its row is written first, so a file never exists without one. */ + const saveSecret = Effect.fnUntraced(function* ( + installationId: PluginInstallationId, + key: string, + value: string, + ) { + // A replaced secret stays saved; its file is swapped in one rename. + yield* sql` + INSERT INTO plugin_setting_secrets (installation_id, key, saved) + VALUES (${installationId}, ${key}, 0) + ON CONFLICT (installation_id, key) DO NOTHING + `; + yield* secrets.set(secretName(installationId, key), textEncoder.encode(value)); + yield* sql` + UPDATE plugin_setting_secrets SET saved = 1 + WHERE installation_id = ${installationId} AND key = ${key} + `; + }); + + /** Deletes a secret's file, then its row; a failure leaves the row for the next attempt. */ + const deleteSecret = Effect.fnUntraced(function* ( + installationId: PluginInstallationId, + key: string, + ) { + yield* sql` + UPDATE plugin_setting_secrets SET saved = 0 + WHERE installation_id = ${installationId} AND key = ${key} + `; + yield* secrets.remove(secretName(installationId, key)); + yield* sql` + DELETE FROM plugin_setting_secrets + WHERE installation_id = ${installationId} AND key = ${key} + `; + }); + + const deleteSecretOrWarn = (installationId: PluginInstallationId, key: string) => + deleteSecret(installationId, key).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not delete a plugin's secret; it is retried later", { + installationId, + cause, + }), + ), + ); + + /** + * Deletes what is saved for keys the declaration no longer has, or has as another kind. Fails + * if a secret's file cannot be deleted, so nothing new is saved beside it. + */ + const retireUndeclared = Effect.fnUntraced(function* ( + installationId: PluginInstallationId, + fields: ReadonlyArray, + ) { + for (const row of yield* secretRows(installationId)) + if (!isSecret(fields, row.key)) yield* deleteSecret(installationId, row.key); + for (const row of yield* savedRows(installationId)) + if (!isValue(fields, row.key)) + yield* sql` + DELETE FROM plugin_settings + WHERE installation_id = ${installationId} AND key = ${row.key} + `; + }); + + const update = Effect.fn("PluginSettings.update")( + function* (input: PluginSettingsUpdateInput) { + const { installationId } = input; + const installation = yield* findInstallation(installationId); + if (installation === undefined) return yield* notFound(installationId); + const fields = installation.manifest?.settings ?? []; + const invalid = (message: string) => catalogError("invalid-setting", message, installationId); + const seen = new Set(); + const plan = []; + for (const change of input.changes) { + const field = fields.find((candidate) => candidate.key === change.key); + if (field === undefined) + return yield* invalid(`This plugin has no setting "${change.key}".`); + if (seen.has(change.key)) return yield* invalid(`"${change.key}" is changed twice.`); + seen.add(change.key); + if (change.value !== null) { + const problem = pluginSettingValueProblem(field, change.value); + if (problem !== undefined) return yield* invalid(problem); + } + plan.push({ key: change.key, secret: field.type === "secret", value: change.value }); + } + const failed = storageFailed(installationId); + yield* retireUndeclared(installationId, fields).pipe(Effect.catch(failed)); + for (const step of plan) { + if (step.secret && step.value !== null) + yield* saveSecret(installationId, step.key, String(step.value)).pipe( + Effect.catch(failed), + ); + else if (step.secret) + yield* deleteSecret(installationId, step.key).pipe(Effect.catch(failed)); + else if (step.value !== null) { + const json = encodeSavedValue(step.value); + yield* sql` + INSERT INTO plugin_settings (installation_id, key, value_json) + VALUES (${installationId}, ${step.key}, ${json}) + ON CONFLICT (installation_id, key) DO UPDATE SET value_json = excluded.value_json + `.pipe(Effect.catch(failed)); + } else { + yield* sql` + DELETE FROM plugin_settings WHERE installation_id = ${installationId} AND key = ${step.key} + `.pipe(Effect.catch(failed)); + } + } + return yield* readValues(installationId, fields); + }, + (effect) => lock.withPermit(effect).pipe(Effect.ensuring(PubSub.publish(events, undefined))), + ); + + /** Deletes everything saved for an installation that no longer exists. */ + const purge = Effect.fnUntraced(function* (installationId: PluginInstallationId) { + for (const row of yield* secretRows(installationId)) + yield* deleteSecretOrWarn(installationId, row.key); + yield* sql`DELETE FROM plugin_settings WHERE installation_id = ${installationId}`; + yield* sql`DELETE FROM plugin_storage WHERE installation_id = ${installationId}`; + }); + + const purgeRemoved = (installationIds: Iterable) => + lock.withPermit( + Effect.forEach( + installationIds, + (installationId) => + purge(installationId).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not delete a removed plugin's settings", { + installationId, + cause, + }), + ), + Effect.andThen(PubSub.publish(events, undefined)), + ), + { discard: true }, + ), + ); + + const owner = (registration: PluginRegistration) => + registration.installationId !== undefined && + registration.manifest.capabilities.includes(PLUGIN_SETTINGS_CAPABILITY) + ? Effect.succeed(registration.installationId) + : Effect.fail(hostError(`The plugin did not declare the "settings" capability.`)); + + const malformed = () => hostError("The request is malformed."); + + const checkStorageKey = (key: string) => + key.length > 0 && key.length <= limits.maxKeyLength && !hasControlCharacter(key) + ? Effect.void + : Effect.fail( + hostError( + `Storage keys must be 1 to ${limits.maxKeyLength} characters without control characters.`, + ), + ); + + const hostFailed = (cause: unknown) => + Effect.logWarning("Plugin storage failed", { cause }).pipe( + Effect.andThen(Effect.fail(hostError("The server could not read or save the value."))), + ); + + const settingsGet = Effect.fnUntraced(function* ({ registration, input }: HostCall) { + const installationId = yield* owner(registration); + const { key } = yield* decodeKeyInput(input).pipe(Effect.mapError(malformed)); + const field = registration.manifest.settings?.find((candidate) => candidate.key === key); + if (field === undefined) return yield* hostError(`"${key}" is not a declared setting.`); + if (field.type === "secret") { + // Under the lock, so no save or clear swaps the file between the check and the read. + return yield* lock.withPermit( + Effect.gen(function* () { + const rows = yield* sql<{ readonly saved: number }>` + SELECT saved FROM plugin_setting_secrets + WHERE installation_id = ${installationId} AND key = ${key} + `.pipe(Effect.catch(hostFailed)); + if (rows[0]?.saved !== 1) return { value: null }; + const secret = yield* secrets + .get(secretName(installationId, key)) + .pipe(Effect.catch(hostFailed)); + return { value: Option.isSome(secret) ? textDecoder.decode(secret.value) : null }; + }), + ); + } + const rows = yield* sql<{ readonly value_json: string }>` + SELECT value_json FROM plugin_settings + WHERE installation_id = ${installationId} AND key = ${key} + `.pipe(Effect.catch(hostFailed)); + const saved = + rows[0] === undefined + ? undefined + : Option.getOrUndefined(decodeSavedValue(rows[0].value_json)); + return { value: resolvePluginSettingValue(field, saved) ?? null }; + }); + + const storageGet = Effect.fnUntraced(function* ({ registration, input }: HostCall) { + const installationId = yield* owner(registration); + const { key } = yield* decodeKeyInput(input).pipe(Effect.mapError(malformed)); + yield* checkStorageKey(key); + const rows = yield* sql<{ readonly value_json: string }>` + SELECT value_json FROM plugin_storage + WHERE installation_id = ${installationId} AND key = ${key} + `.pipe(Effect.catch(hostFailed)); + const value = rows[0] === undefined ? Option.none() : decodeStoredJson(rows[0].value_json); + return Option.match(value, { + onNone: () => ({ found: false, value: null }), + onSome: (json) => ({ found: true, value: json }), + }); + }); + + const storageSet = Effect.fnUntraced(function* ({ registration, input, admitted }: HostCall) { + const installationId = yield* owner(registration); + const { key, value } = yield* decodeSetInput(input).pipe(Effect.mapError(malformed)); + yield* checkStorageKey(key); + const json = encodeStoredJson(value); + const bytes = Buffer.byteLength(json); + if (bytes > limits.maxValueBytes) + return yield* hostError(`The value is ${bytes} bytes; the limit is ${limits.maxValueBytes}.`); + return yield* lock.withPermit( + Effect.gen(function* () { + // Checked under the lock: a removed installation's cleanup may already have run. + if ((yield* findInstallation(installationId)) === undefined) + return yield* hostError("The plugin was removed."); + const usage = yield* sql<{ readonly keys: number; readonly bytes: number | null }>` + SELECT COUNT(*) AS keys, SUM(bytes) AS bytes FROM plugin_storage + WHERE installation_id = ${installationId} AND key != ${key} + `.pipe(Effect.catch(hostFailed)); + const others = usage[0] ?? { keys: 0, bytes: 0 }; + if (others.keys + 1 > limits.maxKeys) + return yield* hostError(`The plugin already stores ${limits.maxKeys} keys.`); + if ((others.bytes ?? 0) + bytes > limits.maxTotalBytes) + return yield* hostError( + `Saving this would store more than ${limits.maxTotalBytes} bytes for the plugin.`, + ); + // A revoked generation's write that waited for the lock is dropped here. + yield* admitted; + yield* sql` + INSERT INTO plugin_storage (installation_id, key, value_json, bytes) + VALUES (${installationId}, ${key}, ${json}, ${bytes}) + ON CONFLICT (installation_id, key) DO UPDATE SET + value_json = excluded.value_json, + bytes = excluded.bytes + `.pipe(Effect.catch(hostFailed)); + return null; + }), + ); + }); + + const storageDelete = Effect.fnUntraced(function* ({ registration, input, admitted }: HostCall) { + const installationId = yield* owner(registration); + const { key } = yield* decodeKeyInput(input).pipe(Effect.mapError(malformed)); + yield* checkStorageKey(key); + return yield* lock.withPermit( + Effect.gen(function* () { + yield* admitted; + yield* sql` + DELETE FROM plugin_storage WHERE installation_id = ${installationId} AND key = ${key} + `.pipe(Effect.catch(hostFailed)); + return null; + }), + ); + }); + + const storageKeys = Effect.fnUntraced(function* ({ registration }: HostCall) { + const installationId = yield* owner(registration); + const rows = yield* sql<{ readonly key: string }>` + SELECT key FROM plugin_storage WHERE installation_id = ${installationId} ORDER BY key + `.pipe(Effect.catch(hostFailed)); + return { keys: rows.map((row) => row.key) }; + }); + + yield* supervisor.serveHostMethod("settings.get", settingsGet); + yield* supervisor.serveHostMethod("storage.get", storageGet); + yield* supervisor.serveHostMethod("storage.set", storageSet); + yield* supervisor.serveHostMethod("storage.delete", storageDelete); + yield* supervisor.serveHostMethod("storage.keys", storageKeys); + + /** Each installation's settings declaration, to tell which ones a catalogue change touched. */ + const declarations = (snapshot: PluginCatalogSnapshot) => + new Map( + snapshot.installations.map((installation) => [ + installation.installationId, + JSON.stringify(installation.manifest?.settings ?? []), + ]), + ); + + // Delete what earlier runs saved for installations that are gone, finish secret writes and + // deletions an earlier run did not complete, then follow removals and changed declarations. + const listed = declarations(yield* catalog.list); + const stored = yield* sql<{ readonly installation_id: PluginInstallationId }>` + SELECT installation_id FROM plugin_settings + UNION + SELECT installation_id FROM plugin_setting_secrets + UNION + SELECT installation_id FROM plugin_storage + `.pipe(Effect.orDie); + yield* purgeRemoved( + stored + .map((row) => row.installation_id) + .filter((installationId) => !listed.has(installationId)), + ); + const unfinished = yield* sql<{ + readonly installation_id: PluginInstallationId; + readonly key: string; + }>`SELECT installation_id, key FROM plugin_setting_secrets WHERE saved = 0`.pipe(Effect.orDie); + yield* lock.withPermit( + Effect.forEach(unfinished, (row) => deleteSecretOrWarn(row.installation_id, row.key), { + discard: true, + }), + ); + let known = listed; + yield* catalog.subscribe.pipe( + Stream.runForEach((snapshot) => { + const next = declarations(snapshot); + const removed = [...known.keys()].filter((installationId) => !next.has(installationId)); + // Snapshots carry only declared fields, so subscribers re-read when the declaration changes. + const redeclared = [...next] + .filter( + ([installationId, fields]) => + known.has(installationId) && known.get(installationId) !== fields, + ) + .map(([installationId]) => installationId); + known = next; + return (removed.length === 0 ? Effect.void : purgeRemoved(removed)).pipe( + Effect.andThen( + Effect.forEach(redeclared, () => PubSub.publish(events, undefined), { discard: true }), + ), + ); + }), + Effect.forkScoped, + ); + + return PluginSettings.of({ + subscribe: (installationId) => + Stream.unwrap( + // Subscribe before the first read so a change in between is not lost. + PubSub.subscribe(events).pipe( + Effect.map((subscription) => + Stream.concat( + Stream.fromEffect(current(installationId)), + Stream.fromSubscription(subscription).pipe( + Stream.mapEffect(() => current(installationId)), + ), + ).pipe(Stream.changes), + ), + ), + ), + update, + }); +}); + +export const layer = (limits?: PluginStorageLimits) => Layer.effect(PluginSettings, make(limits)); diff --git a/apps/server/src/plugins/PluginSettingsRpc.test.ts b/apps/server/src/plugins/PluginSettingsRpc.test.ts new file mode 100644 index 0000000000000..eeb89eb3c2661 --- /dev/null +++ b/apps/server/src/plugins/PluginSettingsRpc.test.ts @@ -0,0 +1,112 @@ +import { + AuthAccessWriteScope, + type AuthEnvironmentScope, + AuthOrchestrationReadScope, + AuthRelayReadScope, + AuthStandardClientScopes, + PluginInstallationId, + type PluginSettingsValues, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as RpcTest from "effect/rpc/RpcTest"; + +import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts"; +import * as RpcAuthorization from "../auth/RpcAuthorization.ts"; + +type SettingsMethod = + | typeof WS_METHODS.pluginsSettingsSubscribe + | typeof WS_METHODS.pluginsSettingsUpdate; +const settingsMethods: ReadonlySet = new Set([ + WS_METHODS.pluginsSettingsSubscribe, + WS_METHODS.pluginsSettingsUpdate, +]); + +const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !settingsMethods.has(tag), + ), +); + +const installationId = PluginInstallationId.make("fixture"); +const values: PluginSettingsValues = { installationId, values: [], secrets: ["token"] }; + +/** Serves the settings RPCs through the real scope middleware; handlers record that they ran. */ +const makeClient = (scopes: ReadonlyArray, handled: Array) => + RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.pluginsSettingsSubscribe, () => + Stream.fromEffect( + Effect.sync(() => handled.push(WS_METHODS.pluginsSettingsSubscribe)).pipe( + Effect.as(values), + ), + ), + ), + group.toLayerHandler(WS_METHODS.pluginsSettingsUpdate, () => + Effect.sync(() => handled.push(WS_METHODS.pluginsSettingsUpdate)).pipe(Effect.as(values)), + ), + RpcAuthorization.layer(scopes), + ), + ), + ); + +const update = { installationId, changes: [{ key: "token", value: "fixture-secret" }] }; + +describe("plugin settings RPC scopes", () => { + it.effect("lets a read-only pairing read values but not save them", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient([AuthOrchestrationReadScope], handled); + + expect( + yield* client[WS_METHODS.pluginsSettingsSubscribe]({ installationId }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ).toEqual([values]); + expect( + yield* client[WS_METHODS.pluginsSettingsUpdate](update).pipe(Effect.flip), + ).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthAccessWriteScope, + requiredPermission: AuthAccessWriteScope, + }); + expect(handled).toEqual([WS_METHODS.pluginsSettingsSubscribe]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a standard pairing save values", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient(AuthStandardClientScopes, handled); + + expect(yield* client[WS_METHODS.pluginsSettingsUpdate](update)).toEqual(values); + expect(handled).toEqual([WS_METHODS.pluginsSettingsUpdate]); + }).pipe(Effect.scoped), + ); + + it.effect("refuses value reads without the orchestration read scope", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient([AuthRelayReadScope], handled); + + expect( + yield* client[WS_METHODS.pluginsSettingsSubscribe]({ installationId }).pipe( + Stream.runCollect, + Effect.flip, + ), + ).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthOrchestrationReadScope, + requiredPermission: AuthOrchestrationReadScope, + }); + expect(handled).toEqual([]); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/plugins/PluginSupervisor.spawn.test.ts b/apps/server/src/plugins/PluginSupervisor.spawn.test.ts new file mode 100644 index 0000000000000..2c3a4fcc983b5 --- /dev/null +++ b/apps/server/src/plugins/PluginSupervisor.spawn.test.ts @@ -0,0 +1,110 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Models Node's failed-spawn child and its next-tick error. +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; +import * as NodeStream from "node:stream"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Scope from "effect/Scope"; +import * as Option from "effect/Option"; +import { vi } from "vite-plus/test"; + +import { loadPluginDirectory } from "./PluginManifestLoader.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; + +vi.mock("node:child_process", async (original) => ({ + ...(await original()), + spawn: vi.fn(), +})); + +it.layer(NodeServices.layer)("PluginSupervisor failed spawn", (it) => { + it.effect.each(["EMFILE", "ENFILE"])( + "supervises %s without stdio before Node emits error", + (code) => + Effect.gen(function* () { + const child = Object.assign(new NodeEvents.EventEmitter(), { + pid: undefined, + stdio: undefined, + kill: vi.fn(), + }); + vi.mocked(NodeChildProcess.spawn).mockReturnValueOnce( + child as unknown as ReturnType, + ); + const supervisor = yield* PluginSupervisor.make().pipe( + Effect.provideService(HostProcess.Arguments, [ + process.execPath, + `${import.meta.dirname}/../bin.ts`, + ]), + ); + const registration = yield* loadPluginDirectory( + `${import.meta.dirname}/testFixtures/plugin`, + ); + yield* supervisor.enable(registration); + const outcome = yield* supervisor + .invoke(registration.manifest.id, "ping", null) + .pipe(Effect.flip); + expect(outcome._tag).toBe("PluginUnavailableError"); + expect(yield* supervisor.state(registration.manifest.id)).toEqual( + Option.some({ _tag: "idle" }), + ); + expect(child.listenerCount("error")).toBeGreaterThan(0); + child.emit("error", Object.assign(new Error(`spawn ${code}`), { code })); + expect(child.kill).not.toHaveBeenCalled(); + yield* supervisor.disable(registration.manifest.id); + }), + ); + it.effect("destroys inherited streams when shutdown interrupts the exit drain", () => + Effect.gen(function* () { + const activated = yield* Deferred.make(); + const stderr = new NodeStream.PassThrough(); + const child = Object.assign(new NodeEvents.EventEmitter(), { + pid: 12345, + exitCode: null as number | null, + signalCode: null, + stderr, + kill: vi.fn(), + }); + const channel = new NodeStream.Duplex({ + read() {}, + write(chunk, _encoding, callback) { + const message = JSON.parse(chunk.toString()); + if (message._tag === "Activate") Deferred.doneUnsafe(activated, Exit.void); + if (message._tag === "Deactivate") { + child.exitCode = 0; + child.emit("exit", 0, null); + } + callback(); + }, + }); + Object.assign(child, { stdio: [null, null, stderr, channel] }); + vi.mocked(NodeChildProcess.spawn).mockReturnValueOnce( + child as unknown as ReturnType, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const supervisor = yield* PluginSupervisor.make().pipe( + Effect.provideService(HostProcess.Arguments, [ + process.execPath, + `${import.meta.dirname}/../bin.ts`, + ]), + Scope.provide(scope), + ); + const registration = yield* loadPluginDirectory(`${import.meta.dirname}/testFixtures/plugin`); + yield* supervisor.enable(registration); + const pending = yield* supervisor + .invoke(registration.manifest.id, "ping", null) + .pipe(Effect.forkChild); + yield* Deferred.await(activated); + yield* supervisor.disable(registration.manifest.id); + yield* Fiber.await(pending); + yield* Scope.close(scope, Exit.void); + expect(channel.destroyed).toBe(true); + expect(stderr.destroyed).toBe(true); + }), + ); +}); diff --git a/apps/server/src/plugins/PluginSupervisor.test.ts b/apps/server/src/plugins/PluginSupervisor.test.ts new file mode 100644 index 0000000000000..4b8b30f282f8a --- /dev/null +++ b/apps/server/src/plugins/PluginSupervisor.test.ts @@ -0,0 +1,769 @@ +// @effect-diagnostics nodeBuiltinImport:off -- A TCP listener hears from a process a plugin started. +import * as NodeNet from "node:net"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import type { PluginHostState, PluginId } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import { TestClock } from "effect/testing"; + +import { loadPluginDirectory } from "./PluginManifestLoader.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; + +const FIXTURE_DIR = `${import.meta.dirname}/testFixtures/plugin`; +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; + +const testOptions = { + heapLimitMb: 64, + maxMessageBytes: 64 * 1024, + activationTimeout: "5 seconds", + callTimeout: "5 seconds", + cancelGrace: "1 second", + stopGrace: "1 second", + maxRestarts: 2, + restartBackoff: "1 second", + maxRestartBackoff: "4 seconds", + stableUptime: "1 minute", +} satisfies Partial; + +const makeSupervisor = (overrides: Partial = {}) => + PluginSupervisor.make({ ...testOptions, ...overrides }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + ); + +/** Copies the fixture plugin into a scoped temp directory under its own manifest. */ +const preparePlugin = Effect.fn("preparePlugin")(function* ( + id: string, + manifest: Record = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-" }); + for (const file of yield* fs.readDirectory(FIXTURE_DIR)) + if (file.endsWith(".mjs")) + yield* fs.copyFile(path.join(FIXTURE_DIR, file), path.join(directory, file)); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id, + name: id, + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + proposedApi: true, + ...manifest, + }), + ); + return { directory, registration: yield* loadPluginDirectory(directory) }; +}); + +type Supervisor = PluginSupervisor.PluginSupervisor["Service"]; +type Subscription = PubSub.Subscription; + +const awaitState = Effect.fn("awaitState")(function* ( + supervisor: Supervisor, + subscription: Subscription, + pluginId: PluginId, + tag: Tag, +) { + let current = yield* supervisor.state(pluginId); + while (true) { + if (Option.isSome(current) && current.value._tag === tag) + return current.value as Extract; + const event = yield* PubSub.take(subscription); + if (event._tag === "StateChanged" && event.pluginId === pluginId) + current = Option.some(event.state); + } +}); + +const awaitLog = Effect.fn("awaitLog")(function* ( + subscription: Subscription, + pluginId: PluginId, + message: string, +) { + while (true) { + const event = yield* PubSub.take(subscription); + if (event._tag === "Log" && event.pluginId === pluginId && event.message === message) return; + } +}); + +const awaitLogMatching = Effect.fn("awaitLogMatching")(function* ( + subscription: Subscription, + pluginId: PluginId, + pattern: RegExp, +) { + while (true) { + const event = yield* PubSub.take(subscription); + if (event._tag === "Log" && event.pluginId === pluginId && pattern.test(event.message)) + return event.message; + } +}); + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +/** Collects what the first connection to a local port sends until it closes. */ +const listenOnce = () => + Effect.acquireRelease( + Effect.promise( + () => + new Promise<{ server: NodeNet.Server; port: number; received: Promise }>( + (resolve) => { + let report!: (text: string) => void; + const received = new Promise((done) => (report = done)); + const server = NodeNet.createServer((socket) => { + let text = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => (text += chunk)); + socket.on("close", () => report(text)); + }); + server.listen(0, "127.0.0.1", () => + resolve({ server, port: (server.address() as NodeNet.AddressInfo).port, received }), + ); + }, + ), + ), + ({ server }) => Effect.sync(() => server.close()), + ); + +const pidOf = (value: unknown) => (value as { readonly pid: number }).pid; + +const isProcessAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +it.layer(NodeServices.layer)("PluginSupervisor", (it) => { + describe("manifests", () => { + it.effect("loads the fixture and refuses incompatible or escaping plugins", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* loadPluginDirectory(FIXTURE_DIR); + expect(fixture.manifest).toMatchObject({ + id: "test.fixture", + apiVersion: 1, + capabilities: [], + proposedApi: true, + }); + expect(fixture.entryPath.endsWith(`${path.sep}main.mjs`)).toBe(true); + + const reason = (manifest: Record) => + preparePlugin("test.invalid", manifest).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + expect(yield* reason({ apiVersion: 2 })).toContain("targets plugin API version 2"); + expect(yield* reason({ capabilities: ["unimplemented"] })).toContain( + "does not support unimplemented", + ); + expect(yield* reason({ entry: "../main.mjs" })).toContain("is invalid"); + expect(yield* reason({ entry: "main.ts" })).toContain("is invalid"); + expect(yield* reason({ id: "Not-Qualified" })).toContain("is invalid"); + + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-outside-" }); + yield* fs.writeFileString( + path.join(outside, "escape.mjs"), + "export function activate() {}", + ); + const { directory } = yield* preparePlugin("test.symlink"); + yield* fs.symlink(path.join(outside, "escape.mjs"), path.join(directory, "link.mjs")); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id: "test.symlink", + name: "Symlink", + version: "1", + apiVersion: 1, + entry: "link.mjs", + }), + ); + const escaped = yield* loadPluginDirectory(directory).pipe(Effect.flip); + expect(escaped.message).toContain("resolves outside the plugin directory"); + }), + ); + }); + + describe("lifecycle", () => { + it.effect("starts no process until first use and stops it on disable and shutdown", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supervisor = yield* makeSupervisor(); + const { directory, registration } = yield* preparePlugin("test.lazy"); + const pluginId = registration.manifest.id; + const marker = path.join(directory, "activated.marker"); + + yield* supervisor.enable(registration); + expect(yield* supervisor.state(pluginId)).toEqual(Option.some({ _tag: "idle" })); + expect(yield* fs.exists(marker)).toBe(false); + const duplicate = yield* supervisor.enable(registration).pipe(Effect.flip); + expect(duplicate._tag).toBe("PluginAlreadyEnabledError"); + + const result = yield* supervisor.invoke(pluginId, "ping", { hello: "world" }); + expect(result).toMatchObject({ input: { hello: "world" } }); + const pid = pidOf(result); + expect(yield* fs.exists(marker)).toBe(true); + expect(yield* supervisor.state(pluginId)).toEqual(Option.some({ _tag: "running" })); + expect(isProcessAlive(pid)).toBe(true); + + yield* supervisor.disable(pluginId); + expect(isProcessAlive(pid)).toBe(false); + expect(yield* supervisor.state(pluginId)).toEqual(Option.none()); + const afterDisable = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(afterDisable._tag).toBe("PluginNotEnabledError"); + + // Closing the supervisor's scope stops every plugin it still runs. + const shutdownPid = yield* Effect.scoped( + Effect.gen(function* () { + const scoped = yield* makeSupervisor(); + yield* scoped.enable(registration); + return pidOf(yield* scoped.invoke(pluginId, "ping", null)); + }), + ); + expect(isProcessAlive(shutdownPid)).toBe(false); + }), + ); + + it.effect("kills a plugin stuck in a synchronous loop while other plugins keep answering", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const spinner = (yield* preparePlugin("test.spinner")).registration; + const bystander = (yield* preparePlugin("test.bystander")).registration; + yield* supervisor.enable(spinner); + yield* supervisor.enable(bystander); + const spinnerPid = pidOf(yield* supervisor.invoke(spinner.manifest.id, "ping", null)); + const bystanderPid = pidOf(yield* supervisor.invoke(bystander.manifest.id, "ping", null)); + + const spinning = yield* supervisor + .invoke(spinner.manifest.id, "spin", null, { timeout: "2 seconds" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + // The server's event loop is free while the spinner's is blocked. + const answer = yield* supervisor.invoke(bystander.manifest.id, "ping", "still here"); + expect(answer).toEqual({ pid: bystanderPid, input: "still here" }); + + yield* TestClock.adjust("2 seconds"); + const timedOut = yield* Fiber.join(spinning); + expect(timedOut._tag).toBe("PluginTimeoutError"); + expect(isProcessAlive(spinnerPid)).toBe(true); + + yield* TestClock.adjust("1 second"); + const backoff = yield* awaitState(supervisor, subscription, spinner.manifest.id, "backoff"); + expect(backoff.reason).toContain('did not stop "spin" within 1000ms of cancellation'); + expect(isProcessAlive(spinnerPid)).toBe(false); + expect(yield* supervisor.invoke(bystander.manifest.id, "ping", null)).toMatchObject({ + pid: bystanderPid, + }); + }), + ); + + it.effect("lets a handler that honours cancellation settle without a kill", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.cooperative"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + + const call = yield* supervisor + .invoke(pluginId, "cooperative", null, { timeout: "1 second" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(call))._tag).toBe("PluginTimeoutError"); + // The child answers the cancel before it answers this later call. + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + + yield* TestClock.adjust("1 second"); + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + expect(yield* supervisor.state(pluginId)).toEqual(Option.some({ _tag: "running" })); + }), + ); + + it.effect("cancels an interrupted caller and releases its concurrency slot", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor({ maxConcurrentCalls: 1 }); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.interrupted-call"); + 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) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "cooperative-started"); + yield* Fiber.interrupt(call); + yield* awaitLog(subscription, pluginId, "cooperative-settled"); + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + yield* supervisor.disable(pluginId); + expect(isProcessAlive(pid)).toBe(false); + }), + ); + + it.effect("fails in-flight calls on disable and drops the plugin's late answer", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.late"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + + const call = yield* supervisor + .invoke(pluginId, "late", null) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "late-started"); + // Deactivation aborts the handler, which then answers "late value". + yield* supervisor.disable(pluginId); + + const exit = yield* Fiber.join(call); + expect(Exit.isFailure(exit)).toBe(true); + expect(Option.getOrUndefined(Exit.findErrorOption(exit))?._tag).toBe("PluginStoppedError"); + expect(isProcessAlive(pid)).toBe(false); + }), + ); + }); + + describe("concurrency", () => { + it.effect("counts cancelled calls against the cap until the plugin answers them", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor({ maxConcurrentCalls: 1 }); + const subscription = yield* supervisor.subscribe; + const stalled = (yield* preparePlugin("test.stalled")).registration; + const polite = (yield* preparePlugin("test.polite")).registration; + yield* supervisor.enable(stalled); + yield* supervisor.enable(polite); + const stalledPid = pidOf(yield* supervisor.invoke(stalled.manifest.id, "ping", null)); + + const stalling = yield* supervisor + .invoke(stalled.manifest.id, "stall", null, { timeout: "1 second" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(stalling))._tag).toBe("PluginTimeoutError"); + // Retries after the timeout are refused while the plugin still holds the call. + const retries = yield* Effect.forEach(Array.from({ length: 4 }), () => + supervisor.invoke(stalled.manifest.id, "ping", null).pipe(Effect.flip), + ); + expect(retries.map((error) => error._tag)).toEqual(Array(4).fill("PluginBusyError")); + // The slot comes back only when the unanswered process is killed. + yield* TestClock.adjust("1 second"); + yield* awaitState(supervisor, subscription, stalled.manifest.id, "backoff"); + expect(isProcessAlive(stalledPid)).toBe(false); + + // A plugin that answers the cancel frees its slot without a kill. + const politePid = pidOf(yield* supervisor.invoke(polite.manifest.id, "ping", null)); + const cooperative = yield* supervisor + .invoke(polite.manifest.id, "cooperative", null, { timeout: "1 second" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + // A cancel the child reads with its invoke is answered before the handler runs, + // and then nothing would log "cooperative-settled". + yield* awaitLog(subscription, polite.manifest.id, "cooperative-started"); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(cooperative))._tag).toBe("PluginTimeoutError"); + yield* awaitLog(subscription, polite.manifest.id, "cooperative-settled"); + expect(pidOf(yield* supervisor.invoke(polite.manifest.id, "ping", null))).toBe(politePid); + }), + ); + }); + + describe("disable", () => { + it.effect("keeps stopping a plugin after the disabling caller is interrupted", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const supervisor = yield* makeSupervisor().pipe(Scope.provide(scope)); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.interrupted"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + yield* supervisor.invoke(pluginId, "holdDeactivate", null); + + const disabling = yield* supervisor + .disable(pluginId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "deactivate-held"); + yield* Fiber.interrupt(disabling); + expect(yield* supervisor.state(pluginId)).toEqual(Option.none()); + expect(isProcessAlive(pid)).toBe(true); + + // Shutdown still owns the stopping process and kills it after the grace. + const closing = yield* Scope.close(scope, Exit.void).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(closing); + expect(isProcessAlive(pid)).toBe(false); + }), + ); + + it.effect("makes a repeated disable wait for the interrupted stop to finish", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.retried"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + yield* supervisor.invoke(pluginId, "holdDeactivate", null); + + const disabling = yield* supervisor + .disable(pluginId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "deactivate-held"); + yield* Fiber.interrupt(disabling); + + const retry = yield* supervisor + .disable(pluginId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(retry.pollUnsafe()).toBeUndefined(); + expect(isProcessAlive(pid)).toBe(true); + expect(yield* supervisor.state(pluginId)).toEqual(Option.none()); + + yield* TestClock.adjust("1 second"); + yield* Fiber.join(retry); + expect(isProcessAlive(pid)).toBe(false); + }), + ); + + it.effect("fails a call waiting for activation once disable begins", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.racing", { + entry: "deferredActivate.mjs", + }); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + + const waiting = yield* supervisor + .invoke(pluginId, "ping", null) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "activating"); + // Deactivation lets activation finish, and the process keeps serving. + const disabling = yield* supervisor + .disable(pluginId) + .pipe(Effect.forkChild({ startImmediately: true })); + expect((yield* Fiber.join(waiting))._tag).toBe("PluginStoppedError"); + + // A re-enabled plugin with the same id waits for the old process to go. + const replacement = (yield* preparePlugin("test.racing")).registration; + yield* supervisor.enable(replacement); + const early = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(early.message).toContain("its previous process is still stopping"); + + yield* TestClock.adjust("1 second"); + yield* Fiber.join(disabling); + expect(yield* supervisor.invoke(pluginId, "ping", "fresh")).toMatchObject({ + input: "fresh", + }); + }), + ); + + it.effect( + "gives calls waiting on a start its outcome after the starting call is interrupted", + () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.abandoned", { + entry: "deferredActivate.mjs", + }); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + + const starting = yield* supervisor + .invoke(pluginId, "ping", null) + .pipe(Effect.forkChild({ startImmediately: true })); + const waiting = yield* supervisor + .invoke(pluginId, "ping", null) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "activating"); + // The start outlives its caller, and the waiting call hears how it ended. + const interrupting = yield* Fiber.interrupt(starting).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(interrupting); + expect((yield* Fiber.join(waiting)).message).toContain("did not activate within 5000ms"); + }), + ); + }); + + describe("faults", () => { + it.effect("reports a plugin that exhausts its heap", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.oom"); + yield* supervisor.enable(registration); + + const error = yield* supervisor + .invoke(registration.manifest.id, "oom", null) + .pipe(Effect.flip); + expect(error._tag).toBe("PluginCrashedError"); + expect(error.message).toContain("ran out of memory (heap limit 64 MB)"); + const state = yield* supervisor.state(registration.manifest.id); + expect(Option.getOrUndefined(state)?._tag).toBe("backoff"); + }), + ); + + it.effect("kills a plugin that sends malformed or oversized IPC", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const malformed = (yield* preparePlugin("test.malformed")).registration; + const oversized = (yield* preparePlugin("test.oversized")).registration; + yield* supervisor.enable(malformed); + yield* supervisor.enable(oversized); + + const garbage = yield* supervisor + .invoke(malformed.manifest.id, "malformed", null) + .pipe(Effect.flip); + expect(garbage.message).toContain("sent a malformed IPC message"); + + const flood = yield* supervisor + .invoke(oversized.manifest.id, "oversizedFrame", { bytes: 70_000 }) + .pipe(Effect.flip); + expect(flood.message).toContain("sent an IPC message larger than 65536 bytes"); + }), + ); + + it.effect("stops reading a plugin that floods logs and still delivers its result", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const flood = (yield* preparePlugin("test.flood")).registration; + const calm = (yield* preparePlugin("test.calm")).registration; + yield* supervisor.enable(flood); + yield* supervisor.enable(calm); + const floodPid = pidOf(yield* supervisor.invoke(flood.manifest.id, "ping", null)); + const calmPid = pidOf(yield* supervisor.invoke(calm.manifest.id, "ping", null)); + + // About 2.4 MB of logs in one synchronous burst against a 64 KiB read budget. + const flooding = yield* supervisor + .invoke(flood.manifest.id, "flood", { count: 8000, size: 250 }) + .pipe(Effect.forkChild({ startImmediately: true })); + expect(yield* supervisor.invoke(calm.manifest.id, "ping", "meanwhile")).toEqual({ + pid: calmPid, + input: "meanwhile", + }); + expect(yield* Fiber.join(flooding)).toEqual({ pid: floodPid, done: true }); + // The child saw the server stop reading and dropped logs, not the result. + const notice = yield* awaitLogMatching(subscription, flood.manifest.id, /^Dropped \d+ /); + expect(notice).toMatch(/^Dropped \d+ log messages while the server was busy\.$/); + expect(pidOf(yield* supervisor.invoke(flood.manifest.id, "ping", null))).toBe(floodPid); + }), + ); + + it.effect("bounds call and result sizes without stopping the plugin", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.bounds"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + + const bigInput = yield* supervisor + .invoke(pluginId, "ping", "x".repeat(70_000)) + .pipe(Effect.flip); + expect(bigInput._tag).toBe("PluginPayloadTooLargeError"); + const bigResult = yield* supervisor + .invoke(pluginId, "bigResult", { bytes: 70_000 }) + .pipe(Effect.flip); + expect(bigResult._tag).toBe("PluginCallFailedError"); + expect(bigResult.message).toContain("Result exceeds 65536 bytes"); + const thrown = yield* supervisor.invoke(pluginId, "throws", null).pipe(Effect.flip); + expect(thrown.message).toBe('Plugin test.bounds failed "throws": nope'); + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + }), + ); + + it.effect("fails results with no JSON form without stopping the plugin", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.unserializable"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + + for (const handler of ["functionResult", "symbolResult", "undefinedJsonResult"]) { + const error = yield* supervisor.invoke(pluginId, handler, null).pipe(Effect.flip); + expect(error._tag).toBe("PluginCallFailedError"); + expect(error.message).toBe(`Plugin ${pluginId} failed "${handler}": Result is not JSON.`); + } + // The serializer's own error is cut to the 2000 characters the server accepts. + const thrown = yield* supervisor + .invoke(pluginId, "throwingJsonResult", null) + .pipe(Effect.flip); + expect(thrown._tag).toBe("PluginCallFailedError"); + const prefix = `Plugin ${pluginId} failed "throwingJsonResult": `; + expect(thrown.message.startsWith(`${prefix}Result is not JSON: xxx`)).toBe(true); + expect(thrown.message.length).toBe(prefix.length + 2000); + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + }), + ); + + it.effect("keeps one plugin's crash away from another", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const crasher = (yield* preparePlugin("test.crasher")).registration; + const survivor = (yield* preparePlugin("test.survivor")).registration; + yield* supervisor.enable(crasher); + yield* supervisor.enable(survivor); + const survivorPid = pidOf(yield* supervisor.invoke(survivor.manifest.id, "ping", null)); + + const crash = yield* supervisor.invoke(crasher.manifest.id, "exit", null).pipe(Effect.flip); + expect(crash._tag).toBe("PluginCrashedError"); + expect(crash.message).toContain("exited with code 3"); + expect(pidOf(yield* supervisor.invoke(survivor.manifest.id, "ping", null))).toBe( + survivorPid, + ); + expect(yield* supervisor.state(survivor.manifest.id)).toEqual( + Option.some({ _tag: "running" }), + ); + }), + ); + + it.effect("closes the stderr of a plugin that exited while a process it started holds it", () => + Effect.gen(function* () { + const { port, received } = yield* listenOnce(); + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.stderr-holder"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const holderPid = pidOf( + yield* supervisor.invoke(pluginId, "holdStderr", { port: `${port}` }), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => isProcessAlive(holderPid) && process.kill(holderPid)), + ); + + // The plugin's exit is handled after the drain timeout, as its stderr never ends. + const crash = yield* supervisor.invoke(pluginId, "exit", null).pipe(Effect.flip); + expect(crash.message).toContain("exited with code 3"); + // Once the server closes its end, the holder's next write fails. + expect(yield* Effect.promise(() => received)).toBe("closed"); + }).pipe(TestClock.withLive), + ); + + it.effect("backs off after each crash and quarantines past the restart cap", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.flaky"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + + const crashAndWait = Effect.fn("crashAndWait")(function* (delay: Duration.Input) { + yield* supervisor.invoke(pluginId, "exit", null).pipe(Effect.flip); + const backoff = yield* awaitState(supervisor, subscription, pluginId, "backoff"); + const refused = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(refused._tag).toBe("PluginUnavailableError"); + yield* TestClock.adjust(delay); + yield* awaitState(supervisor, subscription, pluginId, "idle"); + return backoff; + }); + expect((yield* crashAndWait("1 second")).failures).toBe(1); + expect((yield* crashAndWait("2 seconds")).failures).toBe(2); + + yield* supervisor.invoke(pluginId, "exit", null).pipe(Effect.flip); + const quarantined = yield* awaitState(supervisor, subscription, pluginId, "quarantined"); + expect(quarantined).toMatchObject({ failures: 3 }); + expect(quarantined.reason).toContain("exited with code 3"); + + // Quarantine never lifts on its own. + yield* TestClock.adjust("10 minutes"); + const refused = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(refused.message).toContain("quarantined after 3 failures"); + + yield* supervisor.resume(pluginId); + expect(yield* supervisor.invoke(pluginId, "ping", "back")).toMatchObject({ input: "back" }); + }), + ); + + it.effect("refuses top-level await in an entry or its imports without spending restarts", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + for (const [id, entry] of [ + ["test.async-entry", "asyncEntry.mjs"], + ["test.async-dependency", "asyncDependency.mjs"], + ] as const) { + const { registration } = yield* preparePlugin(id, { entry }); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + // More attempts than the restart cap allows still never back off or quarantine. + for (let attempt = 0; attempt <= testOptions.maxRestarts + 1; attempt++) { + const error = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(error._tag).toBe("PluginIncompatibleError"); + expect(error.message).toContain("uses top-level await"); + const state = Option.getOrUndefined(yield* supervisor.state(pluginId)); + expect(state?._tag).toBe("incompatible"); + // Refused up front until someone resumes it. + const again = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(again._tag).toBe("PluginIncompatibleError"); + yield* supervisor.resume(pluginId); + } + } + }), + ); + + it.effect("fails activation that hangs, throws, or uses unrequested proposed APIs", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const hang = (yield* preparePlugin("test.hang", { entry: "spinActivate.mjs" })) + .registration; + const refuse = (yield* preparePlugin("test.refuse", { entry: "failActivate.mjs" })) + .registration; + const stable = (yield* preparePlugin("test.stable", { proposedApi: false })).registration; + yield* Effect.forEach([hang, refuse, stable], supervisor.enable, { discard: true }); + + const hanging = yield* supervisor + .invoke(hang.manifest.id, "ping", null) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("5 seconds"); + expect((yield* Fiber.join(hanging)).message).toContain("did not activate within 5000ms"); + + const refused = yield* supervisor + .invoke(refuse.manifest.id, "ping", null) + .pipe(Effect.flip); + expect(refused.message).toContain("activation failed: activation refused"); + + const gated = yield* supervisor.invoke(stable.manifest.id, "ping", null).pipe(Effect.flip); + expect(gated.message).toContain("activation failed"); + }), + ); + + it.effect("lets plugins register t3.tool handlers and keeps other t3 names reserved", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.reserved", { + entry: "reservedHandlers.mjs", + }); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + + expect(yield* supervisor.invoke(pluginId, "t3.tool.echo", { text: "hi" })).toEqual({ + handler: "t3.tool.echo", + input: { text: "hi" }, + }); + expect(yield* supervisor.invoke(pluginId, "refusals", null)).toEqual({ + "t3.events": 'Handler names starting with "t3." are reserved.', + "t3.other": 'Handler names starting with "t3." are reserved.', + }); + }), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginSupervisor.ts b/apps/server/src/plugins/PluginSupervisor.ts new file mode 100644 index 0000000000000..400aae36fe001 --- /dev/null +++ b/apps/server/src/plugins/PluginSupervisor.ts @@ -0,0 +1,1078 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Each plugin child gets an extra fd 3 pipe, which needs Node's child_process. +// Plugins are trusted OS-user code; child isolation protects availability, not data. +// The supervisor owns startup and shutdown independently of callers, with bounded IPC and cooperative cancellation. +import * as NodeChildProcess from "node:child_process"; +import type * as NodeStream from "node:stream"; + +import type { PluginHostState, PluginId } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; + +import { + decodePluginChildMessage, + encodePluginHostMessage, + PluginHandlerName, + type PluginHostMessage, + type PluginLogLevel, +} from "./PluginIpc.ts"; +import { + DEFAULT_PLUGIN_IPC_MAX_BYTES, + PLUGIN_IPC_FD, + PLUGIN_IPC_MAX_BYTES_LIMIT, + PLUGIN_MAX_HOST_CALLS, + makeLineDecoder, + makeReadBudget, +} from "./pluginIpcFraming.ts"; +import type { PluginRegistration } from "./PluginManifestLoader.ts"; + +/** Hidden CLI command that bin.ts routes to the plugin child runtime. */ +const PLUGIN_HOST_COMMAND = "__plugin-host"; + +export interface PluginSupervisorOptions { + readonly heapLimitMb: number; + readonly maxMessageBytes: number; + readonly activationTimeout: Duration.Input; + readonly callTimeout: Duration.Input; + readonly cancelGrace: Duration.Input; + readonly stopGrace: Duration.Input; + /** In-flight calls per plugin, counting cancelled calls the plugin has not answered yet. */ + readonly maxConcurrentCalls: number; + /** Plugin processes alive at once across the environment. */ + readonly maxRunningPlugins: number; + /** Consecutive failures that still restart; one more quarantines. */ + readonly maxRestarts: number; + readonly restartBackoff: Duration.Input; + readonly maxRestartBackoff: Duration.Input; + /** A child that ran this long before failing starts a fresh failure count. */ + readonly stableUptime: Duration.Input; +} + +const defaultPluginSupervisorOptions: PluginSupervisorOptions = { + heapLimitMb: 256, + maxMessageBytes: DEFAULT_PLUGIN_IPC_MAX_BYTES, + activationTimeout: Duration.seconds(10), + callTimeout: Duration.seconds(30), + cancelGrace: Duration.seconds(2), + stopGrace: Duration.seconds(2), + maxConcurrentCalls: 16, + maxRunningPlugins: 16, + maxRestarts: 3, + restartBackoff: Duration.seconds(1), + maxRestartBackoff: Duration.seconds(30), + stableUptime: Duration.minutes(1), +}; + +export class PluginAlreadyEnabledError extends Schema.TaggedError()( + "PluginAlreadyEnabledError", + { pluginId: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is already enabled.`; + } +} + +export class PluginNotEnabledError extends Schema.TaggedError()( + "PluginNotEnabledError", + { pluginId: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is not enabled.`; + } +} + +class PluginUnavailableError extends Schema.TaggedError()( + "PluginUnavailableError", + { pluginId: Schema.String, reason: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is unavailable: ${this.reason}`; + } +} + +class PluginCrashedError extends Schema.TaggedError()("PluginCrashedError", { + pluginId: Schema.String, + reason: Schema.String, +}) { + override get message(): string { + return `Plugin ${this.pluginId} stopped unexpectedly: ${this.reason}`; + } +} + +class PluginIncompatibleError extends Schema.TaggedError()( + "PluginIncompatibleError", + { pluginId: Schema.String, reason: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} cannot run on this server: ${this.reason}`; + } +} + +class PluginStoppedError extends Schema.TaggedError()("PluginStoppedError", { + pluginId: Schema.String, +}) { + override get message(): string { + return `Plugin ${this.pluginId} was stopped before the call finished.`; + } +} + +class PluginTimeoutError extends Schema.TaggedError()("PluginTimeoutError", { + pluginId: Schema.String, + handler: Schema.String, + timeoutMs: Schema.Number, +}) { + override get message(): string { + return `Plugin ${this.pluginId} did not answer "${this.handler}" within ${this.timeoutMs}ms.`; + } +} + +class PluginCallFailedError extends Schema.TaggedError()( + "PluginCallFailedError", + { pluginId: Schema.String, handler: Schema.String, reason: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} failed "${this.handler}": ${this.reason}`; + } +} + +class PluginBusyError extends Schema.TaggedError()("PluginBusyError", { + pluginId: Schema.String, + limit: Schema.Number, +}) { + override get message(): string { + return `Plugin ${this.pluginId} already has ${this.limit} calls in flight.`; + } +} + +class PluginPayloadTooLargeError extends Schema.TaggedError()( + "PluginPayloadTooLargeError", + { pluginId: Schema.String, bytes: Schema.Number, limit: Schema.Number }, +) { + override get message(): string { + return `The call to plugin ${this.pluginId} is ${this.bytes} bytes; the limit is ${this.limit}.`; + } +} + +export type PluginInvokeError = + | PluginNotEnabledError + | PluginUnavailableError + | PluginCrashedError + | PluginIncompatibleError + | PluginStoppedError + | PluginTimeoutError + | PluginCallFailedError + | PluginBusyError + | PluginPayloadTooLargeError; + +/** A host method's refusal; `message` reaches the plugin, so it must not carry secrets. */ +export class PluginHostCallError extends Schema.TaggedError()( + "PluginHostCallError", + { message: Schema.String }, +) {} + +/** + * Serves one host method for the plugin whose registration made the call. + * The call is interrupted when that generation is revoked; a method that + * writes runs `admitted` right before committing, after any wait. + */ +export type PluginHostMethod = (call: { + readonly registration: PluginRegistration; + readonly input: Schema.Json; + /** Fails once the calling generation is revoked or its process has exited. */ + readonly admitted: Effect.Effect; +}) => Effect.Effect; + +export type PluginSupervisorEvent = + | { readonly _tag: "StateChanged"; readonly pluginId: PluginId; readonly state: PluginHostState } + | { + readonly _tag: "Log"; + readonly pluginId: PluginId; + readonly level: PluginLogLevel; + readonly message: string; + }; + +type CallOutcome = Exit.Exit; + +interface PendingCall { + readonly handler: string; + readonly deferred: Deferred.Deferred; +} + +type ChildEvent = + | { readonly _tag: "Line"; readonly line: string; readonly bytes: number } + | { readonly _tag: "Overflow" } + | { readonly _tag: "Exited"; readonly code: number | null; readonly signal: string | null } + /** fd 3 and stderr have closed, so every line and the stderr tail have been read. */ + | { readonly _tag: "Drained" }; + +interface Child { + readonly pluginId: PluginId; + readonly process: NodeChildProcess.ChildProcess; + readonly channel: NodeStream.Duplex; + readonly startedAt: number; + readonly ready: Deferred.Deferred< + void, + PluginCrashedError | PluginIncompatibleError | PluginStoppedError + >; + readonly exited: Deferred.Deferred; + readonly pending: Map; + /** Cancelled calls whose answer has not arrived yet. */ + readonly settling: Map>; + nextRequestId: number; + stopping: boolean; + /** The child reported code that cannot load here; set before it is killed. */ + incompatible: string | undefined; + killReason: string | undefined; + stderrTail: string; + /** V8 reported reaching the heap limit; its message can scroll out of the tail. */ + outOfMemory: boolean; + /** Host calls from this child still being served. */ + hostCalls: number; + /** Owns the fibers serving this child's host calls; closed on revocation and exit. */ + readonly hostWork: Scope.Closeable; + /** Set once `hostWork` starts closing; done once it has closed. */ + hostWorkEnded: Deferred.Deferred | undefined; + /** One host-call answer at a time waits for the child to read earlier messages. */ + readonly replies: Semaphore.Semaphore; + /** Set while answers wait for the child to read; logged once per episode. */ + backedUp: boolean; +} + +interface Entry { + readonly registration: PluginRegistration; + readonly pluginId: PluginId; + state: PluginHostState; + /** Set by disable; a removed entry never starts, admits, or reports again. */ + removed: boolean; + failures: number; + child: Child | undefined; + starting: Deferred.Deferred | undefined; +} + +const STDERR_TAIL_BYTES = 4096; +const STOPPED_MESSAGE = "The plugin was stopped."; +// A grandchild that inherited stderr can hold it open after the plugin exits. +const DRAIN_TIMEOUT = Duration.millis(250); + +const LOG_SEVERITY = { debug: "Debug", info: "Info", warn: "Warn", error: "Error" } as const; + +// Enough for a trusted plugin to find its tools and temp space without +// inheriting the server's credentials or Node options. +const CHILD_ENV_KEYS = [ + "PATH", + "Path", + "HOME", + "USERPROFILE", + "TMPDIR", + "TEMP", + "TMP", + "SystemRoot", + "LANG", + "LC_ALL", +]; + +const isHandlerName = Schema.is(PluginHandlerName); + +const isAlive = (child: Child) => + child.process.exitCode === null && child.process.signalCode === null; + +const describeExit = (child: Child, code: number | null, signal: string | null, heapMb: number) => { + if (child.outOfMemory) return `ran out of memory (heap limit ${heapMb} MB).`; + const base = signal ? `was killed by ${signal}` : `exited with code ${code ?? "unknown"}`; + const lastLine = child.stderrTail.trim().split("\n").at(-1)?.slice(0, 300); + return lastLine ? `${base}: ${lastLine}` : `${base}.`; +}; + +export class PluginSupervisor extends Context.Service< + PluginSupervisor, + { + /** Registers a plugin; its process starts on first invoke. */ + readonly enable: ( + registration: PluginRegistration, + ) => Effect.Effect; + /** + * Stops the plugin's process, failing in-flight calls, and forgets it. + * Idempotent: a repeat waits for a stop still in progress. + */ + readonly disable: (pluginId: PluginId) => Effect.Effect; + /** Clears backoff, quarantine, or incompatibility so the next invoke starts a fresh process. */ + readonly resume: (pluginId: PluginId) => Effect.Effect; + readonly invoke: ( + pluginId: PluginId, + handler: string, + input: Schema.Json, + options?: { readonly timeout?: Duration.Input }, + ) => Effect.Effect; + readonly state: (pluginId: PluginId) => Effect.Effect>; + /** Subscribes before returning, so no event after this point is missed. */ + readonly subscribe: Effect.Effect< + PubSub.Subscription, + never, + Scope.Scope + >; + /** Answers plugins' `method` calls with `handler` until the scope closes. One handler per method. */ + readonly serveHostMethod: ( + method: string, + handler: PluginHostMethod, + ) => Effect.Effect; + } +>()("t3/plugins/PluginSupervisor") {} + +export const make = Effect.fn("PluginSupervisor.make")(function* ( + overrides: Partial = {}, +) { + const options = { ...defaultPluginSupervisorOptions, ...overrides }; + const maxMessageBytes = Math.min(options.maxMessageBytes, PLUGIN_IPC_MAX_BYTES_LIMIT); + const activationTimeout = Duration.fromInputUnsafe(options.activationTimeout); + const callTimeout = Duration.fromInputUnsafe(options.callTimeout); + const cancelGrace = Duration.fromInputUnsafe(options.cancelGrace); + const stopGrace = Duration.fromInputUnsafe(options.stopGrace); + const restartBackoffMs = Duration.toMillis(Duration.fromInputUnsafe(options.restartBackoff)); + const maxRestartBackoffMs = Duration.toMillis( + Duration.fromInputUnsafe(options.maxRestartBackoff), + ); + const stableUptimeMs = Duration.toMillis(Duration.fromInputUnsafe(options.stableUptime)); + + const invocation = yield* resolveSelfInvocation(); + const hostEnvironment = yield* HostProcess.Environment; + const heapFlag = `--max-old-space-size=${options.heapLimitMb}`; + const childEnvironment: Record = { ELECTRON_RUN_AS_NODE: "1" }; + for (const key of CHILD_ENV_KEYS) { + const value = hostEnvironment[key]; + if (value !== undefined) childEnvironment[key] = value; + } + // The single executable takes no Node flags on its command line. + const spawnArgs = + invocation.entrypoint === undefined + ? [PLUGIN_HOST_COMMAND] + : [heapFlag, invocation.entrypoint, PLUGIN_HOST_COMMAND]; + if (invocation.entrypoint === undefined) childEnvironment.NODE_OPTIONS = heapFlag; + + const entries = new Map(); + const hostMethods = new Map(); + /** Every child process not yet exited, including ones whose plugin was disabled. */ + const children = new Set(); + /** Completes once a disabled plugin's processes have all exited. */ + const stops = new Map>(); + const events = yield* PubSub.sliding(1024); + // Child readers and timers live here and end after every child has stopped. + const fibers = yield* Scope.make(); + + const setState = (entry: Entry, state: PluginHostState) => + Effect.suspend(() => { + if (entry.removed) return Effect.void; + entry.state = state; + return PubSub.publish(events, { _tag: "StateChanged", pluginId: entry.pluginId, state }); + }); + + const kill = (child: Child, reason: string) => { + child.killReason ??= reason; + if (isAlive(child)) child.process.kill("SIGKILL"); + }; + + /** Sends a message, or says why it cannot be sent. */ + const write = (child: Child, message: PluginHostMessage) => { + const encoded = encodePluginHostMessage(message); + if (Exit.isFailure(encoded)) return { _tag: "invalid" as const }; + const bytes = Buffer.byteLength(encoded.value); + if (bytes > maxMessageBytes) return { _tag: "tooLarge" as const, bytes }; + if (!child.channel.destroyed) child.channel.write(`${encoded.value}\n`); + return undefined; + }; + + const recordFailure = Effect.fnUntraced(function* ( + entry: Entry, + startedAt: number, + reason: string, + ) { + const now = yield* Clock.currentTimeMillis; + entry.failures = now - startedAt >= stableUptimeMs ? 1 : entry.failures + 1; + if (entry.failures > options.maxRestarts) { + yield* Effect.logWarning("Plugin quarantined", { pluginId: entry.pluginId, reason }); + return yield* setState(entry, { + _tag: "quarantined", + failures: entry.failures, + reason: reason.slice(0, 1000), + }); + } + const delay = Math.min(restartBackoffMs * 2 ** (entry.failures - 1), maxRestartBackoffMs); + const backoff: PluginHostState = { + _tag: "backoff", + failures: entry.failures, + reason: reason.slice(0, 1000), + retryAt: DateTime.formatIso(DateTime.makeUnsafe(now + delay)), + }; + yield* Effect.logWarning("Plugin failed; backing off", { + pluginId: entry.pluginId, + reason, + delayMs: delay, + }); + yield* setState(entry, backoff); + yield* Effect.sleep(Duration.millis(delay)).pipe( + Effect.andThen( + Effect.suspend(() => + entry.state === backoff ? setState(entry, { _tag: "idle" }) : Effect.void, + ), + ), + Effect.forkIn(fibers, { startImmediately: true }), + ); + }); + + // Only a write that filled the stream's own buffer is followed by `drain`, so a bound + // below that buffer waits for nothing else. + const hasRoom = (child: Child) => + child.channel.destroyed || + child.channel.writableLength < maxMessageBytes || + !child.channel.writableNeedDrain; + + /** Waits until the child has read enough of what was sent to it, or can no longer read. */ + const awaitRoom = (child: Child) => + Effect.suspend(() => { + if (hasRoom(child) || child.stopping) return Effect.void; + const waiting = Effect.callback((resume) => { + const channel = child.channel; + const done = () => { + cleanup(); + resume(Effect.void); + }; + const cleanup = () => { + channel.off("drain", done); + channel.off("close", done); + }; + channel.on("drain", done); + channel.on("close", done); + if (hasRoom(child)) done(); + return Effect.sync(cleanup); + }).pipe(Effect.raceFirst(Deferred.await(child.exited))); + if (child.backedUp) return waiting; + child.backedUp = true; + return Effect.logWarning("Plugin is not reading the server's answers; waiting", { + pluginId: child.pluginId, + unreadBytes: child.channel.writableLength, + }).pipe(Effect.andThen(waiting)); + }).pipe(Effect.ensuring(Effect.sync(() => (child.backedUp = !hasRoom(child))))); + + const hostCallFailed = (requestId: number, message: string): PluginHostMessage => ({ + _tag: "HostCallFailed", + requestId, + message: message.slice(0, 2000), + }); + + /** Sends a host call's answer to a live child; a revoked generation only learns it was stopped. */ + const sendAnswer = (child: Child, requestId: number, answer: PluginHostMessage) => { + if (!isAlive(child)) return; + if (child.stopping) { + // Never wait for a stopping child; it is killed if it does not exit. + if (hasRoom(child)) write(child, hostCallFailed(requestId, STOPPED_MESSAGE)); + return; + } + const unsent = write(child, answer); + if (unsent) + write( + child, + hostCallFailed( + requestId, + unsent._tag === "tooLarge" + ? `The answer exceeds ${maxMessageBytes} bytes.` + : "The answer is not JSON.", + ), + ); + }; + + const answer = (child: Child, requestId: number, message: PluginHostMessage) => + child.replies.withPermit( + awaitRoom(child).pipe( + Effect.andThen(Effect.sync(() => sendAnswer(child, requestId, message))), + ), + ); + + const outcomeMessage = ( + requestId: number, + outcome: Exit.Exit, + ): PluginHostMessage => + Exit.isSuccess(outcome) + ? { _tag: "HostCallSucceeded", requestId, value: outcome.value } + : hostCallFailed( + requestId, + Exit.findErrorOption(outcome).pipe( + Option.match({ + onNone: () => "The server could not answer.", + onSome: (error) => error.message, + }), + ), + ); + + /** Whether host calls from `child` may still start or commit. */ + const isAdmitted = (entry: Entry, child: Child) => + !entry.removed && !child.stopping && entry.child === child && isAlive(child); + + /** Runs a host call outside the read loop, owned by the child's generation. */ + const serveHostCall = Effect.fn("PluginSupervisor.serveHostCall")(( + entry: Entry, + child: Child, + requestId: number, + method: string, + input: Schema.Json, + ) => { + const refuse = (message: string) => + answer(child, requestId, hostCallFailed(requestId, message)); + if (!isAdmitted(entry, child)) return refuse(STOPPED_MESSAGE); + const handler = hostMethods.get(method); + if (!handler) return refuse(`This server has no method "${method}".`); + if (child.hostCalls >= PLUGIN_MAX_HOST_CALLS) + return refuse(`${PLUGIN_MAX_HOST_CALLS} calls to the server are already in flight.`); + child.hostCalls++; + const admitted = Effect.suspend(() => + isAdmitted(entry, child) + ? Effect.void + : Effect.fail(new PluginHostCallError({ message: STOPPED_MESSAGE })), + ); + return Effect.suspend(() => + handler({ registration: entry.registration, input, admitted }), + ).pipe( + Effect.exit, + Effect.flatMap((outcome) => answer(child, requestId, outcomeMessage(requestId, outcome))), + Effect.onInterrupt(() => + Effect.sync(() => sendAnswer(child, requestId, hostCallFailed(requestId, STOPPED_MESSAGE))), + ), + Effect.ensuring(Effect.sync(() => child.hostCalls--)), + Effect.forkIn(child.hostWork, { startImmediately: true }), + Effect.asVoid, + ); + }); + + const handleLine = Effect.fnUntraced(function* (entry: Entry, child: Child, line: string) { + const decoded = decodePluginChildMessage(line); + if (Exit.isFailure(decoded)) return kill(child, "sent a malformed IPC message."); + const message = decoded.value; + switch (message._tag) { + case "Ready": + yield* Deferred.succeed(child.ready, undefined); + return; + case "ActivationFailed": + // The exit fails activation, after the state reflects the failure. + return kill(child, `activation failed: ${message.message}`); + case "Incompatible": + child.incompatible = message.message; + return kill(child, message.message); + case "Succeeded": + case "Failed": { + const pending = child.pending.get(message.requestId); + if (pending) { + child.pending.delete(message.requestId); + yield* message._tag === "Succeeded" + ? Deferred.succeed(pending.deferred, message.value) + : Deferred.fail( + pending.deferred, + new PluginCallFailedError({ + pluginId: entry.pluginId, + handler: pending.handler, + reason: message.message, + }), + ); + return; + } + const settling = child.settling.get(message.requestId); + child.settling.delete(message.requestId); + if (settling) yield* Deferred.succeed(settling, undefined); + return; + } + case "Log": + yield* Effect.logWithLevel(LOG_SEVERITY[message.level])(message.message).pipe( + Effect.annotateLogs({ pluginId: entry.pluginId, pluginLogLevel: message.level }), + ); + yield* PubSub.publish(events, { + _tag: "Log", + pluginId: entry.pluginId, + level: message.level, + message: message.message, + }); + return; + case "Deactivated": + return; + case "HostCall": + // A child that does not read its answers is not read either. + yield* awaitRoom(child); + return yield* serveHostCall(entry, child, message.requestId, message.method, message.input); + } + }); + + /** Closes a child's host work; a caller that comes while it is closing waits for the end. */ + const endHostWork = (child: Child) => + Effect.suspend(() => { + if (child.hostWorkEnded) return Deferred.await(child.hostWorkEnded); + const ended = Deferred.makeUnsafe(); + child.hostWorkEnded = ended; + return Scope.close(child.hostWork, Exit.void).pipe( + Effect.ensuring(Deferred.succeed(ended, undefined)), + ); + }); + + const handleExit = Effect.fnUntraced(function* ( + entry: Entry, + child: Child, + code: number | null, + signal: string | null, + ) { + // Host work of a dead process ends before anything else can run for this plugin. + yield* endHostWork(child); + // Anything still unread from the dead process is discarded, including a stderr + // that a process it started still holds open after the drain timeout. + child.channel.destroy(); + child.process.stderr?.destroy(); + const reason = child.killReason ?? describeExit(child, code, signal, options.heapLimitMb); + const crashed = child.stopping + ? new PluginStoppedError({ pluginId: entry.pluginId }) + : child.incompatible !== undefined + ? new PluginIncompatibleError({ pluginId: entry.pluginId, reason: child.incompatible }) + : new PluginCrashedError({ pluginId: entry.pluginId, reason }); + // Settle the state first so a caller woken below already sees it. + if (entry.child === child) entry.child = undefined; + if (!entry.removed) { + if (child.stopping) yield* setState(entry, { _tag: "idle" }); + // Retrying cannot help, so this spends none of the restart budget. + else if (child.incompatible !== undefined) + yield* setState(entry, { _tag: "incompatible", reason: child.incompatible.slice(0, 1000) }); + else yield* recordFailure(entry, child.startedAt, reason); + } + yield* Deferred.fail(child.ready, crashed); + const pending = [...child.pending.values()]; + child.pending.clear(); + for (const call of pending) yield* Deferred.fail(call.deferred, crashed); + const settling = [...child.settling.values()]; + child.settling.clear(); + for (const deferred of settling) yield* Deferred.succeed(deferred, undefined); + }); + + const spawnChild = Effect.fnUntraced(function* (entry: Entry) { + const queue = yield* Queue.unbounded(); + const startedAt = yield* Clock.currentTimeMillis; + const childProcess = NodeChildProcess.spawn(invocation.command, spawnArgs, { + cwd: entry.registration.directory, + env: childEnvironment, + // fd 3 must be overlapped on Windows for the child to open it as a socket. + stdio: ["ignore", "ignore", "pipe", "overlapped"], + windowsHide: true, + }); + const spawned: { child?: Child } = {}; + // spawn can return without stdio on EMFILE/ENFILE and emit error next tick. + // Listen before inspecting the IPC channel, including on the early failure path. + childProcess.on("error", (error) => { + if (spawned.child === undefined) return; + spawned.child.killReason ??= `could not start: ${error.message}`; + if (childProcess.pid === undefined) onExit(null, null); + }); + const channel = childProcess.stdio?.[PLUGIN_IPC_FD] as NodeStream.Duplex | undefined; + if (channel === undefined || channel === null) { + if (childProcess.pid !== undefined) childProcess.kill(); + return yield* new PluginUnavailableError({ + pluginId: entry.pluginId, + reason: "could not open the plugin IPC channel.", + }); + } + const child: Child = { + pluginId: entry.pluginId, + process: childProcess, + channel, + startedAt, + ready: Deferred.makeUnsafe(), + exited: Deferred.makeUnsafe(), + pending: new Map(), + settling: new Map(), + nextRequestId: 0, + stopping: false, + incompatible: undefined, + killReason: undefined, + stderrTail: "", + outOfMemory: false, + hostCalls: 0, + hostWork: Scope.forkUnsafe(fibers, "parallel"), + hostWorkEnded: undefined, + replies: Semaphore.makeUnsafe(1), + backedUp: false, + }; + spawned.child = child; + // 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); + + 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.ensuring( + Effect.sync(() => { + channel.destroy(); + childProcess.stderr?.destroy(); + }), + ), + Effect.forkIn(fibers, { startImmediately: true }), + ); + return child; + }); + + /** Marks a disabled plugin's child as stopping and fails everyone waiting on it. */ + const revokeChild = (entry: Entry, child: Child) => { + child.stopping = true; + const stopped = new PluginStoppedError({ pluginId: entry.pluginId }); + Deferred.doneUnsafe(child.ready, Exit.fail(stopped)); + for (const call of child.pending.values()) + Deferred.doneUnsafe(call.deferred, Exit.fail(stopped)); + child.pending.clear(); + }; + + /** + * Starts the plugin's process if needed and waits until it has activated. + * Claiming a start through publishing its outcome is uninterruptible, so an + * interrupted starter never strands the calls waiting on its start. + */ + const ensureChild = Effect.fnUntraced(function* (entry: Entry) { + const claim = yield* Effect.sync(() => { + if (entry.child && !entry.starting) return { _tag: "running" as const, child: entry.child }; + if (entry.starting) return { _tag: "wait" as const, deferred: entry.starting }; + // A disabled plugin's process counts until it has exited. + let running = children.size; + for (const other of entries.values()) if (other.starting && !other.child) running++; + if (running >= options.maxRunningPlugins) return { _tag: "limit" as const }; + for (const other of children) + if (other.pluginId === entry.pluginId) return { _tag: "previous" as const }; + const deferred = Deferred.makeUnsafe(); + entry.starting = deferred; + return { _tag: "start" as const, deferred }; + }); + if (claim._tag === "running") return claim.child; + if (claim._tag === "wait") return yield* Effect.interruptible(Deferred.await(claim.deferred)); + if (claim._tag === "limit") + return yield* new PluginUnavailableError({ + pluginId: entry.pluginId, + reason: `${options.maxRunningPlugins} plugin processes are already running.`, + }); + if (claim._tag === "previous") + return yield* new PluginUnavailableError({ + pluginId: entry.pluginId, + reason: "its previous process is still stopping.", + }); + + const started = yield* Effect.gen(function* () { + yield* setState(entry, { _tag: "starting" }); + if (entry.removed) return yield* new PluginStoppedError({ pluginId: entry.pluginId }); + const child = yield* spawnChild(entry); + entry.child = child; + if (entry.removed) revokeChild(entry, child); + const { manifest, entryPath } = entry.registration; + write(child, { + _tag: "Activate", + pluginId: manifest.id, + version: manifest.version, + apiVersion: manifest.apiVersion, + entryPath, + proposedApi: manifest.proposedApi, + capabilities: manifest.capabilities, + maxMessageBytes, + }); + const activated = yield* Deferred.await(child.ready).pipe( + Effect.timeoutOption(activationTimeout), + ); + if (Option.isNone(activated)) { + kill(child, `did not activate within ${Duration.toMillis(activationTimeout)}ms.`); + yield* Deferred.await(child.exited); + return yield* Deferred.await(child.ready).pipe(Effect.as(child)); + } + // Ready can arrive after disable began; that generation never runs. + if (child.stopping) return yield* new PluginStoppedError({ pluginId: entry.pluginId }); + yield* setState(entry, { _tag: "running" }); + return child; + }).pipe(Effect.exit); + entry.starting = undefined; + if (Exit.isFailure(started) && entry.state._tag === "starting") + yield* setState(entry, { _tag: "idle" }); + 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); + + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + 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* restore( + Deferred.await(call.deferred).pipe(Effect.exit, Effect.timeoutOption(timeout)), + ).pipe(Effect.onInterrupt(() => cancel(entry, child, call.requestId, handler))); + if (Option.isNone(outcome)) { + yield* cancel(entry, child, call.requestId, handler); + return yield* new PluginTimeoutError({ + pluginId, + handler, + timeoutMs: Duration.toMillis(timeout), + }); + } + return yield* outcome.value; + }), + ); + }, + ); + + /** Deactivates a removed plugin's child, killing it after `stopGrace`, and waits for its exit. */ + const stopEntry = Effect.fnUntraced(function* (entry: Entry) { + // Only reachable between claiming a start and spawning; the start then fails fast. + if (!entry.child && entry.starting) yield* Deferred.await(entry.starting).pipe(Effect.ignore); + const child = entry.child; + if (!child) return; + // A process that already exited may still be ending its host work. + if (Deferred.isDoneUnsafe(child.exited)) return yield* endHostWork(child); + revokeChild(entry, child); + // The revoked generation's host work ends before its plugin is asked to deactivate. + yield* endHostWork(child); + write(child, { _tag: "Deactivate" }); + const exited = yield* Deferred.await(child.exited).pipe(Effect.timeoutOption(stopGrace)); + if (Option.isNone(exited)) { + kill(child, `did not stop within ${Duration.toMillis(stopGrace)}ms.`); + yield* Deferred.await(child.exited); + } + }); + + const disable = Effect.fn("PluginSupervisor.disable")(function* (pluginId: PluginId) { + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + 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* restore(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* restore(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: Effect.fn("PluginSupervisor.state")((pluginId) => + Effect.sync(() => Option.fromUndefinedOr(entries.get(pluginId)?.state)), + ), + subscribe: PubSub.subscribe(events), + serveHostMethod: Effect.fn("PluginSupervisor.serveHostMethod")((method, handler) => + Effect.acquireRelease( + Effect.sync(() => { + if (hostMethods.has(method)) throw new Error(`Host method ${method} is already served.`); + hostMethods.set(method, handler); + }), + () => Effect.sync(() => hostMethods.delete(method)), + ), + ), + }); +}); + +export const layer = (overrides: Partial = {}) => + Layer.effect(PluginSupervisor, make(overrides)); diff --git a/apps/server/src/plugins/PluginTools.test.ts b/apps/server/src/plugins/PluginTools.test.ts new file mode 100644 index 0000000000000..206a4be388efb --- /dev/null +++ b/apps/server/src/plugins/PluginTools.test.ts @@ -0,0 +1,475 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + PLUGIN_TOOL_LIMITS, + PluginInstallation, + ThreadId, + type PluginToolDeclaration, + type PluginToolError, + type PluginToolsListResult, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as PluginCatalog from "./PluginCatalog.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; +import { jsonBytes } from "./pluginToolDeclarations.ts"; +import * as PluginTools from "./PluginTools.ts"; + +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; +const FIXTURE = `${import.meta.dirname}/testFixtures/toolsPlugin`; + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const context = { + environmentId: EnvironmentId.make("environment-tools"), + threadId: ThreadId.make("thread-tools"), +}; + +/** A real supervisor, catalogue, and tool service in `scope`, as one server start would run them. */ +const start = Effect.fn("start")(function* (scope: Scope.Scope) { + const supervisor = yield* PluginSupervisor.make({ + heapLimitMb: 64, + activationTimeout: "10 seconds", + stopGrace: "1 second", + }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + const catalog = yield* PluginCatalog.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(Scope.Scope, scope), + ); + const tools = yield* PluginTools.make.pipe( + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + ); + /** Adds, approves, and enables a plugin directory. */ + const install = Effect.fn("install")(function* (directory: string) { + const { installation } = yield* catalog.add({ directory }); + const installationId = installation.installationId; + yield* catalog.consent({ installationId, digest: installation.source!.digest }); + return (yield* catalog.enable({ installationId })).installation; + }); + return { supervisor, catalog, tools, install }; +}); + +/** A scoped copy of the committed fixture, so tests never share a plugin directory. */ +const copyFixture = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-tools-" }), + "plugin", + ); + yield* fs.copy(FIXTURE, directory); + return directory; +}); + +/** A scoped tool plugin that declares `tools` and answers every call with "pong". */ +const declaringPlugin = Effect.fn("declaringPlugin")(function* ( + id: string, + tools: ReadonlyArray>, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-tools-" }), + "plugin", + ); + yield* fs.makeDirectory(directory); + yield* fs.writeFileString( + path.join(directory, "main.mjs"), + [ + `export function activate(context) {`, + ...tools.map((tool) => ` context.proposed.handle("t3.tool.${tool.name}", () => "pong");`), + `}`, + ``, + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id, + name: id, + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + capabilities: ["tools"], + proposedApi: true, + tools, + }), + ); + return directory; +}); + +const tool = (name: string, description: string) => ({ + name, + description, + inputSchema: { type: "object", additionalProperties: false }, + sideEffect: "read", +}); + +const reasonOf = (error: PluginToolError) => error.reason; + +const withDatabase = (effect: Effect.Effect) => + effect.pipe(Effect.provide(SqlitePersistence.layerMemory)); + +const DIGEST = `sha256:${"0".repeat(64)}`; +const decodeInstallation = Schema.decodeUnknownSync(PluginInstallation); + +/** + * An in-memory catalogue of `count` enabled one-tool plugins. It records which + * plugins had their declarations read and how often the catalogue was listed. + */ +const inventory = (count: number) => { + const prepared = new Set(); + const counts = { lists: 0, revision: 0 }; + const rows = Array.from({ length: count }, (_, index) => { + const id = `test.p${String(index).padStart(4, "0")}`; + const row = decodeInstallation({ + installationId: `installation-${index}`, + generation: 1, + directory: `/plugins/${id}`, + manifest: { id, name: id, version: "1.0.0", capabilities: ["tools"], proposedApi: true }, + source: { digest: DIGEST, files: 1, bytes: 1 }, + problem: null, + inspectedAt: "2026-01-01T00:00:00.000Z", + consent: { digest: DIGEST, capabilities: ["tools"], grantedAt: "2026-01-01T00:00:00.000Z" }, + enabled: true, + addedAt: "2026-01-01T00:00:00.000Z", + }); + const declaration: PluginToolDeclaration = { + name: "ping", + description: "x".repeat(1_500), + sideEffect: "read", + openWorld: false, + get inputSchema() { + prepared.add(id); + return { type: "object", additionalProperties: false }; + }, + }; + return { ...row, manifest: { ...row.manifest!, tools: [declaration] } }; + }); + const unused = () => Effect.die("not used by PluginTools"); + const catalog = PluginCatalog.PluginCatalog.of({ + ready: Effect.void, + list: Effect.sync(() => { + counts.lists++; + return { installations: rows }; + }), + revision: Effect.sync(() => counts.revision), + subscribe: Stream.empty, + add: unused, + refresh: unused, + consent: unused, + enable: unused, + disable: unused, + remove: unused, + resume: unused, + invoke: () => Effect.succeed("pong"), + }); + return { rows, prepared, counts, catalog }; +}; + +it.layer(NodeServices.layer)("PluginTools", (it) => { + describe("granted plugins", () => { + it.effect("lists declared tools without starting the plugin and calls them in context", () => + withDatabase( + Effect.gen(function* () { + const { catalog, tools, install } = yield* start(yield* Scope.Scope); + const installation = yield* install(yield* copyFixture); + const grants = yield* tools.grants; + expect(grants).toEqual([{ installationId: installation.installationId, generation: 1 }]); + + const listed = yield* tools.list(grants); + expect(listed.tools.map((tool) => tool.tool)).toEqual([ + "test.tools/word_count", + "test.tools/echo_context", + "test.tools/wait_for_cancel", + "test.tools/big_result", + ]); + expect(listed.tools[0]).toMatchObject({ + tool: "test.tools/word_count", + plugin: { id: "test.tools", name: "Tools fixture" }, + title: "Count words", + description: "Count the words in a text.", + // Exactly what the manifest declares. + inputSchema: { + type: "object", + properties: { text: { type: "string", maxLength: 10000 } }, + required: ["text"], + additionalProperties: false, + }, + sideEffect: "read", + openWorld: false, + }); + expect(listed.tools[2]).toMatchObject({ sideEffect: "write", openWorld: true }); + expect(listed).not.toHaveProperty("nextCursor"); + expect(listed.notInThisSession).toEqual([]); + const hostState = Effect.map( + catalog.list, + (snapshot) => snapshot.installations[0]?.hostState, + ); + // Listing reads the consented manifest; only a call starts the plugin. + expect(yield* hostState).toEqual({ _tag: "idle" }); + + const call = (tool: string, input: unknown) => + tools.call(grants, { tool, input, context }); + expect(yield* call("test.tools/word_count", { text: "one two three" })).toEqual({ + words: 3, + }); + expect((yield* hostState)?._tag).toBe("running"); + // The context comes from the session, never from the input. + expect(yield* call("test.tools/echo_context", {})).toEqual({ input: {}, context }); + + const failures = yield* Effect.forEach( + [ + ["test.tools/word_count", { text: 5 }], + ["test.tools/word_count", {}], + ["test.tools/word_count", { text: "x".repeat(10_001) }], + ["test.tools/word_count", { text: "closed", extra: 1 }], + ["test.tools/echo_context", { context: { threadId: "other" } }], + ["test.tools/nope", {}], + ["word_count", {}], + ["test.tools/big_result", { length: 70_000 }], + ] as const, + ([tool, input]) => call(tool, input).pipe(Effect.flip, Effect.map(reasonOf)), + ); + expect(failures).toEqual([ + "invalid-input", + "invalid-input", + "invalid-input", + "invalid-input", + "invalid-input", + "unknown-tool", + "unknown-tool", + "result-too-large", + ]); + }), + ), + ); + + it.effect("refuses a disabled plugin at once, also mid-call, and its next registration", () => + withDatabase( + Effect.gen(function* () { + const { supervisor, catalog, tools, install } = yield* start(yield* Scope.Scope); + const installation = yield* install(yield* copyFixture); + const installationId = installation.installationId; + const grants = yield* tools.grants; + const events = yield* supervisor.subscribe; + + const waiting = yield* tools + .call(grants, { tool: "test.tools/wait_for_cancel", input: {}, context }) + .pipe(Effect.flip, Effect.forkChild); + yield* Stream.fromSubscription(events).pipe( + Stream.filter((event) => event._tag === "Log" && event.message === "wait-started"), + Stream.runHead, + ); + yield* catalog.disable({ installationId }); + const revoked = yield* Fiber.join(waiting); + expect(revoked.reason).toBe("unavailable"); + + expect(yield* tools.list(grants)).toEqual({ tools: [], notInThisSession: [] }); + const disabled = yield* tools + .call(grants, { tool: "test.tools/word_count", input: { text: "a" }, context }) + .pipe(Effect.flip); + expect(disabled.reason).toBe("unavailable"); + + // A re-enable is a new registration: the old session's grant does not reach it. + yield* catalog.enable({ installationId }); + const stale = yield* tools.list(grants); + expect(stale.tools).toEqual([]); + expect(stale.notInThisSession).toEqual([{ id: "test.tools", name: "Tools fixture" }]); + const notGranted = yield* tools + .call(grants, { tool: "test.tools/word_count", input: { text: "a" }, context }) + .pipe(Effect.flip); + expect(notGranted.reason).toBe("not-granted"); + const fresh = yield* tools.grants; + expect(fresh).toEqual([{ installationId, generation: 2 }]); + expect( + yield* tools.call(fresh, { + tool: "test.tools/word_count", + input: { text: "a b" }, + context, + }), + ).toEqual({ words: 2 }); + }), + ), + ); + it.effect("refuses a plugin once the catalogue finds its files changed", () => + withDatabase( + Effect.gen(function* () { + const { supervisor, catalog, tools, install } = yield* start(yield* Scope.Scope); + const fs = yield* FileSystem.FileSystem; + const directory = yield* copyFixture; + const edit = (note: string) => + fs.writeFileString(`${directory}/main.mjs`, `// ${note}\n`, { flag: "a" }); + const { installationId } = yield* install(directory); + + // Idle: the bytes are checked before a call would start a process. + const grants = yield* tools.grants; + yield* edit("changed while idle"); + const idle = yield* tools + .call(grants, { tool: "test.tools/word_count", input: { text: "a" }, context }) + .pipe(Effect.flip); + expect(idle.reason).toBe("unavailable"); + expect(yield* tools.list(grants)).toEqual({ tools: [], notInThisSession: [] }); + + // Running: a refresh finds the change and cancels the call in flight. + const [changed] = (yield* catalog.list).installations; + yield* catalog.consent({ installationId, digest: changed!.source!.digest }); + yield* catalog.enable({ installationId }); + const fresh = yield* tools.grants; + const events = yield* supervisor.subscribe; + const waiting = yield* tools + .call(fresh, { tool: "test.tools/wait_for_cancel", input: {}, context }) + .pipe(Effect.flip, Effect.forkChild); + yield* Stream.fromSubscription(events).pipe( + Stream.filter((event) => event._tag === "Log" && event.message === "wait-started"), + Stream.runHead, + ); + yield* edit("changed while running"); + yield* catalog.refresh({ installationId }); + expect((yield* Fiber.join(waiting)).reason).toBe("unavailable"); + expect(yield* tools.list(fresh)).toEqual({ tools: [], notInThisSession: [] }); + }), + ), + ); + }); + + describe("declarations", () => { + it.effect("refuses a manifest whose tools the host cannot enforce when it is added", () => + withDatabase( + Effect.gen(function* () { + const { catalog } = yield* start(yield* Scope.Scope); + const refused = (id: string, tools: ReadonlyArray>) => + declaringPlugin(id, tools).pipe( + Effect.flatMap((directory) => catalog.add({ directory })), + Effect.flip, + Effect.map((error) => error.message), + ); + expect( + yield* refused("test.pattern", [ + { + ...tool("ping", "Uses a pattern."), + inputSchema: { + type: "object", + properties: { id: { type: "string", pattern: "^(a+)+$" } }, + }, + }, + ]), + ).toContain("#/properties/id/pattern: this keyword is not supported."); + expect( + yield* refused("test.duplicate", [tool("ping", "One."), tool("ping", "Two.")]), + ).toContain("it declares the tool ping twice."); + expect( + yield* refused("test.scalar", [ + { ...tool("ping", "Takes a string."), inputSchema: { type: "string" } }, + ]), + ).toContain('the root must be "object"'); + }), + ), + ); + + it.effect("pages whole plugins by id within the byte limit and starts none of them", () => + withDatabase( + Effect.gen(function* () { + const { catalog, tools, install } = yield* start(yield* Scope.Scope); + // About 21 KB each to list, so a 64 KiB page holds two of them at most. + const large = (id: string) => + declaringPlugin( + id, + Array.from({ length: 10 }, (_, index) => tool(`tool_${index}`, "é".repeat(1_000))), + ); + for (const id of ["test.large-c", "test.large-a", "test.large-b"]) + yield* install(yield* large(id)); + const grants = yield* tools.grants; + // Enabled after the snapshot: listed by name only, under notInThisSession. + yield* install(yield* declaringPlugin("test.late", [tool("ping", "Late.")])); + + const pages: Array = []; + let cursor: string | undefined; + do { + const page: PluginToolsListResult = yield* tools.list( + grants, + cursor === undefined ? {} : { cursor }, + ); + pages.push(page); + cursor = page.nextCursor; + } while (cursor !== undefined); + + for (const page of pages) + expect(jsonBytes(page)).toBeLessThanOrEqual(PLUGIN_TOOL_LIMITS.maxListBytes); + expect(pages.length).toBeGreaterThan(1); + const order = pages.flatMap((page) => [ + ...new Set(page.tools.map((listing) => listing.plugin.id)), + ...page.notInThisSession.map((plugin) => `late:${plugin.id}`), + ]); + expect(order).toEqual(["test.large-a", "test.large-b", "test.large-c", "late:test.late"]); + expect(pages.flatMap((page) => page.tools)).toHaveLength(30); + + const one = yield* tools.list(grants, { plugin: "test.large-b" }); + expect(one.tools).toHaveLength(10); + expect(one).not.toHaveProperty("nextCursor"); + expect(jsonBytes(one)).toBeLessThanOrEqual(PLUGIN_TOOL_LIMITS.maxListBytes); + + const states = (yield* catalog.list).installations.map((row) => row.hostState?._tag); + expect(states).toEqual(["idle", "idle", "idle", "idle"]); + }), + ), + ); + + it.effect("prepares only the plugins a page shows, however many are installed", () => + Effect.gen(function* () { + const { rows, prepared, counts, catalog } = inventory(1_000); + const tools = yield* PluginTools.make.pipe( + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + ); + const grants = yield* tools.grants; + expect(grants).toHaveLength(1_000); + expect(prepared.size).toBe(0); + + const one = yield* tools.list(grants, { plugin: "test.p0500" }); + expect(one.tools.map((listing) => listing.tool)).toEqual(["test.p0500/ping"]); + expect([...prepared]).toEqual(["test.p0500"]); + + prepared.clear(); + const first = yield* tools.list(grants); + const shown = first.tools.map((listing) => listing.plugin.id); + expect(first.nextCursor).toBe(shown.at(-1)); + expect(shown.length).toBeLessThan(100); + // The page, and the one plugin after it that did not fit. + expect([...prepared].toSorted()).toEqual( + [...shown, `test.p${String(shown.length).padStart(4, "0")}`].toSorted(), + ); + + prepared.clear(); + const second = yield* tools.list(grants, { cursor: first.nextCursor! }); + expect(second.tools[0]?.plugin.id).toBe(`test.p${String(shown.length).padStart(4, "0")}`); + expect(prepared.size).toBe(second.tools.length); + expect(yield* tools.call(grants, { tool: "test.p0999/ping", input: {}, context })).toBe( + "pong", + ); + // Requests read the catalogue only when its records changed. + expect(counts.lists).toBe(1); + + rows[999] = { ...rows[999]!, enabled: false }; + counts.revision++; + const refused = yield* tools + .call(grants, { tool: "test.p0999/ping", input: {}, context }) + .pipe(Effect.flip); + expect(refused.reason).toBe("unavailable"); + expect((yield* tools.list(grants, { plugin: "test.p0999" })).tools).toEqual([]); + expect(counts.lists).toBe(2); + }), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginTools.ts b/apps/server/src/plugins/PluginTools.ts new file mode 100644 index 0000000000000..eb14179845f17 --- /dev/null +++ b/apps/server/src/plugins/PluginTools.ts @@ -0,0 +1,299 @@ +// Grants pin installation generations; every list/call intersects them with the current catalogue. +// Listing never starts plugins; revocation rejects in-flight calls and later registrations. +import { + PLUGIN_TOOL_LIMITS, + PLUGIN_TOOLS_CAPABILITY, + type PluginInstallation, + type PluginInstallationId, + PluginToolError, + type PluginToolListing, + type PluginToolsListResult, + parseQualifiedPluginToolName, + pluginInstallationStatus, + pluginToolHandlerName, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { PluginCatalog } from "./PluginCatalog.ts"; +import { + jsonBytes, + preparePluginTools, + type PreparedPluginTools, +} from "./pluginToolDeclarations.ts"; + +/** One tool plugin a session may use: the registration that was enabled when it was prepared. */ +export interface PluginToolGrant { + readonly installationId: PluginInstallationId; + readonly generation: number; +} + +type Plugin = PluginToolListing["plugin"]; + +/** A live tool plugin, as the sorted index holds it. */ +interface Indexed { + readonly plugin: Plugin; + readonly installation: PluginInstallation; + /** Its size under notInThisSession, separator included. */ + readonly nameBytes: number; +} + +/** The live tool plugins by id, rebuilt only when the catalogue's records change. */ +interface Index { + readonly revision: number; + readonly ids: ReadonlyArray; + readonly byId: ReadonlyMap; +} + +const MAX_REASON_LENGTH = 500; +/** The page envelope with the longest cursor a plugin id allows. */ +const LIST_ENVELOPE_BYTES = jsonBytes({ + tools: [], + notInThisSession: [], + nextCursor: "x".repeat(128), +}); + +const decodeJsonObject = Schema.decodeUnknownExit(Schema.Record(Schema.String, Schema.Json)); +const cut = (text: string) => + text.length > MAX_REASON_LENGTH ? `${text.slice(0, MAX_REASON_LENGTH)}…` : text; + +const toolError = (reason: string, message: string) => + new PluginToolError({ reason, message: cut(message) }); + +const pluginOf = (installation: PluginInstallation): Plugin | undefined => + installation.manifest === null + ? undefined + : { id: installation.manifest.id, name: installation.manifest.name }; + +/** Enabled now, declaring tools, with consent that covers the tools capability. */ +const offersTools = (installation: PluginInstallation) => + pluginInstallationStatus(installation) === "enabled" && + (installation.manifest?.tools?.length ?? 0) > 0 && + installation.consent?.capabilities.includes(PLUGIN_TOOLS_CAPABILITY) === true; + +interface PluginToolCallRequest { + readonly tool: string; + readonly input: unknown; + readonly context: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; +} + +export class PluginTools extends Context.Service< + PluginTools, + { + /** The tool plugins enabled right now, for a session's credential to hold. */ + readonly grants: Effect.Effect>; + /** One page of the tools of granted plugins still enabled under the same registration. */ + readonly list: ( + grants: ReadonlyArray, + options?: { readonly plugin?: string; readonly cursor?: string }, + ) => Effect.Effect; + /** Validates `input` against the tool's schema and calls it under the granted registration. */ + readonly call: ( + grants: ReadonlyArray, + request: PluginToolCallRequest, + ) => Effect.Effect; + } +>()("t3/plugins/PluginTools") {} + +export const make = Effect.gen(function* () { + const catalog = yield* PluginCatalog; + // A registration's manifest cannot change: its bytes are checked before every fresh process. + const prepared = new Map< + PluginInstallationId, + { readonly generation: number; readonly tools: PreparedPluginTools | undefined } + >(); + let index: Index = { revision: -1, ids: [], byId: new Map() }; + // A session's grants are fixed for its credential, so their lookup is built once. + const grantLookups = new WeakMap< + ReadonlyArray, + ReadonlyMap + >(); + + /** + * The live tool plugins. Reading the catalogue and sorting happen once per + * change to its records, never per request, so a request's own work is the + * page it returns. + */ + const current = Effect.gen(function* () { + const revision = yield* catalog.revision; + if (revision === index.revision) return index; + const byId = new Map(); + for (const installation of (yield* catalog.list).installations) { + const plugin = pluginOf(installation); + // Ids are unique among registered plugins; the oldest installation wins otherwise. + if (plugin !== undefined && offersTools(installation) && !byId.has(plugin.id)) + byId.set(plugin.id, { plugin, installation, nameBytes: jsonBytes(plugin) + 1 }); + } + const live = new Map( + [...byId.values()].map(({ installation }) => [installation.installationId, installation]), + ); + for (const [installationId, cached] of prepared) + if (live.get(installationId)?.generation !== cached.generation) + prepared.delete(installationId); + // Reading `revision` first means a change during the read rebuilds again next time. + index = { revision, ids: [...byId.keys()].toSorted(), byId }; + return index; + }); + + const isGranted = (grants: ReadonlyArray, installation: PluginInstallation) => { + let lookup = grantLookups.get(grants); + if (lookup === undefined) { + lookup = new Map(grants.map((grant) => [grant.installationId, grant.generation])); + grantLookups.set(grants, lookup); + } + return lookup.get(installation.installationId) === installation.generation; + }; + + /** The installation's declared tools, prepared once per registration. */ + const toolsOf = Effect.fnUntraced(function* (installation: PluginInstallation, plugin: Plugin) { + const cached = prepared.get(installation.installationId); + if (cached?.generation === installation.generation) return cached.tools; + const result = preparePluginTools(plugin, installation.manifest?.tools ?? []); + // The loader refuses a manifest whose tools do not prepare, so this is not expected. + if ("problem" in result) + yield* Effect.logWarning("Plugin tools could not be prepared", { + installationId: installation.installationId, + problem: result.problem, + }); + const tools = "problem" in result ? undefined : result; + prepared.set(installation.installationId, { generation: installation.generation, tools }); + return tools; + }); + + const grants = catalog.ready.pipe( + Effect.andThen(current), + Effect.map(({ byId }) => + [...byId.values()].map(({ installation: { installationId, generation } }) => ({ + installationId, + generation, + })), + ), + ); + + const list = Effect.fn("PluginTools.list")(function* ( + grants: ReadonlyArray, + options?: { readonly plugin?: string; readonly cursor?: string }, + ) { + const { ids, byId } = yield* current; + const cursor = options?.cursor; + const only = options?.plugin; + let from = cursor === undefined ? 0 : firstAfter(ids, cursor); + let to = ids.length; + if (only !== undefined) { + const at = firstAfter(ids, only) - 1; + [from, to] = at >= from && ids[at] === only ? [at, at + 1] : [0, 0]; + } + + // Every plugin fits a page on its own (the loader bounds its listing), so each page + // makes progress and the whole result, envelope included, stays within the limit. + // Only the plugins on the page, and the one after it, are prepared. + const result: { tools: Array; notInThisSession: Array } = { + tools: [], + notInThisSession: [], + }; + let budget = PLUGIN_TOOL_LIMITS.maxListBytes - LIST_ENVELOPE_BYTES; + let last: string | undefined; + for (let position = from; position < to; position++) { + const id = ids[position]!; + const { plugin, installation, nameBytes } = byId.get(id)!; + const granted = isGranted(grants, installation); + const tools = granted ? yield* toolsOf(installation, plugin) : undefined; + if (granted && tools === undefined) continue; + const bytes = tools?.listingBytes ?? nameBytes; + if (bytes > budget) return { ...result, ...(last === undefined ? {} : { nextCursor: last }) }; + budget -= bytes; + last = id; + if (tools === undefined) result.notInThisSession.push(plugin); + else for (const tool of tools.tools.values()) result.tools.push(tool.listing); + } + return result satisfies PluginToolsListResult; + }); + + const call = Effect.fn("PluginTools.call")(function* ( + grants: ReadonlyArray, + request: PluginToolCallRequest, + ) { + const parsed = parseQualifiedPluginToolName(request.tool); + if (Option.isNone(parsed)) + return yield* toolError( + "unknown-tool", + `${request.tool} is not a plugin tool name. Use a tool value from plugin_tools_list.`, + ); + const { pluginId, name } = parsed.value; + const live = (yield* current).byId.get(pluginId); + if (live === undefined) + return yield* toolError("unavailable", `Plugin ${pluginId} is not enabled.`); + const { plugin, installation } = live; + if (!isGranted(grants, installation)) + return yield* toolError( + "not-granted", + `Plugin ${pluginId} was enabled after this session started. Start a new session to use its tools.`, + ); + const tool = (yield* toolsOf(installation, plugin))?.tools.get(name); + if (tool === undefined) + return yield* toolError("unknown-tool", `Plugin ${pluginId} has no tool named ${name}.`); + const validated = tool.validate(request.input ?? {}); + if (Exit.isFailure(validated)) + return yield* toolError( + "invalid-input", + `The input does not match ${request.tool}'s inputSchema: ${Option.match( + Exit.findErrorOption(validated), + { onNone: () => "unknown error", onSome: (error) => error.message }, + )}`, + ); + const input = decodeJsonObject(validated.value); + if (Exit.isFailure(input)) + return yield* toolError("invalid-input", "The input must be a JSON object."); + const value = yield* catalog + .invoke( + installation.installationId, + pluginToolHandlerName(name), + { input: input.value, context: request.context }, + { timeout: `${tool.timeoutSeconds} seconds`, generation: installation.generation }, + ) + .pipe( + Effect.mapError((error) => { + switch (error._tag) { + case "PluginCatalogError": + case "PluginStoppedError": + case "PluginNotEnabledError": + case "PluginUnavailableError": + case "PluginIncompatibleError": + return toolError("unavailable", error.message); + case "PluginTimeoutError": + return toolError("timeout", error.message); + default: + return toolError("failed", error.message); + } + }), + ); + if (jsonBytes(value) > PLUGIN_TOOL_LIMITS.maxResultBytes) + return yield* toolError( + "result-too-large", + `${request.tool} returned more than ${PLUGIN_TOOL_LIMITS.maxResultBytes} bytes.`, + ); + return value; + }); + + return PluginTools.of({ grants, list, call }); +}); + +/** The position of the first id after `cursor` in sorted `ids`. */ +const firstAfter = (ids: ReadonlyArray, cursor: string) => { + let low = 0; + let high = ids.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (ids[middle]! <= cursor) low = middle + 1; + else high = middle; + } + return low; +}; + +export const layer = Layer.effect(PluginTools, make); diff --git a/apps/server/src/plugins/pluginApi.ts b/apps/server/src/plugins/pluginApi.ts new file mode 100644 index 0000000000000..000b43d5eb116 --- /dev/null +++ b/apps/server/src/plugins/pluginApi.ts @@ -0,0 +1,90 @@ +import type { PluginEvent as PluginEventSchema } from "@t3tools/contracts"; + +// Activation runs once per child on first use; the signal aborts before bounded deactivation. +// Proposed APIs require manifest opt-in and can 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; +/** + * Reads the settings declared under `settings` in the manifest. Present with + * the `settings` capability. + */ +interface PluginSettingsApi { + /** + * The saved value of a declared setting, else its default, else undefined. + * A secret resolves to its saved text. Rejects for an undeclared key. + */ + get(key: string): Promise; +} + +/** + * Small JSON values the plugin keeps between runs, private to this + * installation and deleted when it is removed. Bounded: keys up to 128 + * characters, values up to 64 KiB of JSON, 256 keys and 1 MiB in total; a + * write past a bound rejects. Present with the `settings` capability. + */ +interface PluginStorageApi { + get(key: string): Promise; + set(key: string, value: PluginJson): Promise; + delete(key: string): Promise; + keys(): Promise>; +} + +export interface PluginProposedApi { + /** + * Registers the entry point the server calls by `name`. Names are unique per + * plugin; names starting with `t3.` are reserved. + */ + handle(name: string, handler: PluginHandler): PluginDisposable; + // Register during activate; a failed handler retries the whole page. + // Delivery is at-least-once: deduplicate by deliveryId and ignore unknown event types. + onEvent(handler: PluginEventHandler): PluginDisposable; + readonly settings: PluginSettingsApi | undefined; + readonly storage: PluginStorageApi | undefined; +} + +export interface PluginContext { + readonly apiVersion: 1; + readonly plugin: { readonly id: string; readonly version: string }; + readonly signal: AbortSignal; + readonly log: PluginLog; + readonly proposed: PluginProposedApi | undefined; +} + +export interface PluginModule { + activate(context: PluginContext): void | Promise; + deactivate?(): void | Promise; +} diff --git a/apps/server/src/plugins/pluginHostChild.test.ts b/apps/server/src/plugins/pluginHostChild.test.ts new file mode 100644 index 0000000000000..b28962874fdc1 --- /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 0000000000000..0bb6603b5043a --- /dev/null +++ b/apps/server/src/plugins/pluginHostChild.ts @@ -0,0 +1,328 @@ +// @effect-diagnostics nodeBuiltinImport:off +// The child hosts one plugin and runs without the Effect runtime: bin.ts +// dispatches `__plugin-host` here before the CLI module graph loads, so each +// plugin process pays only for this file. Nothing here may run on import. +import * as NodeModule from "node:module"; +import * as NodeNet from "node:net"; + +import type { PLUGIN_TOOL_HANDLER_PREFIX as ContractToolHandlerPrefix } from "@t3tools/contracts"; +import type { + PluginContext, + PluginEvent, + PluginEventHandler, + PluginHandler, + PluginJson, + PluginModule, + PluginProposedApi, +} from "./pluginApi.ts"; +import type { PluginChildMessage, PluginHostMessage, PluginLogLevel } from "./PluginIpc.ts"; +import { + DEFAULT_PLUGIN_IPC_MAX_BYTES, + PLUGIN_IPC_FD, + PLUGIN_EVENTS_HANDLER, + PLUGIN_IPC_MAX_BYTES_LIMIT, + PLUGIN_MAX_HOST_CALLS, + makeLineDecoder, +} from "./pluginIpcFraming.ts"; + +// Restates the contract's prefix, which plugins may register under: importing +// @t3tools/contracts at runtime would load Effect into every plugin process. +const PLUGIN_TOOL_HANDLER_PREFIX: typeof ContractToolHandlerPrefix = "t3.tool."; + +// Every launcher loads the entry through require, so a plugin behaves the same +// under Node, Electron, and the single executable (which can only import() +// built-ins). require accepts CommonJS and ES modules, except an ES module +// graph that uses top-level await. +const loadEntry = (entryPath: string): Partial => + NodeModule.createRequire(entryPath)(entryPath); + +const isAsyncModuleError = (error: unknown) => + error instanceof Error && "code" in error && error.code === "ERR_REQUIRE_ASYNC_MODULE"; + +// The server refuses a longer `Failed.message`, and a line it cannot decode kills the child. +const errorMessage = (error: unknown, prefix = "") => + `${prefix}${error instanceof Error ? error.message : String(error)}`.slice(0, 2000); + +/** Serves one plugin over fd 3 until the server deactivates it or goes away. */ +export const runPluginHostChild = (): void => { + const channel = new NodeNet.Socket({ fd: PLUGIN_IPC_FD, readable: true, writable: true }); + let maxBytes = DEFAULT_PLUGIN_IPC_MAX_BYTES; + let activated: { module: Partial; controller: AbortController } | undefined; + const handlers = new Map(); + const eventHandlers = new Set(); + const requests = new Map(); + const hostCalls = new Map< + number, + { resolve: (value: PluginJson) => void; reject: (error: Error) => void } + >(); + let nextHostCallId = 0; + + const write = (line: string) => { + if (!channel.destroyed && channel.writable) channel.write(`${line}\n`); + }; + const send = (message: PluginChildMessage) => write(JSON.stringify(message)); + // Logs are lossy: while the server is not reading, they are counted and + // dropped instead of buffered. Results and lifecycle messages always queue. + let droppedLogs = 0; + const log = (level: PluginLogLevel, message: unknown) => { + if (channel.writableNeedDrain) droppedLogs++; + else send({ _tag: "Log", level, message: String(message).slice(0, 4000) }); + }; + channel.on("drain", () => { + if (droppedLogs === 0) return; + const message = `Dropped ${droppedLogs} log messages while the server was busy.`; + droppedLogs = 0; + send({ _tag: "Log", level: "warn", message }); + }); + + const settle = (requestId: number, outcome: PluginJson | Error) => { + requests.delete(requestId); + if (outcome instanceof Error) { + send({ _tag: "Failed", requestId, message: errorMessage(outcome) }); + return; + } + let value: string | undefined; + try { + value = JSON.stringify(outcome ?? null); + } catch (error) { + send({ _tag: "Failed", requestId, message: errorMessage(error, "Result is not JSON: ") }); + return; + } + // A function, symbol or `toJSON` returning undefined has no JSON form at all. + if (value === undefined) { + send({ _tag: "Failed", requestId, message: "Result is not JSON." }); + return; + } + const line = `{"_tag":"Succeeded","requestId":${requestId},"value":${value}}`; + if (Buffer.byteLength(line) > maxBytes) { + send({ _tag: "Failed", requestId, message: `Result exceeds ${maxBytes} bytes.` }); + return; + } + write(line); + }; + + /** Asks the server for something a capability provides; settles with the server's answer. */ + const hostCall = (method: string, input: PluginJson) => + new Promise((resolve, reject) => { + const requestId = ++nextHostCallId; + let line: string; + try { + line = JSON.stringify({ _tag: "HostCall", requestId, method, input }); + } catch (error) { + reject(new Error(`The value is not JSON: ${errorMessage(error)}`)); + return; + } + if (Buffer.byteLength(line) > maxBytes) { + reject(new Error(`The request exceeds ${maxBytes} bytes.`)); + return; + } + if (hostCalls.size >= PLUGIN_MAX_HOST_CALLS) { + reject(new Error(`${PLUGIN_MAX_HOST_CALLS} calls to the server are already in flight.`)); + return; + } + hostCalls.set(requestId, { resolve, reject }); + write(line); + }); + + const settingsApi = (): Pick => ({ + settings: { + get: async (key) => { + const { value } = (await hostCall("settings.get", { key })) as { + value: string | number | boolean | null; + }; + return value ?? undefined; + }, + }, + storage: { + get: async (key) => { + const result = (await hostCall("storage.get", { key })) as { + found: boolean; + value: PluginJson; + }; + return result.found ? result.value : undefined; + }, + set: async (key, value) => { + await hostCall("storage.set", { key, value }); + }, + delete: async (key) => { + await hostCall("storage.delete", { key }); + }, + keys: async () => ((await hostCall("storage.keys", {})) as { keys: string[] }).keys, + }, + }); + + const activate = async (message: Extract) => { + maxBytes = message.maxMessageBytes; + const controller = new AbortController(); + const proposed: PluginProposedApi | undefined = message.proposedApi + ? { + handle(name: string, handler: PluginHandler) { + if (name.startsWith("t3.") && !name.startsWith(PLUGIN_TOOL_HANDLER_PREFIX)) + throw new Error(`Handler names starting with "t3." are reserved.`); + if (handlers.has(name)) throw new Error(`Handler "${name}" is already registered.`); + handlers.set(name, handler); + return { + dispose() { + if (handlers.get(name) === handler) handlers.delete(name); + }, + }; + }, + onEvent(handler: PluginEventHandler) { + // A wrapper per registration, so registering one function twice runs it twice. + const registered: PluginEventHandler = (event, context) => handler(event, context); + eventHandlers.add(registered); + return { dispose: () => void eventHandlers.delete(registered) }; + }, + ...(message.capabilities.includes("settings") + ? settingsApi() + : { settings: undefined, storage: undefined }), + } + : undefined; + const context: PluginContext = { + apiVersion: 1, + plugin: { id: message.pluginId, version: message.version }, + signal: controller.signal, + log: { + debug: (text) => log("debug", text), + info: (text) => log("info", text), + warn: (text) => log("warn", text), + error: (text) => log("error", text), + }, + proposed, + }; + let module: Partial; + try { + module = loadEntry(message.entryPath); + } catch (error) { + send( + isAsyncModuleError(error) + ? { + _tag: "Incompatible", + message: + "The plugin's entry or a module it imports uses top-level await, which plugins cannot use. Move asynchronous setup into activate().", + } + : { _tag: "ActivationFailed", message: errorMessage(error) }, + ); + return; + } + try { + if (typeof module.activate !== "function") + throw new Error("The plugin entry does not export an activate function."); + activated = { module, controller }; + await module.activate(context); + send({ _tag: "Ready" }); + } catch (error) { + // A plugin that failed to activate serves nothing it registered before + // failing, and is not deactivated later as if it had started. + activated = undefined; + handlers.clear(); + eventHandlers.clear(); + controller.abort(new Error("Activation failed.")); + send({ _tag: "ActivationFailed", message: errorMessage(error) }); + } + }; + + // Runs every onEvent handler for each event of the page, in order. Any failure fails the + // whole page, so the server keeps its cursor before it and delivers the page again. + const deliverEvents: PluginHandler = async (input, context) => { + if (eventHandlers.size === 0) + throw new Error( + "The plugin declares the events capability but registered no onEvent handler.", + ); + const { events } = input as unknown as { readonly events: ReadonlyArray }; + for (const event of events) { + for (const handler of eventHandlers) { + context.signal.throwIfAborted(); + try { + await handler(event, context); + } catch (error) { + throw new Error(`onEvent failed for ${event.deliveryId}: ${errorMessage(error)}`, { + cause: error, + }); + } + } + } + return null; + }; + + const invoke = (message: Extract) => { + const handler = + message.handler === PLUGIN_EVENTS_HANDLER ? deliverEvents : handlers.get(message.handler); + if (!handler) { + settle(message.requestId, new Error(`No handler named "${message.handler}".`)); + return; + } + const controller = new AbortController(); + requests.set(message.requestId, controller); + Promise.resolve() + .then(() => { + // A cancel read with its invoke aborts before the handler could listen for it. + if (controller.signal.aborted) throw controller.signal.reason; + return handler(message.input, { signal: controller.signal }); + }) + .then( + (value) => settle(message.requestId, value), + (error) => settle(message.requestId, new Error(errorMessage(error))), + ); + }; + + const deactivate = async () => { + const reason = new Error("Plugin deactivated."); + activated?.controller.abort(reason); + for (const controller of requests.values()) controller.abort(reason); + try { + await activated?.module.deactivate?.(); + } catch (error) { + log("error", `deactivate failed: ${errorMessage(error)}`); + } + send({ _tag: "Deactivated" }); + channel.end(() => process.exit(0)); + }; + + const receive = (line: string) => { + let message: PluginHostMessage; + try { + message = JSON.parse(line) as PluginHostMessage; + } catch { + // A corrupt stream ends the child the same way an oversized line does. + process.exit(1); + } + switch (message._tag) { + case "Activate": + void activate(message); + return; + case "Invoke": + invoke(message); + return; + case "Cancel": + requests.get(message.requestId)?.abort(new Error("Call cancelled.")); + return; + case "Deactivate": + void deactivate(); + return; + case "HostCallSucceeded": + case "HostCallFailed": { + const pending = hostCalls.get(message.requestId); + if (!pending) return; + hostCalls.delete(message.requestId); + if (message._tag === "HostCallSucceeded") pending.resolve(message.value); + else pending.reject(new Error(message.message)); + return; + } + } + }; + + channel.on( + "data", + // The server bounds what it sends by its configured limit; this only stops a + // corrupt stream from growing without end. + makeLineDecoder({ + maxBytes: PLUGIN_IPC_MAX_BYTES_LIMIT, + onLine: receive, + onOverflow: () => process.exit(1), + }), + ); + // The server closed the channel or died: nothing can reach this plugin again. + channel.on("end", () => process.exit(0)); + channel.on("error", () => process.exit(1)); +}; diff --git a/apps/server/src/plugins/pluginIpcFraming.test.ts b/apps/server/src/plugins/pluginIpcFraming.test.ts new file mode 100644 index 0000000000000..fb14a91d0cf27 --- /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 0000000000000..29fde2d4d8505 --- /dev/null +++ b/apps/server/src/plugins/pluginIpcFraming.ts @@ -0,0 +1,110 @@ +// Shared by the server and the plugin child, which loads without the Effect +// runtime: keep this module free of imports. + +/** File descriptor of the newline-delimited JSON channel inside the child. */ +export const PLUGIN_IPC_FD = 3; + +/** + * Handler name the server delivers event pages to. The child runtime answers + * it with the plugin's `onEvent` handlers, so plugins cannot register it. + */ +export const PLUGIN_EVENTS_HANDLER = "t3.events"; + +/** Default upper bound for one encoded IPC message, newline excluded. */ +export const DEFAULT_PLUGIN_IPC_MAX_BYTES = 1024 * 1024; + +/** Largest per-message bound the server may be configured with. */ +export const PLUGIN_IPC_MAX_BYTES_LIMIT = 16 * 1024 * 1024; + +/** Host calls one child may have waiting for an answer; the child and the server refuse more. */ +export const PLUGIN_MAX_HOST_CALLS = 16; + +/** + * Bounds the bytes of complete lines a reader holds before handling them. + * `hold` pauses the source once `maxBytes` are held; `release` resumes it + * when the backlog falls below half. The source may deliver one more chunk + * after pausing, so the backlog stays under `maxBytes` plus one chunk. + */ +export const makeReadBudget = (input: { + readonly maxBytes: number; + readonly pause: () => void; + readonly resume: () => void; +}) => { + let held = 0; + let paused = false; + return { + hold: (bytes: number): void => { + held += bytes; + if (!paused && held >= input.maxBytes) { + paused = true; + input.pause(); + } + }, + release: (bytes: number): void => { + held -= bytes; + if (paused && held < input.maxBytes / 2) { + paused = false; + input.resume(); + } + }, + }; +}; + +/** + * Splits a byte stream into UTF-8 lines and refuses to buffer more than + * `maxBytes` for one line, so a peer cannot make the reader hold an unbounded + * message in memory. After an overflow it stops delivering lines. + */ +export const makeLineDecoder = (input: { + readonly maxBytes: number; + /** Receives each line with its size in bytes. */ + readonly onLine: (line: string, bytes: number) => void; + readonly onOverflow: () => void; +}) => { + // The unterminated tail is copied into one buffer that grows by doubling, so a + // line trickled in tiny chunks costs its bytes, not one allocation per chunk, + // and does not keep the chunks it arrived in alive. + let pending: Buffer | undefined; + let buffered = 0; + let overflowed = false; + const append = (piece: Buffer) => { + const needed = buffered + piece.length; + if (pending === undefined || needed > pending.length) { + const grown = Buffer.allocUnsafe( + Math.min(input.maxBytes, Math.max(needed, 2 * (pending?.length ?? 0), 256)), + ); + pending?.copy(grown, 0, 0, buffered); + pending = grown; + } + piece.copy(pending, buffered); + buffered = needed; + }; + return (chunk: Buffer): void => { + let start = 0; + while (!overflowed) { + const newline = chunk.indexOf(10, start); + const piece = chunk.subarray(start, newline === -1 ? chunk.length : newline); + if (buffered + piece.length > input.maxBytes) { + overflowed = true; + pending = undefined; + input.onOverflow(); + return; + } + if (newline === -1) { + if (piece.length > 0) append(piece); + return; + } + // The delimiter counts too, so even an empty line holds read budget. + const bytes = buffered + piece.length + 1; + const line = ( + pending === undefined || buffered === 0 + ? piece + : Buffer.concat([pending.subarray(0, buffered), piece]) + ).toString("utf8"); + pending = undefined; + buffered = 0; + start = newline + 1; + input.onLine(line, bytes); + } + }; +}; diff --git a/apps/server/src/plugins/pluginSource.test.ts b/apps/server/src/plugins/pluginSource.test.ts new file mode 100644 index 0000000000000..e95f193f6ca54 --- /dev/null +++ b/apps/server/src/plugins/pluginSource.test.ts @@ -0,0 +1,157 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Raw filename bytes are not supported by Effect FileSystem paths. +import * as NodeFSP from "node:fs/promises"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +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 valid filenames containing the Unicode replacement character", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* makeTree({ "a�.js": "A" }); + const first = yield* digestPluginSource(directory); + yield* fs.writeFileString(path.join(directory, "a�.js"), "B"); + expect((yield* digestPluginSource(directory)).digest).not.toBe(first.digest); + }), + ); + + it.effect.each(["file", "directory"] as const)( + "refuses invalid UTF-8 %s names", + (kind, context) => + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + if (platform !== "linux") context.skip(); + const directory = yield* makeTree({ "a�.js": "B" }); + const raw = Buffer.concat([ + Buffer.from(`${directory}/`), + Buffer.from([0x61, 0xff, 0x2e, 0x6a, 0x73]), + ]); + yield* Effect.tryPromise(() => + kind === "file" ? NodeFSP.writeFile(raw, "A") : NodeFSP.mkdir(raw), + ); + const error = yield* digestPluginSource(directory).pipe(Effect.flip); + expect(error.reason).toBe("a filename is not valid UTF-8."); + }), + ); + + 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.each(["wide", "deep"])("bounds empty directory trees (%s)", (shape) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* makeTree({}); + for (let index = 0; index < 4; index++) + yield* fs.makeDirectory( + path.join( + directory, + shape === "wide" + ? `dir-${index}` + : Array(index + 1) + .fill("dir") + .join("/"), + ), + { recursive: true }, + ); + const error = yield* digestPluginSource(directory, { maxFiles: 3, maxBytes: 100 }).pipe( + Effect.flip, + ); + expect(error.reason).toBe("it has more than 3 directories."); + }), + ); + + 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 0000000000000..3403f024791c2 --- /dev/null +++ b/apps/server/src/plugins/pluginSource.ts @@ -0,0 +1,126 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Digesting opens every file with O_NOFOLLOW, which Effect's FileSystem cannot ask for. +// Consent covers every regular file, including hidden paths; links, special files and oversized trees are refused. +// The digest is a snapshot: later edits and code loaded outside the directory are outside its guarantee. +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 directories = [""]; + let directoryCount = 0; + for (let index = 0; index < directories.length; index++) { + const relative = directories[index]!; + // Latin-1 preserves each filename byte while opendir bounds the listing buffer. + const entries = await NodeFSP.opendir(NodePath.join(root, relative), { encoding: "latin1" }); + for await (const entry of entries) { + if (signal.aborted) throw new Refusal("the inspection was cancelled."); + const rawName = Buffer.from(entry.name, "latin1"); + const name = rawName.toString("utf8"); + if (!Buffer.from(name, "utf8").equals(rawName)) + throw new Refusal("a filename is not valid UTF-8."); + const child = relative === "" ? name : `${relative}/${name}`; + if (entry.isSymbolicLink()) throw new Refusal(`${child} is a symbolic link.`); + if (entry.isDirectory()) { + if (++directoryCount > limits.maxFiles) + throw new Refusal(`it has more than ${limits.maxFiles} directories.`); + directories.push(child); + continue; + } + if (!entry.isFile()) throw new Refusal(`${child} is not a regular file.`); + if (files.length >= limits.maxFiles) + throw new Refusal(`it has more than ${limits.maxFiles} files.`); + files.push(child); + declaredBytes += (await NodeFSP.lstat(NodePath.join(root, child))).size; + if (declaredBytes > limits.maxBytes) + throw new Refusal(`it is larger than ${limits.maxBytes} bytes.`); + } + } + // Code unit order of the relative path, independent of how the OS lists entries. + files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + + const digest = NodeCrypto.createHash("sha256"); + let bytes = 0; + for (const file of files) { + const content = NodeCrypto.createHash("sha256"); + let size = 0; + // A file swapped for a link after listing is refused, not followed. + const handle = await NodeFSP.open(NodePath.join(root, file), OPEN_FLAGS); + try { + if (!(await handle.stat()).isFile()) throw new Refusal(`${file} is not a regular file.`); + for await (const chunk of handle.createReadStream({ autoClose: false, signal })) { + size += chunk.length; + if (bytes + size > limits.maxBytes) + throw new Refusal(`it is larger than ${limits.maxBytes} bytes.`); + content.update(chunk); + } + } finally { + await handle.close(); + } + bytes += size; + digest.update(`${file}\0${size}\0${content.digest("hex")}\n`); + } + return { digest: `sha256:${digest.digest("hex")}`, files: files.length, bytes }; +}; + +/** Digests `directory` (a real path), or says why its bytes cannot be pinned. */ +export const digestPluginSource = ( + directory: string, + limits: PluginSourceLimits = defaultPluginSourceLimits, +) => + Effect.tryPromise({ + try: (signal) => walk(directory, limits, signal), + catch: (cause) => + new PluginSourceError({ + directory, + reason: + cause instanceof Refusal + ? cause.reason + : (cause as NodeJS.ErrnoException).code === "ELOOP" + ? "a file was replaced by a symbolic link while it was read." + : "a file could not be read.", + }), + }).pipe(Effect.withSpan("pluginSource.digest")); diff --git a/apps/server/src/plugins/pluginToolDeclarations.test.ts b/apps/server/src/plugins/pluginToolDeclarations.test.ts new file mode 100644 index 0000000000000..d8f20d8e48174 --- /dev/null +++ b/apps/server/src/plugins/pluginToolDeclarations.test.ts @@ -0,0 +1,408 @@ +import { describe, expect, it } from "@effect/vitest"; +import { PLUGIN_TOOL_LIMITS, PluginToolDeclaration } from "@t3tools/contracts"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { compileInputSchema, jsonBytes, preparePluginTools } from "./pluginToolDeclarations.ts"; + +const validator = (inputSchema: Record) => { + const compiled = compileInputSchema(inputSchema); + if ("problem" in compiled) throw new Error(compiled.problem); + return (input: unknown) => Exit.isSuccess(compiled.validate(input)); +}; + +const problemOf = (inputSchema: Record) => { + const compiled = compileInputSchema(inputSchema); + return "problem" in compiled ? compiled.problem : undefined; +}; + +const decodeDeclaration = Schema.decodeUnknownSync(PluginToolDeclaration); + +describe("compileInputSchema", () => { + it("rejects extra keys on closed objects at every level and keeps them on open ones", () => { + const accepts = validator({ + type: "object", + properties: { + text: { type: "string" }, + options: { + type: "object", + properties: { exact: { type: "boolean" } }, + additionalProperties: false, + }, + extra: { type: "object", properties: { a: { type: "number" } } }, + }, + required: ["text"], + additionalProperties: false, + }); + expect(accepts({ text: "yes" })).toBe(true); + expect(accepts({ text: "yes", extra: 1 })).toBe(false); + expect(accepts({ text: "yes", options: { exact: true } })).toBe(true); + expect(accepts({ text: "yes", options: { exact: true, fuzzy: 1 } })).toBe(false); + expect(accepts({ text: "yes", extra: { a: 1, anything: [1, "x"] } })).toBe(true); + expect(accepts({ text: "yes", extra: { a: "1" } })).toBe(false); + expect(accepts({})).toBe(false); + expect(accepts({ text: null })).toBe(false); + + const open = validator({ type: "object", properties: { n: { type: "integer" } } }); + expect(open({ n: 1, other: true })).toBe(true); + expect(open({ n: 1.5 })).toBe(false); + expect(open([])).toBe(false); + expect(validator({ type: "object", additionalProperties: false })({ a: 1 })).toBe(false); + }); + + it("counts string lengths in code points, as JSON Schema does", () => { + const accepts = validator({ + type: "object", + properties: { x: { type: "string", minLength: 2, maxLength: 2 } }, + }); + expect(accepts({ x: "😀😀" })).toBe(true); + expect(accepts({ x: "😀" })).toBe(false); + expect(accepts({ x: "abc" })).toBe(false); + }); + + it("enforces numbers, arrays, enums, unions and type lists with JSON Schema's meaning", () => { + const accepts = validator({ + type: "object", + properties: { + n: { type: "number", exclusiveMinimum: 0, maximum: 10 }, + i: { type: "integer", minimum: -2 }, + tags: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 2 }, + mode: { type: "string", enum: ["a", "b"] }, + flag: { const: true }, + id: { anyOf: [{ type: "string", maxLength: 3 }, { type: "integer" }] }, + note: { type: ["string", "null"], maxLength: 1 }, + any: { description: "Anything." }, + }, + }); + expect( + accepts({ + n: 10, + i: 1.0, + tags: ["x"], + mode: "b", + flag: true, + id: 7, + note: null, + any: { deep: [1] }, + }), + ).toBe(true); + for (const bad of [ + { n: 0 }, + { n: 10.5 }, + { i: -3 }, + { i: 0.5 }, + { tags: [] }, + { tags: ["a", "b", "c"] }, + { tags: [1] }, + { mode: "c" }, + { flag: false }, + { id: "abcd" }, + { id: 1.5 }, + { note: "ab" }, + ]) + expect(accepts(bad), JSON.stringify(bad)).toBe(false); + }); + + it("resolves root definitions, including recursion through properties", () => { + const accepts = validator({ + type: "object", + properties: { tree: { $ref: "#/$defs/Node" } }, + $defs: { + Node: { + type: "object", + properties: { + name: { type: "string" }, + children: { type: "array", items: { $ref: "#/$defs/Node" } }, + }, + required: ["name"], + additionalProperties: false, + }, + }, + }); + expect(accepts({ tree: { name: "a", children: [{ name: "b", children: [] }] } })).toBe(true); + expect(accepts({ tree: { name: "a", children: [{ children: [] }] } })).toBe(false); + expect(accepts({ tree: { name: "a", children: [{ name: "b", x: 1 }] } })).toBe(false); + }); + + it("accepts an Effect Schema exported as the module doc shows and enforces it", () => { + const Range = Schema.Struct({ + from: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + to: Schema.optionalKey(Schema.Int), + }).annotate({ identifier: "Range" }); + const Input = Schema.Struct({ + text: Schema.String.check(Schema.isMaxLength(100)), + mode: Schema.Literals(["fast", "exact"]), + ranges: Schema.Array(Range), + note: Schema.optionalKey(Schema.NullOr(Schema.String)), + }); + const { schema, definitions } = Schema.toJsonSchemaDocument(Input, { + onExcessProperty: "error", + }); + const inputSchema = + Object.keys(definitions).length === 0 ? schema : { ...schema, $defs: definitions }; + expect(inputSchema).toHaveProperty("$defs.Range"); + const accepts = validator(inputSchema); + expect(accepts({ text: "t", mode: "fast", ranges: [{ from: 1 }], note: null })).toBe(true); + expect(accepts({ text: "t", mode: "fast", ranges: [{ from: -1 }] })).toBe(false); + expect(accepts({ text: "t", mode: "fast", ranges: [{ from: 1, step: 2 }] })).toBe(false); + expect(accepts({ text: "t", mode: "slow", ranges: [] })).toBe(false); + }); + + it("names whatever is outside the subset instead of weakening it", () => { + const object = (properties: Record) => ({ type: "object", properties }); + const cases: ReadonlyArray, string]> = [ + [object({ id: { type: "string", pattern: "^(a+)+$" } }), "#/properties/id/pattern"], + [object({ id: { oneOf: [{ type: "string" }] } }), "#/properties/id/oneOf"], + [object({ id: { allOf: [{ type: "string" }] } }), "#/properties/id/allOf"], + [object({ n: { type: "number", multipleOf: 2 } }), "#/properties/n/multipleOf"], + [object({ n: { maxLength: 2 } }), "#/properties/n/maxLength: needs a type"], + [object({ n: { type: "number", maxLength: 2 } }), "#/properties/n/maxLength: needs a type"], + [object({ x: { $ref: "#/$defs/X", type: "string" } }), "type cannot be used beside $ref"], + [object({ x: { $ref: "#/$defs/Missing" } }), "#/properties/x/$ref"], + [object({ x: { type: "array", items: [{ type: "string" }] } }), "a schema must be an object"], + [ + object({ x: { type: "object", additionalProperties: { type: "string" } } }), + "true or false", + ], + [object({ x: { enum: [{ a: 1 }] } }), "enum and const take"], + [object({ x: { type: "string", enum: [1] } }), "does not match its type"], + [{ ...object({}), required: ["missing"] }, "#/required"], + [{ type: "string" }, 'the root must be "object"'], + [{ type: ["object", "null"] }, 'the root must be "object"'], + [ + { + ...object({ x: { $ref: "#/$defs/A" } }), + $defs: { A: { anyOf: [{ $ref: "#/$defs/B" }] }, B: { anyOf: [{ $ref: "#/$defs/A" }] } }, + }, + "reference cycle must pass through properties or items", + ], + ]; + for (const [schema, expected] of cases) expect(problemOf(schema)).toContain(expected); + + let deep: Record = { type: "string" }; + for (let level = 0; level <= PLUGIN_TOOL_LIMITS.maxInputSchemaDepth; level++) + deep = { type: "array", items: deep }; + expect(problemOf(object({ deep }))).toContain("nest deeper than"); + expect( + problemOf(object({ big: { type: "string", description: "x".repeat(16 * 1024) } })), + ).toContain("exceeds 16384 bytes"); + }); + + describe("the subset boundary", () => { + const object = (properties: Record) => ({ type: "object", properties }); + const property = (schema: unknown) => object({ x: schema }); + const repeatedReferences = Object.fromEntries([ + ["Leaf", { type: "string" }], + ...Array.from({ length: 13 }, (_, index) => [ + `Level${index}`, + { + anyOf: Array.from({ length: 2 }, () => ({ + $ref: `#/$defs/${index === 0 ? "Leaf" : `Level${index - 1}`}`, + })), + }, + ]), + ]); + + it.each>([ + { type: "object" }, + { type: "object", properties: {}, required: [], additionalProperties: true }, + { type: "object", $defs: {} }, + { type: "object", $defs: { Unused: { type: "string", minLength: 1 } } }, + { + ...property({ $ref: "#/$defs/Used", description: "Annotations may sit beside $ref." }), + $defs: { Used: { type: "string" } }, + }, + property({ anyOf: [{ type: "string" }, { type: "null" }], title: "Beside anyOf too." }), + property({ + type: "string", + title: "t", + description: "d", + $comment: "c", + format: "uri", + examples: [], + deprecated: false, + readOnly: true, + writeOnly: false, + default: null, + }), + property({ type: ["string", "null"], maxLength: 0 }), + property({ enum: [1, "a", true, null] }), + property({ const: null }), + property({ type: "number", exclusiveMinimum: 0, exclusiveMaximum: 1.5 }), + property({ type: "array", minItems: 0 }), + ])("accepts %j", (schema) => expect(problemOf(schema)).toBeUndefined()); + + it.each, string]>([ + // A default only replaces an absent keyword; null is a wrong shape. + ["null properties", { type: "object", properties: null }, "#/properties: must be an object"], + ["null required", { type: "object", required: null }, "#/required"], + [ + "null additionalProperties", + { type: "object", additionalProperties: null }, + "#/additionalProperties: must be true or false", + ], + ["null $defs", { type: "object", $defs: null }, "#/$defs: must be an object"], + ["array $defs", { type: "object", $defs: [] }, "#/$defs: must be an object"], + // Names are own properties, never inherited ones. + [ + "an inherited required name", + { ...object({}), required: ["toString"], additionalProperties: false }, + "#/required", + ], + ["an inherited definition", property({ $ref: "#/$defs/toString" }), "#/properties/x/$ref"], + // Every definition is checked, referenced or not. + [ + "an unused definition outside the subset", + { type: "object", $defs: { Unused: { type: "string", pattern: "x" } } }, + "#/$defs/Unused/pattern", + ], + [ + // A is compiled through the property first; reusing it must not skip the cycle check. + "an unguarded cycle beside a guarded path to the same definition", + { + ...property({ $ref: "#/$defs/A" }), + $defs: { + A: { + anyOf: [ + { type: "object", properties: { child: { $ref: "#/$defs/B" } } }, + { $ref: "#/$defs/B" }, + ], + }, + B: { $ref: "#/$defs/A" }, + }, + }, + "#/$defs/B/$ref: a reference cycle must pass through properties or items", + ], + [ + "an unused definition with an unguarded cycle", + { type: "object", $defs: { A: { anyOf: [{ $ref: "#/$defs/A" }] } } }, + "reference cycle", + ], + [ + "exponentially repeated acyclic references", + { ...property({ $ref: "#/$defs/Level12" }), $defs: repeatedReferences }, + "references expand beyond", + ], + ["a bad definition name", { type: "object", $defs: { "a b": {} } }, "#/$defs/a b"], + ["nested $defs", property({ $defs: {} }), "only supported at the root"], + ["a draft-7 ref", property({ $ref: "#/definitions/X" }), "#/properties/x/$ref"], + // Annotations are shown, so they must have their shape. + ["a numeric title", property({ type: "string", title: 1 }), "#/properties/x/title"], + ["a null description", property({ description: null }), "#/properties/x/description"], + ["object examples", property({ examples: {} }), "#/properties/x/examples"], + ["a string deprecated", property({ deprecated: "yes" }), "#/properties/x/deprecated"], + ["a null readOnly", property({ readOnly: null }), "#/properties/x/readOnly"], + ["a numeric writeOnly", property({ writeOnly: 0 }), "#/properties/x/writeOnly"], + ["a numeric format", property({ type: "string", format: 1 }), "#/properties/x/format"], + ["an array $comment", property({ $comment: [] }), "#/properties/x/$comment"], + // Supported keywords with the wrong shape. + ["an empty type list", property({ type: [] }), "#/properties/x/type"], + ["a repeated type", property({ type: ["string", "string"] }), "#/properties/x/type"], + ["an unknown type", property({ type: "date" }), "#/properties/x/type"], + ["a null type", property({ type: null }), "#/properties/x/type"], + ["a negative minLength", property({ type: "string", minLength: -1 }), "minLength"], + ["a fractional minLength", property({ type: "string", minLength: 1.5 }), "minLength"], + ["a null maxLength", property({ type: "string", maxLength: null }), "maxLength"], + ["a string maxItems", property({ type: "array", maxItems: "2" }), "maxItems"], + ["a null minimum", property({ type: "number", minimum: null }), "minimum"], + [ + "a draft-4 exclusiveMinimum", + property({ type: "number", exclusiveMinimum: true }), + "exclusiveMinimum", + ], + ["null items", property({ type: "array", items: null }), "#/properties/x/items"], + ["boolean items", property({ type: "array", items: true }), "#/properties/x/items"], + ["a boolean schema", object({ x: true }), "#/properties/x: a schema must be an object"], + ["a null schema", object({ x: null }), "#/properties/x: a schema must be an object"], + ["an empty anyOf", property({ anyOf: [] }), "#/properties/x/anyOf"], + ["an object anyOf", property({ anyOf: {} }), "#/properties/x/anyOf"], + ["an empty enum", property({ enum: [] }), "enum and const take"], + ["a null enum", property({ enum: null }), "enum and const take"], + ["an object const", property({ const: {} }), "enum and const take"], + ["enum and const", property({ enum: [1], const: 1 }), "use enum or const"], + // Keywords outside the subset, named. + ...[ + "$schema", + "$id", + "$anchor", + "$dynamicRef", + "definitions", + "patternProperties", + "propertyNames", + "minProperties", + "maxProperties", + "dependentRequired", + "dependentSchemas", + "unevaluatedProperties", + "unevaluatedItems", + "prefixItems", + "contains", + "uniqueItems", + "if", + "not", + "nullable", + "contentMediaType", + ].map( + (keyword) => + [ + keyword, + property({ type: "object", [keyword]: {} }), + `#/properties/x/${keyword}: this keyword is not supported`, + ] as const, + ), + ])("refuses %s", (_, schema, expected) => expect(problemOf(schema)).toContain(expected)); + }); + + it("shows defaults and formats without applying or asserting them", () => { + const accepts = validator({ + type: "object", + properties: { url: { type: "string", format: "uri", default: "https://example.com" } }, + additionalProperties: false, + }); + expect(accepts({})).toBe(true); + expect(accepts({ url: "not a uri" })).toBe(true); + }); +}); + +describe("preparePluginTools", () => { + const plugin = { id: "test.prepare", name: "Prepare" }; + const tool = (name: string, description: string) => + decodeDeclaration({ name, description, inputSchema: { type: "object" }, sideEffect: "read" }); + + it("lists the declared schema unchanged and counts exact UTF-8 listing bytes", () => { + const declared = decodeDeclaration({ + name: "lookup", + title: "Look up", + description: "Look up a word, déjà vu.", + inputSchema: { type: "object", properties: { q: { type: "string" } }, required: ["q"] }, + sideEffect: "read", + openWorld: true, + timeoutSeconds: 5, + }); + const prepared = preparePluginTools(plugin, [declared]); + if ("problem" in prepared) throw new Error(prepared.problem); + const listing = prepared.tools.get("lookup")!.listing; + expect(listing).toEqual({ + tool: "test.prepare/lookup", + plugin, + title: "Look up", + description: "Look up a word, déjà vu.", + inputSchema: declared.inputSchema, + sideEffect: "read", + openWorld: true, + }); + expect(prepared.tools.get("lookup")!.timeoutSeconds).toBe(5); + expect(prepared.listingBytes).toBe(jsonBytes(listing) + 1); + }); + + it("refuses duplicate names and a listing past its byte limit, multibyte included", () => { + expect(preparePluginTools(plugin, [tool("a", "One."), tool("a", "Two.")])).toEqual({ + problem: "it declares the tool a twice.", + }); + // 32 tools of 1,000 three-byte characters: under 48 KiB in characters, over it in bytes. + const wide = Array.from({ length: 32 }, (_, index) => tool(`t_${index}`, "€".repeat(1000))); + expect(preparePluginTools(plugin, wide)).toEqual({ + problem: `its tools take more than ${PLUGIN_TOOL_LIMITS.maxPluginListingBytes} bytes to list.`, + }); + }); +}); diff --git a/apps/server/src/plugins/pluginToolDeclarations.ts b/apps/server/src/plugins/pluginToolDeclarations.ts new file mode 100644 index 0000000000000..ef1abf2bc78b5 --- /dev/null +++ b/apps/server/src/plugins/pluginToolDeclarations.ts @@ -0,0 +1,416 @@ +// Accepted JSON Schema keywords retain their meaning, including extra-key rejection and code-point lengths. +// Unsupported or malformed keywords refuse the manifest; compiled declarations must match what agents see. +import { + PLUGIN_TOOL_LIMITS, + type PluginToolDeclaration, + type PluginToolListing, + qualifyPluginToolName, +} from "@t3tools/contracts"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import type * as SchemaAST from "effect/SchemaAST"; + +interface PreparedPluginTool { + readonly listing: PluginToolListing; + readonly validate: (input: unknown) => Exit.Exit; + readonly timeoutSeconds: number; +} + +export interface PreparedPluginTools { + readonly tools: ReadonlyMap; + /** Serialized size of the listings as one page holds them, separators included. */ + readonly listingBytes: number; +} + +type JsonObject = { readonly [key: string]: unknown }; +type Compiled = Schema.Top; + +class SchemaProblem { + readonly message: string; + constructor(message: string) { + this.message = message; + } +} + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +export const jsonBytes = (value: unknown) => Buffer.byteLength(encodeJson(value), "utf8"); + +const isString = (value: unknown) => typeof value === "string"; +const isBoolean = (value: unknown) => typeof value === "boolean"; +const TYPES = new Set(["object", "array", "string", "number", "integer", "boolean", "null"]); +/** Annotations and the shape each must have; `default` may be any JSON value. */ +const ANNOTATIONS = new Map boolean>([ + ["title", isString], + ["description", isString], + ["default", () => true], + ["examples", Array.isArray], + ["deprecated", isBoolean], + ["readOnly", isBoolean], + ["writeOnly", isBoolean], + ["format", isString], + ["$comment", isString], +]); +const KEYWORDS_BY_TYPE: Record> = { + object: ["properties", "required", "additionalProperties"], + array: ["items", "minItems", "maxItems"], + string: ["minLength", "maxLength"], + number: ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"], + integer: ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"], +}; +const TYPED_KEYWORDS = new Set(Object.values(KEYWORDS_BY_TYPE).flat()); +const DEFINITION_NAME = /^[A-Za-z0-9_.-]{1,64}$/; +const MAX_UNGUARDED_SCHEMA_REFERENCES = 4096; + +const isObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); +const isPrimitive = (value: unknown) => + value === null || ["string", "number", "boolean"].includes(typeof value); +const isCount = (value: unknown): value is number => + Number.isSafeInteger(value) && Number(value) >= 0; + +const withChecks = ( + schema: Schema.Codec, + checks: ReadonlyArray>, +): Compiled => { + const [first, ...rest] = checks; + return (first === undefined ? schema : schema.check(first, ...rest)) as Compiled; +}; + +const codePoints = (text: string) => { + let count = 0; + for (const _ of text) count++; + return count; +}; + +const literal = (value: unknown): Compiled => + value === null ? Schema.Null : Schema.Literal(value as string | number | boolean); + +const matchesType = (value: unknown, type: string) => + type === "null" + ? value === null + : type === "integer" + ? Number.isInteger(value) + : type === "array" || type === "object" + ? false + : typeof value === type; + +// A cycle must consume input through properties or items; scan every definition so reuse cannot hide one. +const refuseUnguardedCycles = (definitions: JsonObject) => { + // Each definition's references that are reached without consuming input. + const edges = new Map>(); + const collect = (schema: unknown, path: string, into: Array<{ name: string; path: string }>) => { + if (!isObject(schema)) return; + const ref = schema.$ref; + if (typeof ref === "string" && ref.startsWith("#/$defs/")) { + const name = ref.slice(8); + if (Object.hasOwn(definitions, name)) into.push({ name, path: `${path}/$ref` }); + } else if (Array.isArray(schema.anyOf)) + schema.anyOf.forEach((member, index) => collect(member, `${path}/anyOf/${index}`, into)); + }; + for (const [name, schema] of Object.entries(definitions)) { + const into: Array<{ name: string; path: string }> = []; + collect(schema, `#/$defs/${name}`, into); + edges.set(name, into); + } + const state = new Map(); + const expandedReferences = new Map(); + const visit = (name: string) => { + state.set(name, "visiting"); + let expanded = 1; + for (const edge of edges.get(name)!) { + const seen = state.get(edge.name); + if (seen === "visiting") + throw new SchemaProblem( + `${edge.path}: a reference cycle must pass through properties or items.`, + ); + if (seen === undefined) visit(edge.name); + // Effect traverses repeated union references separately, even when their compiled schema is shared. + expanded += expandedReferences.get(edge.name)!; + if (expanded > MAX_UNGUARDED_SCHEMA_REFERENCES) + throw new SchemaProblem( + `${edge.path}: references expand beyond ${MAX_UNGUARDED_SCHEMA_REFERENCES} unguarded schema visits.`, + ); + } + expandedReferences.set(name, expanded); + state.set(name, "done"); + }; + for (const name of edges.keys()) if (!state.has(name)) visit(name); +}; + +/** + * Compiles one `inputSchema`. Throws `SchemaProblem` with a JSON-pointer-like + * path; `compileInputSchema` turns it into a result. + */ +const compile = (root: JsonObject): Compiled => { + const definitions = root.$defs === undefined ? {} : root.$defs; + if (!isObject(definitions)) throw new SchemaProblem("#/$defs: must be an object."); + for (const name of Object.keys(definitions)) + if (!DEFINITION_NAME.test(name)) + throw new SchemaProblem( + `#/$defs/${name}: definition names are 1 to 64 letters, digits, "_", "." or "-".`, + ); + refuseUnguardedCycles(definitions); + const done = new Map(); + const inProgress = new Set(); + + /** The compiled definition `name`; any cycle back into it consumes input. */ + const definition = (name: string, depth: number): Compiled => { + const compiled = done.get(name); + if (compiled !== undefined) return compiled; + if (inProgress.has(name)) return Schema.suspend((): Compiled => done.get(name)!); + inProgress.add(name); + const result = node(definitions[name], `#/$defs/${name}`, depth); + inProgress.delete(name); + done.set(name, result); + return result; + }; + + const node = (schema: unknown, path: string, depth: number): Compiled => { + if (depth > PLUGIN_TOOL_LIMITS.maxInputSchemaDepth) + throw new SchemaProblem( + `${path}: schemas nest deeper than ${PLUGIN_TOOL_LIMITS.maxInputSchemaDepth}.`, + ); + if (!isObject(schema)) throw new SchemaProblem(`${path}: a schema must be an object.`); + for (const [key, valid] of ANNOTATIONS) + if (Object.hasOwn(schema, key) && !valid(schema[key])) + throw new SchemaProblem(`${path}/${key}: not a valid ${key} annotation.`); + const keywords = Object.keys(schema).filter( + (key) => !ANNOTATIONS.has(key) && !(path === "#" && key === "$defs"), + ); + if (path !== "#" && "$defs" in schema) + throw new SchemaProblem(`${path}: $defs is only supported at the root.`); + const only = (keyword: string) => { + const others = keywords.filter((key) => key !== keyword); + if (others.length > 0) + throw new SchemaProblem(`${path}: ${others[0]} cannot be used beside ${keyword}.`); + }; + + if ("$ref" in schema) { + only("$ref"); + const ref = schema.$ref; + const name = typeof ref === "string" && ref.startsWith("#/$defs/") ? ref.slice(8) : ""; + if (!Object.hasOwn(definitions, name)) + throw new SchemaProblem( + `${path}/$ref: only "#/$defs/" references to root definitions are supported.`, + ); + return definition(name, depth + 1); + } + + if ("anyOf" in schema) { + only("anyOf"); + const members = schema.anyOf; + if (!Array.isArray(members) || members.length === 0) + throw new SchemaProblem(`${path}/anyOf: must be a non-empty array.`); + return Schema.Union( + members.map((member, index) => node(member, `${path}/anyOf/${index}`, depth + 1)), + ); + } + + const types = + schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type]; + if ( + types.some((type) => typeof type !== "string" || !TYPES.has(type)) || + new Set(types).size !== types.length || + (schema.type !== undefined && types.length === 0) + ) + throw new SchemaProblem( + `${path}/type: must be one or more distinct of ${[...TYPES].join(", ")}.`, + ); + for (const keyword of keywords) { + if (keyword === "type" || keyword === "enum" || keyword === "const") continue; + if (!TYPED_KEYWORDS.has(keyword)) + throw new SchemaProblem(`${path}/${keyword}: this keyword is not supported.`); + if (!types.some((type: string) => KEYWORDS_BY_TYPE[type]?.includes(keyword))) + throw new SchemaProblem(`${path}/${keyword}: needs a type it applies to.`); + } + + if ("enum" in schema || "const" in schema) { + if ("enum" in schema && "const" in schema) + throw new SchemaProblem(`${path}: use enum or const, not both.`); + const values = "const" in schema ? [schema.const] : schema.enum; + if (!Array.isArray(values) || values.length === 0 || !values.every(isPrimitive)) + throw new SchemaProblem( + `${path}: enum and const take string, number, boolean, or null values.`, + ); + const other = keywords.find((key) => key !== "type" && key !== "enum" && key !== "const"); + if (other !== undefined) + throw new SchemaProblem(`${path}/${other}: cannot be used beside enum or const.`); + if ( + types.length > 0 && + !values.every((value) => types.some((type) => matchesType(value, type))) + ) + throw new SchemaProblem(`${path}: an enum or const value does not match its type.`); + return values.length === 1 ? literal(values[0]) : Schema.Union(values.map(literal)); + } + + if (types.length === 0) return Schema.Json as Compiled; + const byType = types.map((type: string): Compiled => { + switch (type) { + case "null": + return Schema.Null; + case "boolean": + return Schema.Boolean; + case "string": + return stringSchema(schema, path); + case "number": + case "integer": + return numberSchema(schema, path, type === "integer"); + case "array": { + const items = + schema.items === undefined + ? (Schema.Json as Compiled) + : node(schema.items, `${path}/items`, depth + 1); + return withChecks(Schema.Array(items as Schema.Codec), [ + ...(schema.minItems === undefined + ? [] + : [Schema.isMinLength(count(schema, "minItems", path))]), + ...(schema.maxItems === undefined + ? [] + : [Schema.isMaxLength(count(schema, "maxItems", path))]), + ]); + } + default: + return objectSchema(schema, path, depth); + } + }); + return byType.length === 1 ? byType[0]! : Schema.Union(byType); + }; + + const objectSchema = (schema: JsonObject, path: string, depth: number): Compiled => { + // Only an absent keyword takes its default; `null` is not a valid value for any of them. + const properties = schema.properties === undefined ? {} : schema.properties; + if (!isObject(properties)) throw new SchemaProblem(`${path}/properties: must be an object.`); + const required = schema.required === undefined ? [] : schema.required; + if ( + !Array.isArray(required) || + !required.every((name) => typeof name === "string" && Object.hasOwn(properties, name)) || + new Set(required).size !== required.length + ) + throw new SchemaProblem(`${path}/required: must list distinct names from properties.`); + const additional = + schema.additionalProperties === undefined ? true : schema.additionalProperties; + if (typeof additional !== "boolean") + throw new SchemaProblem(`${path}/additionalProperties: must be true or false.`); + const fields: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (key === "__proto__") + throw new SchemaProblem(`${path}/properties: __proto__ is not supported.`); + const property = node(value, `${path}/properties/${key}`, depth + 1); + fields[key] = required.includes(key) ? property : Schema.optionalKey(property); + } + // An empty struct accepts any object, so a closed empty object is a record of nothing. + if (!additional && Object.keys(fields).length === 0) + return Schema.Record(Schema.String, Schema.Never); + const struct = Schema.Struct(fields); + // Calls are decoded with `onExcessProperty: "error"`, so a closed object rejects + // extra keys and an open one keeps them. + return additional + ? Schema.StructWithRest(struct, [Schema.Record(Schema.String, Schema.Json)]) + : struct; + }; + + if (root.type !== "object") throw new SchemaProblem(`#/type: the root must be "object".`); + const compiled = node(root, "#", 0); + // A definition nothing references is still part of the declaration, so it is held to the subset too. + for (const name of Object.keys(definitions)) definition(name, 1); + return compiled; +}; + +const count = (schema: JsonObject, keyword: string, path: string) => { + const value = schema[keyword]; + if (!isCount(value)) + throw new SchemaProblem(`${path}/${keyword}: must be a non-negative integer.`); + return value; +}; + +const stringSchema = (schema: JsonObject, path: string): Compiled => { + const min = schema.minLength === undefined ? undefined : count(schema, "minLength", path); + const max = schema.maxLength === undefined ? undefined : count(schema, "maxLength", path); + if (min === undefined && max === undefined) return Schema.String; + return Schema.String.check( + Schema.makeFilter((text: string) => { + const length = codePoints(text); + if (min !== undefined && length < min) return `Expected at least ${min} characters`; + return max === undefined || length <= max || `Expected at most ${max} characters`; + }), + ); +}; + +const numberSchema = (schema: JsonObject, path: string, integer: boolean): Compiled => { + const bound = (keyword: string) => { + const value = schema[keyword]; + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isFinite(value)) + throw new SchemaProblem(`${path}/${keyword}: must be a number.`); + return value; + }; + return withChecks(Schema.Number, [ + ...(integer + ? [Schema.makeFilter((value: number) => Number.isInteger(value) || "Expected an integer")] + : []), + ...[ + [bound("minimum"), Schema.isGreaterThanOrEqualTo], + [bound("exclusiveMinimum"), Schema.isGreaterThan], + [bound("maximum"), Schema.isLessThanOrEqualTo], + [bound("exclusiveMaximum"), Schema.isLessThan], + ].flatMap(([value, check]) => + typeof value === "number" ? [(check as typeof Schema.isLessThan)(value)] : [], + ), + ]); +}; + +/** The validator for one declared `inputSchema`, or why it is outside the subset. */ +export const compileInputSchema = ( + inputSchema: JsonObject, +): + | { readonly validate: (input: unknown) => Exit.Exit } + | { readonly problem: string } => { + if (jsonBytes(inputSchema) > PLUGIN_TOOL_LIMITS.maxInputSchemaBytes) + return { problem: `inputSchema exceeds ${PLUGIN_TOOL_LIMITS.maxInputSchemaBytes} bytes.` }; + try { + return { + validate: Schema.decodeUnknownExit(compile(inputSchema) as Schema.Codec, { + onExcessProperty: "error", + }), + }; + } catch (error) { + if (error instanceof SchemaProblem) return { problem: `inputSchema ${error.message}` }; + throw error; + } +}; + +/** Prepares every declared tool of one plugin, or names the first that cannot be offered. */ +export const preparePluginTools = ( + plugin: { readonly id: string; readonly name: string }, + declarations: ReadonlyArray, +): PreparedPluginTools | { readonly problem: string } => { + const tools = new Map(); + let listingBytes = 0; + for (const declaration of declarations) { + if (tools.has(declaration.name)) + return { problem: `it declares the tool ${declaration.name} twice.` }; + const compiled = compileInputSchema(declaration.inputSchema); + if ("problem" in compiled) + return { problem: `the tool ${declaration.name}'s ${compiled.problem}` }; + const listing: PluginToolListing = { + tool: qualifyPluginToolName(plugin.id, declaration.name), + plugin: { id: plugin.id, name: plugin.name }, + ...(declaration.title === undefined ? {} : { title: declaration.title }), + description: declaration.description, + inputSchema: declaration.inputSchema, + sideEffect: declaration.sideEffect, + openWorld: declaration.openWorld, + }; + listingBytes += jsonBytes(listing) + 1; + tools.set(declaration.name, { + listing, + validate: compiled.validate, + timeoutSeconds: declaration.timeoutSeconds ?? PLUGIN_TOOL_LIMITS.defaultTimeoutSeconds, + }); + } + if (listingBytes > PLUGIN_TOOL_LIMITS.maxPluginListingBytes) + return { + problem: `its tools take more than ${PLUGIN_TOOL_LIMITS.maxPluginListingBytes} bytes to list.`, + }; + return { tools, listingBytes }; +}; diff --git a/apps/server/src/plugins/testFixtures/plugin/asyncDependency.mjs b/apps/server/src/plugins/testFixtures/plugin/asyncDependency.mjs new file mode 100644 index 0000000000000..25624522e6fc4 --- /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 0000000000000..b73658c3f9042 --- /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 0000000000000..9c06799d6fa2a --- /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 0000000000000..3e5b2d8c0df53 --- /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 0000000000000..382ada3cd0787 --- /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 0000000000000..f7cbf8d6b6ef5 --- /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 0000000000000..aafd1c71fa1c4 --- /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 0000000000000..061cdce41868e --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/registerThenFail.mjs @@ -0,0 +1,14 @@ +// Registers a handler and an event handler, watches its activation signal, then fails to activate. +let log; + +export function activate(context) { + log = context.log; + context.proposed.handle("leftover", () => "served after a failed activation"); + context.proposed.onEvent(() => log.info("event-delivered")); + context.signal.addEventListener("abort", () => log.info("activation-aborted")); + throw new Error("activation refused"); +} + +export function deactivate() { + log.info("deactivate-called"); +} diff --git a/apps/server/src/plugins/testFixtures/plugin/reservedHandlers.mjs b/apps/server/src/plugins/testFixtures/plugin/reservedHandlers.mjs new file mode 100644 index 0000000000000..4e0cb84d34b1c --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/reservedHandlers.mjs @@ -0,0 +1,13 @@ +// Registers one handler under each `t3.` namespace so PluginSupervisor.test.ts +// can check which ones the child runtime reserves. +export function activate(context) { + const refusals = {}; + for (const name of ["t3.tool.echo", "t3.events", "t3.other"]) { + try { + context.proposed.handle(name, (input) => ({ handler: name, input })); + } catch (error) { + refusals[name] = error.message; + } + } + context.proposed.handle("refusals", () => refusals); +} diff --git a/apps/server/src/plugins/testFixtures/plugin/spinActivate.mjs b/apps/server/src/plugins/testFixtures/plugin/spinActivate.mjs new file mode 100644 index 0000000000000..1381940722315 --- /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 0000000000000..b92816dd17de8 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/plugin/t3-plugin.json @@ -0,0 +1,8 @@ +{ + "id": "test.fixture", + "name": "Supervisor fixture", + "version": "1.0.0", + "apiVersion": 1, + "entry": "main.mjs", + "proposedApi": true +} diff --git a/apps/server/src/plugins/testFixtures/rawHostCallChild.mjs b/apps/server/src/plugins/testFixtures/rawHostCallChild.mjs new file mode 100644 index 0000000000000..e3f932d9271bb --- /dev/null +++ b/apps/server/src/plugins/testFixtures/rawHostCallChild.mjs @@ -0,0 +1,61 @@ +// Drives raw IPC from a configured child; flood pauses reads until SIGUSR2 and burst reads every answer. +import * as NodeFS from "node:fs"; +import * as NodeNet from "node:net"; + +const config = JSON.parse(NodeFS.readFileSync("raw-child.json", "utf8")); +NodeFS.writeFileSync("raw-child.pid", String(process.pid)); +const channel = new NodeNet.Socket({ fd: 3, readable: true, writable: true }); +const send = (message) => channel.write(`${JSON.stringify(message)}\n`); +const log = (message) => send({ _tag: "Log", level: "info", message }); + +let answered = 0; +const failures = []; +const hostCalls = () => { + for (let requestId = 1; requestId <= config.requests; requestId++) + send({ _tag: "HostCall", requestId, method: config.method, input: {} }); +}; + +const receive = (message) => { + switch (message._tag) { + case "Activate": + send({ _tag: "Ready" }); + if (config.mode === "echo") return; + hostCalls(); + if (config.mode === "flood") channel.pause(); + return; + case "Invoke": + send({ _tag: "Succeeded", requestId: message.requestId, value: message.input }); + return; + case "Deactivate": + process.exit(0); + return; + case "HostCallSucceeded": + case "HostCallFailed": + answered++; + if (message._tag === "HostCallFailed") { + failures.push(message.message); + log(`refused: ${message.message}`); + } + if (answered === config.requests) + log( + `answered ${answered}, refused ${failures.length}: ${JSON.stringify([...new Set(failures)])}`, + ); + return; + } +}; + +process.on("SIGUSR2", () => channel.resume()); +// A paused socket does not keep the process alive; the server ends it with Deactivate or a kill. +setInterval(() => {}, 1 << 30); +let buffered = ""; +channel.on("data", (chunk) => { + buffered += chunk.toString("utf8"); + let newline; + while ((newline = buffered.indexOf("\n")) !== -1) { + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + receive(JSON.parse(line)); + } +}); +channel.on("end", () => process.exit(0)); +channel.on("error", () => process.exit(1)); diff --git a/apps/server/src/plugins/testFixtures/settingsPlugin/main.mjs b/apps/server/src/plugins/testFixtures/settingsPlugin/main.mjs new file mode 100644 index 0000000000000..51f1c28689a97 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/settingsPlugin/main.mjs @@ -0,0 +1,56 @@ +// Reads its settings and uses its storage on request so PluginSettings.test.ts and the live +// proof can check what the plugin sees. It never writes into its own directory. +let modeAtActivation; +let api; + +export async function activate(context) { + const { handle, settings, storage } = context.proposed; + api = { log: context.log, settings }; + // Host calls work before the plugin reports ready. + modeAtActivation = await settings.get("mode"); + handle("activationMode", () => modeAtActivation ?? null); + handle("read", async ({ key }) => { + const value = await settings.get(key); + return value === undefined ? { unset: true } : { value }; + }); + handle("store", async ({ key, value }) => { + await storage.set(key, value); + return null; + }); + handle("load", async ({ key }) => { + const value = await storage.get(key); + return value === undefined ? { missing: true } : { value }; + }); + handle("drop", async ({ key }) => { + await storage.delete(key); + return null; + }); + handle("keys", () => storage.keys()); + // Reports how each call settled, so a refusal is visible to the caller. + handle("attempt", async ({ method, key, value }) => { + try { + const result = method === "set" ? await storage.set(key, value) : await settings.get(key); + return { ok: true, result: result ?? null }; + } catch (error) { + return { ok: false, message: error.message }; + } + }); + handle("exit", () => process.exit(1)); + // Logs each refusal, so a test can hold the accepted calls until one is refused. + handle("burst", async ({ count }) => { + const calls = Array.from({ length: count }, (_, index) => storage.get(`burst-${index}`)); + for (const call of calls) call.catch(() => context.log.info("host-call-refused")); + const results = await Promise.allSettled(calls); + return results.map((result) => (result.status === "fulfilled" ? "ok" : result.reason.message)); + }); +} + +// Reports whether the server still answers a revoked plugin. +export async function deactivate() { + try { + await api.settings.get("mode"); + api.log.info("deactivate: answered"); + } catch (error) { + api.log.info(`deactivate: ${error.message}`); + } +} diff --git a/apps/server/src/plugins/testFixtures/settingsPlugin/t3-plugin.json b/apps/server/src/plugins/testFixtures/settingsPlugin/t3-plugin.json new file mode 100644 index 0000000000000..1a33f7a3a9aa2 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/settingsPlugin/t3-plugin.json @@ -0,0 +1,38 @@ +{ + "id": "test.settings", + "name": "Settings fixture", + "version": "1.0.0", + "apiVersion": 1, + "entry": "main.mjs", + "proposedApi": true, + "capabilities": ["settings"], + "settings": [ + { "type": "text", "key": "apiUrl", "label": "API URL", "default": "https://api.example.com" }, + { + "type": "secret", + "key": "token", + "label": "API token", + "description": "Sent with every request." + }, + { "type": "boolean", "key": "verbose", "label": "Verbose logging", "default": false }, + { + "type": "number", + "key": "retries", + "label": "Retries", + "default": 2, + "min": 0, + "max": 5, + "integer": true + }, + { + "type": "select", + "key": "mode", + "label": "Mode", + "default": "safe", + "options": [ + { "value": "safe", "label": "Safe" }, + { "value": "fast", "label": "Fast" } + ] + } + ] +} diff --git a/apps/server/src/plugins/testFixtures/toolsPlugin/main.mjs b/apps/server/src/plugins/testFixtures/toolsPlugin/main.mjs new file mode 100644 index 0000000000000..e481b66397eb3 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/toolsPlugin/main.mjs @@ -0,0 +1,19 @@ +// A tool plugin for PluginTools.test.ts. Its tools are declared in +// t3-plugin.json; activate only registers their handlers. +export function activate(context) { + const { handle } = context.proposed; + context.log.info("activated"); + handle("t3.tool.word_count", ({ input }) => ({ + words: input.text.split(/\s+/).filter(Boolean).length, + })); + handle("t3.tool.echo_context", (call) => call); + handle( + "t3.tool.wait_for_cancel", + (_call, { signal }) => + new Promise((_resolve, reject) => { + context.log.info("wait-started"); + signal.addEventListener("abort", () => reject(new Error("cancelled"))); + }), + ); + handle("t3.tool.big_result", ({ input }) => "x".repeat(input.length)); +} diff --git a/apps/server/src/plugins/testFixtures/toolsPlugin/t3-plugin.json b/apps/server/src/plugins/testFixtures/toolsPlugin/t3-plugin.json new file mode 100644 index 0000000000000..7fcd0a8dfa9f5 --- /dev/null +++ b/apps/server/src/plugins/testFixtures/toolsPlugin/t3-plugin.json @@ -0,0 +1,51 @@ +{ + "id": "test.tools", + "name": "Tools fixture", + "version": "1.0.0", + "apiVersion": 1, + "entry": "main.mjs", + "capabilities": ["tools"], + "proposedApi": true, + "tools": [ + { + "name": "word_count", + "title": "Count words", + "description": "Count the words in a text.", + "inputSchema": { + "type": "object", + "properties": { "text": { "type": "string", "maxLength": 10000 } }, + "required": ["text"], + "additionalProperties": false + }, + "sideEffect": "read" + }, + { + "name": "echo_context", + "description": "Return the input and the session context the host passed.", + "inputSchema": { + "type": "object", + "properties": { "note": { "type": "string" } }, + "additionalProperties": false + }, + "sideEffect": "read" + }, + { + "name": "wait_for_cancel", + "description": "Wait until the call is cancelled.", + "inputSchema": { "type": "object" }, + "sideEffect": "write", + "openWorld": true + }, + { + "name": "big_result", + "description": "Return a string of the given length.", + "inputSchema": { + "type": "object", + "properties": { "length": { "type": "integer", "minimum": 0 } }, + "required": ["length"], + "additionalProperties": false + }, + "sideEffect": "read" + } + ] +} diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 885053c7d0f24..f3c2c8fd14ebe 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -8,6 +8,7 @@ import { McpServer } from "effect/ai"; import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; import { ServerConfig } from "../config.ts"; +import { PluginTools } from "../plugins/PluginTools.ts"; import { DeviceService } from "../device/DeviceService.ts"; import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; import { GitWorkflowService } from "../git/GitWorkflowService.ts"; @@ -67,6 +68,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(PullRequestReviewGuideService)({}), Layer.mock(RemoteDelegation)({}), Layer.mock(ScheduledTaskService)({}), + Layer.mock(PluginTools)({}), Layer.mock(SecretRequests)({}), ServerConfig.layerTest(process.cwd(), { prefix: "t3-agent-context-budget-" }), Layer.mock(ServerEnvironment)({}), diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index 3245ee61294e4..1bca335008fb8 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"; @@ -16,7 +17,7 @@ export type ProviderOrchestrationAdapterInfrastructure = | CursorKeychain.CursorKeychain | IdAllocator.IdAllocatorV2; -/** Reuse the continuation worker's exact layer reference so memoization shares one queue. */ +// Reuse exact continuation and contribution-status layer references so their consumers share state. export const layer = Layer.mergeAll( ClaudeAdapterV2.layerQueryRunner, CodexAdapterV2.layerAppServerClientFactory, @@ -24,4 +25,5 @@ export const layer = Layer.mergeAll( CursorKeychain.layer, IdAllocator.layer, ProviderContinuationRequests.layer, + ContributionStatusStore.layer, ); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 1719d3ca6bd19..c4bd01ae98f0f 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5153, "codexAdditionalContextBytes": 6883, - "mcpToolListJsonBytes": 47538 + "mcpToolListJsonBytes": 48907 } diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index f3c7b893afa83..c87d1a6811fac 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -118,6 +118,8 @@ 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 452addfb4a667..767de5b54fd04 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 { @@ -78,6 +79,12 @@ 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 PluginSettings from "./plugins/PluginSettings.ts"; +import * as PluginSupervisor from "./plugins/PluginSupervisor.ts"; +import * as PluginTools from "./plugins/PluginTools.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as ServerBrowser from "./preview/ServerBrowser.ts"; @@ -460,6 +467,14 @@ const layerDevice = DeviceService.layer.pipe( Layer.provide(NetService.layer), ); +// Zero enabled plugins means zero plugin processes; each starts on first use. +const layerPlugin = Layer.mergeAll(PluginTools.layer, PluginSettings.layer()).pipe( + Layer.provideMerge(PluginCatalog.layer()), + Layer.provide(PluginSupervisor.layer()), + // Shared with the event feed: the catalogue starts event cursors on enable. + Layer.provideMerge(PluginEventDelivery.layer), +); + const layerWorkspaceEntries = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer)); const layerWorkspaceFileSystem = WorkspaceFileSystem.layer.pipe( @@ -625,6 +640,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( layerProviderInstallationRefresh, ReplayMarkers.layer, Layer.effectDiscard(ProjectSettingsOverrideSweep.sweep), + // The orchestrator's own event sink, so commits wake event delivery. + PluginEventFeed.layer().pipe( + Layer.provide(Layer.merge(ProjectionStoreV2.layer, RuntimeLayer.layerEventSink)), + ), ).pipe( Layer.provideMerge(layerOrchestrationApplication), Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), @@ -634,7 +653,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), Layer.provideMerge( Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer), diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index ba647514ecef4..45e9b555eb1ca 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -107,6 +107,8 @@ import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as TriageService from "./triage/TriageService.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import * as PluginCatalog from "./plugins/PluginCatalog.ts"; +import * as PluginSettings from "./plugins/PluginSettings.ts"; import * as ServerConfigModule from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import * as ServerSettings from "./serverSettings.ts"; @@ -563,6 +565,8 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ReviewService.ReviewService)({}), Layer.mock(ScheduledTasks.ScheduledTaskService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(PluginCatalog.PluginCatalog)({}), + Layer.mock(PluginSettings.PluginSettings)({}), Layer.mock(SecretResolver.SecretResolver)({}), Layer.mock(GuestShareService.GuestShareService)({}), Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8373bbfae0c55..3064c95b606e4 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, @@ -97,6 +97,8 @@ import { type TerminalMetadataStreamEvent, type PullRequestRef, WS_METHODS, + WsCoreRpcGroup, + WsPluginRpcGroup, WsRpcGroup, } from "@t3tools/contracts"; import { @@ -122,6 +124,8 @@ import * as DocumentText from "./provider/DocumentText.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; +import * as PluginCatalog from "./plugins/PluginCatalog.ts"; +import * as PluginSettings from "./plugins/PluginSettings.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import { bufferShellLiveStream, @@ -227,6 +231,7 @@ import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as DefectReporter from "./observability/DefectReporter.ts"; import * as EventLoopMonitor from "./observability/EventLoopMonitor.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"; @@ -586,12 +591,14 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; // Middleware added later wraps middleware added earlier, so instrumentation wraps authorization. const ServerWsRpcGroup = WsRpcGroup.middleware(RpcInstrumentation); +const ServerWsCoreRpcGroup = WsCoreRpcGroup.middleware(RpcInstrumentation); +const ServerWsPluginRpcGroup = WsPluginRpcGroup.middleware(RpcInstrumentation); // RpcGroup.toLayer derives each handler's requirements by scanning every RPC in // the group, so its type cost grows with handlers × RPCs. Near 200 methods that // crosses TypeScript's instantiation limit and the layer's requirements degrade to // `any`, so the handlers are registered as two disjoint, smaller groups. -type ServerWsRpc = RpcGroup.Rpcs; +type ServerWsRpc = RpcGroup.Rpcs; const SPLIT_WS_RPC_PREFIXES = ["server", "pullRequests", "provider", "orchestration"] as const; type SplitWsRpc = Extract< ServerWsRpc, @@ -601,7 +608,7 @@ const isSplitWsRpc = (rpc: ServerWsRpc): rpc is SplitWsRpc => SPLIT_WS_RPC_PREFIXES.some((prefix) => rpc._tag.startsWith(`${prefix}.`)); const isOtherWsRpc = (rpc: ServerWsRpc): rpc is Exclude => !isSplitWsRpc(rpc); -const serverWsRpcs = Array.from(ServerWsRpcGroup.requests.values()); +const serverWsRpcs = Array.from(ServerWsCoreRpcGroup.requests.values()); const SplitWsRpcGroup = RpcGroup.make(...serverWsRpcs.filter(isSplitWsRpc)); const OtherWsRpcGroup = RpcGroup.make(...serverWsRpcs.filter(isOtherWsRpc)); // Replaying each intervening event costs a shell refetch; past this gap a @@ -1247,6 +1254,28 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }, ); +// The plugin RPCs' handlers; see `WsPluginRpcGroup` for why they are a layer of their own. +const layerWsPluginRpc = ServerWsPluginRpcGroup.toLayer( + Effect.gen(function* () { + const pluginCatalog = yield* PluginCatalog.PluginCatalog; + const pluginSettings = yield* PluginSettings.PluginSettings; + return ServerWsPluginRpcGroup.of({ + [WS_METHODS.pluginsList]: (_input) => pluginCatalog.list, + [WS_METHODS.pluginsSubscribe]: (_input) => pluginCatalog.subscribe, + [WS_METHODS.pluginsAdd]: (input) => pluginCatalog.add(input), + [WS_METHODS.pluginsRefresh]: (input) => pluginCatalog.refresh(input), + [WS_METHODS.pluginsConsent]: (input) => pluginCatalog.consent(input), + [WS_METHODS.pluginsEnable]: (input) => pluginCatalog.enable(input), + [WS_METHODS.pluginsDisable]: (input) => pluginCatalog.disable(input), + [WS_METHODS.pluginsRemove]: (input) => pluginCatalog.remove(input), + [WS_METHODS.pluginsResume]: (input) => pluginCatalog.resume(input), + [WS_METHODS.pluginsSettingsSubscribe]: (input) => + pluginSettings.subscribe(input.installationId), + [WS_METHODS.pluginsSettingsUpdate]: (input) => pluginSettings.update(input), + }); + }), +); + export const layerWsRpc = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientAnalyticsProps: Readonly>, @@ -1403,6 +1432,7 @@ export 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; // Webhook URLs start agents; expose them only to operate-scoped sessions while keeping tasks readable. const withVisibleWebhookUrls = (result: ScheduledTaskListResult): ScheduledTaskListResult => @@ -1890,7 +1920,7 @@ export const layerWsRpc = ( return yield* mutateProjectWithClone(projectService, projectCloneTracker, mutation); }); - const handlers = ServerWsRpcGroup.of({ + const handlers = ServerWsCoreRpcGroup.of({ [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -3279,6 +3309,8 @@ export const layerWsRpc = ( Stream.concat(Stream.make(latest), changes), ), ), + [WS_METHODS.subscribeContributionStatus]: (_input) => + ContributionStatusStore.subscriptionStream(contributionStatus), }); const splitHandlers: Pick = handlers; const otherHandlers: Pick["_tag"]> = @@ -3288,7 +3320,7 @@ export const layerWsRpc = ( yield* OtherWsRpcGroup.toHandlers(otherHandlers), ); }), - ); + ).pipe(Layer.merge(layerWsPluginRpc)); // Default socket-level Defect frames end every pending client request. export const WS_RPC_SERVER_OPTIONS = { diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 9c4a6c803ff5b..923aed5050a90 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -54,18 +54,30 @@ 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; readonly openPreview: OpenPreviewMutation; readonly profileId?: PreviewOpenInput["profileId"]; readonly background?: boolean; + readonly isScopeCurrent?: ScopeCheck; }): Promise> { // Read before any await: a side chat pane that unmounts meanwhile drops its host mapping. const threadRef = rightPanelHostRef(input.threadRef); 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)); } @@ -115,6 +127,7 @@ export async function openFileInPreview(input: { readonly input: { readonly resource: AssetResource }; }) => Promise>; readonly openPreview: OpenPreviewMutation; + readonly isScopeCurrent?: ScopeCheck; }): Promise< AtomCommandResult< void, @@ -143,6 +156,7 @@ export async function openFileInPreview(input: { }, }, }); + if (leftScope(input.isScopeCurrent)) return AsyncResult.failure(Cause.interrupt()); if (assetResult._tag === "Failure") { return AsyncResult.failure(assetResult.cause); } @@ -156,5 +170,6 @@ export async function openFileInPreview(input: { threadRef: hostRef, 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 ea477b9144791..2e270a70b8808 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -112,6 +112,12 @@ import { type SetStateAction, use, useEffect, useLayoutEffect, useRef, useState import { BackgroundThreadContext } from "./chat/backgroundThread"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; +import { + PanelHostContext, + threadBoundAnnotationSender, + type PanelHost, + type ThreadAnnotationSender, +} from "~/panels/panelHost"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useStore } from "zustand"; import { shallow } from "zustand/vanilla/shallow"; @@ -2346,7 +2352,6 @@ export default function ChatView(props: ChatViewProps) { addPullRequestsSurfaceStable, closePreviewPanelStable, copyRightPanelFilePathStable, - createBrowserSurface, createBrowserSurfaceStable, moveRightPanelSurfaceStable, onOpenToolDiffStable, @@ -3169,6 +3174,36 @@ 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); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- lends the committed composer sender to browser annotation callbacks + useLayoutEffect(() => { + annotationSenderRef.current = activeThreadKey + ? { + threadKey: activeThreadKey, + send: (annotation, image) => { + void onSend(undefined, "auto", "foreground", { annotation, image }); + }, + } + : null; + }); + const panelHost: PanelHost | null = activeThreadRef + ? { + threadRef: activeThreadRef, + visible: rightPanelOpen, + composerDraftTarget, + workspaceMutationId, + sendAnnotation: (annotation, image) => { + threadBoundAnnotationSender( + () => annotationSenderRef.current, + scopedThreadKey(activeThreadRef), + )(annotation, image); + }, + } + : null; + const addDroppedFilesToComposer = useStableCallback( (files: Parameters[0]) => composerRef.current?.addDroppedFiles(files), @@ -3187,7 +3222,7 @@ export default function ChatView(props: ChatViewProps) { return embedded ? null : ; } - const rightPanelContent = activeThreadRef ? ( + const rightPanelSurfaceContent = activeThreadRef ? ( ) : null; + const rightPanelContent = ( + {rightPanelSurfaceContent} + ); + const sidePanelLaunchers = { + preview: { + available: canOperatePreview && browserAvailable, + onOpen: createBrowserSurfaceStable, + }, + diff: { available: isServerThread && isGitRepo, onOpen: addDiffSurfaceStable }, + terminal: { + available: activeProject !== null && canOperateTerminal, + onOpen: addTerminalSurfaceStable, + }, + device: { available: activeThreadRef !== null, onOpen: addDeviceSurfaceStable }, + files: { available: activeProject !== null, onOpen: addFilesSurfaceStable }, + "pull-request": { available: pullRequestSurfaceAvailable, onOpen: addPullRequestSurfaceStable }, + "pull-requests": { + available: pullRequestsSurfaceAvailable, + onOpen: addPullRequestsSurfaceStable, + }, + }; const panelToggleControlProps = { terminalAvailable: activeProject !== null, terminalOpen: terminalUiState.terminalOpen, @@ -3962,22 +4018,9 @@ export default function ChatView(props: ChatViewProps) { onCloseAllSurfaces={closeAllRightPanelSurfacesStable} onMoveSurface={moveRightPanelSurfaceStable} onCopyFilePath={copyRightPanelFilePathStable} - onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurfaceStable} - onAddTerminal={addTerminalSurfaceStable} - onAddDiff={addDiffSurfaceStable} - onAddFiles={addFilesSurfaceStable} - onAddPullRequest={addPullRequestSurfaceStable} - onAddPullRequests={addPullRequestsSurfaceStable} - onAddDevice={addDeviceSurfaceStable} + panels={sidePanelLaunchers} onAddSideChat={sideChatsOffered ? openSideChatStable : undefined} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} sideChatAvailable={sideChat.available} sideChatDisabledReason={sideChatDisabledReason} > diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 505c11130d692..c30f445991d3a 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -81,7 +81,8 @@ vi.mock("./diffs/AnnotatableCodeView", () => ({ AnnotatableCodeView: () => null vi.mock("./diffs/DiffFileTree", () => ({ DiffFileTree: () => null })); vi.mock("./DiffFilePathCopyButton", () => ({ DiffFilePathCopyButton: () => null })); -import DiffPanel from "./DiffPanel"; +import DiffSidePanel from "~/panels/diff/DiffSidePanel"; +import { PanelHostContext } from "~/panels/panelHost"; import { useDiffPanelSessionStore, useDiffPanelStore } from "~/diffPanelStore"; const cwd = "/project-worktree"; @@ -194,7 +195,17 @@ it.each([ using context = setup(failedBaseRef); await using screen = await render( - + {}, + }} + > + + , ); await expect.element(screen.getByText(errorMessage, { exact: true })).toBeVisible(); diff --git a/apps/web/src/components/RightPanelTabs.browserProfile.browser.test.tsx b/apps/web/src/components/RightPanelTabs.browserProfile.browser.test.tsx new file mode 100644 index 0000000000000..eef6bb31fd6f0 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.browserProfile.browser.test.tsx @@ -0,0 +1,116 @@ +import { render as renderScreen, stubGlobal, type Screen } from "../../test/render"; +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 { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; +vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) })); + +// The profile list normally comes from client settings; the built-ins are enough to choose from. +vi.mock("~/browser/browserDefaults", () => ({ + useBrowserDefaults: () => ({ profiles: BUILT_IN_BROWSER_PROFILES }), +})); + +import { RightPanelTabs } from "./RightPanelTabs"; + +let opened: string[]; + +// 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(screen: Screen, label: string): HTMLButtonElement { + const launcher = screen.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 userEvent.click(element); +} + +describe("opening Browser from the launcher", () => { + it("opens the default profile from the row and another profile from the chooser", async () => { + resetFixture(); + using _desktopBridge = stubGlobal("desktopBridge", { preview: {} }); + using _resizeObserver = stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + await using screen = await renderScreen(null, { router: false }); + await screen.rerender(); + + await click(launcherRow(screen, "Browser")); + expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID]); + + await click(screen.container.querySelector('[aria-label="Open browser in a profile"]')!); + await click(menuItem("Incognito")); + expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID]); + }); +}); + +function resetFixture() { + opened = []; +} diff --git a/apps/web/src/components/RightPanelTabs.terminal.browser.test.tsx b/apps/web/src/components/RightPanelTabs.terminal.browser.test.tsx new file mode 100644 index 0000000000000..028f71e8e940f --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.terminal.browser.test.tsx @@ -0,0 +1,131 @@ +import { act, render as renderScreen, stubGlobal, type Screen } from "../../test/render"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { describe, expect, it } 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"), +}; + +// 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(screen: Screen, label: string): HTMLElement { + const launcher = screen.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 () => { + resetFixture(); + using _resizeObserver = stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + await using screen = await renderScreen(null, { router: false }); + await screen.rerender(); + + await click(launcherRow(screen, "Terminal")); + expect(threadSurfaces()).toMatchObject([{ kind: "terminal", activeTerminalId: "term-1" }]); + expect(screen.container.querySelector('[aria-label="Open a surface"]')).toBeNull(); + expect(screen.container.textContent).toContain("Terminal 1"); + }); + + it("keeps the row disabled and opens nothing without a project", async () => { + resetFixture(); + using _resizeObserver = stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + await using screen = await renderScreen(null, { router: false }); + await screen.rerender(); + + const row = launcherRow(screen, "Terminal"); + expect(row.getAttribute("aria-disabled")).toBe("true"); + await click(row); + expect(threadSurfaces()).toEqual([]); + expect(screen.container.querySelector('[aria-label="Open a surface"]')).not.toBeNull(); + }); +}); + +function resetFixture() { + useRightPanelStore.setState({ byThreadKey: {} }); +} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 88793cce44850..f7eb2b2ec82e1 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -1,12 +1,48 @@ import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { resolvePullRequestTabLink, + rightPanelSurfaceActions, surfaceShortcutActionForKey, surfaceShortcutTargetsTypingContext, } from "./RightPanelTabs"; +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]> = {}, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 596bdee96ad1f..2a5c3c8b60500 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -35,20 +35,10 @@ 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, - MessagesSquare, - Plus, - TerminalSquare, -} from "lucide-react"; +import { ChevronDown, ChevronLeft, ChevronRight, Plus, MessagesSquare } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { + type ComponentType, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -60,6 +50,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"; @@ -132,26 +123,11 @@ export interface RightPanelTabsProps { onCloseAllSurfaces: () => void; onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; - onAddBrowser: () => void; - /** Keep separate from the DOM click handler so a MouseEvent cannot become 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; - /** Absent where side chats are not offered. */ onAddSideChat?: (() => void) | undefined; - browserAvailable: boolean; - terminalAvailable: boolean; - diffAvailable: boolean; - filesAvailable: boolean; - pullRequestAvailable: boolean; - pullRequestsAvailable: boolean; - deviceAvailable: boolean; sideChatAvailable?: boolean; - /** Overrides the default reason while side chats are available but cannot start. */ sideChatDisabledReason?: string | null; pullRequestStatusSeeds?: Readonly>; children: ReactNode; @@ -171,17 +147,6 @@ function shouldOpenDefaultBrowserProfileFromMenuClick(pointerType: string | unde 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.", - sideChat: "Side chats start after the first message.", -} as const; - // Closed keepMounted popups must stop blocking shortcuts once their exit transition ends. const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ "dialog-popup", @@ -196,16 +161,75 @@ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ .map((slot) => `[data-slot="${slot}"]:is([data-open],[data-ending-style])`) .join(","); -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.", - sideChat: "Available after the first message.", -} 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< + RightPanelTabsProps, + "panels" | "onAddSideChat" | "sideChatAvailable" | "sideChatDisabledReason" +>; + +/** + * 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"), + ...(props.onAddSideChat + ? [ + { + id: "side-chat", + label: "Side chat", + icon: MessagesSquare, + shortcut: "S", + available: props.sideChatAvailable === true && !props.sideChatDisabledReason, + unavailableHint: props.sideChatDisabledReason ?? "Available after the first message.", + unavailableReason: + props.sideChatDisabledReason ?? "Side chats start after the first message.", + onClick: () => props.onAddSideChat?.(), + }, + ] + : []), + ]; +} type TabContextMenuAction = | "rename" @@ -313,103 +337,12 @@ function SurfaceMenuItem(props: { } 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; - /** Absent where side chats are not offered. */ - onAddSideChat?: (() => void) | undefined; - browserAvailable: boolean; - terminalAvailable: boolean; - diffAvailable: boolean; - filesAvailable: boolean; - pullRequestAvailable: boolean; - pullRequestsAvailable: boolean; - deviceAvailable: boolean; - sideChatAvailable: boolean; - sideChatDisabledReason: string | null; }) { 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, - }, - ...(props.onAddSideChat - ? [ - { - label: "Side chat", - description: "Ask a side question while this thread keeps working.", - icon: MessagesSquare, - shortcut: "S", - available: props.sideChatAvailable && !props.sideChatDisabledReason, - disabledReason: props.sideChatDisabledReason ?? SURFACE_UNAVAILABLE_HINTS.sideChat, - onClick: props.onAddSideChat, - }, - ] - : []), - ] as const; - - type SurfaceAction = (typeof actions)[number]; + const actions = props.actions; const availableActions = actions.filter((action) => action.available); const highlightIndex = @@ -488,7 +421,7 @@ function RightPanelEmptyState(props: { {actions.map((action) => action.available ? (
setHighlight(availableActions.indexOf(action))} onMouseLeave={() => @@ -509,14 +442,14 @@ function RightPanelEmptyState(props: { 1 && "pr-7", + action.id === "preview" && props.browserProfiles.length > 1 && "pr-7", )} > {action.label} {action.shortcut} - {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; } } } @@ -609,10 +543,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" /> ); @@ -657,10 +592,14 @@ function SurfaceIcon({ switch (surface.kind) { case "preview": 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 "pull-requests": { + const Icon = getSidePanelMetadata("pull-requests").icon; + return ; + } case "side-chat": return ; - case "device": + case "device": { + const DeviceIcon = getSidePanelMetadata("device").icon; return surface.target?.platform === "ios" ? ( ) : surface.target?.platform === "android" ? ( ) : ( - + ); + } } } @@ -1090,76 +1035,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, - }, - ...(props.onAddSideChat - ? [ - { - label: "Side chat", - icon: MessagesSquare, - shortcut: "S", - available: props.sideChatAvailable === true && !props.sideChatDisabledReason, - disabledReason: props.sideChatDisabledReason ?? SURFACE_DISABLED_REASONS.sideChat, - onClick: props.onAddSideChat, - }, - ] - : []), - ] as const; + const addSurfaceActions = rightPanelSurfaceActions(props); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); @@ -1413,9 +1289,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { > {addSurfaceActions.map((action) => { const Icon = action.icon; - if (action.label === "Browser" && action.available) { + if (action.id === "preview" && action.available) { return ( - + @@ -1524,25 +1400,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 49f21a1ddc829..bc91deb826995 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -41,6 +41,7 @@ import { observeResize } from "~/lib/observeResize"; import { cn } from "~/lib/utils"; import { textDirection } from "~/textDirection"; import { useClientSettings } from "../../hooks/useSettings"; +import { ThreadContributionStatus } from "./ThreadContributionStatus"; import { GuestShareIndicator } from "./GuestShareControls"; interface ChatHeaderProps { @@ -430,6 +431,12 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} + {isServerThread ? ( + + ) : null} {isServerThread ? : null}
); diff --git a/apps/web/src/components/chat/ChatTerminalDrawers.tsx b/apps/web/src/components/chat/ChatTerminalDrawers.tsx index c6e14b90ec185..784925fb55d5e 100644 --- a/apps/web/src/components/chat/ChatTerminalDrawers.tsx +++ b/apps/web/src/components/chat/ChatTerminalDrawers.tsx @@ -18,7 +18,7 @@ import { import { PersistentThreadTerminalDrawer, type PersistentTerminalLaunchContext, -} from "../PersistentThreadTerminals"; +} from "~/panels/terminal/PersistentThreadTerminalDrawer"; interface ChatTerminalDrawersProps { readonly activeThreadKey: string | null; diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index b703e5d05da97..7333185148d3d 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -9,30 +9,17 @@ import type { ThreadPullRequestLink, } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { lazy, Suspense } from "react"; import SideChatPane from "./SideChatPane"; import type { ComposerImageAttachment, DraftId } from "../../composerDraftStore"; import type { ShortcutMatchContext } from "../../keybindings"; import type { SideChatParentPanel } from "../../lib/sideChatPane"; import { type RightPanelSurface, useRightPanelStore } from "../../rightPanelStore"; -import { - PersistentThreadTerminalPanel, - type PersistentThreadTerminalPanelProps, -} from "../PersistentThreadTerminals"; +import { type PersistentThreadTerminalPanelProps } from "~/panels/terminal/PersistentThreadTerminalDrawer"; import { pullRequestPanelContext } from "../pullRequest/pullRequestDetail.logic"; -import { PullRequestDetailPanel } from "../pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "../pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "../pullRequest/PullRequestsUnavailableState"; -import { ThreadPullRequestsPanel } from "../pullRequest/ThreadPullRequestsPanel"; -const PreviewPanel = lazy(() => - import("../preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), -); -const DiffPanel = lazy(() => import("../DiffPanel")); -const DevicePanel = lazy(() => - import("../device/DevicePanel").then((module) => ({ default: module.DevicePanel })), -); -const FilePreviewPanel = lazy(() => import("../files/FilePreviewPanel")); +import { RegisteredSidePanel } from "~/panels/bundledPanels"; type RightPanelTerminalProps = Omit< PersistentThreadTerminalPanelProps, @@ -84,38 +71,16 @@ export function RightPanelSurfaceContent(props: { switch (surface.kind) { case "preview": return ( - - - - ); - case "terminal": - return ( - ); + case "terminal": + return ; case "diff": - return ( - - - - ); + return ; case "pull-request": { const pullRequest = props.pullRequest; if (!pullRequest.capabilityKnown) return ; @@ -128,20 +93,11 @@ export function RightPanelSurfaceContent(props: { ); } return ( - { - useRightPanelStore.getState().openPullRequest(threadRef, { - projectId: reference.projectId, - repository: reference.repository, - number: reference.number, - ...(reference.host ? { host: reference.host } : {}), - }); - }} - threadRef={threadRef} reference={{ projectId: surface.projectId as ProjectId, ...(surface.host ? { host: surface.host } : {}), @@ -161,13 +117,12 @@ export function RightPanelSurfaceContent(props: { }, surface, )} - composerDraftTarget={props.composerDraftTarget} onBack={pullRequest.onBack} /> ); } case "pull-requests": - return ; + return ; case "side-chat": // Not lazy: revealing a suspended tiptap editor tears down its view and crashes the composer. return ( @@ -181,19 +136,15 @@ export function RightPanelSurfaceContent(props: { ); case "device": return ( - - { - props.onCloseSurface(surface); - useRightPanelStore.getState().show(threadRef); - }} - /> - + { + props.onCloseSurface(surface); + useRightPanelStore.getState().show(threadRef); + }} + /> ); case "files": case "file": { @@ -201,35 +152,28 @@ export function RightPanelSurfaceContent(props: { const attachment = surface.kind === "file" ? surface.attachment : undefined; if (!attachment && (files.projectName === null || !files.workspaceRoot)) return null; return ( - - - useRightPanelStore - .getState() - .navigateFolder(threadRef, surface.id, relativePath, folderTrail, keepSource) - } - folderTrail={surface.folderTrail} - onPendingChange={files.onPendingChange} - selectedFilePending={surface.kind === "file" && files.pendingSurfaceIds.has(surface.id)} - workspaceMutationId={props.workspaceMutationId} - /> - + + useRightPanelStore + .getState() + .navigateFolder(threadRef, surface.id, relativePath, folderTrail, keepSource) + } + folderTrail={surface.folderTrail} + onPendingChange={files.onPendingChange} + selectedFilePending={surface.kind === "file" && files.pendingSurfaceIds.has(surface.id)} + /> ); } } diff --git a/apps/web/src/components/chat/ThreadContributionStatus.browser.test.tsx b/apps/web/src/components/chat/ThreadContributionStatus.browser.test.tsx new file mode 100644 index 0000000000000..16fe1914e61f9 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.browser.test.tsx @@ -0,0 +1,122 @@ +import { act, render as renderScreen, type Screen } from "../../../test/render"; +import { + type ContributionStatusEntry, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; +import { 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, +}); +async function renderStatus(screen: Screen, entries: ReadonlyArray) { + testState.entries = entries; + await act(async () => { + await screen.rerender( + , + ); + }); +} +/** The trigger's accessible name: its explicit label, else its text. */ +function accessibleName(element: Element) { + return element.getAttribute("aria-label") ?? element.textContent ?? ""; +} +function statusButton(screen: Screen) { + const button = [...screen.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 () => { + using _setup = await setup(); + await using screen = await renderScreen(null, { router: false }); + await renderStatus(screen, [ + 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(screen); + 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 () => { + using _setup = await setup(); + await using screen = await renderScreen(null, { router: false }); + await renderStatus(screen, [entry([{ key: "mode", text: "● plan" }])]); + const button = statusButton(screen); + 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 () => { + using _setup = await setup(); + await using screen = await renderScreen(null, { router: false }); + const longText = `● ${"x".repeat(78)}`; + await renderStatus(screen, [ + 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(screen))).toBe( + `Provider status: Plan, ${longText}, disk almost full`, + ); + await act(async () => statusButton(screen).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 () => { + using _setup = await setup(); + await using screen = await renderScreen(null, { router: false }); + await renderStatus(screen, []); + expect(screen.container.childElementCount).toBe(0); + }); +}); + +async function setup() { + return { + [Symbol.dispose]() { + testState.entries = []; + }, + }; +} 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 0000000000000..d7a1cb968ab3e --- /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 0000000000000..0393585f23455 --- /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.tsx b/apps/web/src/components/chat/ThreadContributionStatus.tsx new file mode 100644 index 0000000000000..0449a2c34f0ff --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.tsx @@ -0,0 +1,101 @@ +import type { ContributionStatusTone, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { memo } 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; + 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} + {chip.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 = contributionStatusChips(entries); + if (chips.length === 0) return null; + return ; +}); diff --git a/apps/web/src/components/device/DevicePanel.browser.test.tsx b/apps/web/src/components/device/DevicePanel.browser.test.tsx index 8e8e0d27eb966..89277bf316369 100644 --- a/apps/web/src/components/device/DevicePanel.browser.test.tsx +++ b/apps/web/src/components/device/DevicePanel.browser.test.tsx @@ -11,7 +11,30 @@ import type { RightPanelSurface } from "~/rightPanelStore"; import { act, render } from "../../../test/render"; -import { DevicePanel } from "./DevicePanel"; +import DeviceSidePanel from "~/panels/device/DeviceSidePanel"; +import { PanelHostContext } from "~/panels/panelHost"; + +function DevicePanel(props: { + mode: "embedded"; + threadRef: ScopedThreadRef; + surface: Extract; + visible: boolean; + onDismissSetup: () => void; +}) { + return ( + undefined, + }} + > + + + ); +} import "~/index.css"; type CommandResult = diff --git a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx index c1af18d7a6966..3cee8e39ae7e5 100644 --- a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx +++ b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useStableCallback } from "~/hooks/useStableCallback"; import { observeIntersection } from "~/lib/observeIntersection"; -import { DiffFileHeaderSkeleton } from "../DiffPanelShell"; +import { DiffFileHeaderSkeleton } from "../diffs/DiffLoadingState"; export function DiffFileLoadingBoundary({ load, count }: { load: () => void; count: number }) { const [isNear, setIsNear] = useState(false); 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 456f70516b23b..34b1e49605871 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/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index fc5a1aa7b91f9..857079de2b6a7 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -462,7 +462,7 @@ import { OFFICE_PREVIEW_MAX_COLUMNS, type OfficeWorkerResult } from "./officePre describe("large text preview recovery", () => { it("retains the workspace viewer across URL renewals and reloads revisions and manual refreshes", async () => { - const { WorkspaceLargeTextPreview } = await import("./FilePreviewPanel"); + const { WorkspaceLargeTextPreview } = await import("~/panels/files/FilesSidePanel"); const original = URL.createObjectURL(new Blob(["original\n".repeat(1000)])); const changed = URL.createObjectURL(new Blob(["changed\n".repeat(1000)])); const workers: Worker[] = []; @@ -2346,7 +2346,7 @@ it("toggles a Mermaid attachment between diagram and source through the lazy pre }); it("keeps a workspace PDF open across URL renewals and reloads it when the file changes", async () => { - const { WorkspaceBrowserPreview } = await import("./FilePreviewPanel"); + const { WorkspaceBrowserPreview } = await import("~/panels/files/FilesSidePanel"); using _native = vi.spyOn(navigator, "pdfViewerEnabled", "get").mockReturnValue(true); const original = URL.createObjectURL(new Blob(["%PDF-1.4"], { type: "application/pdf" })); const edited = URL.createObjectURL(new Blob(["%PDF-1.4 edited"], { type: "application/pdf" })); @@ -2446,7 +2446,7 @@ function planningPdf( async function workspacePdfPreview() { assetUrlState.mockReset(); - const { WorkspaceBrowserPreview } = await import("./FilePreviewPanel"); + const { WorkspaceBrowserPreview } = await import("~/panels/files/FilesSidePanel"); const native = vi.spyOn(navigator, "pdfViewerEnabled", "get").mockReturnValue(false); const urls = (() => { const original = URL.createObjectURL(planningPdf()); diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 1c0e1bc914bed..c52c9b2e0017f 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -54,6 +54,7 @@ describe("FileBrowserPanel", () => { selectedPath={null} selectedPathRevealId={0} onOpenFile={() => {}} + addToChat={() => "inserted"} workspaceMutationId={null} />, ); @@ -92,6 +93,7 @@ describe("FileBrowserPanel", () => { selectedPath={null} selectedPathRevealId={0} onOpenFile={onOpenFile} + addToChat={() => "inserted"} workspaceMutationId={null} />, ); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index b02baf31b0acf..b71496de5f253 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -17,7 +17,6 @@ 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 { useStableCallback } from "~/hooks/useStableCallback"; import { useTheme } from "~/hooks/useTheme"; @@ -35,7 +34,10 @@ import { loadFileAncestors } from "./loadFileAncestors"; import { useDirectoryEntries } from "./useDirectoryEntries"; import { useProjectPathSearch } from "~/state/queries"; +export type AddToChatResult = "inserted" | "no-composer" | "not-ready" | "dropped"; + interface FileBrowserPanelProps { + addToChat: (text: string) => AddToChatResult; environmentId: EnvironmentId; cwd: string; projectName: string; @@ -142,12 +144,12 @@ export default function FileBrowserPanel({ onOpenFile, onRefreshSelectedFile, workspaceMutationId, + addToChat, hostFolder = false, }: FileBrowserPanelProps) { const panelPath = (relativePath: string) => hostFolder ? joinHostPath(cwd, relativePath) : relativePath; const { resolvedTheme } = useTheme(); - const composerRef = useComposerHandleContext(); const fileContextMenu = useFileContextMenu(environmentId); const { entries: directoryEntries, @@ -229,8 +231,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", @@ -238,8 +240,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", @@ -257,6 +258,9 @@ export default function FileBrowserPanel({ const dragMention = createFileTreeDragMentionController( hostFolder ? { mentionPath: (path: string) => joinHostPath(cwd, path) } : {}, ); + const openSelectedFile = useStableCallback((relativePath: string) => + onOpenFile(panelPath(relativePath)), + ); const { model } = useFileTree({ composition: { contextMenu: { @@ -282,7 +286,7 @@ export default function FileBrowserPanel({ const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { treeSelectionPathRef.current = selectedPath; - onOpenFile(panelPath(selectedPath)); + openSelectedFile(selectedPath); } }, paths: [], diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx index 49857b5db36a4..541869e1e9966 100644 --- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx @@ -73,7 +73,8 @@ vi.mock(import("./projectFilesQueryState"), async (original) => ({ confirmProjectFileQueryData: () => true, })); -import FilePreviewPanel from "./FilePreviewPanel"; +import FilePreviewPanel from "~/panels/files/FilesSidePanel"; +import { PanelHostContext } from "~/panels/panelHost"; const environmentId = EnvironmentId.make("markdown-preview-test"); const threadRef = { environmentId, threadId: ThreadId.make("thread") }; @@ -103,25 +104,29 @@ function markdownPreview(truncated: boolean) { truncated, }; return ( - + undefined, + }} + > + + ); } diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 86e2e27b98365..00b83e02cf42c 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -77,7 +77,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 { useDiffWorkerPool } from "../DiffWorkerPoolProvider"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { DiffFileTree } from "../diffs/DiffFileTree"; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 1bade1b4e160f..00f161e99f223 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -100,7 +100,7 @@ import { PullRequestDetailCrumbs } from "./PullRequestDetailCrumbs"; import { PullRequestTitleEditor } from "./PullRequestTitleEditor"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { SourceControlAppAccessNotice } from "../SourceControlAppAccessNotice"; -import { DiffPanelLoadingState } from "../DiffPanelShell"; +import { DiffPanelLoadingState } from "../diffs/DiffLoadingState"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; import { openOnHostLabel } from "./pullRequestLinkContextMenu"; diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 359d86b4fc766..e13956c14938b 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -134,6 +134,15 @@ const MAX_PAGE_SIZE = 500; const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {}; const EMPTY_PARTITIONS: PullRequestPartitionsSnapshot = { authored: [], reviewing: [] }; 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_PREVIEW_SESSIONS = {}; const EMPTY_PREVIEW_DESKTOP_STATE = {}; const EMPTY_TERMINAL_LABELS = new Map(); @@ -1487,21 +1496,8 @@ export function PullRequestsPage() { 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} > ({ + diff: 0, + preview: 0, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + previewRenders: [] as unknown[], +})); +const imports = vi.hoisted(() => { + const deferred = () => { + let resolve!: () => void; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; + }; + return { preview: deferred(), terminal: deferred() }; +}); +vi.mock("./diff/DiffSidePanel", () => { + loaded.diff += 1; + return { default: () => null }; +}); +vi.mock("./terminal/TerminalSidePanel", () => { + loaded.terminal += 1; + imports.terminal.resolve(); + return { default: () => Terminal body }; +}); +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; + imports.preview.resolve(); + return { + default: function PreviewSidePanel(props: unknown) { + loaded.previewRenders.push({ props, host: usePanelHost() }); + return Preview body; + }, + }; +}); + +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 () => { + await using screen = await renderScreen(null, { router: false }); + expect(loaded).toMatchObject({ + diff: 0, + preview: 0, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + }); + await screen.rerender( + + + + + , + ); + // Await the selected chunk after the registry has requested it. + await act(() => imports.preview.promise); + expect(loaded).toMatchObject({ + diff: 0, + preview: 1, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + }); + await expect.element(screen.getByText("Preview body", { exact: true })).toBeVisible(); + expect(loaded.previewRenders).toEqual([{ props: { tabId: "tab-1" }, host }]); + }); + + it("loads only the terminal body when the terminal is selected", async () => { + await using screen = await renderScreen(null, { router: false }); + const previewLoadsBefore = loaded.preview; + const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", + }; + await act(async () => { + await screen.rerender( + + + undefined} + onSplitTerminal={() => undefined} + onSplitTerminalVertical={() => undefined} + onNewTerminal={() => undefined} + onActiveTerminalChange={() => undefined} + onCloseTerminal={() => undefined} + /> + + , + ); + }); + await act(async () => { + await imports.terminal.promise; + }); + await expect.element(screen.getByText("Terminal body", { exact: true })).toBeVisible(); + expect(screen.container.textContent).toBe("Terminal body"); + expect(loaded).toMatchObject({ + diff: 0, + preview: previewLoadsBefore, + terminal: 1, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + }); + }); +}); + +const filesProps = { + onNavigateFolder: () => undefined, + folderTrail: undefined, + 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, + 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 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 0000000000000..b8211ca1b5055 --- /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.browser.test.tsx b/apps/web/src/panels/device/DeviceSidePanel.browser.test.tsx new file mode 100644 index 0000000000000..47e31dd6eb6a6 --- /dev/null +++ b/apps/web/src/panels/device/DeviceSidePanel.browser.test.tsx @@ -0,0 +1,292 @@ +import { act, render as renderScreen, type Screen } from "../../../test/render"; +import { + EnvironmentId, + ThreadId, + type DeviceServiceState, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { 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", async () => { + const { Atom, AsyncResult } = await import("effect/reactivity"); + const Effect = await import("effect/Effect"); + return { + deviceHostProbeAtom: Atom.family(() => Atom.make(Effect.void)), + NO_DEVICE_PROBE_ATOM: Atom.make(AsyncResult.initial()), + 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", () => ({ + selectThreadRightPanelState: () => ({ activeSurfaceId: null }), + useRightPanelStore: { + getState: () => ({ openDevice: mocks.openDevice, closeSurface: mocks.closeSurface }), + }, +})); +vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null })); +vi.mock("~/components/device/DeviceLoadingView", () => ({ + DeviceLoadingView: ({ name, message }: { name: string; message: string }) => ( +
+ {message} +
+ ), +})); +vi.mock("~/components/device/DeviceWorkspace", () => ({ + DeviceWorkspace: ({ onPowerOff }: { onPowerOff: () => void }) => ( + + ), +})); +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 (screen: Screen, threadRef: ScopedThreadRef, surface = picker) => { + await screen.rerender(render(threadRef, surface)); + // Wait for the registered lazy body to load and replace the Suspense fallback. + await act(async () => { + await import("./DeviceSidePanel"); + }); +}; +const deferred = () => { + let settle!: (result: CommandResult) => void; + const promise = new Promise((resolve) => (settle = resolve)); + return { promise, settle }; +}; +const startPhone = (screen: Screen) => + act(async () => { + screen.container.querySelector('[aria-label="Start Phone"]')!.click(); + }); +const errors = (screen: Screen) => screen.container.querySelectorAll('[role="alert"]'); +const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const; + +describe("registered Device panel", () => { + it("opens a picked device when the pick settles in the same thread", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + await mount(screen, picking); + + await startPhone(screen); + 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 () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + mocks.open.mockReturnValue(deferred().promise); + await mount(screen, picking); + + await startPhone(screen); + await screen.rerender(render(next)); + + expect( + screen.container.querySelector('[aria-label="Start Phone"]')!.disabled, + ).toBe(false); + }); + + it("opens a pick that settles after a switch in the thread it started in", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + await mount(screen, picking); + + await startPhone(screen); + await screen.rerender(render(next)); + await act(async () => pick.settle(opened)); + + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, { + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", + }); + expect( + screen.container.querySelector('[aria-label="Start Phone"]')!.disabled, + ).toBe(false); + }); + + it("shows a late failed pick when returning to its starting scope", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + await mount(screen, picking); + + await startPhone(screen); + await screen.rerender(render(next)); + await screen.rerender(render(picking)); + expect(screen.container.querySelector('[aria-label="Start Phone"]')).toBeNull(); + expect( + screen.container.querySelector('[role="status"][aria-label="Opening Phone"]')?.textContent, + ).toBe("Starting device…"); + expect(errors(screen)).toHaveLength(0); + await act(async () => pick.settle({ _tag: "Failure", cause: new Error("boom") })); + + expect(errors(screen)).toHaveLength(1); + expect(errors(screen)[0]?.textContent).toContain("Device failed"); + expect( + screen.container.querySelector('[role="status"][aria-label="Opening Phone"]'), + ).toBeNull(); + expect( + screen.container.querySelector('[aria-label="Start Phone"]')!.disabled, + ).toBe(false); + expect(mocks.openDevice).not.toHaveBeenCalled(); + }); + + it("does not carry an operation error into another thread", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + mocks.open.mockResolvedValue({ _tag: "Failure", cause: new Error("boom") }); + await mount(screen, picking); + + await startPhone(screen); + expect(errors(screen)).toHaveLength(1); + await screen.rerender(render(next)); + + expect(errors(screen)).toHaveLength(0); + }); + + it("closes a powered-off surface in its own thread after the panel moved", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + const powerOff = deferred(); + mocks.close.mockReturnValue(powerOff.promise); + await mount(screen, picking, streaming); + + await act(async () => { + screen.container.querySelector("button")!.click(); + }); + expect(mocks.close).toHaveBeenCalledWith({ + environmentId: picking.environmentId, + input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", shutdown: true }, + }); + await screen.rerender(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 () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + mocks.close.mockResolvedValue(opened); + await mount(screen, picking, streaming); + + await act(async () => { + screen.container.querySelector("button")!.click(); + }); + + expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(picking, streaming.id); + }); +}); + +function resetFixture() { + vi.clearAllMocks(); +} diff --git a/apps/web/src/panels/device/DeviceSidePanel.thread-switches.browser.test.tsx b/apps/web/src/panels/device/DeviceSidePanel.thread-switches.browser.test.tsx new file mode 100644 index 0000000000000..09af0943d2c5f --- /dev/null +++ b/apps/web/src/panels/device/DeviceSidePanel.thread-switches.browser.test.tsx @@ -0,0 +1,340 @@ +import { + EnvironmentId, + ThreadId, + type DeviceServiceState, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { act, render } from "../../../test/render"; + +import DeviceSidePanel from "./DeviceSidePanel"; +import { PanelHostContext } from "../panelHost"; +import "~/index.css"; + +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, +}; +let currentState = deviceState; +vi.mock("~/state/device", async () => { + const { Atom, AsyncResult } = await import("effect/reactivity"); + const Effect = await import("effect/Effect"); + return { + deviceHostProbeAtom: Atom.family(() => Atom.make(Effect.void)), + NO_DEVICE_PROBE_ATOM: Atom.make(AsyncResult.initial()), + deviceEnvironment: { list: "list", open: "open", close: "close" }, + useDeviceState: () => ({ state: currentState, 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", () => ({ + selectThreadRightPanelState: () => ({ activeSurfaceId: null }), + useRightPanelStore: { + getState: () => ({ openDevice: mocks.openDevice, closeSurface: mocks.closeSurface }), + }, +})); +vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null })); +vi.mock("~/components/device/DeviceWorkspace", () => ({ + DeviceWorkspace: ({ onPowerOff }: { onPowerOff: () => void }) => ( + + ), +})); +vi.mock("~/components/preview/PreviewPanelShell", () => ({ + PreviewPanelShell: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +const threadA = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-1"), +}; +// The panel stays mounted across both kinds of switch. +const switches = [ + ["another thread", { environmentId: threadA.environmentId, threadId: ThreadId.make("thread-2") }], + [ + "the same thread id in another environment", + { ...threadA, environmentId: EnvironmentId.make("environment-b") }, + ], +] as const; +const threadB = switches[0][1]; +const picker: Extract = { id: "device", kind: "device" }; +const phoneTarget = { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }; +const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const; +const failed = { _tag: "Failure", cause: new Error("boom") } as const; + +const deferred = () => { + let settle!: (result: CommandResult) => void; + const promise = new Promise((resolve) => (settle = resolve)); + return { promise, settle }; +}; + +let container: HTMLDivElement; +// Fixed key and a changing threadRef, as RightPanelSurfaceContent mounts it. +const devicePanel = (threadRef: ScopedThreadRef, surface = picker) => ( + undefined, + }} + > + undefined} /> + +); +const startButton = () => container.querySelector('[aria-label="Start Phone"]'); +const click = (button: HTMLButtonElement | null) => + act(async () => { + button!.click(); + }); +const errors = () => container.querySelectorAll('[role="alert"]'); + +function setup() { + vi.resetAllMocks(); + currentState = deviceState; + container = document.createElement("div"); + container.style.width = "480px"; + container.style.height = "800px"; +} + +describe("DevicePanel thread switches", () => { + it("keeps the latest pick pending across switches away and back", async () => { + setup(); + const first = deferred(); + const second = deferred(); + const latest = deferred(); + mocks.open + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + .mockReturnValueOnce(latest.promise); + await using screen = await render(devicePanel(threadA), { container }); + await click(startButton()); + await screen.rerender(devicePanel(threadB)); + await click(startButton()); + await screen.rerender(devicePanel(threadA)); + await click(startButton()); + + await act(async () => first.settle(failed)); + + expect(startButton()).toBeNull(); + expect(errors()).toHaveLength(0); + expect(mocks.openDevice).not.toHaveBeenCalled(); + + await act(async () => second.settle(opened)); + + expect(startButton()).toBeNull(); + expect(mocks.openDevice.mock.calls).toEqual([[threadB, phoneTarget]]); + + await act(async () => latest.settle(opened)); + + expect(startButton()?.disabled).toBe(false); + expect(errors()).toHaveLength(0); + expect(mocks.openDevice.mock.calls).toEqual([ + [threadB, phoneTarget], + [threadA, phoneTarget], + ]); + }); +}); + +it("keeps a late power-off error in the environment that started it", async () => { + setup(); + const streaming: Extract = { + id: "device:phone", + kind: "device", + target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }, + }; + const powerOff = deferred(); + mocks.close.mockReturnValue(powerOff.promise); + await using screen = await render(devicePanel(threadA, streaming), { container }); + await screen.getByRole("button", { name: "Power off", exact: true }).click(); + expect(mocks.close).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadA.environmentId, + input: { threadId: threadA.threadId, hostId: "local", deviceId: "phone", shutdown: true }, + }); + await screen.rerender(devicePanel(switches[1][1], streaming)); + await act(async () => powerOff.settle(failed)); + expect(errors()).toHaveLength(0); + expect(mocks.closeSurface).not.toHaveBeenCalled(); + await screen.rerender(devicePanel(threadA, streaming)); + expect(errors()).toHaveLength(1); +}); + +const otherEnvironment = switches[1][1]; +const streaming: Extract = { + id: "device:phone", + kind: "device", + target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }, +}; + +describe("DevicePanel notices", () => { + it("keeps discovery warnings dismissed until the diagnostic or environment changes", async () => { + setup(); + await using screen = await render(null, { container, router: false }); + currentState = { + ...deviceState, + hostStatusDetail: "Could not read simulator usage timestamps.", + }; + await screen.rerender(devicePanel(threadA)); + expect(errors()).toHaveLength(1); + expect(startButton()?.disabled).toBe(false); + await click(container.querySelector('[aria-label="Dismiss device warning"]')); + expect(errors()).toHaveLength(0); + await screen.rerender(devicePanel(threadA)); + expect(errors()).toHaveLength(0); + + currentState = { ...currentState, hostStatusDetail: "A different discovery warning." }; + await screen.rerender(devicePanel(threadA)); + expect(errors()).toHaveLength(1); + await click(container.querySelector('[aria-label="Dismiss device warning"]')); + await screen.rerender(devicePanel(otherEnvironment)); + expect(errors()).toHaveLength(1); + }); + + it("keeps the loading content centered when discovery and boot notices arrive or are dismissed", async () => { + setup(); + await using screen = await render(null, { container, router: false }); + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + await screen.rerender(devicePanel(threadA)); + await click(startButton()); + const loadingBounds = () => + container + .querySelector('[aria-label="Step 1 of 2: open device"]')! + .parentElement!.getBoundingClientRect(); + const before = loadingBounds(); + const panel = container.getBoundingClientRect(); + expect(before.height).toBeGreaterThan(0); + expect(before.top + before.height / 2).toBeCloseTo(panel.top + panel.height / 2, 0); + + currentState = { + ...deviceState, + hostStatusDetail: "Could not read CoreSimulator plist.\n".repeat(8), + bootingDevices: [{ ...phone, threadId: threadA.threadId }], + }; + await screen.rerender(devicePanel(threadA)); + expect(loadingBounds().top).toBe(before.top); + expect(loadingBounds().left).toBe(before.left); + await click(container.querySelector('[aria-label="Dismiss device warning"]')); + expect(loadingBounds().top).toBe(before.top); + expect(loadingBounds().left).toBe(before.left); + await act(async () => pick.settle(failed)); + }); +}); + +describe("DevicePanel thread switches", () => { + it.each(switches)( + "keeps a pending or failed pick in its starting scope when switching to %s", + async (_, target) => { + setup(); + await using screen = await render(null, { container, router: false }); + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + await screen.rerender(devicePanel(threadA)); + await click(startButton()); + expect(startButton()).toBeNull(); + + await screen.rerender(devicePanel(target)); + expect(startButton()?.disabled).toBe(false); + + await act(async () => pick.settle(failed)); + expect(errors()).toHaveLength(0); + expect(mocks.openDevice).not.toHaveBeenCalled(); + + await screen.rerender(devicePanel(threadA)); + expect(startButton()?.disabled).toBe(false); + expect(errors()).toHaveLength(1); + + await screen.rerender(devicePanel(target)); + expect(startButton()?.disabled).toBe(false); + expect(errors()).toHaveLength(0); + }, + ); + + it.each([ + [ + "pick", + picker, + () => mocks.open, + () => click(startButton()), + () => expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(threadA, phoneTarget), + ], + [ + "power-off", + streaming, + () => mocks.close, + () => click(container.querySelector("button")), + () => expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(threadA, streaming.id), + ], + ] as const)( + "applies a late %s to its starting thread after switching environments with the same thread id", + async (_, surface, command, start, expectApplied) => { + setup(); + await using screen = await render(null, { container, router: false }); + const late = deferred(); + command().mockReturnValue(late.promise); + await screen.rerender(devicePanel(threadA, surface)); + await start(); + + await screen.rerender(devicePanel(otherEnvironment, surface)); + await act(async () => late.settle(opened)); + + expectApplied(); + }, + ); +}); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/panels/device/DeviceSidePanel.tsx similarity index 92% rename from apps/web/src/components/device/DevicePanel.tsx rename to apps/web/src/panels/device/DeviceSidePanel.tsx index cee7a3e4e4bcd..a4892da63669f 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/panels/device/DeviceSidePanel.tsx @@ -1,10 +1,6 @@ -import { DeviceHostUpdates } from "./DeviceHostUpdates"; -import type { - DevicePlatform, - DeviceServiceState, - DeviceSummary, - ScopedThreadRef, -} from "@t3tools/contracts"; +import { usePanelHost } from "../panelHost"; +import { DeviceHostUpdates } from "~/components/device/DeviceHostUpdates"; +import type { DevicePlatform, DeviceServiceState, DeviceSummary } from "@t3tools/contracts"; import { ArrowUpRight, CircleAlert, Play, Smartphone, X } from "lucide-react"; import { useAtomMount } from "@effect/atom-react"; import { type ReactNode, useState } from "react"; @@ -30,10 +26,14 @@ import { } from "~/state/device"; import { formatEnvironmentQueryError } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; -import { DeviceLoadingView } from "./DeviceLoadingView"; -import { DeviceSetup, deviceHubReadiness, isAndroidDiscoveryUncertain } from "./DeviceSetup"; -import { DeviceWorkspace } from "./DeviceWorkspace"; -import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; +import { DeviceLoadingView } from "~/components/device/DeviceLoadingView"; +import { + DeviceSetup, + deviceHubReadiness, + isAndroidDiscoveryUncertain, +} from "~/components/device/DeviceSetup"; +import { DeviceWorkspace } from "~/components/device/DeviceWorkspace"; +import { PreviewPanelShell } from "~/components/preview/PreviewPanelShell"; const platformLabel = (platform: DevicePlatform) => platform === "ios" ? "iOS Simulators" : "Android Emulators"; @@ -41,14 +41,12 @@ const platformLabel = (platform: DevicePlatform) => const deviceKey = (device: Pick) => `${device.hostId}\u0000${device.id}`; -export function DevicePanel(props: { - readonly mode: PreviewPanelMode; - readonly threadRef: ScopedThreadRef; +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, error: stateError, refresh: refreshState } = useDeviceState(environmentId); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); const open = useAtomCommand(deviceEnvironment.open); @@ -68,9 +66,7 @@ export function DevicePanel(props: { const hostDisabled = state.hostStatus === "disabled"; useAtomMount( - props.visible && loaded && !hostDisabled - ? deviceHostProbeAtom(environmentId) - : NO_DEVICE_PROBE_ATOM, + visible && loaded && !hostDisabled ? deviceHostProbeAtom(environmentId) : NO_DEVICE_PROBE_ATOM, ); const sessions = state.sessions.filter((session) => session.threadId === threadId); @@ -118,7 +114,7 @@ export function DevicePanel(props: { if (result._tag === "Failure") settleOperation(formatEnvironmentQueryError(result.cause)); else { // The server opened the device for the starting thread, so its tab opens there. - useRightPanelStore.getState().openDevice(props.threadRef, { + useRightPanelStore.getState().openDevice(threadRef, { hostId: result.value.hostId, deviceId: result.value.deviceId, platform: device.platform, @@ -130,21 +126,21 @@ export function DevicePanel(props: { 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 closeSurface = () => { const store = useRightPanelStore.getState(); - const { activeSurfaceId } = selectThreadRightPanelState(store.byThreadKey, props.threadRef); - store.closeSurface(props.threadRef, props.surface.id); - focusPanelTerminalAfterClose(props.threadRef, activeSurfaceId); + const { activeSurfaceId } = selectThreadRightPanelState(store.byThreadKey, threadRef); + store.closeSurface(threadRef, props.surface.id); + focusPanelTerminalAfterClose(threadRef, activeSurfaceId); }; const closeActive = (powerOff: boolean) => { @@ -190,7 +186,7 @@ export function DevicePanel(props: { if (loaded && (!state.onboardingCompleted || hostDisabled)) { return ( { if (!isOpen) props.onDismissSetup(); }} @@ -203,7 +199,7 @@ export function DevicePanel(props: { } return ( - +
hostId === activeDevice.hostId)?.detail } - visible={props.visible} + visible={visible} onFloat={floatActive} onClose={() => closeActive(false)} onPowerOff={() => closeActive(true)} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/panels/diff/DiffSidePanel.tsx similarity index 94% rename from apps/web/src/components/DiffPanel.tsx rename to apps/web/src/panels/diff/DiffSidePanel.tsx index 0f1d1e9e28379..cf307ccf2b60c 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/panels/diff/DiffSidePanel.tsx @@ -6,7 +6,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import type { ReviewBranchCommitsResult, ScopedThreadRef, RunId } from "@t3tools/contracts"; +import type { ReviewBranchCommitsResult, RunId } from "@t3tools/contracts"; import { ArrowRightIcon, CheckIcon, @@ -20,14 +20,13 @@ import { } from "lucide-react"; import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; -import { type MouseEvent, useEffect, useRef, useState } from "react"; -import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; +import { type MouseEvent, type ReactNode, useEffect, useRef, useState } from "react"; +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 { toolCallDiff } from "../lib/toolCallDiff"; +import { useOpenInPreferredEditor } from "~/editorPreferences"; +import { useFileContextMenuHandler } from "~/fileContextMenu"; +import { openDiffFilePrimaryAction } from "~/diffFileActions"; +import { toolCallDiff } from "~/lib/toolCallDiff"; import { useCheckpointDiff, useTurnItemDetail } from "~/state/queries"; import { revisionedQuery } from "~/state/revisionedQuery"; import { cn } from "~/lib/utils"; @@ -37,12 +36,12 @@ import { selectThreadDiffPanelSelection, useDiffPanelSessionStore, useDiffPanelStore, -} from "../diffPanelStore"; -import { useLocalStorage } from "../hooks/useLocalStorage"; +} from "~/diffPanelStore"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; -import { useHideTestFiles } from "./diffs/useHideTestFiles"; -import { useRenderedAppearance } from "../hooks/useTheme"; -import { useStableCallback } from "../hooks/useStableCallback"; +import { useHideTestFiles } from "~/components/diffs/useHideTestFiles"; +import { useRenderedAppearance } from "~/hooks/useTheme"; +import { useStableCallback } from "~/hooks/useStableCallback"; import { buildFileDiffContentVersion, buildFileDiffIdentityKey, @@ -50,29 +49,30 @@ import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, -} from "../lib/diffRendering"; -import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; -import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; -import { LIVE_REFRESH_MIN_INTERVAL_MS, useLiveRefresh } from "../hooks/useLiveRefresh"; +} from "~/lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; +import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "~/lib/diffCollapse"; +import { LIVE_REFRESH_MIN_INTERVAL_MS, useLiveRefresh } from "~/hooks/useLiveRefresh"; import { useProject, useThreadCheckpointSummaries, useQuietThreadShell, useQuietThreadVisibleTurnItems, -} from "../state/entities"; -import { useClientSettings, updateClientSettings } from "../hooks/useSettings"; -import { formatRelativeTimeLabel, formatShortTimestamp } from "../timestampFormat"; -import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; -import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { DiffStatLabel } from "./chat/DiffStatLabel"; -import { AnnotatableCodeView } from "./diffs/AnnotatableCodeView"; -import { DiffFileTree } from "./diffs/DiffFileTree"; -import { FileExplorerPane } from "./files/FileExplorerPane"; -import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; -import { Button } from "./ui/button"; +} from "~/state/entities"; +import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; +import { formatRelativeTimeLabel, formatShortTimestamp } from "~/timestampFormat"; +import { DiffFilePathCopyButton } from "~/components/DiffFilePathCopyButton"; +import { DiffPanelLoadingState } from "~/components/diffs/DiffLoadingState"; +import { usePanelHost } from "../panelHost"; +import { DiffStatLabel } from "~/components/chat/DiffStatLabel"; +import { AnnotatableCodeView } from "~/components/diffs/AnnotatableCodeView"; +import { DiffFileTree } from "~/components/diffs/DiffFileTree"; +import { FileExplorerPane } from "~/components/files/FileExplorerPane"; +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, @@ -81,7 +81,7 @@ import { ComboboxList, ComboboxPopup, ComboboxTrigger, -} from "./ui/combobox"; +} from "~/components/ui/combobox"; import { DropdownMenu, DropdownMenuContent, @@ -92,22 +92,22 @@ 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 { 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"; -import { useReviewFilePatches } from "./diffs/useReviewFilePatches"; -import { DiffFileLoadingBoundary } from "./diffs/DiffFileLoadingBoundary"; -import { DiffFileStatus } from "./diffs/DiffFileStatus"; -import { useThreadRouteRef } from "../hooks/useThreadRouteTarget"; +import { useReviewFilePatches } from "~/components/diffs/useReviewFilePatches"; +import { DiffFileLoadingBoundary } from "~/components/diffs/DiffFileLoadingBoundary"; +import { DiffFileStatus } from "~/components/diffs/DiffFileStatus"; +import { useThreadRouteRef } from "~/hooks/useThreadRouteTarget"; import { projectCommitUrl } from "@t3tools/client-runtime/change-request-links"; -import { useOpenLink } from "../browser/useOpenLink"; +import { useOpenLink } from "~/browser/useOpenLink"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -197,19 +197,23 @@ function DiffFileHeaderSuffix({ ); } -interface DiffPanelProps { - mode?: DiffPanelMode; - composerDraftTarget: ScopedThreadRef | DraftId; - workspaceMutationId: string | null; - visible: boolean; +// 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, - visible, -}: DiffPanelProps) { +export default function DiffSidePanel() { + const { composerDraftTarget, workspaceMutationId, visible } = usePanelHost(); const resolvedTheme = useRenderedAppearance(); const settings = useClientSettings((settings) => ({ diffFilesCollapsed: settings.diffFilesCollapsed, @@ -1249,7 +1253,7 @@ export default function DiffPanel({ ); return ( - + {!activeThread ? (
Select a thread to inspect turn diffs. @@ -1490,6 +1494,6 @@ export default function DiffPanel({
)} -
+ ); } diff --git a/apps/web/src/panels/files/FilesSidePanel.browser.test.tsx b/apps/web/src/panels/files/FilesSidePanel.browser.test.tsx new file mode 100644 index 0000000000000..9f188731f646e --- /dev/null +++ b/apps/web/src/panels/files/FilesSidePanel.browser.test.tsx @@ -0,0 +1,726 @@ +import { act, render as renderScreen, type Screen } from "../../../test/render"; +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 { + cloneElement, + Suspense, + use, + useLayoutEffect, + type ReactElement, + type ReactNode, +} from "react"; +import { 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", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomValue: () => [], +})); +vi.mock("~/state/server", async () => { + const { Atom } = await import("effect/reactivity"); + return { + environmentServerConfigsAtom: Atom.make(new Map()), + primaryServerKeybindingsAtom: {}, + primaryServerEnvironmentThemesAtom: Atom.make([]), + serverEnvironment: { configValueAtom: () => null }, + }; +}); +// This client holds every scope, so files can be read, written and previewed. +vi.mock("~/state/filesystem", () => ({ + filesystemEnvironment: {}, + useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), +})); +vi.mock("~/state/session", async () => { + const { Atom, AsyncResult } = await import("effect/reactivity"); + const Option = await import("effect/Option"); + return { + environmentSession: { + initialConfigValueAtom: Atom.family(() => Atom.make(null)), + preparedConnectionValueAtom: Atom.family(() => Atom.make(Option.none())), + sessionStateAtom: Atom.family(() => Atom.make(AsyncResult.initial())), + }, + environmentSessionStatesAtom: () => Atom.make([]), + readPreparedConnection: () => null, + usePreparedConnection: (): ReturnType => + Option.none(), + + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, + sessionHasScope: () => true, + }; +}); +// The desktop app hosts these browsers itself, so no environment's server browser is consulted. +vi.mock("~/browser/previewRuntime", () => ({ + previewRuntimeFor: () => undefined, + isPreviewAvailableFor: () => true, + usePreviewAvailable: () => true, +})); +vi.mock("~/state/environments", () => ({ + useEnvironmentHttpBaseUrl: () => "http://localhost:3773/", + usePrimaryEnvironmentId: () => null, + readPrimaryEnvironmentId: () => null, + useEnvironment: () => ({ label: "Local" }), +})); +vi.mock("~/remoteOpen", () => ({ + openRemoteEditorUrl: vi.fn(), + useRemoteCapableEditors: () => [], + useRemoteOpenHint: () => null, + resolveRemoteOpenState: () => ({ mode: "local-exec" }), + useRemoteOpenResolution: () => ({ mode: "local-exec" }), + useRemoteOpenState: () => ({ mode: "local-exec" }), +})); +vi.mock("~/hooks/useSettings", () => ({ + updateClientSettings: vi.fn(), + isClientSettingsHydrated: () => true, + ensureClientSettingsHydrated: () => Promise.resolve(), + getClientSettings: () => DEFAULT_CLIENT_SETTINGS, + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), + useUpdateClientSettings: () => vi.fn(), +})); +vi.mock("~/hooks/useTheme", () => ({ + useRenderedAppearance: () => "light", + 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", () => ({ + browserDefaultOpenProfileId: () => "default", + browserDefaultOpenViewport: () => ({ _tag: "fill" }), + resolveBrowserDefaults: () => + late.browserDefaults ?? Promise.resolve({ viewport: { _tag: "fill" }, profileId: "default" }), +})); +vi.mock("~/components/files/projectFilesQueryState", async () => { + const { Atom } = await import("effect/reactivity"); + return { + confirmProjectFileQueryData: vi.fn(), + getUnsavedProjectFileQueryData: () => null, + getOptimisticProjectFileQueryData: () => null, + getProjectFileContents: () => undefined, + getProjectFileQueryAtom: () => Atom.make(null), + setProjectFileQueryData: vi.fn(), + 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: () => File tree ready, + 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/OpenInPicker", () => ({ OpenInPicker: () => null })); +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", () => ({ + MenuItemLabel: Wrapper, + MenuShortcut: () => null, + MenuSub: Wrapper, + MenuSubTrigger: Wrapper, + MenuSubPopup: Wrapper, + 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), +}); + +// 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( + screen: Screen, + 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} + folderTrail={undefined} + cwd="/repo" + projectName="repo" + relativePath={relativePath} + availableEditors={[]} + revealLine={null} + revealRequestId={0} + onPendingChange={() => undefined} + selectedFilePending={false} + /> + + + + + ); + await act(async () => { + composerRef.current = { + insertTextAtEnd: composerOf(threadRef), + } as unknown as ChatComposerHandle; + await screen.rerender(element); + }); + if (relativePath === null) { + await expect.element(screen.getByText("File tree ready", { exact: true })).toBeVisible(); + } else { + await expect + .element(screen.getByRole("navigation", { name: "File path", exact: true })) + .toBeVisible(); + } +} + +async function press(node: HTMLButtonElement) { + await act(async () => node.click()); +} + +async function openSrcFolder(screen: Screen) { + await expect + .element(screen.getByRole("button", { name: "Browse src", exact: true })) + .toBeVisible(); + const browse = screen.container.querySelectorAll( + 'button[aria-label="Browse src"]', + ); + expect(browse).toHaveLength(1); + await press(browse[0]!); +} + +const siblingValues = (screen: Screen) => + Array.from(screen.container.querySelectorAll("button[data-value]")).map( + (node) => node.dataset.value, + ); + +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(screen: Screen) { + late.assetUrl = late.deferred(); + late.session = late.deferred(); + await press( + screen.container.querySelector( + 'button[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"); + +describe("files side panel", () => { + it("opens a sibling file as a tab in the host's own thread", async () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef); + await openSrcFolder(screen); + expect(siblingValues(screen)).toEqual(["src/a.ts", "src/b.ts"]); + + await press( + screen.container.querySelector('button[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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a")); + const threadRef = refOn("environment-b"); + await renderFileFor(screen, threadRef); + await openSrcFolder(screen); + expect(siblingValues(screen)).toEqual(["src/a.ts", "src/c.ts"]); + + await press( + screen.container.querySelector('button[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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + // Threads in the same project share one tree, so it outlives the switch. + await renderFileFor(screen, refOn("environment-a")); + const threadRef = refOn("environment-a", "thread-b"); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a")); + const threadRef = refOn("environment-a", "thread-b"); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a"), null); + const pick = rightClickTreeRow(); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a"), null); + const pick = rightClickTreeRow(); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, null); + const pick = rightClickTreeRow(); + await renderFileFor(screen, refOn("environment-a", "thread-other"), null); + await renderFileFor(screen, threadRef, null); + await pick("add-to-chat"); + expect(insertedAnywhere()).toBe(false); + }); + + it("opens a Browser tab in the thread that is still showing", async () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, "index.html"); + await openInBrowser(screen); + 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, "index.html"); + await openInBrowser(screen); + // Editing a queued message moves the composer to a draft; the thread stays. + await renderFileFor( + screen, + 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, "index.html"); + await openInBrowser(screen); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, "index.html"); + await openInBrowser(screen); + await settleAsset(); + expect(late.previewRequests).toBe(1); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, "index.html"); + await openInBrowser(screen); + await renderFileFor(screen, refOn("environment-a", "thread-other"), "index.html"); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a"), "index.html"); + await openInBrowser(screen); + await failAsset(); + expect(browserErrorToasts()).toHaveLength(1); + }); + + it("stays silent when the file fails to prepare after the thread was left", async () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a"), "index.html"); + await openInBrowser(screen); + await renderFileFor(screen, refOn("environment-b"), "index.html"); + await failAsset(); + expect(browserErrorToasts()).toEqual([]); + }); + + it("stays silent when browser settings fail to load after the panel closed", async () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a"), "index.html"); + let rejectSettings!: (error: Error) => void; + late.browserDefaults = new Promise((_, reject) => (rejectSettings = reject)); + await openInBrowser(screen); + await settleAsset(); + await screen.unmount(); + 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, "index.html"); + await openInBrowser(screen); + await settleAsset(); + await renderFileFor(screen, refOn("environment-a", "thread-other"), "index.html"); + await renderFileFor(screen, threadRef, "index.html"); + await failSession(); + expect(browserErrorToasts()).toEqual([]); + }); +}); + +function resetFixture() { + late.selectTreeRows = null; + localStorage.clear(); + siblingsByEnvironment.clear(); + composers.clear(); + late.previewRequests = 0; + late.browserDefaults = null; + 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: {}, + }); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/panels/files/FilesSidePanel.tsx similarity index 95% rename from apps/web/src/components/files/FilePreviewPanel.tsx rename to apps/web/src/panels/files/FilesSidePanel.tsx index b88d92fc4edcc..abcd00126baa7 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/panels/files/FilesSidePanel.tsx @@ -1,3 +1,8 @@ +import { usePanelHost } from "../panelHost"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { useScopedComposerInsert, useScopeLifetime } from "./fileScope"; +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { useRightPanelStore } from "~/rightPanelStore"; import { DownloadIcon, CopyIcon, CheckIcon } from "lucide-react"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { Spinner } from "~/components/ui/spinner"; @@ -5,7 +10,6 @@ import { AuthPreviewOperateScope, type EditorId, type EnvironmentId, - type ResolvedKeybindingsConfig, type ScopedThreadRef, } from "@t3tools/contracts"; import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; @@ -37,7 +41,7 @@ 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, @@ -78,16 +82,20 @@ import { useEnvironmentScope } from "~/state/session"; 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 { PreviewImage } from "./PreviewImage"; -import FileBrowserPanel from "./FileBrowserPanel"; -import { FileExplorerPane } from "./FileExplorerPane"; -import { FileBreadcrumbs } from "./FileBreadcrumbs"; -import { breadcrumbPathOf, retainedBreadcrumbTrail, trailChildOf } from "./filePath"; -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 { PreviewImage } from "~/components/files/PreviewImage"; +import FileBrowserPanel from "~/components/files/FileBrowserPanel"; +import { FileExplorerPane } from "~/components/files/FileExplorerPane"; +import { FileBreadcrumbs } from "~/components/files/FileBreadcrumbs"; +import { + breadcrumbPathOf, + retainedBreadcrumbTrail, + trailChildOf, +} from "~/components/files/filePath"; +import { FileMarkdownPreview } from "~/components/files/FileMarkdownPreview"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, @@ -96,8 +104,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, @@ -105,11 +113,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, @@ -117,8 +125,8 @@ import { setMarkdownTaskChecked, shouldShowFileExplorer, workspaceAssetResource, -} from "./filePreviewMode"; -import { changeFileSaveSession, useFileSaveSession } from "./fileSaveSessions"; +} from "~/components/files/filePreviewMode"; +import { changeFileSaveSession, useFileSaveSession } from "~/components/files/fileSaveSessions"; import { revisionedQuery } from "~/state/revisionedQuery"; import { getOptimisticProjectFileQueryData, @@ -126,34 +134,31 @@ import { getProjectFileQueryAtom, setProjectFileQueryData, useProjectFileQuery, -} from "./projectFilesQueryState"; +} from "~/components/files/projectFilesQueryState"; const NO_FILE_REVISION_ATOM = Atom.make(null); -interface FilePreviewPanelProps { - environmentId: EnvironmentId; +interface FilesSidePanelProps { cwd: string; projectName: string; relativePath: string | null; attachment?: ChatFileAttachment; - threadRef: ScopedThreadRef; - composerDraftTarget: ScopedThreadRef | DraftId; - keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; openInShortcutEnabled: boolean; revealLine: number | null; revealRequestId: number; - onOpenFile: (relativePath: string) => void; + /** Moves this folder browser to another folder in place; see `navigateFolder`. */ onNavigateFolder: (relativePath: string, folderTrail: string, keepSource: boolean) => void; - /** Retained descendant breadcrumb path after navigating up; see navigateFolder. */ + /** The deeper path breadcrumbs keep after the browser moved up; see `navigateFolder`. */ folderTrail: string | undefined; onPendingChange: (relativePath: string, pending: boolean) => void; selectedFilePending: boolean; - workspaceMutationId: string | null; } const MermaidFilePreview = lazy(() => - import("./MermaidFilePreview").then((module) => ({ default: module.MermaidFilePreview })), + import("~/components/files/MermaidFilePreview").then((module) => ({ + default: module.MermaidFilePreview, + })), ); const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; @@ -985,26 +990,29 @@ function initialExplorerOpen(): boolean { } } -export default function FilePreviewPanel({ - environmentId, +export default function FilesSidePanel({ cwd, projectName, relativePath: requestedPath, attachment, - threadRef, - composerDraftTarget, - keybindings, availableEditors, openInShortcutEnabled, revealLine, revealRequestId, - onOpenFile, onNavigateFolder, folderTrail, onPendingChange, selectedFilePending, - workspaceMutationId, -}: FilePreviewPanelProps) { +}: FilesSidePanelProps) { + const { threadRef, composerDraftTarget, workspaceMutationId } = usePanelHost(); + const environmentId = threadRef.environmentId; + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const onOpenFile = (path: string) => useRightPanelStore.getState().openFile(threadRef, path); + const isScopeCurrent = useScopeLifetime( + `${scopedThreadKey(threadRef)}|${typeof composerDraftTarget === "string" ? composerDraftTarget : scopedThreadKey(composerDraftTarget)}`, + ); + const addToChat = useScopedComposerInsert(isScopeCurrent); + const isThreadVisitCurrent = useScopeLifetime(scopedThreadKey(threadRef)); const relativePath = attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; const draft = typeof composerDraftTarget === "string"; @@ -1208,13 +1216,18 @@ 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)) { + if ( + result._tag === "Success" || + isAtomCommandInterrupted(result) || + !isThreadVisitCurrent() + ) { return; } const error = squashAtomCommandFailure(result); @@ -1541,6 +1554,7 @@ export default function FilePreviewPanel({ selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} workspaceMutationId={workspaceMutationId} + addToChat={addToChat} {...(previewPath && !isMedia && !isPdf ? { onRefreshSelectedFile: () => { @@ -1557,7 +1571,7 @@ export default function FilePreviewPanel({ ); } -const LargeTextViewer = lazy(() => import("./LargeTextViewer")); +const LargeTextViewer = lazy(() => import("~/components/files/LargeTextViewer")); export function WorkspaceLargeTextPreview(props: { environmentId: EnvironmentId; threadRef: ScopedThreadRef; diff --git a/apps/web/src/panels/files/fileScope.ts b/apps/web/src/panels/files/fileScope.ts new file mode 100644 index 0000000000000..3e1fe30e52185 --- /dev/null +++ b/apps/web/src/panels/files/fileScope.ts @@ -0,0 +1,34 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +import type { AddToChatResult } from "~/components/files/FileBrowserPanel"; +import { useComposerHandleContext } from "~/composerHandleContext"; + +// A late action belongs to one committed visit; A -> B -> A must retire the first visit. +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. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- The committed lifetime guards external actions after navigation or unmount. + useLayoutEffect(() => { + liveRef.current = visit; + return () => { + liveRef.current = null; + }; + }, [visit]); + return () => liveRef.current === visit; +} + +// The composer ref is shared by the layout, so a late action must verify its original visit. +export function useScopedComposerInsert(isScopeCurrent: () => boolean) { + const composerRef = useComposerHandleContext(); + return (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"; + }; +} diff --git a/apps/web/src/panels/panelHost.test.ts b/apps/web/src/panels/panelHost.test.ts new file mode 100644 index 0000000000000..5a2423868c5b2 --- /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 0000000000000..09805d1f32274 --- /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.browser.test.tsx b/apps/web/src/panels/panelRegistry.browser.test.tsx new file mode 100644 index 0000000000000..d7e9cf4fdc8ca --- /dev/null +++ b/apps/web/src/panels/panelRegistry.browser.test.tsx @@ -0,0 +1,132 @@ +import { act, render as renderScreen } from "../../test/render"; +import { Suspense, useEffect } from "react"; +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", async () => { + 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 () => { + await using screen = await renderScreen(null, { router: false }); + 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; + await screen.rerender( + + + , + ); + 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 screen.rerender( + + + , + ); + expect(load).toHaveBeenCalledTimes(1); + expect(mounted).toHaveBeenCalledTimes(1); + expect(disposed).not.toHaveBeenCalled(); + await screen.rerender(null); + expect(disposed).toHaveBeenCalledTimes(1); + }); + + it("keeps heterogeneous panels independent and their lazy identities stable", async () => { + await using screen = await renderScreen(null, { router: false }); + 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(); + + await screen.rerender( + + + , + ); + await screen.rerender( + + + , + ); + expect(loadNotes).toHaveBeenCalledTimes(1); + expect(loadCounter).not.toHaveBeenCalled(); + expect(events).toEqual(["mount notes:a"]); + + const CounterPanel = registry.get("counter").Component; + await screen.rerender( + + + , + ); + 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.test.ts b/apps/web/src/panels/panelRegistry.test.ts new file mode 100644 index 0000000000000..95f20edb356b0 --- /dev/null +++ b/apps/web/src/panels/panelRegistry.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { createPanelRegistry } from "./panelRegistry"; + +const metadata = { + title: "Diff", + icon: () => null, + launcherKey: "D", + unavailableHint: "Unavailable.", + unavailableReason: "Unavailable here.", +}; + +describe("panel registration", () => { + it("rejects duplicate ids without loading either panel", () => { + const load = vi.fn(); + const definition = { ...metadata, id: "diff", load } as const; + expect(() => createPanelRegistry([definition, definition])).toThrow("Duplicate panel id: diff"); + expect(load).not.toHaveBeenCalled(); + }); + it("retains lazy component identity without loading on lookup", () => { + const load = vi.fn(); + const registry = createPanelRegistry([{ ...metadata, id: "diff", load }]); + expect(registry.get("diff")?.Component).toBe(registry.get("diff")?.Component); + expect(() => { + // @ts-expect-error Unknown ids are refused at compile time and at runtime. + registry.get("missing"); + }).toThrow("Unknown panel id: missing"); + expect(load).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/panels/panelRegistry.ts b/apps/web/src/panels/panelRegistry.ts new file mode 100644 index 0000000000000..b3b925413a502 --- /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.browser.test.tsx b/apps/web/src/panels/preview/PreviewSidePanel.browser.test.tsx new file mode 100644 index 0000000000000..86ec1fee0deec --- /dev/null +++ b/apps/web/src/panels/preview/PreviewSidePanel.browser.test.tsx @@ -0,0 +1,307 @@ +import { act, render as renderScreen, type Screen } from "../../../test/render"; +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 { useEffect, type ReactNode } from "react"; +import { 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, + setAnnotationSendEnabled: vi.fn(async () => undefined), + }, +})); +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 Preview chrome ready; + }, +})); +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, + PreviewFileNotShown: () => null, +})); +vi.mock("~/components/preview/ZoomIndicator", () => ({ ZoomIndicator: () => null })); +vi.mock("~/components/preview/AgentBrowserCursor", () => ({ + AgentBrowserCursor: () => null, + AgentCursorMark: () => null, +})); +vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); +vi.mock("~/browser/browserSurfaceStore", () => ({ + useBrowserSurfaceStore: (select: (state: { byTabId: object }) => unknown) => + select({ byTabId: {} }), +})); +vi.mock("~/browser/browserDefaults", () => ({ + browserDefaultOpenProfileId: () => DEFAULT_BROWSER_PROFILE_ID, + browserDefaultOpenViewport: () => FILL_PREVIEW_VIEWPORT, + resolveBrowserDefaults: () => Promise.resolve(BROWSER_DEFAULTS), + 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", async () => { + const Schema = await import("effect/Schema"); + return { + DraftId: Schema.String.pipe(Schema.brand("DraftId")), + clearComposerDraftsEnvironment: vi.fn(), + useComposerDraftStore: (select: (store: object) => unknown) => + select({ addPreviewAnnotation: mocks.addPreviewAnnotation, addImage: vi.fn() }), + }; +}); +vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn() })); +vi.mock("~/previewStateStore", () => ({ + beginPreviewSessionClose: vi.fn(), + cancelPreviewSessionClose: vi.fn(), + applyPreviewServerSnapshot: vi.fn(), + readThreadPreviewState: () => ({ sessions: {}, activeTabId: null }), + setActivePreviewTab: vi.fn(), + whenPreviewTabKnown: vi.fn(), + 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", () => ({ + rightPanelHostRef: (ref: ScopedThreadRef) => ref, + selectSelectedRightPanelSurface: () => null, + useRightPanelStore: { getState: () => ({ close: vi.fn() }) }, +})); +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ environments: [] }), + usePrimaryEnvironment: () => null, + 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 () => { + const { Atom, AsyncResult } = await import("effect/reactivity"); + const Option = await import("effect/Option"); + return { + environmentSession: { + initialConfigValueAtom: Atom.family(() => Atom.make(null)), + preparedConnectionValueAtom: Atom.family(() => Atom.make(Option.none())), + sessionStateAtom: Atom.family(() => Atom.make(AsyncResult.initial())), + }, + environmentSessionStatesAtom: () => Atom.make([]), + readPreparedConnection: () => null, + usePreparedConnection: () => null, + + 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", () => { + async function render(screen: Screen, host: PanelHost) { + await screen.rerender(panel(host)); + await expect.element(screen.getByText("Preview chrome ready", { exact: true })).toBeVisible(); + } + + 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 () => { + resetFixture(); + await import("./PreviewSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadA = thread("thread-a"); + const first = hostFor(threadA); + await render(screen, first); + const pick = await startPick(); + expect(mocks.pickActive).toBe(true); + + const second = hostFor({ ...threadA }); + await render(screen, 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 () => { + resetFixture(); + await import("./PreviewSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadA = thread("thread-a"); + const threadB = thread("thread-b"); + const hostA = hostFor(threadA); + await render(screen, hostA); + const pick = await startPick(); + expect(mocks.pickElement).toHaveBeenCalledWith(previewRuntimeTabId(threadA, null, "tab-1")); + + const hostB = hostFor(threadB); + await render(screen, 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); + }); +}); + +function resetFixture() { + mocks.pickElement.mockReset(); + mocks.cancelPickElement.mockClear(); + mocks.addPreviewAnnotation.mockClear(); + mocks.togglePick = null; + mocks.pickActive = false; + mocks.mounts = 0; + mocks.disposals = 0; +} diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/panels/preview/PreviewSidePanel.tsx similarity index 57% rename from apps/web/src/components/preview/PreviewPanel.tsx rename to apps/web/src/panels/preview/PreviewSidePanel.tsx index 9de48226672f5..3282869e7dff7 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/panels/preview/PreviewSidePanel.tsx @@ -1,41 +1,26 @@ -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 @@ -48,13 +33,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 0000000000000..2331e8aec07f3 --- /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.browser.test.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.browser.test.tsx new file mode 100644 index 0000000000000..2d8ca7114e79d --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.browser.test.tsx @@ -0,0 +1,372 @@ +import { act, render as renderScreen, type Screen } from "../../../test/render"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ProjectId, + SourceControlProviderKind, + ThreadId, + type PullRequestDetailView, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { use, Suspense, type ComponentProps, type ReactElement, type ReactNode } from "react"; +import { 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", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomValue: () => [], +})); +vi.mock("~/state/server", async () => { + const { Atom } = await import("effect/reactivity"); + return { + environmentServerConfigsAtom: Atom.make(new Map()), + serverEnvironment: { configValueAtom: () => null }, + primaryServerKeybindingsAtom: {}, + }; +}); +vi.mock("~/state/entities", () => ({ + readProjects: () => [], + 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("~/commandPaletteBus", () => ({ isCommandPaletteOpen: () => false })); +vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} })); +vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("~/state/sourceControlActions", () => ({ + usePreparePullRequestThreadAction: () => ({ run: vi.fn() }), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("~/state/pullRequests", () => ({ + newestPullRequestSummary: (current: unknown, observed: unknown) => observed ?? current, + pullRequestListEntryToSummary: () => null, + pullRequestEnvironment: { + detail: () => "detail", + changeBreakdown: () => "change-breakdown", + activity: () => "activity", + reviewGuide: () => "review-guide", + generateReviewGuide: { permissionAtom: () => "generate-review-guide" }, + }, + 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() }, + stackedThreadToast: vi.fn(), +})); +vi.mock("~/components/ui/tooltip", () => ({ + TooltipProvider: Wrapper, + Tooltip: Wrapper, + TooltipTrigger: Trigger, + TooltipPopup: () => null, +})); +vi.mock("~/components/ui/menu", () => ({ + Menu: Wrapper, + MenuPopup: Wrapper, + MenuGroup: Wrapper, + MenuTrigger: Trigger, + MenuItem: "button", + MenuCheckboxItem: "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("~/components/pullRequest/PullRequestGuideTab", () => ({ + PullRequestGuideTab: () => null, +})); +vi.mock("./PullRequestSidePanel", async (importOriginal) => { + const { default: Body } = await importOriginal(); + return { + default: function DeferredBody(props: ComponentProps) { + use(bodyImport.promise); + return ; + }, + }; +}); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +const detail: PullRequestDetailView = { + provider: SourceControlProviderKind.make("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 }; + +// 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(screen: Screen, threadRef: ScopedThreadRef) { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + await screen.rerender( + + + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + /> + + , + ); +} + +const hasText = (screen: Screen, text: string) => + screen.container.textContent?.includes(text) ?? false; + +async function click(screen: Screen, label: string) { + const button = Array.from(screen.container.querySelectorAll("button")).find( + (node) => node.getAttribute("aria-label") === label || node.textContent?.includes(label), + ); + expect(button, label).toBeDefined(); + await act(async () => button!.click()); +} + +const isLoading = (screen: Screen) => + screen.container.querySelector('[role="status"][aria-label="Loading pull request"]') !== null; + +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 body is ready", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + await renderFor(screen, refOn("environment-new")); + expect(isLoading(screen)).toBe(true); + expect(hasText(screen, "Test pull request")).toBe(false); + + await act(loadBody); + expect(isLoading(screen)).toBe(false); + expect(hasText(screen, "Test pull request")).toBe(true); + await click(screen, "Ask a question"); + expect( + useComposerDraftStore.getState().getComposerDraft(refOn("environment-new"))?.reviewComments, + ).toHaveLength(1); + }); + + it("follows the host environment's pull request support", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + await loadBody(); + await renderFor(screen, refOn("environment-loading")); + expect(isLoading(screen)).toBe(true); + expect(hasText(screen, "Test pull request")).toBe(false); + expect(hasText(screen, "Pull requests unavailable")).toBe(false); + + await renderFor(screen, refOn("environment-old")); + expect(hasText(screen, "Pull requests unavailable")).toBe(true); + expect( + hasText(screen, "Update this environment's T3 Code server to browse pull requests."), + ).toBe(true); + expect(hasText(screen, "Test pull request")).toBe(false); + + await renderFor(screen, refOn("environment-new")); + expect(hasText(screen, "Test pull request")).toBe(true); + }); + + it("writes a question into the host thread's composer only", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + await loadBody(); + const threadRef = refOn("environment-new"); + useComposerDraftStore.getState().setPrompt(threadRef, "Keep my draft"); + await renderFor(screen, threadRef); + await click(screen, "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 () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + await loadBody(); + const threadRef = refOn("environment-new"); + await renderFor(screen, threadRef); + await click(screen, "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(); + }); +}); + +function resetFixture() { + 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: {}, + }); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx new file mode 100644 index 0000000000000..7858864e6c014 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx @@ -0,0 +1,68 @@ +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 { type PullRequestDetailTab, useRightPanelStore } from "~/rightPanelStore"; +import { useEnvironment } from "~/state/environments"; + +import { usePanelHost } from "../panelHost"; + +interface PullRequestSidePanelProps { + reference: PullRequestRef; + initialTab?: PullRequestDetailTab; + onTabChange?: ((tab: PullRequestDetailTab) => void) | undefined; + 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, + initialTab = "summary", + onTabChange, +}: 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} + initialTab={initialTab} + onTabChange={onTabChange} + onBack={onBack} + /> + ); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestsSidePanel.browser.test.tsx b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.browser.test.tsx new file mode 100644 index 0000000000000..208735eb5c93b --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.browser.test.tsx @@ -0,0 +1,218 @@ +import { act, render as renderScreen, type Screen } from "../../../test/render"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ThreadId, + type ScopedThreadRef, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { Fragment, Suspense, type ReactElement, type ReactNode } from "react"; +import { 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("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("~/state/pullRequests", async () => { + const { Atom } = await import("effect/reactivity"); + return { + useSharedPullRequestSummary: () => null, + pullRequestEnvironment: { + changeBreakdown: () => null, + runAction: { permissionAtom: () => Atom.make(false) }, + }, + }; +}); +// The panel's list behavior is exercised without the virtualizer's browser geometry. +vi.mock("@legendapp/list/react", () => ({ + LegendList: ({ + data, + renderItem, + keyExtractor, + }: { + data: ReadonlyArray; + renderItem: (input: { item: T; index: number }) => ReactNode; + keyExtractor: (item: T, index: number) => string; + }) => + data.map((item, index) => ( + {renderItem({ item, index })} + )), +})); +vi.mock("~/state/entities", () => ({ + readProject: () => null, + readProjects: () => [], + readThreadShell: () => null, + readThreadShells: () => [], + useQuietThreadShell: () => null, + // No projects, so rows offer no project-scoped fast actions. + useProjects: () => [], + // The other environment has no connected server configuration. + useServerConfigs: () => + new Map( + ["environment-new"].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, + MenuGroup: Wrapper, + MenuRadioGroup: Wrapper, + MenuRadioItem: "button", + MenuTrigger: Trigger, + MenuItem: "button", + MenuSeparator: () => null, +})); + +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, +}); + +const panelFor = (threadRef: ScopedThreadRef) => { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + return ( + + + + + + ); +}; + +// One root across hosts, so a host change reaches the already mounted list. +async function renderFor(screen: Screen, threadRef: ScopedThreadRef) { + await screen.rerender(panelFor(threadRef)); + const expected = + threadRef.environmentId === "environment-new" + ? "owner/alpha" + : "Linked pull requests unavailable"; + if (threadRef.environmentId === "environment-new") { + const first = linksByThread.get(scopedThreadKey(threadRef))?.[0]; + await expect + .element(screen.getByText(first?.repository ?? expected, { exact: false }).first()) + .toBeVisible(); + } else { + await expect.element(screen.getByText(expected, { exact: true })).toBeVisible(); + } +} + +const hasText = (screen: Screen, text: string) => + screen.container.textContent?.includes(text) ?? false; + +describe("linked pull requests side panel", () => { + it("lists and unlinks the host thread's pull requests", async () => { + resetFixture(); + await import("./PullRequestsSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFor(screen, refOn("environment-new")); + expect(hasText(screen, "owner/alpha")).toBe(true); + expect(hasText(screen, "owner/beta")).toBe(false); + + const unlink = Array.from(screen.container.querySelectorAll("button")).find( + (node) => node.textContent?.includes("Unlink from thread"), + ); + expect(unlink).toBeDefined(); + await act(async () => unlink!.click()); + 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 () => { + resetFixture(); + await import("./PullRequestsSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFor(screen, refOn("environment-new")); + expect(hasText(screen, "owner/alpha")).toBe(true); + + await renderFor(screen, refOn("environment-old")); + expect(hasText(screen, "Linked pull requests unavailable")).toBe(true); + expect(hasText(screen, "owner/alpha")).toBe(false); + expect(hasText(screen, "owner/beta")).toBe(false); + + linksByThread.set(scopedThreadKey(refOn("environment-new")), [link("owner/gamma", 33)]); + await renderFor(screen, refOn("environment-new")); + expect(hasText(screen, "owner/gamma")).toBe(true); + }); +}); + +function resetFixture() { + 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)]); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx new file mode 100644 index 0000000000000..b5e49f45f2fcd --- /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/components/PersistentThreadTerminals.tsx b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx similarity index 95% rename from apps/web/src/components/PersistentThreadTerminals.tsx rename to apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx index a387349b4c038..1cd1c120b7fa9 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx @@ -9,23 +9,23 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { memo, useEffect } from "react"; -import { useComposerDraftStore } from "../composerDraftStore"; -import { useStableCallback } from "../hooks/useStableCallback"; -import type { TerminalContextSelection } from "../lib/terminalContext"; -import { focusActiveDrawerTerminal, focusTerminal } from "../lib/terminalFocus"; +import { useComposerDraftStore } from "~/composerDraftStore"; +import { useStableCallback } from "~/hooks/useStableCallback"; +import type { TerminalContextSelection } from "~/lib/terminalContext"; +import { focusActiveDrawerTerminal, focusTerminal } from "~/lib/terminalFocus"; import { cn } from "~/lib/utils"; import { selectThreadRightPanelState, useRightPanelStore, type RightPanelSurface, -} from "../rightPanelStore"; -import { useProject, useQuietThreadShell } from "../state/entities"; -import { useEnvironmentScope } from "../state/session"; -import { useKnownTerminalSessions } from "../state/terminalSessions"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useThreadTerminalActions } from "./terminal/useThreadTerminalActions"; -import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { resolveTerminalWorktreePath } from "./ChatView.logic"; +} from "~/rightPanelStore"; +import { useProject, useQuietThreadShell } from "~/state/entities"; +import { useEnvironmentScope } from "~/state/session"; +import { useKnownTerminalSessions } from "~/state/terminalSessions"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "~/terminalUiStateStore"; +import { useThreadTerminalActions } from "~/components/terminal/useThreadTerminalActions"; +import ThreadTerminalDrawer from "~/components/ThreadTerminalDrawer"; +import { resolveTerminalWorktreePath } from "~/components/ChatView.logic"; /** Where a drawer or panel opens new terminals when a script run overrides the thread's own. */ export type PersistentTerminalLaunchContext = { diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.attach.browser.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.attach.browser.test.tsx new file mode 100644 index 0000000000000..08f997624221f --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.attach.browser.test.tsx @@ -0,0 +1,207 @@ +import { act, render as renderScreen, stubGlobal, type Screen } from "../../../test/render"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { Suspense } from "react"; +import { 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"); + const { Atom } = await import("effect/reactivity"); + const sessions = Atom.family((_key: string) => + Atom.make({ ...empty, status: "running" as const, version: 1 }), + ); + return { + attachedTerminalSessionAtom: (environmentId: string, terminal: unknown) => { + transport.attached.push({ environmentId, terminal }); + return sessions(JSON.stringify([environmentId, terminal])); + }, + useKnownTerminalSessions: () => [], + useAttachedTerminalSession: (input: unknown) => { + transport.attached.push(input); + return empty; + }, + }; +}); +vi.mock("~/components/preview/openTerminalLinkInPreview", () => ({ + openTerminalLinkInPreview: () => undefined, +})); +// This client holds every scope, so the terminal accepts typing. +vi.mock("~/state/session", async () => { + const { Atom, AsyncResult } = await import("effect/reactivity"); + const Option = await import("effect/Option"); + return { + environmentSession: { + initialConfigValueAtom: Atom.family(() => Atom.make(null)), + preparedConnectionValueAtom: Atom.family(() => Atom.make(Option.none())), + sessionStateAtom: Atom.family(() => Atom.make(AsyncResult.initial())), + }, + environmentSessionStatesAtom: () => Atom.make([]), + readPreparedConnection: () => null, + usePreparedConnection: () => null, + + 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", () => ({ + 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( + { input: document.createElement("textarea") }, + { + get: (target, key) => + key === "then" ? undefined : key in target ? Reflect.get(target, key) : () => undefined, + }, + ); + }, + }, +})); +vi.mock("~/state/entities", () => { + const project = { workspaceRoot: "/repo" }; + return { + readEnvironmentSupportsServerBrowser: () => false, + useEnvironmentSupportsServerBrowser: () => false, + useQuietThreadShell: (ref: ScopedThreadRef) => ({ + environmentId: ref.environmentId, + projectId: "project-a", + worktreePath: null, + }), + useProject: () => project, + readProject: () => project, + readProjects: () => [], + readThreadShell: () => null, + useProjects: () => [], + }; +}); + +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, +}); + +async function renderTerminal(screen: Screen, threadRef: ScopedThreadRef) { + await screen.rerender( + + + 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 () => { + resetFixture(); + using _resizeObserver = stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + await using screen = await renderScreen(null, { router: false }); + await renderTerminal(screen, 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(screen, 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" }, + }, + }, + ]); + }); +}); + +function resetFixture() { + transport.attached = []; + transport.commands = []; + transport.surfaces = []; +} diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.browser.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.browser.test.tsx new file mode 100644 index 0000000000000..b207481bd5c03 --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.browser.test.tsx @@ -0,0 +1,137 @@ +import { render as renderScreen } from "../../../test/render"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +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 { useQuietThreadShell: () => 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, + 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", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + await screen.rerender(panelIn(hostFor(true))); + expect(drawerRenders).toEqual([{ visible: true, threadRef }]); + + await screen.rerender(panelIn(hostFor(true))); + expect(drawerRenders).toHaveLength(1); + + await screen.rerender(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", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + thread.worktreePath = "/repo/.worktrees/feature"; + drawerWorktreePaths.length = 0; + const launchedLocally = ( + + + + ); + await screen.rerender(launchedLocally); + expect(drawerWorktreePaths).toEqual([null]); + + await screen.rerender(null); + drawerWorktreePaths.length = 0; + await screen.rerender(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", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + thread.worktreePath = "/repo/.worktrees/feature"; + terminalSessions.push({ + target: { terminalId: "term-1" }, + state: { summary: { cwd: "/repo", worktreePath: null } }, + }); + await screen.rerender(null); + drawerWorktreePaths.length = 0; + await screen.rerender(panelIn(hostFor(true))); + expect(drawerWorktreePaths).toEqual([null]); + terminalSessions.length = 0; + thread.worktreePath = null; + }); +}); + +function resetFixture() { + drawerRenders.length = 0; + drawerWorktreePaths.length = 0; + thread.worktreePath = null; + terminalSessions.length = 0; +} diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.tsx new file mode 100644 index 0000000000000..a4d02af4d475f --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.tsx @@ -0,0 +1,29 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { ThreadId } from "@t3tools/contracts"; +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { usePanelHost } from "../panelHost"; +import { + PersistentThreadTerminalPanel, + type PersistentThreadTerminalPanelProps, +} from "./PersistentThreadTerminalDrawer"; + +export interface TerminalLaunchContext { + threadId: ThreadId; + cwd: string; + worktreePath: string | null; +} + +export default function TerminalSidePanel( + props: Omit, +) { + const { threadRef, visible } = usePanelHost(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + return ( + + ); +} diff --git a/apps/web/src/state/contributionStatus.ts b/apps/web/src/state/contributionStatus.ts new file mode 100644 index 0000000000000..20192a2f38f6f --- /dev/null +++ b/apps/web/src/state/contributionStatus.ts @@ -0,0 +1,19 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createContributionStatusEnvironmentAtoms } from "@t3tools/client-runtime/state/contribution-status"; +import type { ContributionStatusEntry, EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { serverEnvironment } from "./server"; + +const contributionStatusEnvironment = createContributionStatusEnvironmentAtoms( + connectionAtomRuntime, + { configValueAtom: serverEnvironment.configValueAtom }, +); + +/** A thread's advisory statuses, empty when its server lacks the capability. */ +export function useThreadContributionStatus( + environmentId: EnvironmentId, + threadId: ThreadId, +): ReadonlyArray { + return useAtomValue(contributionStatusEnvironment.threadStatus(environmentId, threadId)); +} diff --git a/apps/web/src/state/device.browser.test.tsx b/apps/web/src/state/device.browser.test.tsx index 1188119271c79..a148e4d77b90b 100644 --- a/apps/web/src/state/device.browser.test.tsx +++ b/apps/web/src/state/device.browser.test.tsx @@ -12,7 +12,8 @@ import { page } from "vite-plus/test/browser"; import { useEffect } from "react"; import { act, render } from "../../test/render"; -import { DevicePanel } from "../components/device/DevicePanel"; +import DeviceSidePanel from "../panels/device/DeviceSidePanel"; +import { PanelHostContext } from "../panels/panelHost"; import { startAgentOpenedPreviews, resetDeviceSessionBaselinesForTests, @@ -164,16 +165,26 @@ it("reports a failed first device load and retries it", async () => { using _subscription = vi.spyOn(deviceEnvironment, "state").mockReturnValue(stateAtom); await using screen = await render( - undefined, }} - surface={{ id: "device", kind: "device" }} - visible - onDismissSetup={() => undefined} - /> + > + undefined} + /> + , ); diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 9e5cf6c9d8a6c..55389188f3894 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -71,6 +71,14 @@ checkpoint or diff must not extend the recorded provider duration or keep the cl provider work as active. PR discovery after completion also checks that the checkout still matches the thread's non-default branch and that a newer run is not active. +Every newly finished run that was not rolled back records exactly one of `run.finalized` or +`run.finalization-failed`. A run that never enqueues a checkpoint capture records it in the commit +that writes its terminal status, through [EventSink](../../apps/server/src/orchestration-v2/EventSink.ts), +so new terminal paths need no extra work. A run that captures records it in a later write, after +the capture and the workspace refresh; a replayed capture first checks for a recorded outcome and +stops there, so a restart either redoes the unrecorded work or honours the recorded outcome. +Neither event is `thread.settled`, which is the sidebar's parking state. + [Checkpoints](../../apps/server/src/checkpointing/CheckpointStore.ts) use hidden Git refs to capture workspace state without adding commits to the user's branch. A revert must coordinate workspace state with the provider conversation. A provider that cannot roll back its conversation diff --git a/docs/user/plugin-settings.md b/docs/user/plugin-settings.md new file mode 100644 index 0000000000000..0e715220c0c3b --- /dev/null +++ b/docs/user/plugin-settings.md @@ -0,0 +1,58 @@ +# Plugin settings and storage + +A trusted local plugin can declare settings that you fill in, including +secrets such as API tokens, and keep a small amount of its own data between +runs. Forms for filling in settings arrive in the clients later; until then, +settings are saved from an administrative connection with the +`plugins.settings.update` request. + +## Declaring settings + +In the plugin's `t3-plugin.json`, add `"settings"` to `capabilities`, set +`"proposedApi": true`, and list up to 32 fields in `settings`. Each field has +a `type` (`text`, `secret`, `boolean`, `number` or `select`), a unique `key` +and a `label`, and may have a `description`. Every type except `secret` can +have a `default`; `number` can set `min`, `max` and `integer`, and `select` +lists its `options`: + +```json +{ + "capabilities": ["settings"], + "proposedApi": true, + "settings": [ + { "type": "text", "key": "apiUrl", "label": "API URL", "default": "https://api.example.com" }, + { "type": "secret", "key": "token", "label": "API token" } + ] +} +``` + +A manifest whose settings T3 Code cannot honor, such as a default outside its +own bounds, is refused when the plugin is added. + +## Reading settings + +`context.proposed.settings.get(key)` returns the saved value if it still fits +the field, else the field's default, else `undefined`. A secret returns its saved text. Asking for +a key the manifest does not declare rejects. + +Secrets are write-only for clients: a client learns only whether a secret is +saved, never its value. T3 Code stores each secret as a plain-text file, +readable only by the server's OS account, in the server's secrets directory. It is not +encrypted. + +## Plugin storage + +`context.proposed.storage` keeps JSON values under string keys with `get`, +`set`, `delete` and `keys`. Each installation has its own storage, limited to +keys of 1 to 128 characters, values up to 64 KiB of JSON, 256 keys and 1 MiB +in total. A write past a limit rejects. + +## How long values last + +Settings, secrets and storage belong to the installation. They survive +disabling and enabling it again, server restarts and updates to the plugin's +files. When a new version stops declaring a field, or switches it between +secret and non-secret, its saved value is deleted at the next save. A saved +value that no longer fits a changed field is kept, but reads fall back to the +default until it fits again. Removing the plugin deletes everything saved for +it. diff --git a/docs/user/plugin-tools.md b/docs/user/plugin-tools.md new file mode 100644 index 0000000000000..9622eaeac083f --- /dev/null +++ b/docs/user/plugin-tools.md @@ -0,0 +1,46 @@ +# Plugin tools + +A trusted local plugin can offer tools that agents call through T3 Code's own +MCP server. Plugins are code that runs as the T3 Code server's OS account, so +only add plugins you trust. + +## Offering a tool + +In the plugin's `t3-plugin.json`, add `"tools"` to `capabilities`, set +`"proposedApi": true`, and declare each tool in `tools` with a `name`, +`description`, `inputSchema` and `sideEffect` (`read`, `write` or +`destructive`). The plugin answers a call by registering +`context.proposed.handle("t3.tool.", handler)`; the handler receives +`{ input, context: { environmentId, threadId } }`. + +`inputSchema` must stay within the JSON Schema subset T3 Code enforces. A +manifest outside it is refused when the plugin is added, with the path of the +keyword it cannot enforce. Input that does not match the schema never reaches +the plugin. + +## Making tools available + +Plugins are managed from an administrative connection with the `plugins.add`, +`plugins.consent` and `plugins.enable` requests. Consent covers the plugin's +exact files, tool declarations included. Listing tools never starts the plugin; +the first call does. + +Agents see two tools in every session that uses T3 Code's MCP server: +`plugin_tools_list` and `plugin_tool_call`. + +## Which sessions can use a plugin + +Calls require a live thread in full-access/default mode. Restricted and planning +threads can list tools but cannot execute them, because a trusted plugin's +side-effect declaration does not constrain the code it runs. + +A session can use the tool plugins that were enabled when it started. A plugin +you enable later, or enable again after disabling it, shows up as not +available in this session. To use it, start a new thread. + +Disabling or removing a plugin refuses its tools at once in every session and +cancels calls in progress. T3 Code checks a plugin's files when you refresh, +consent to or enable it, when the server starts, and before a call starts the +plugin. If the files changed, the plugin is disabled until you consent to the +new version. A plugin that is already running is not checked again until one of +those points, so refresh it after editing its files. diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index 37026d50ab76c..dfc72fe8ccac7 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -49,8 +49,10 @@ loads for the current workspace; selecting one uses Pi's native skill expansion. Pi loads its normal user extensions, and project extensions once the folder is trusted in Pi. 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. +the work log. On web and desktop, extension statuses (`setStatus`) appear beside the thread title; on mobile, +they appear under the thread header. +They can lag a session change until the extension updates them. Pi terminal titles and widgets +do not have a T3 Code equivalent. ## Permission Modes diff --git a/knip.jsonc b/knip.jsonc index c2093737fd942..0594af6072fd6 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -52,6 +52,7 @@ "scripts/update-test-shard-weights.ts", "scripts/verify-background-live.ts", "src/provider/testFixtures/*.mjs", + "src/plugins/testFixtures/**/*.mjs", // graphqlgen reads its config. "graphql.config.ts", ], diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d1b48e1d76f4f..04f2216aafbd4 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -219,6 +219,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 2832a4e231556..b1ccf7bac787a 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -66,6 +66,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 0000000000000..9c04e8c562a9d --- /dev/null +++ b/packages/client-runtime/src/state/contributionStatus.test.ts @@ -0,0 +1,242 @@ +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, + inboundFrames: Effect.succeed(0), + 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 0000000000000..2170a1a94c738 --- /dev/null +++ b/packages/client-runtime/src/state/contributionStatus.ts @@ -0,0 +1,99 @@ +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 + ); + }) + ); + }) + ); +} + +// Subscribe on demand only to capable servers; reconnect snapshots retire old statuses. +// Keep source identity so a replacement provider session re-keys even identical status text. +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 fbba7d1bd9dd4..e3068ba46f8a7 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -302,6 +302,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 0000000000000..ba18aaed1e38b --- /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 0000000000000..ef7f2acad997c --- /dev/null +++ b/packages/contracts/src/contributionStatus.ts @@ -0,0 +1,98 @@ +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; + +// Frames replace the entire snapshot, including after reconnect; unknown entries are dropped. +// Order is thread id, provider sources first, then source key. +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 f9cd98311d19d..ffab7176e213a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -147,6 +147,15 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ commandOutputStreaming: Schema.optionalKey(Schema.Boolean), /** Hosts headless Chromium tabs streamed over /api/preview-stream for clients without a local runtime. */ serverBrowser: Schema.optionalKey(Schema.Boolean), + /** Server serves subscribeContributionStatus. Absent on older servers, which + lack the method, so clients must not subscribe and show no statuses. */ + contributionStatus: Schema.optionalKey(Schema.Boolean), + /** Server exposes the trusted local plugin catalogue (`plugins.*`). Absent on + older servers, so clients must not call or subscribe to those methods. */ + plugins: Schema.optionalKey(Schema.Boolean), + /** Server stores plugin setting values and secrets (`plugins.settings.*`). Absent on + older servers, so clients must not call or subscribe to those methods. */ + pluginSettings: Schema.optionalKey(Schema.Boolean), /** Server links to other environments (`PeerLink`) and its agents can work in them. Absent on servers from before linking, which clients must not offer linking for. */ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f00976d243b37..c62a79ab3cc6c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -57,9 +57,16 @@ export * from "./mcpApps.ts"; export * from "./browserImport.ts"; export * from "./browserProfile.ts"; export * from "./device.ts"; +export * from "./plugin.ts"; +export * from "./pluginCatalog.ts"; +export * from "./pluginEvents.ts"; +export * from "./pluginTools.ts"; +export * from "./pluginSettingFields.ts"; +export * from "./pluginSettings.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./previewCapture.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 3782307171e87..9693d0371842f 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -224,6 +224,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("decodes persisted capability snapshots that predate runtimePolicy", () => { const legacyCapabilities = { sessions: { diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 1c80128f8bbc5..bdf583989bf2c 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -668,6 +668,45 @@ export type OrchestrationV2ThreadLaunchWorkspaceStrategy = 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; + +// Finalization follows capture/workspace refresh, or the terminal write for runs without capture. +// Each run records either this or finalization-failed once; 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, @@ -1901,6 +1940,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"), @@ -2781,6 +2830,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 0000000000000..df8a3cf1c145a --- /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 0000000000000..3ed642fbda6e5 --- /dev/null +++ b/packages/contracts/src/plugin.ts @@ -0,0 +1,86 @@ +// Plugins run as trusted OS-user code; the supervised process boundary is not a sandbox. +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { IsoDateTime, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PLUGIN_TOOL_LIMITS, PluginToolDeclaration } from "./pluginTools.ts"; +import { PluginSettingsDeclaration } from "./pluginSettingFields.ts"; + +/** + * The one plugin API version this server implements. A manifest names the + * version it was written against; any other value is not loaded. Additive + * changes keep the version, removals and meaning changes bump it. New APIs + * ship under the manifest's `proposedApi` opt-in first. + */ +export const PLUGIN_API_VERSION = 1; + +export const PLUGIN_MANIFEST_FILE = "t3-plugin.json"; + +/** Owner-qualified id such as `acme.notifier`: two or more lowercase dot segments. */ +export const PluginId = TrimmedNonEmptyString.check( + Schema.isMaxLength(128), + Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/), +).pipe(Schema.brand("PluginId")); +export type PluginId = typeof PluginId.Type; + +/** + * A capability the plugin asks the host for. The host refuses to load a plugin + * that declares a capability it does not implement, so a plugin never runs + * with a silently missing feature. + */ +const PluginCapabilityName = Schema.String.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/), +); +export type PluginCapabilityName = typeof PluginCapabilityName.Type; + +/** Relative `.js` or `.mjs` path inside the plugin directory, without `..` segments. */ +const PluginEntryPath = Schema.String.check( + Schema.isMaxLength(256), + Schema.isPattern(/^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[^\\:\0]+\.m?js$/), +); + +export const PluginManifest = Schema.Struct({ + id: PluginId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + /** Display identity of the installed bytes; the host does not order versions. */ + version: TrimmedNonEmptyString.check(Schema.isMaxLength(64)), + description: Schema.optional(Schema.String.check(Schema.isMaxLength(500))), + apiVersion: Schema.Int, + entry: PluginEntryPath, + capabilities: Schema.Array(PluginCapabilityName) + .check(Schema.isMaxLength(32)) + .pipe(Schema.withDecodingDefault(Effect.succeed([]))), + /** Opts into APIs that may change or disappear without an API version bump. */ + proposedApi: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** Tools for agents; needs the `tools` capability (see PluginTools). */ + tools: Schema.optionalKey( + Schema.Array(PluginToolDeclaration).check( + Schema.isMaxLength(PLUGIN_TOOL_LIMITS.maxToolsPerPlugin), + ), + ), + /** Fields users can set for this plugin; needs the `settings` capability. */ + settings: Schema.optionalKey(PluginSettingsDeclaration), +}); +export type PluginManifest = typeof PluginManifest.Type; + +const PluginFailureReason = Schema.String.check(Schema.isMaxLength(1000)); + +// Decode through ForwardCompatibleOptional: an absent state is unknown, never idle or safe to enable. +// Incompatible code and quarantined crashes require explicit resume; ordinary backoff retries automatically. +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 0000000000000..9fc07ac14df8b --- /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 0000000000000..c1d600d4bb512 --- /dev/null +++ b/packages/contracts/src/pluginCatalog.ts @@ -0,0 +1,152 @@ +// Installations are environment-local; consent pins bytes and generation distinguishes replacement registrations. +import * as Schema from "effect/Schema"; + +import { + ForwardCompatibleArray, + ForwardCompatibleOptional, + IsoDateTime, + NonNegativeInt, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +import { PluginHostState } from "./plugin.ts"; +import { PluginEventDeliveryState } from "./pluginEvents.ts"; +import { PluginToolDeclaration } from "./pluginTools.ts"; +import { PluginSettingsFieldList } from "./pluginSettingFields.ts"; + +export const PluginInstallationId = TrimmedNonEmptyString.check(Schema.isMaxLength(64)).pipe( + Schema.brand("PluginInstallationId"), +); +export type PluginInstallationId = typeof PluginInstallationId.Type; + +/** `sha256:` and the hex digest of every file in the plugin directory. */ +const PluginSourceDigest = Schema.String.check(Schema.isPattern(/^sha256:[0-9a-f]{64}$/)); + +/** + * What the manifest said when the server last read it. Ids and capability + * names are plain strings here so an older client keeps rows whose values a + * newer server accepts. + */ +const PluginInstallationManifest = Schema.Struct({ + id: TrimmedNonEmptyString.pipe(Schema.brand("PluginId")), + name: Schema.String, + /** Display metadata only; never proof of the installed bytes. */ + version: Schema.String, + description: Schema.optionalKey(Schema.String), + capabilities: Schema.Array(Schema.String), + proposedApi: Schema.Boolean, + /** The declared tools, when there are any. Unknown shapes from a newer server are dropped. */ + tools: Schema.optionalKey(ForwardCompatibleArray(PluginToolDeclaration)), + /** Declared settings fields (`settings` capability); absent when the plugin has none. */ + settings: Schema.optionalKey(PluginSettingsFieldList), +}); +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 0000000000000..8b436fbebbcc2 --- /dev/null +++ b/packages/contracts/src/pluginEvents.ts @@ -0,0 +1,106 @@ +// At-least-once delivery requires deduplication by deliveryId; the projection omits message bodies. +// Handlers must ignore additive event types and optional fields. +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)); + +// Delivery quarantine is independent of process state; resume clears both. +// Unreadable events quarantine with zero failures; retrying retains the same page until acknowledgment. +export const PluginEventDeliveryState = Schema.Union([ + Schema.TaggedStruct("active", {}), + Schema.TaggedStruct("retrying", { + failures: NonNegativeInt, + reason: PluginEventDeliveryReason, + retryAt: IsoDateTime, + }), + Schema.TaggedStruct("quarantined", { + failures: NonNegativeInt, + reason: PluginEventDeliveryReason, + }), +]); +export type PluginEventDeliveryState = typeof PluginEventDeliveryState.Type; diff --git a/packages/contracts/src/pluginSettingFields.ts b/packages/contracts/src/pluginSettingFields.ts new file mode 100644 index 0000000000000..e5c09b9cd6782 --- /dev/null +++ b/packages/contracts/src/pluginSettingFields.ts @@ -0,0 +1,170 @@ +// Settings apply to the environment and persist with their installation through registration changes. +// Secrets are write-only for clients; only the plugin can read their values. +import * as Schema from "effect/Schema"; + +import { ForwardCompatibleArray, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** The manifest capability that grants settings, secrets and plugin storage. */ +export const PLUGIN_SETTINGS_CAPABILITY = "settings"; + +export const PLUGIN_SETTINGS_MAX_FIELDS = 32; +const PLUGIN_SETTING_MAX_OPTIONS = 32; +const PLUGIN_SETTING_TEXT_MAX_LENGTH = 2000; +const PLUGIN_SETTING_SECRET_MAX_LENGTH = 8192; + +/** A setting's identity within its installation, such as `apiUrl`. */ +export const PluginSettingKey = Schema.String.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[A-Za-z][A-Za-z0-9_.-]*$/), +); +export type PluginSettingKey = typeof PluginSettingKey.Type; + +const PluginSettingLabel = TrimmedNonEmptyString.check(Schema.isMaxLength(60)); +const PluginSettingDescription = Schema.String.check(Schema.isMaxLength(240)); +const PluginSettingText = Schema.String.check(Schema.isMaxLength(PLUGIN_SETTING_TEXT_MAX_LENGTH)); + +/** A select option. `value` is what is saved and must stay stable; `label` is display text. */ +const PluginSettingOption = Schema.Struct({ + value: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(64)), + label: PluginSettingLabel, +}); + +const fieldBase = { + key: PluginSettingKey, + label: PluginSettingLabel, + description: Schema.optionalKey(PluginSettingDescription), +}; + +const PluginTextSettingField = Schema.Struct({ + type: Schema.Literal("text"), + ...fieldBase, + default: Schema.optionalKey(PluginSettingText), +}); + +/** Never has a default, and its value never leaves the server. */ +const PluginSecretSettingField = Schema.Struct({ + type: Schema.Literal("secret"), + ...fieldBase, +}); + +const PluginBooleanSettingField = Schema.Struct({ + type: Schema.Literal("boolean"), + ...fieldBase, + default: Schema.optionalKey(Schema.Boolean), +}); + +const PluginNumberSettingField = Schema.Struct({ + type: Schema.Literal("number"), + ...fieldBase, + default: Schema.optionalKey(Schema.Finite), + min: Schema.optionalKey(Schema.Finite), + max: Schema.optionalKey(Schema.Finite), + integer: Schema.optionalKey(Schema.Boolean), +}); + +const PluginSelectSettingField = Schema.Struct({ + type: Schema.Literal("select"), + ...fieldBase, + options: Schema.Array(PluginSettingOption).check( + Schema.isMinLength(1), + Schema.isMaxLength(PLUGIN_SETTING_MAX_OPTIONS), + ), + default: Schema.optionalKey(Schema.String), +}); + +/** A saved setting value; which one a field takes depends on its `type`. */ +export const PluginSettingValue = Schema.Union([Schema.String, Schema.Finite, Schema.Boolean]); +export type PluginSettingValue = typeof PluginSettingValue.Type; + +const PluginSettingFieldShape = Schema.Union([ + PluginTextSettingField, + PluginSecretSettingField, + PluginBooleanSettingField, + PluginNumberSettingField, + PluginSelectSettingField, +]); +type PluginSettingFieldShape = typeof PluginSettingFieldShape.Type; + +/** + * Why `value` cannot be saved for `field`, or undefined when it can. The + * message never repeats the value, so it is safe for secrets. + */ +export const pluginSettingValueProblem = ( + field: PluginSettingFieldShape, + value: PluginSettingValue, +): string | undefined => { + switch (field.type) { + case "text": + if (typeof value !== "string") return `${field.label} must be text.`; + if (value.length > PLUGIN_SETTING_TEXT_MAX_LENGTH) + return `${field.label} must be at most ${PLUGIN_SETTING_TEXT_MAX_LENGTH} characters.`; + return undefined; + case "secret": + if (typeof value !== "string" || value.length === 0) + return `${field.label} must be non-empty text.`; + if (value.length > PLUGIN_SETTING_SECRET_MAX_LENGTH) + return `${field.label} must be at most ${PLUGIN_SETTING_SECRET_MAX_LENGTH} characters.`; + return undefined; + case "boolean": + return typeof value === "boolean" ? undefined : `${field.label} must be on or off.`; + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) + return `${field.label} must be a number.`; + if (field.integer === true && !Number.isInteger(value)) + return `${field.label} must be a whole number.`; + if (field.min !== undefined && value < field.min) + return `${field.label} must be at least ${field.min}.`; + if (field.max !== undefined && value > field.max) + return `${field.label} must be at most ${field.max}.`; + return undefined; + case "select": + return typeof value === "string" && field.options.some((option) => option.value === value) + ? undefined + : `${field.label} must be one of its options.`; + } +}; + +const fieldDeclarationProblem = (field: PluginSettingFieldShape): string | undefined => { + if (field.type === "number" && field.min !== undefined && field.max !== undefined) + if (field.min > field.max) return `${field.key}: min is greater than max.`; + if (field.type === "select") { + const values = new Set(field.options.map((option) => option.value)); + if (values.size !== field.options.length) return `${field.key}: option values repeat.`; + } + if (field.type !== "secret" && field.default !== undefined) { + const problem = pluginSettingValueProblem(field, field.default); + if (problem !== undefined) return `${field.key}: the default is invalid. ${problem}`; + } + return undefined; +}; + +/** One declared setting. */ +export const PluginSettingField = PluginSettingFieldShape.check( + Schema.makeFilter((field) => fieldDeclarationProblem(field) ?? true), +); +export type PluginSettingField = typeof PluginSettingField.Type; + +/** The `settings` list of a manifest: unique keys, at most 32 fields. */ +export const PluginSettingsDeclaration = Schema.Array(PluginSettingField).check( + Schema.isMaxLength(PLUGIN_SETTINGS_MAX_FIELDS), + Schema.makeFilter((fields) => { + const keys = new Set(fields.map((field) => field.key)); + return keys.size === fields.length || "settings: keys repeat."; + }), +); + +/** + * The declared fields as clients read them from the catalogue. A field type a + * newer server knows and this client does not is dropped, not the whole row. + */ +export const PluginSettingsFieldList = ForwardCompatibleArray(PluginSettingField); + +/** The value a plugin reads for a non-secret field: what was saved if it still fits, else the default. */ +export const resolvePluginSettingValue = ( + field: PluginSettingField, + saved: PluginSettingValue | undefined, +): PluginSettingValue | undefined => { + if (field.type === "secret") return undefined; + if (saved !== undefined && pluginSettingValueProblem(field, saved) === undefined) return saved; + return field.default; +}; diff --git a/packages/contracts/src/pluginSettings.test.ts b/packages/contracts/src/pluginSettings.test.ts new file mode 100644 index 0000000000000..1867fd8eb9c8e --- /dev/null +++ b/packages/contracts/src/pluginSettings.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { PluginManifest } from "./plugin.ts"; +import { PluginCatalogSnapshot } from "./pluginCatalog.ts"; +import { + PluginSettingsDeclaration, + pluginSettingValueProblem, + resolvePluginSettingValue, + type PluginSettingField, +} from "./pluginSettingFields.ts"; +import { PluginSettingsValues } from "./pluginSettings.ts"; + +const decodeDeclaration = Schema.decodeUnknownExit(PluginSettingsDeclaration); +const decodeManifest = Schema.decodeUnknownSync(PluginManifest); +const decodeSnapshot = Schema.decodeUnknownSync(PluginCatalogSnapshot); +const decodeValues = Schema.decodeUnknownSync(PluginSettingsValues); +const select = { + type: "select", + key: "mode", + label: "Mode", + options: [ + { value: "safe", label: "Safe" }, + { value: "fast", label: "Fast" }, + ], + default: "safe", +} as const; + +describe("PluginSettingsDeclaration", () => { + it("accepts each field type and refuses inconsistent declarations", () => { + const declaration = [ + { type: "text", key: "apiUrl", label: "API URL", default: "https://example.com" }, + // A default on a secret is not part of its shape and is dropped. + { type: "secret", key: "token", label: "Token", default: "leaked" }, + { type: "boolean", key: "verbose", label: "Verbose" }, + { type: "number", key: "retries", label: "Retries", min: 0, max: 5, integer: true }, + select, + ]; + const decoded = decodeDeclaration(declaration); + expect(Exit.isSuccess(decoded)).toBe(true); + if (Exit.isSuccess(decoded)) expect(decoded.value[1]).not.toHaveProperty("default"); + + for (const invalid of [ + [select, select], + [{ ...select, default: "turbo" }], + [{ ...select, options: [select.options[0], select.options[0]] }], + [{ ...select, options: [] }], + [{ type: "number", key: "n", label: "N", min: 3, max: 1 }], + [{ type: "number", key: "n", label: "N", integer: true, default: 1.5 }], + [{ type: "text", key: "1st", label: "First" }], + [{ type: "color", key: "c", label: "C" }], + ]) + expect(Exit.isFailure(decodeDeclaration(invalid))).toBe(true); + }); + + it("is optional in the manifest", () => { + const manifest = decodeManifest({ + id: "acme.notifier", + name: "Notifier", + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + }); + expect(manifest.settings).toBeUndefined(); + }); +}); + +describe("setting values", () => { + const retries: PluginSettingField = { + type: "number", + key: "retries", + label: "Retries", + min: 0, + max: 5, + integer: true, + default: 2, + }; + + it("checks a value against its field", () => { + expect(pluginSettingValueProblem(retries, 3)).toBeUndefined(); + expect(pluginSettingValueProblem(retries, 6)).toBe("Retries must be at most 5."); + expect(pluginSettingValueProblem(retries, "3")).toBe("Retries must be a number."); + expect(pluginSettingValueProblem(select, "fast")).toBeUndefined(); + expect(pluginSettingValueProblem(select, "Fast")).toBe("Mode must be one of its options."); + }); + + it("falls back to the default when a saved value no longer fits its field", () => { + expect(resolvePluginSettingValue(retries, 4)).toBe(4); + // For example after an update narrowed the range. + expect(resolvePluginSettingValue(retries, 9)).toBe(2); + expect(resolvePluginSettingValue(retries, undefined)).toBe(2); + expect( + resolvePluginSettingValue({ type: "secret", key: "token", label: "Token" }, "x"), + ).toBeUndefined(); + }); +}); + +describe("from a newer server", () => { + it("drops field types and values this client does not know, not the installation", () => { + const digest = `sha256:${"a".repeat(64)}`; + const snapshot = decodeSnapshot({ + installations: [ + { + installationId: "installation-1", + generation: 1, + directory: "/srv/plugins/notifier", + manifest: { + id: "acme.notifier", + name: "Notifier", + version: "1.0.0", + capabilities: ["settings"], + proposedApi: true, + settings: [{ type: "color", key: "accent", label: "Accent" }, select], + }, + source: { digest, files: 2, bytes: 120 }, + problem: null, + inspectedAt: "2026-10-04T00:00:00.000Z", + consent: null, + enabled: false, + addedAt: "2026-10-04T00:00:00.000Z", + }, + ], + }); + expect(snapshot.installations[0]?.manifest?.settings?.map((field) => field.key)).toEqual([ + "mode", + ]); + + const values = decodeValues({ + installationId: "installation-1", + values: [ + { key: "mode", value: "fast" }, + { key: "tags", value: ["a", "b"] }, + ], + secrets: ["token"], + }); + expect(values.values).toEqual([{ key: "mode", value: "fast" }]); + }); +}); diff --git a/packages/contracts/src/pluginSettings.ts b/packages/contracts/src/pluginSettings.ts new file mode 100644 index 0000000000000..49c2d4a80fcb7 --- /dev/null +++ b/packages/contracts/src/pluginSettings.ts @@ -0,0 +1,40 @@ +// Values persist with installations, while writes validate against the manifest current at arrival. +import * as Schema from "effect/Schema"; + +import { ForwardCompatibleArray } from "./baseSchemas.ts"; +import { PluginInstallationId } from "./pluginCatalog.ts"; +import { + PLUGIN_SETTINGS_MAX_FIELDS, + PluginSettingKey, + PluginSettingValue, +} from "./pluginSettingFields.ts"; + +/** What clients see of an installation's saved settings. */ +export const PluginSettingsValues = Schema.Struct({ + installationId: PluginInstallationId, + /** Saved non-secret values. A field without one uses its default. */ + values: ForwardCompatibleArray(Schema.Struct({ key: Schema.String, value: PluginSettingValue })), + /** Keys of secret fields that have a saved value. The values themselves are never sent. */ + secrets: Schema.Array(Schema.String), +}); +export type PluginSettingsValues = typeof PluginSettingsValues.Type; + +export const PluginSettingsInput = Schema.Struct({ + installationId: PluginInstallationId, +}); +export type PluginSettingsInput = typeof PluginSettingsInput.Type; + +const PluginSettingChange = Schema.Struct({ + key: PluginSettingKey, + /** `null` clears the saved value: a field returns to its default, a secret is deleted. */ + value: Schema.NullOr(PluginSettingValue), +}); + +export const PluginSettingsUpdateInput = Schema.Struct({ + installationId: PluginInstallationId, + changes: Schema.Array(PluginSettingChange).check( + Schema.isMinLength(1), + Schema.isMaxLength(PLUGIN_SETTINGS_MAX_FIELDS), + ), +}); +export type PluginSettingsUpdateInput = typeof PluginSettingsUpdateInput.Type; diff --git a/packages/contracts/src/pluginTools.ts b/packages/contracts/src/pluginTools.ts new file mode 100644 index 0000000000000..0253232c939b1 --- /dev/null +++ b/packages/contracts/src/pluginTools.ts @@ -0,0 +1,124 @@ +// Declared schemas are enforced; unsupported keywords reject the manifest instead of being dropped. +// Side-effect and open-world flags inform agents; trusted OS-user plugin code is not sandboxed. +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** The manifest capability a plugin declares to offer tools. */ +export const PLUGIN_TOOLS_CAPABILITY = "tools"; + +/** Handler names starting with this run declared tools; the prefix is reserved. */ +export const PLUGIN_TOOL_HANDLER_PREFIX = "t3.tool."; + +/** + * Handler that runs one tool. It receives `{ input, context: { environmentId, threadId } }`: + * `input` already matched the tool's schema, and `context` comes from the calling + * session's credential, never from the agent. + */ +export const pluginToolHandlerName = (name: PluginToolName) => + `${PLUGIN_TOOL_HANDLER_PREFIX}${name}`; + +export const PLUGIN_TOOL_LIMITS = { + maxToolsPerPlugin: 32, + /** Serialized `inputSchema` of one tool. */ + maxInputSchemaBytes: 16 * 1024, + /** Nesting of schemas inside one `inputSchema`. */ + maxInputSchemaDepth: 32, + /** One plugin's tools as `plugin_tools_list` shows them, so every plugin fits on a page. */ + maxPluginListingBytes: 48 * 1024, + /** A whole serialized `plugin_tools_list` result. */ + maxListBytes: 64 * 1024, + /** Serialized result of one tool call. */ + maxResultBytes: 64 * 1024, + defaultTimeoutSeconds: 60, + maxTimeoutSeconds: 600, +} as const; + +/** Unique within its plugin; agents address it as `/`. */ +export const PluginToolName = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9_]{0,63}$/)); +export type PluginToolName = typeof PluginToolName.Type; + +/** + * What a tool may do: `read` only observes, `write` changes state, and + * `destructive` may lose data. Shown to the agent; not enforced. + */ +export const PluginToolSideEffect = Schema.Literals(["read", "write", "destructive"]); +export type PluginToolSideEffect = typeof PluginToolSideEffect.Type; + +const PluginToolTitle = Schema.String.check(Schema.isMaxLength(100)); +const PluginToolDescriptionText = TrimmedNonEmptyString.check(Schema.isMaxLength(2000)); + +/** A JSON Schema object in the subset above; the root describes an object. */ +const JsonSchemaObject = Schema.Record(Schema.String, Schema.Json); + +/** One entry of the manifest's `tools` array. */ +export const PluginToolDeclaration = Schema.Struct({ + name: PluginToolName, + title: Schema.optionalKey(PluginToolTitle), + description: PluginToolDescriptionText, + inputSchema: JsonSchemaObject, + sideEffect: PluginToolSideEffect, + /** True when the tool reaches outside this machine (network, external services). */ + openWorld: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** Deadline of the tool's handler once its plugin runs; default 60. */ + timeoutSeconds: Schema.optionalKey( + Schema.Int.check( + Schema.isBetween({ minimum: 1, maximum: PLUGIN_TOOL_LIMITS.maxTimeoutSeconds }), + ), + ), +}); +export type PluginToolDeclaration = typeof PluginToolDeclaration.Type; + +/** `/`, the name agents call a tool by. */ +export const qualifyPluginToolName = (pluginId: string, name: PluginToolName) => + `${pluginId}/${name}`; + +const decodeToolName = Schema.decodeUnknownOption(PluginToolName); + +/** Splits `/` at the first `/`; plugin ids never contain one. */ +export const parseQualifiedPluginToolName = (tool: string) => { + const slash = tool.indexOf("/"); + if (slash <= 0) return Option.none(); + return decodeToolName(tool.slice(slash + 1)).pipe( + Option.map((name) => ({ pluginId: tool.slice(0, slash), name })), + ); +}; + +const PluginToolPlugin = Schema.Struct({ id: Schema.String, name: Schema.String }); + +/** One tool as `plugin_tools_list` shows it. */ +export const PluginToolListing = Schema.Struct({ + tool: Schema.String, + plugin: PluginToolPlugin, + title: Schema.optionalKey(Schema.String), + description: Schema.String, + /** Exactly the declared schema; calls are checked against it. */ + inputSchema: JsonSchemaObject, + sideEffect: PluginToolSideEffect, + openWorld: Schema.Boolean, +}); +export type PluginToolListing = typeof PluginToolListing.Type; + +/** + * One page of tools, ordered by plugin id. A plugin's tools are never split + * across pages. Pass `nextCursor` back as `cursor` for the next page. + */ +export const PluginToolsListResult = Schema.Struct({ + tools: Schema.Array(PluginToolListing), + /** Tool plugins enabled after this session started; a new session can use them. */ + notInThisSession: Schema.Array(PluginToolPlugin), + nextCursor: Schema.optionalKey(Schema.String), +}); +export type PluginToolsListResult = typeof PluginToolsListResult.Type; + +/** + * Why a plugin tool call or listing failed. `reason` is an open string; in + * use: `not-granted`, `unavailable`, `unknown-tool`, `invalid-input`, + * `timeout`, `failed`, `result-too-large`. + */ +export class PluginToolError extends Schema.TaggedError()("PluginToolError", { + reason: Schema.String, + message: Schema.String, +}) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 37ea5866ae733..74e45181013f1 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -38,6 +38,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 { ProviderAuthCancelInput, ProviderAuthCompleteInput, @@ -376,6 +377,21 @@ import { ScheduledTaskMutationResult, } from "./scheduledTask.ts"; import { SecretRequestAnswerInput, SecretRequestError } from "./secretRequest.ts"; +import { + PluginAddInput, + PluginCatalogError, + PluginCatalogSnapshot, + PluginConsentInput, + PluginInstallationInput, + PluginInstallationResult, + PluginRefreshInput, + PluginRemoveResult, +} from "./pluginCatalog.ts"; +import { + PluginSettingsInput, + PluginSettingsUpdateInput, + PluginSettingsValues, +} from "./pluginSettings.ts"; import { ProjectCloneActionInput, ProjectCloneActionResult, @@ -548,6 +564,20 @@ export const WS_METHODS = { scheduledTasksListWebhookDeliveries: "scheduledTasks.listWebhookDeliveries", scheduledTasksGetWebhookDelivery: "scheduledTasks.getWebhookDelivery", + // Trusted local plugins (gated on the `plugins` environment capability) + pluginsList: "plugins.list", + pluginsSubscribe: "plugins.subscribe", + pluginsAdd: "plugins.add", + pluginsRefresh: "plugins.refresh", + pluginsConsent: "plugins.consent", + pluginsEnable: "plugins.enable", + pluginsDisable: "plugins.disable", + pluginsRemove: "plugins.remove", + pluginsResume: "plugins.resume", + // Plugin setting values (gated on the `pluginSettings` environment capability) + pluginsSettingsSubscribe: "plugins.settings.subscribe", + pluginsSettingsUpdate: "plugins.settings.update", + cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -610,6 +640,7 @@ export const WS_METHODS = { subscribeServerLifecycle: "subscribeServerLifecycle", subscribeAuthAccess: "subscribeAuthAccess", subscribeResourceTelemetry: "subscribeResourceTelemetry", + subscribeContributionStatus: "subscribeContributionStatus", } as const; const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -1949,6 +1980,85 @@ const WsScheduledTasksGetWebhookDeliveryRpc = Rpc.make( error: Schema.Union([ScheduledTaskError, EnvironmentAuthorizationError]), }, ); +const pluginRpcError = Schema.Union([PluginCatalogError, EnvironmentAuthorizationError]); + +const WsPluginsListRpc = Rpc.make(WS_METHODS.pluginsList, { + payload: Schema.Struct({}), + success: PluginCatalogSnapshot, + error: pluginRpcError, +}); + +/** One snapshot on subscribe, then a fresh one after every catalogue or plugin state change. */ +const WsPluginsSubscribeRpc = Rpc.make(WS_METHODS.pluginsSubscribe, { + payload: Schema.Struct({}), + success: PluginCatalogSnapshot, + error: pluginRpcError, + stream: true, +}); + +/** Reads the manifest and digests the directory; runs nothing. */ +const WsPluginsAddRpc = Rpc.make(WS_METHODS.pluginsAdd, { + payload: PluginAddInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +/** Inspects the bytes again; an enabled installation whose bytes changed is stopped. */ +const WsPluginsRefreshRpc = Rpc.make(WS_METHODS.pluginsRefresh, { + payload: PluginRefreshInput, + success: PluginCatalogSnapshot, + error: pluginRpcError, +}); + +const WsPluginsConsentRpc = Rpc.make(WS_METHODS.pluginsConsent, { + payload: PluginConsentInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +const WsPluginsEnableRpc = Rpc.make(WS_METHODS.pluginsEnable, { + payload: PluginInstallationInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +const WsPluginsDisableRpc = Rpc.make(WS_METHODS.pluginsDisable, { + payload: PluginInstallationInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +/** + * Disables and forgets the installation and deletes its saved settings and + * storage. The directory is left untouched. + */ +const WsPluginsRemoveRpc = Rpc.make(WS_METHODS.pluginsRemove, { + payload: PluginInstallationInput, + success: PluginRemoveResult, + error: pluginRpcError, +}); + +/** Clears backoff, quarantine, or incompatibility; the next use starts a fresh process. */ +const WsPluginsResumeRpc = Rpc.make(WS_METHODS.pluginsResume, { + payload: PluginInstallationInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +/** The installation's saved values now, then after every change; fails `not-found` once it is removed. */ +const WsPluginsSettingsSubscribeRpc = Rpc.make(WS_METHODS.pluginsSettingsSubscribe, { + payload: PluginSettingsInput, + success: PluginSettingsValues, + error: pluginRpcError, + stream: true, +}); + +/** Saves or clears values; all changes are checked before any is saved. Never echoes a secret. */ +const WsPluginsSettingsUpdateRpc = Rpc.make(WS_METHODS.pluginsSettingsUpdate, { + payload: PluginSettingsUpdateInput, + success: PluginSettingsValues, + error: pluginRpcError, +}); const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { payload: Schema.Struct({}), @@ -1964,12 +2074,20 @@ 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, +}); + export class RpcScopeAuthorization extends RpcMiddleware.Service()( "t3/contracts/RpcScopeAuthorization", { error: EnvironmentAuthorizationError }, ) {} -export const WsRpcGroup = RpcGroup.make( +export const WsCoreRpcGroup = RpcGroup.make( WsGuestShareCreateRpc, WsServerProbeRpc, WsServerGetConfigRpc, @@ -2159,6 +2277,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeServerLifecycleRpc, WsSubscribeAuthAccessRpc, WsSubscribeResourceTelemetryRpc, + WsSubscribeContributionStatusRpc, WsOrchestrationV2DispatchCommandRpc, WsOrchestrationV2GetWorkflowScriptRpc, WsOrchestrationV2StopWorkflowRpc, @@ -2179,3 +2298,24 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2SubscribeThreadRpc, WsOrchestrationV2SubscribeCommandOutputRpc, ).middleware(RpcScopeAuthorization); + +/** + * The plugin RPCs. The server builds their handlers in a layer of their own: + * one handler object for every RPC exceeds the type checker's instantiation + * limit and silently types the WebSocket layer as `any`. + */ +export const WsPluginRpcGroup = RpcGroup.make( + WsPluginsListRpc, + WsPluginsSubscribeRpc, + WsPluginsAddRpc, + WsPluginsRefreshRpc, + WsPluginsConsentRpc, + WsPluginsEnableRpc, + WsPluginsDisableRpc, + WsPluginsRemoveRpc, + WsPluginsResumeRpc, + WsPluginsSettingsSubscribeRpc, + WsPluginsSettingsUpdateRpc, +).middleware(RpcScopeAuthorization); + +export const WsRpcGroup = WsCoreRpcGroup.merge(WsPluginRpcGroup); diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index ab93310316ac4..f9c4e3a014373 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -27,6 +27,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 0000000000000..16ef272350bc9 --- /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 0000000000000..6c49908d5933a --- /dev/null +++ b/packages/provider-core/src/server/ContributionStatusStore.ts @@ -0,0 +1,339 @@ +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"; + +// Replacing a thread's source retires the previous handle until it binds again; +// closing its scope clears its statuses without touching the replacement's. +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. +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g; +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 */ +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"] = Effect.fn( + "ContributionStatusStore.openSource", + )(function* (source) { + 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 5c94de3040209..c091f4fdd66de 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -13,6 +13,7 @@ import { RunId, ThreadId, type ChatAttachment, + type ContributionStatusSnapshot, type ModelSelection, type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, @@ -27,9 +28,11 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Logger from "effect/Logger"; 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 ChildProcess from "effect/process/ChildProcess"; @@ -45,6 +48,7 @@ import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { AgentScope } from "@t3tools/shared/AgentScope"; import { toolOutputImages, compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import { makePiAdapterV2, PiAdapterV2Driver, @@ -166,8 +170,12 @@ interface FakePi { readonly failNextState: () => void; readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; readonly deferNextModelSelection: () => void; + /** Send the held lifecycle request's normal response. */ + readonly resolveDeferredLifecycle: Effect.Effect; readonly queueModels: (models: ReadonlyArray) => void; readonly vetoNextNewSession: () => void; + readonly vetoNextFork: () => void; + readonly rejectNextFork: () => void; readonly allRequests: () => ReadonlyArray; readonly queueStats: (data: unknown) => void; /** Pi before 0.84.4 answers clear_queue as an unknown command. */ @@ -210,7 +218,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 = []; @@ -279,8 +290,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 } }; + } case "clear_queue": return clearQueue === "unknown_command" ? { ...base, success: false, error: "Unknown command: clear_queue" } @@ -309,6 +327,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { } if (record["type"] === deferredLifecycle) { deferredLifecycle = undefined; + deferredLifecycleRequest = record; continue; } const response = respondTo(record); @@ -383,12 +402,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; @@ -473,6 +505,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 { @@ -1588,6 +1709,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 a32a5759ba707..7b990c0702a86 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -6,6 +6,7 @@ import * as Predicate from "effect/Predicate"; import { ProviderDriverKind, type ChatAttachment, + type ContributionStatusItem, type ModelSelection, type OrchestrationV2ExecutionNode, type OrchestrationV2ProviderCapabilities, @@ -29,6 +30,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 Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; @@ -46,6 +48,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, @@ -417,6 +420,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) => @@ -524,6 +528,34 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const sessionApprovals = new Set(); // Pi can settle immediately after dialog response; serialize so terminal cannot overtake request resolution. 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; + /** + * 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, + ) => + // The marker carries its own target, so concurrent callers cannot + // pair a marker with another caller's thread. + Queue.offer(connection.events, { type: "t3.status_generation", rollback, threadId }).pipe( + Effect.asVoid, + ); let threadState: PiThreadState | null = null; let registrationAttempted = false; let lastNativeThreadId: string | null = null; @@ -636,21 +668,36 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return null; }; - const lifecycleRequest = (record: PiRpcRecord) => - request(record, PI_SESSION_TIMEOUT_MS).pipe( - // Timeout does not cancel Pi's lifecycle hook; retire the process before fallback can race it. - 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 = ( @@ -1298,6 +1345,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" && @@ -2064,6 +2121,32 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( yield* cancelPrompt(pending, yield* DateTime.now); return; } + case "t3.status_generation": { + // Statuses queued before this marker came from the session Pi is + // leaving. Pi exposes no native-session boundary, so shutdown writes + // after the marker are attributed to the target session. + rollbackStatuses = + event["rollback"] === true + ? new Map((yield* statusSource.items).map((item) => [item.key, item])) + : null; + yield* statusSource.clearAll; + yield* statusSource.bindThread( + (event["threadId"] as OrchestrationV2ProviderThread["appThreadId"] | undefined) ?? + 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": { for (const extensionError of outOfTurnExtensionErrors.splice(0)) { yield* emitExtensionError(extensionError); @@ -2175,7 +2258,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( Effect.forkIn(scope), ); - const registerThread = Effect.fnUntraced(function* ( + const registerThreadUnguarded = Effect.fnUntraced(function* ( threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, publish = true, ) { @@ -2202,8 +2285,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; // Failed lifecycle operations can still change native sessions; invalidate bindings and defaults. threadState = null; @@ -2217,6 +2301,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"); @@ -2288,6 +2373,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); const baselineEntries = yield* request({ type: "get_entries" }).pipe( Effect.orElseSucceed(() => undefined), ); @@ -2302,6 +2389,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) { @@ -2919,19 +3016,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(