diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 653ab85f739..d2195282ef5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,7 +11,7 @@ "start:dev": "APP_VARIANT=development expo start", "start:preview": "APP_VARIANT=preview expo start", "start:prod": "APP_VARIANT=production expo start", - "showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code --clear", + "showcase": "APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code-dev --clear", "screenshots": "node ../../scripts/mobile-showcase.ts", "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 06bd4bc5773..c6947471a45 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -11,6 +11,7 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati import { RegistryContext } from "@effect/atom-react"; import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; +import { IdentityClaimGate } from "./features/identity/IdentityClaimGate"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; import { @@ -88,6 +89,7 @@ export default function App() { /> + {/* Anchored-menu overlays render here — in-window, so the keyboard stays up while a dropdown is open. */} diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b083b8c1d97..661442a3098 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -15,7 +15,6 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; -import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; @@ -394,7 +393,7 @@ export const RootStack = createNativeStackNavigator({ ...GLASS_HEADER_OPTIONS, contentStyle: { backgroundColor: "transparent" }, headerBackVisible: false, - ...getCompactBrandHeaderOptions(), + title: "Threads", }, }), Board: createNativeStackScreen({ diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index d52aa05b446..772d5e8cc14 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,21 +1,14 @@ import { SymbolView } from "./AppSymbol"; import { Image } from "expo-image"; -import { useLayoutEffect, useMemo, useState } from "react"; +import { useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; -import { - getProjectFaviconCacheKey, - isProjectFaviconFallbackUrl, -} from "@t3tools/shared/projectFavicon"; +import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; -import { - beginProjectFaviconRequest, - createProjectFaviconRequest, - hasLoadedProjectFavicon, - markProjectFaviconFailed, - markProjectFaviconLoaded, -} from "./projectFaviconCache"; + +/* ─── Favicon cache (matches web pattern) ────────────────────────────── */ +const loadedFaviconUrls = new Set(); /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { @@ -33,15 +26,10 @@ export function ProjectFavicon(props: { : { _tag: "project-favicon", cwd: props.workspaceRoot }, ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; - const cacheKey = - renderableFaviconUrl && props.workspaceRoot - ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) - : null; return ( createProjectFaviconRequest(props.cacheKey, props.faviconUrl), - [props.cacheKey, props.faviconUrl], - ); - const [activeFaviconRequest, setActiveFaviconRequest] = useState(null); - useLayoutEffect(() => { - if (faviconRequest === null) return; - - const endRequest = beginProjectFaviconRequest(faviconRequest); - setActiveFaviconRequest(faviconRequest); - return endRequest; - }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", + props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading", ); - const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; - const showImage = requestIsActive && status === "loaded"; + const showImage = props.faviconUrl !== null && status === "loaded"; return ( { - if (!markProjectFaviconLoaded(faviconRequest)) return; + if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); setStatus("loaded"); }} - onError={() => { - if (!markProjectFaviconFailed(faviconRequest)) return; - setStatus("error"); - }} + onError={() => setStatus("error")} /> ) : null} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 01440007bc6..916802e9faf 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -29,10 +29,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { - createNativeMailSearchToolbarItem, - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, -} from "../layout/native-mail-search-toolbar"; +import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; export interface ArchivedThreadsHeaderEnvironment { @@ -73,8 +70,7 @@ function ArchivedThreadsHeader(props: { const searchIconColor = useThemeColor("--color-icon"); const searchTextColor = useThemeColor("--color-foreground"); const usesNativeChrome = Platform.OS === "ios"; - const usesCompactMailToolbar = - Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const usesCompactMailToolbar = Platform.OS === "ios" && width < 700; const androidFilterActions = useMemo( () => [ { @@ -276,11 +272,7 @@ function ArchivedThreadsHeader(props: { ...(usesNativeChrome ? { allowToolbarIntegration: true, - // "integratedButton" is an iOS 26 search-bar placement; - // pre-glass iOS keeps the default pull-down placement. - ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED - ? { placement: "integratedButton" as const } - : null), + placement: "integratedButton" as const, } : { placement: "stacked" as const, diff --git a/apps/mobile/src/features/board/BoardScreen.tsx b/apps/mobile/src/features/board/BoardScreen.tsx index 8c73177dd07..34b845506ab 100644 --- a/apps/mobile/src/features/board/BoardScreen.tsx +++ b/apps/mobile/src/features/board/BoardScreen.tsx @@ -27,6 +27,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { SymbolView } from "../../components/AppSymbol"; +import { ThreadIdentityLeading } from "../identity/ParticipantStack"; import { relativeTime } from "../../lib/time"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -173,9 +174,19 @@ function BoardCard(props: { /> ) : null} - - {props.thread.title} - + + + + {props.thread.title} + + {subtitleParts.length > 0 ? ( {subtitleParts.join(" · ")} diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 958827ee492..5d619c688f1 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -134,14 +134,7 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayEnvironmentLinkProofInvalidError": return `Relay rejected the environment link proof (${error.reason}).`; case "RelayEnvironmentConnectNotAuthorizedError": - // "Not authorized" covers non-auth causes too; surface the reason so a - // missing link doesn't read as a credential problem. - if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; - } - return error.reason - ? `Relay rejected the environment connection request (${error.reason}).` - : "Relay rejected the environment connection request."; + return "Relay rejected the environment connection request."; case "RelayEnvironmentEndpointUnavailableError": return `Relay could not reach the environment endpoint (${error.reason}).`; case "RelayEnvironmentEndpointTimedOutError": diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 19392768479..18b6c71a293 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -1,32 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import { - buildPairingUrl, extractPairingUrlFromQrPayload, PairingQrPayloadEmptyError, parsePairingUrl, } from "./pairing"; -describe("buildPairingUrl", () => { - it("uses HTTP for a schemeless IP address", () => { - expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe( - "http://192.168.1.100:3773/#token=pairing-token", - ); - }); - - it("keeps HTTPS as the default for a schemeless hostname", () => { - expect(buildPairingUrl("remote.example.com", "pairing-token")).toBe( - "https://remote.example.com/#token=pairing-token", - ); - }); - - it("preserves an explicit scheme for an IP address", () => { - expect(buildPairingUrl("https://192.168.1.100:3773", "pairing-token")).toBe( - "https://192.168.1.100:3773/#token=pairing-token", - ); - }); -}); - describe("extractPairingUrlFromQrPayload", () => { it("trims raw pairing urls from qr payloads", () => { expect( diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index 569d00cbdd3..910efa7f256 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -3,21 +3,6 @@ import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; -function isIpLiteral(host: string): boolean { - try { - const hostname = new URL(`http://${host}`).hostname.replace(/^\[|\]$/g, ""); - if (hostname.includes(":")) return true; - - const octets = hostname.split("."); - return ( - octets.length === 4 && - octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) - ); - } catch { - return false; - } -} - export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass()( "PairingQrPayloadEmptyError", {}, @@ -34,7 +19,7 @@ export function buildPairingUrl(host: string, code: string): string { if (!c) return h; try { - const url = new URL(h.includes("://") ? h : `${isIpLiteral(h) ? "http" : "https"}://${h}`); + const url = new URL(h.includes("://") ? h : `https://${h}`); url.hash = new URLSearchParams([["token", c]]).toString(); return url.toString(); } catch { diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 7f5105aac17..012f99536d2 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -29,10 +29,7 @@ import { useAdaptiveWorkspacePaneRole, useRegisterWorkspaceInspector, } from "../layout/AdaptiveWorkspaceLayout"; -import { - createNativeMailSearchToolbarItem, - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, -} from "../layout/native-mail-search-toolbar"; +import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; @@ -357,8 +354,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ); } - const usesCompactMailToolbar = - Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView; return ( <> diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 8f550d65f18..b16d971b2d2 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,6 +1,6 @@ import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; import Constants from "expo-constants"; +import type { MenuAction } from "@react-native-menu/menu"; import { NativeHeaderToolbar, NativeStackScreenOptions, @@ -14,9 +14,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; -import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; -import { resolveMobileStageLabel } from "../../lib/mobileBranding"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { resolveMobileStageLabel } from "../../lib/mobileBranding"; import { useThemeColor } from "../../lib/useThemeColor"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; @@ -29,6 +28,12 @@ import { } from "./home-list-filter-menu"; import { hasCustomHomeListOptions, + OWNERSHIP_FILTER_LABELS, + OWNERSHIP_FILTERS, + OWNERSHIP_RELATION_LABELS, + OWNERSHIP_RELATIONS, + type OwnershipFilter, + type OwnershipRelation, PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS, } from "./home-list-options"; @@ -57,6 +62,8 @@ export function HomeHeader(props: { readonly threadGrouping: HomeThreadGrouping; readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly selectedProjectKey: string | null; + readonly ownershipFilter: OwnershipFilter; + readonly ownershipRelation: OwnershipRelation; /** * Hide settled from the main Threads inbox. Recency/none default on; * project grouping defaults off at the call site. @@ -70,6 +77,8 @@ export function HomeHeader(props: { readonly onClearEnvironments: () => void; readonly onToggleEnvironment: (environmentId: EnvironmentId) => void; readonly onProjectChange: (projectKey: string | null) => void; + readonly onOwnershipFilterChange: (filter: OwnershipFilter) => void; + readonly onOwnershipRelationChange: (relation: OwnershipRelation) => void; readonly onHideSettledThreadsChange: (hide: boolean) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; @@ -107,6 +116,8 @@ function AndroidHomeHeader(props: HomeHeaderProps) { const alternateModes = otherHomeListModes(props.listMode); const hasCustomListOptions = props.selectedEnvironmentIds.length > 0 || + props.ownershipFilter !== "any" || + props.ownershipRelation !== "both" || props.selectedProjectKey !== null || (props.listMode === "threads" && props.hideSettledThreads !== defaultHideSettledForGrouping(props.threadGrouping)) || @@ -114,6 +125,8 @@ function AndroidHomeHeader(props: HomeHeaderProps) { (listOrganization && hasCustomHomeListOptions({ selectedEnvironmentIds: props.selectedEnvironmentIds, + ownershipFilter: props.ownershipFilter, + ownershipRelation: props.ownershipRelation, listMode: props.listMode, threadGrouping: props.threadGrouping, projectSortOrder: props.projectSortOrder, @@ -140,6 +153,28 @@ function AndroidHomeHeader(props: HomeHeaderProps) { })), ], }, + { + id: "ownership", + title: "Ownership", + subactions: OWNERSHIP_FILTERS.map((value) => ({ + id: `ownership:${value}`, + title: OWNERSHIP_FILTER_LABELS[value], + state: checkedMenuState(value === props.ownershipFilter), + })), + }, + ...(props.ownershipFilter === "mine" || props.ownershipFilter === "theirs" + ? ([ + { + id: "ownership-relation", + title: props.ownershipFilter === "mine" ? "Mine includes" : "Theirs includes", + subactions: OWNERSHIP_RELATIONS.map((value) => ({ + id: `ownership-relation:${value}`, + title: OWNERSHIP_RELATION_LABELS[value], + state: checkedMenuState(value === props.ownershipRelation), + })), + }, + ] satisfies MenuAction[]) + : []), ...(props.projects.length === 0 || props.listMode === "board" ? [] : ([ @@ -206,6 +241,8 @@ function AndroidHomeHeader(props: HomeHeaderProps) { props.environments, props.hideSettledThreads, props.listMode, + props.ownershipFilter, + props.ownershipRelation, props.projectSortOrder, props.projects, props.selectedEnvironmentIds, @@ -233,6 +270,22 @@ function AndroidHomeHeader(props: HomeHeaderProps) { return; } + if (id.startsWith("ownership-relation:")) { + const relation = id.slice("ownership-relation:".length); + if (relation === "created" || relation === "participated" || relation === "both") { + props.onOwnershipRelationChange(relation); + } + return; + } + + if (id.startsWith("ownership:")) { + const ownership = id.slice("ownership:".length); + if (ownership === "any" || ownership === "mine" || ownership === "theirs") { + props.onOwnershipFilterChange(ownership); + } + return; + } + if (id.startsWith("project:")) { const projectKey = id.slice("project:".length); if (props.projects.some((project) => project.key === projectKey)) { @@ -275,9 +328,8 @@ function AndroidHomeHeader(props: HomeHeaderProps) { <> @@ -290,7 +342,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { - {stageLabel} + Alpha @@ -396,6 +448,8 @@ function IosHomeHeader(props: HomeHeaderProps) { const useSolidBoardHeader = isBoardMode && NATIVE_LIQUID_GLASS_SUPPORTED; const hasCustomListOptions = props.selectedEnvironmentIds.length > 0 || + props.ownershipFilter !== "any" || + props.ownershipRelation !== "both" || props.selectedProjectKey !== null || (props.listMode === "threads" && props.hideSettledThreads !== defaultHideSettledForGrouping(props.threadGrouping)) || @@ -403,6 +457,8 @@ function IosHomeHeader(props: HomeHeaderProps) { (listOrganization && hasCustomHomeListOptions({ selectedEnvironmentIds: props.selectedEnvironmentIds, + ownershipFilter: props.ownershipFilter, + ownershipRelation: props.ownershipRelation, listMode: props.listMode, threadGrouping: props.threadGrouping, projectSortOrder: props.projectSortOrder, @@ -419,11 +475,15 @@ function IosHomeHeader(props: HomeHeaderProps) { projects: props.projects, selectedEnvironmentIds: props.selectedEnvironmentIds, selectedProjectKey: props.selectedProjectKey, + ownershipFilter: props.ownershipFilter, + ownershipRelation: props.ownershipRelation, projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, onClearEnvironments: props.onClearEnvironments, onToggleEnvironment: props.onToggleEnvironment, onProjectChange: props.onProjectChange, + onOwnershipFilterChange: props.onOwnershipFilterChange, + onOwnershipRelationChange: props.onOwnershipRelationChange, onProjectSortOrderChange: props.onProjectSortOrderChange, onThreadSortOrderChange: props.onThreadSortOrderChange, listOrganization, @@ -507,20 +567,20 @@ function IosHomeHeader(props: HomeHeaderProps) { searchTextChangeId: "home-search-text", }), ] + : undefined, + headerSearchBarOptions: + Platform.OS === "ios" + ? undefined : { - // Pre-Liquid-Glass iOS: standard pull-down search in the nav - // bar; create + sort live in the plain bottom toolbar below. - headerSearchBarOptions: { - ref: searchBarRef, - autoCapitalize: "none" as const, - hideNavigationBar: false, - placeholder: "Search", - onCancelButtonPress: () => { - props.onSearchQueryChange(""); - }, - onChangeText: (event: { nativeEvent: { text: string } }) => { - props.onSearchQueryChange(event.nativeEvent.text); - }, + ref: searchBarRef, + allowToolbarIntegration: true, + hideNavigationBar: false, + placeholder: "Search", + onCancelButtonPress: () => { + props.onSearchQueryChange(""); + }, + onChangeText: (event: { nativeEvent: { text: string } }) => { + props.onSearchQueryChange(event.nativeEvent.text); }, }, }} @@ -580,6 +640,7 @@ function IosHomeHeader(props: HomeHeaderProps) { ))} + {props.projects.length > 0 && props.listMode !== "board" ? ( Project @@ -601,6 +662,7 @@ function IosHomeHeader(props: HomeHeaderProps) { ))} ) : null} + {props.listMode === "threads" ? ( <> @@ -626,6 +688,7 @@ function IosHomeHeader(props: HomeHeaderProps) { ) : null} + {listOrganization ? ( <> @@ -654,9 +717,11 @@ function IosHomeHeader(props: HomeHeaderProps) { ))} - ) : null}{" "} + ) : null} - + + + + threads.filter((thread) => + threadMatchesMine({ + claimPersonId: claimPersonIdForEnvironment( + claimPersonIdByEnvironment, + thread.environmentId, + ), + originPersonId: thread.originSource?.personId ?? null, + participantPersonIds: (thread.participantSummaries ?? []).map( + (participant) => participant.personId, + ), + mode: listOptions.ownershipFilter, + relation: listOptions.ownershipRelation, + }), + ), + [ + claimPersonIdByEnvironment, + listOptions.ownershipFilter, + listOptions.ownershipRelation, + threads, + ], + ); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); // Recency/none default to hide settled; project grouping defaults to show. @@ -131,9 +162,7 @@ export function HomeRouteScreen() { if (layout.usesSplitView) { return ( <> - [] }} - /> + navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Title is owned by HomeHeader (tracks list mode). */}{" "} + {/* Title is owned by HomeHeader (tracks list mode). */} navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} @@ -179,6 +212,7 @@ export function HomeRouteScreen() { onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} onThreadSortOrderChange={setThreadSortOrder} /> + diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6eda60c4421..23483276c3a 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -31,6 +31,8 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; + +const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -137,7 +139,6 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; -const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android * header (AndroidHomeHeader) is rendered in-flow above this screen and @@ -1072,7 +1073,7 @@ export function HomeScreen(props: HomeScreenProps) { @@ -1276,7 +1277,7 @@ export function HomeScreen(props: HomeScreenProps) { contentContainerStyle={{ paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance + ? Math.max(insets.bottom, 24) + 96 : Math.max(insets.bottom, 16) + 88, }} /> @@ -1336,19 +1337,16 @@ export function HomeScreen(props: HomeScreenProps) { scrollEventThrottle={16} contentContainerStyle={{ // Android reserves room for the floating new-task FAB - // (56 button + 16 gap + bottom inset). Pre-glass iOS shows a - // standard 44pt bottom toolbar that overlays the list and is not - // reflected in insets while contentInsetAdjustmentBehavior is - // "never". + // (56 button + 16 gap + bottom inset). paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance + ? Math.max(insets.bottom, 24) + 24 : Math.max(insets.bottom, 16) + 88, }} scrollIndicatorInsets={ Platform.OS === "ios" ? { - bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance, + bottom: Math.max(insets.bottom, 16) + 24, top: 0, } : undefined diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts index f5f860e9951..3f65be020c6 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.test.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -2,25 +2,42 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; +function baseProps( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + environments: [], + projects: [], + selectedEnvironmentIds: [], + selectedProjectKey: null, + ownershipFilter: "any", + ownershipRelation: "both", + projectSortOrder: "updated_at", + threadSortOrder: "updated_at", + onClearEnvironments: vi.fn(), + onToggleEnvironment: vi.fn(), + onProjectChange: vi.fn(), + onOwnershipFilterChange: vi.fn(), + onOwnershipRelationChange: vi.fn(), + onProjectSortOrderChange: vi.fn(), + onThreadSortOrderChange: vi.fn(), + ...overrides, + }; +} + describe("buildHomeListFilterMenu", () => { it("adds a project scope submenu that selects and clears the same scope as the chips", () => { const onProjectChange = vi.fn(); - const menu = buildHomeListFilterMenu({ - environments: [], - projects: [ - { key: "environment-1:project-1", label: "Codething" }, - { key: "environment-1:project-2", label: "Website" }, - ], - selectedEnvironmentIds: [], - selectedProjectKey: "environment-1:project-1", - projectSortOrder: "updated_at", - threadSortOrder: "updated_at", - onClearEnvironments: vi.fn(), - onToggleEnvironment: vi.fn(), - onProjectChange, - onProjectSortOrderChange: vi.fn(), - onThreadSortOrderChange: vi.fn(), - }); + const menu = buildHomeListFilterMenu( + baseProps({ + projects: [ + { key: "environment-1:project-1", label: "Codething" }, + { key: "environment-1:project-2", label: "Website" }, + ], + selectedProjectKey: "environment-1:project-1", + onProjectChange, + }), + ); const projectMenu = menu.items.find( (item) => item.type === "submenu" && item.title === "Project", @@ -45,22 +62,17 @@ describe("buildHomeListFilterMenu", () => { it("supports multi-select environment toggles", () => { const onToggleEnvironment = vi.fn(); const onClearEnvironments = vi.fn(); - const menu = buildHomeListFilterMenu({ - environments: [ - { environmentId: "env-1" as never, label: "Smart" }, - { environmentId: "env-2" as never, label: "t3vm" }, - ], - projects: [], - selectedEnvironmentIds: ["env-1" as never], - selectedProjectKey: null, - projectSortOrder: "updated_at", - threadSortOrder: "updated_at", - onClearEnvironments, - onToggleEnvironment, - onProjectChange: vi.fn(), - onProjectSortOrderChange: vi.fn(), - onThreadSortOrderChange: vi.fn(), - }); + const menu = buildHomeListFilterMenu( + baseProps({ + environments: [ + { environmentId: "env-1" as never, label: "Smart" }, + { environmentId: "env-2" as never, label: "t3vm" }, + ], + selectedEnvironmentIds: ["env-1" as never], + onClearEnvironments, + onToggleEnvironment, + }), + ); const environmentMenu = menu.items.find( (item) => item.type === "submenu" && item.title === "Environment", @@ -79,4 +91,60 @@ describe("buildHomeListFilterMenu", () => { expect(onClearEnvironments).toHaveBeenCalledOnce(); expect(onToggleEnvironment).toHaveBeenCalledWith("env-2"); }); + + it("offers Anyone, Mine, and Theirs ownership filters", () => { + const onOwnershipFilterChange = vi.fn(); + const menu = buildHomeListFilterMenu( + baseProps({ + ownershipFilter: "mine", + onOwnershipFilterChange, + }), + ); + + const ownershipMenu = menu.items.find( + (item) => item.type === "submenu" && item.title === "Ownership", + ); + expect(ownershipMenu).toMatchObject({ + type: "submenu", + items: [ + { title: "Anyone", state: "off" }, + { title: "Mine", state: "on" }, + { title: "Theirs", state: "off" }, + ], + }); + if (ownershipMenu?.type !== "submenu") throw new Error("Expected ownership submenu"); + ownershipMenu.items[2]?.onPress(); + expect(onOwnershipFilterChange).toHaveBeenCalledWith("theirs"); + }); + + it("offers created / participated / both sub-filters when Mine or Theirs is selected", () => { + const onOwnershipRelationChange = vi.fn(); + const menu = buildHomeListFilterMenu( + baseProps({ + ownershipFilter: "mine", + ownershipRelation: "created", + onOwnershipRelationChange, + }), + ); + + const relationMenu = menu.items.find( + (item) => item.type === "submenu" && item.title === "Mine includes", + ); + expect(relationMenu).toMatchObject({ + type: "submenu", + items: [ + { title: "Created or participated", state: "off" }, + { title: "Created", state: "on" }, + { title: "Participated", state: "off" }, + ], + }); + if (relationMenu?.type !== "submenu") throw new Error("Expected relation submenu"); + relationMenu.items[2]?.onPress(); + expect(onOwnershipRelationChange).toHaveBeenCalledWith("participated"); + + const anyoneMenu = buildHomeListFilterMenu(baseProps({ ownershipFilter: "any" })); + expect( + anyoneMenu.items.some((item) => item.type === "submenu" && item.title === "Mine includes"), + ).toBe(false); + }); }); diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index 8ae4699281c..0e6f56d0ed8 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -7,7 +7,16 @@ import { type HomeThreadGrouping, } from "./homeListMode"; import type { HomeProjectSortOrder } from "./homeThreadList"; -import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; +import { + OWNERSHIP_FILTER_LABELS, + OWNERSHIP_FILTERS, + OWNERSHIP_RELATION_LABELS, + OWNERSHIP_RELATIONS, + PROJECT_SORT_OPTIONS, + THREAD_SORT_OPTIONS, + type OwnershipFilter, + type OwnershipRelation, +} from "./home-list-options"; export interface HomeListFilterMenuEnvironment { readonly environmentId: EnvironmentId; @@ -43,11 +52,15 @@ export function buildHomeListFilterMenu(props: { readonly projects: ReadonlyArray; readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly selectedProjectKey: string | null; + readonly ownershipFilter: OwnershipFilter; + readonly ownershipRelation: OwnershipRelation; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly onClearEnvironments: () => void; readonly onToggleEnvironment: (environmentId: EnvironmentId) => void; readonly onProjectChange: (projectKey: string | null) => void; + readonly onOwnershipFilterChange: (filter: OwnershipFilter) => void; + readonly onOwnershipRelationChange: (relation: OwnershipRelation) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; /** @@ -93,6 +106,30 @@ export function buildHomeListFilterMenu(props: { ], }); + items.push({ + type: "submenu", + title: "Ownership", + items: OWNERSHIP_FILTERS.map((value) => ({ + type: "action" as const, + title: OWNERSHIP_FILTER_LABELS[value], + state: props.ownershipFilter === value ? ("on" as const) : ("off" as const), + onPress: () => props.onOwnershipFilterChange(value), + })), + }); + + if (props.ownershipFilter === "mine" || props.ownershipFilter === "theirs") { + items.push({ + type: "submenu", + title: props.ownershipFilter === "mine" ? "Mine includes" : "Theirs includes", + items: OWNERSHIP_RELATIONS.map((value) => ({ + type: "action" as const, + title: OWNERSHIP_RELATION_LABELS[value], + state: props.ownershipRelation === value ? ("on" as const) : ("off" as const), + onPress: () => props.onOwnershipRelationChange(value), + })), + }); + } + if (props.showProjectFilter !== false && props.projects.length > 0) { items.push({ type: "submenu", diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts index 71a7f8f71f3..7466630a76d 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -8,6 +8,8 @@ import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-opti const defaults: HomeListOptions = { selectedEnvironmentIds: [], + ownershipFilter: "any", + ownershipRelation: "both", listMode: "threads", threadGrouping: "project", projectSortOrder: @@ -34,6 +36,12 @@ describe("home list options", () => { ).toBe(true); }); + it("marks ownership filters as customized", () => { + expect(hasCustomHomeListOptions({ ...defaults, ownershipFilter: "mine" })).toBe(true); + expect(hasCustomHomeListOptions({ ...defaults, ownershipFilter: "theirs" })).toBe(true); + expect(hasCustomHomeListOptions({ ...defaults, ownershipRelation: "created" })).toBe(true); + }); + it("marks non-default thread grouping as customized", () => { expect(hasCustomHomeListOptions({ ...defaults, threadGrouping: "recency" })).toBe(true); expect(hasCustomHomeListOptions({ ...defaults, threadGrouping: "none" })).toBe(true); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index ca56b5e4735..1a8153945ef 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -7,6 +7,10 @@ import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, } from "@t3tools/contracts"; +import { + DEFAULT_OWNERSHIP_RELATION, + type OwnershipRelation, +} from "@t3tools/client-runtime/state/identity"; import { createContext, createElement, @@ -30,6 +34,9 @@ import { } from "./homeListMode"; import type { HomeProjectSortOrder } from "./homeThreadList"; +export type { OwnershipRelation }; +export { DEFAULT_OWNERSHIP_RELATION }; + export interface HomeListOptions { /** * Multi-select environment filter. Empty means all environments. @@ -37,6 +44,12 @@ export interface HomeListOptions { * the provider is given a storage callback. */ readonly selectedEnvironmentIds: readonly EnvironmentId[]; + readonly ownershipFilter: OwnershipFilter; + /** + * Sub-filter for mine/theirs: created, participated, or both (default). + * Ignored when ownership is "any". Persisted with ownership filter. + */ + readonly ownershipRelation: OwnershipRelation; readonly listMode: HomeListMode; /** Organization of the Threads list (ignored on Board). */ readonly threadGrouping: HomeThreadGrouping; @@ -44,6 +57,36 @@ export interface HomeListOptions { readonly threadSortOrder: SidebarThreadSortOrder; } +export type OwnershipFilter = "any" | "mine" | "theirs"; + +export const OWNERSHIP_FILTERS = [ + "any", + "mine", + "theirs", +] as const satisfies readonly OwnershipFilter[]; + +export const OWNERSHIP_FILTER_LABELS: Record = { + any: "Anyone", + mine: "Mine", + theirs: "Theirs", +}; + +export const OWNERSHIP_RELATIONS = [ + "both", + "created", + "participated", +] as const satisfies readonly OwnershipRelation[]; + +export const OWNERSHIP_RELATION_LABELS: Record = { + both: "Created or participated", + created: "Created", + participated: "Participated", +}; + +export function isOwnershipFilter(value: unknown): value is OwnershipFilter { + return value === "any" || value === "mine" || value === "theirs"; +} + export interface ResolvedHomeListOptions extends HomeListOptions { readonly projectGroupingMode: SidebarProjectGroupingMode; } @@ -73,6 +116,8 @@ export const THREAD_SORT_OPTIONS: ReadonlyArray<{ function defaultHomeListOptions(): HomeListOptions { return { selectedEnvironmentIds: [], + ownershipFilter: "any", + ownershipRelation: DEFAULT_OWNERSHIP_RELATION, listMode: DEFAULT_HOME_LIST_MODE, threadGrouping: DEFAULT_HOME_THREAD_GROUPING, projectSortOrder: @@ -97,9 +142,9 @@ const HomeListOptionsContext = createContext /** * Keeps list preferences stable while the app moves between compact and split - * shells. Optional storedEnvironmentIds / storedThreadGrouping + store - * callbacks make the env filter and Recent/project/none grouping survive app - * restarts (device preferences). + * shells. Optional stored* + store callbacks make filters survive app restarts + * (device preferences). Ownership is included so Mine/Theirs does not reset + * on every launch (especially painful on mobile). */ export function HomeListOptionsProvider({ children, @@ -116,18 +161,33 @@ export function HomeListOptionsProvider({ */ storedThreadGrouping, onStoreThreadGrouping, + /** + * `undefined` = storage not loaded yet (do not hydrate). + */ + storedOwnershipFilter, + onStoreOwnershipFilter, + storedOwnershipRelation, + onStoreOwnershipRelation, }: PropsWithChildren<{ readonly projectGroupingMode: SidebarProjectGroupingMode; readonly storedEnvironmentIds?: readonly EnvironmentId[]; readonly onStoreEnvironmentIds?: (ids: readonly EnvironmentId[]) => void; readonly storedThreadGrouping?: HomeThreadGrouping; readonly onStoreThreadGrouping?: (grouping: HomeThreadGrouping) => void; + readonly storedOwnershipFilter?: OwnershipFilter; + readonly onStoreOwnershipFilter?: (filter: OwnershipFilter) => void; + readonly storedOwnershipRelation?: OwnershipRelation; + readonly onStoreOwnershipRelation?: (relation: OwnershipRelation) => void; }>) { const [options, setOptions] = useState(defaultHomeListOptions); const envFilterHydratedRef = useRef(false); const lastPersistedEnvKeyRef = useRef(null); const threadGroupingHydratedRef = useRef(false); const lastPersistedThreadGroupingRef = useRef(null); + const ownershipFilterHydratedRef = useRef(false); + const lastPersistedOwnershipFilterRef = useRef(null); + const ownershipRelationHydratedRef = useRef(false); + const lastPersistedOwnershipRelationRef = useRef(null); useEffect(() => { if (envFilterHydratedRef.current) return; @@ -173,6 +233,46 @@ export function HomeListOptionsProvider({ onStoreThreadGrouping(options.threadGrouping); }, [onStoreThreadGrouping, options.threadGrouping]); + useEffect(() => { + if (ownershipFilterHydratedRef.current) return; + if (storedOwnershipFilter === undefined) return; + setOptions((current) => + current.ownershipFilter === storedOwnershipFilter + ? current + : { ...current, ownershipFilter: storedOwnershipFilter }, + ); + lastPersistedOwnershipFilterRef.current = storedOwnershipFilter; + ownershipFilterHydratedRef.current = true; + }, [storedOwnershipFilter]); + + useEffect(() => { + if (!ownershipFilterHydratedRef.current) return; + if (!onStoreOwnershipFilter) return; + if (lastPersistedOwnershipFilterRef.current === options.ownershipFilter) return; + lastPersistedOwnershipFilterRef.current = options.ownershipFilter; + onStoreOwnershipFilter(options.ownershipFilter); + }, [onStoreOwnershipFilter, options.ownershipFilter]); + + useEffect(() => { + if (ownershipRelationHydratedRef.current) return; + if (storedOwnershipRelation === undefined) return; + setOptions((current) => + current.ownershipRelation === storedOwnershipRelation + ? current + : { ...current, ownershipRelation: storedOwnershipRelation }, + ); + lastPersistedOwnershipRelationRef.current = storedOwnershipRelation; + ownershipRelationHydratedRef.current = true; + }, [storedOwnershipRelation]); + + useEffect(() => { + if (!ownershipRelationHydratedRef.current) return; + if (!onStoreOwnershipRelation) return; + if (lastPersistedOwnershipRelationRef.current === options.ownershipRelation) return; + lastPersistedOwnershipRelationRef.current = options.ownershipRelation; + onStoreOwnershipRelation(options.ownershipRelation); + }, [onStoreOwnershipRelation, options.ownershipRelation]); + const value = useMemo( () => ({ options, setOptions, projectGroupingMode }), [options, projectGroupingMode], @@ -191,6 +291,8 @@ export function hasCustomHomeListOptions( : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER; return ( options.selectedEnvironmentIds.length > 0 || + options.ownershipFilter !== "any" || + options.ownershipRelation !== DEFAULT_OWNERSHIP_RELATION || (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) || options.threadGrouping !== DEFAULT_HOME_THREAD_GROUPING || options.projectSortOrder !== defaultProjectSortOrder || @@ -240,6 +342,18 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { + setOptions((current) => ({ ...current, ownershipFilter: value })); + }, + [setOptions], + ); + const setOwnershipRelation = useCallback( + (value: OwnershipRelation) => { + setOptions((current) => ({ ...current, ownershipRelation: value })); + }, + [setOptions], + ); const setThreadGrouping = useCallback( (value: HomeThreadGrouping) => { setOptions((current) => ({ ...current, threadGrouping: value })); @@ -263,6 +377,8 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet + + {model.initials} + + + ); +} diff --git a/apps/mobile/src/features/identity/IdentityClaimGate.test.ts b/apps/mobile/src/features/identity/IdentityClaimGate.test.ts new file mode 100644 index 00000000000..00e91298c95 --- /dev/null +++ b/apps/mobile/src/features/identity/IdentityClaimGate.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { resolveIdentityClaimCandidate } from "./identityClaimCandidate"; + +describe("resolveIdentityClaimCandidate", () => { + const candidates = [{ environmentId: "first" }, { environmentId: "second" }]; + + it("advances through candidates without wrapping after the final candidate", () => { + expect(resolveIdentityClaimCandidate(candidates, 0)).toBe(candidates[0]); + expect(resolveIdentityClaimCandidate(candidates, 1)).toBe(candidates[1]); + expect(resolveIdentityClaimCandidate(candidates, 2)).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/features/identity/IdentityClaimGate.tsx b/apps/mobile/src/features/identity/IdentityClaimGate.tsx new file mode 100644 index 00000000000..7b861120890 --- /dev/null +++ b/apps/mobile/src/features/identity/IdentityClaimGate.tsx @@ -0,0 +1,234 @@ +import { + filterPeopleForTypeahead, + identityClaimRequired, +} from "@t3tools/client-runtime/state/identity"; +import { IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS, IdentityUsername } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Modal, Pressable, TextInput, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { useEnvironments, type EnvironmentPresentation } from "../../state/environments"; +import { identityEnvironment } from "../../state/identity"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { resolveIdentityClaimCandidate } from "./identityClaimCandidate"; + +/** + * Claim gate for multi-env mobile (e.g. local + t3vm). + * + * - Only **connected** environments are probed (never while reconnecting — + * identity RPCs on a flapping link heat the device and leave a Modal that + * steals all touches). + * - Secondary remotes are ordered before primary so t3vm (map on) wins over + * smart/desktop primary (map off). + * - At most one Modal is shown. + */ +export function IdentityClaimGate() { + const { environments } = useEnvironments(); + const candidates = useMemo(() => { + const connected = environments.filter( + (environment) => environment.connection.phase === "connected", + ); + const secondary = connected.filter( + (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget", + ); + const primary = connected.filter( + (environment) => environment.entry.target._tag === "PrimaryConnectionTarget", + ); + return [...secondary, ...primary]; + }, [environments]); + + const [activeIndex, setActiveIndex] = useState(0); + const candidateKey = candidates.map((environment) => environment.environmentId).join("\0"); + + // A changed connection set starts a fresh probe. Reaching the end of an unchanged + // set stays exhausted instead of wrapping to zero and probing forever. + useEffect(() => { + setActiveIndex(0); + }, [candidateKey]); + + const onSkip = useCallback(() => { + setActiveIndex((index) => index + 1); + }, []); + + const environment = resolveIdentityClaimCandidate(candidates, activeIndex); + if (environment === undefined) { + return null; + } + + // Probe candidates in order: each reports whether it needs a claim; we advance + // past envs that do not (map off / already claimed) without showing a Modal. + return ( + + ); +} + +function IdentityClaimGateBody(props: { + readonly environment: EnvironmentPresentation; + readonly onSkip: () => void; +}) { + const environmentId = props.environment.environmentId; + const target = useMemo(() => ({ environmentId, input: {} as const }), [environmentId]); + const snapshotQuery = useEnvironmentQuery(identityEnvironment.snapshot(target)); + const claimQuery = useEnvironmentQuery(identityEnvironment.sessionClaim(target)); + const claimCommand = useAtomCommand(identityEnvironment.claim, { + label: "identity-claim", + reportFailure: true, + }); + const [query, setQuery] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const didSkipRef = useRef(false); + + const needsClaim = identityClaimRequired(snapshotQuery.data, claimQuery.data); + + // Finished loading and this env does not need a claim → try next. + useEffect(() => { + if (snapshotQuery.isPending || claimQuery.isPending) { + return; + } + // Snapshot failed or map disabled / already claimed. + if ( + snapshotQuery.error !== null || + snapshotQuery.data === null || + !snapshotQuery.data.enabled || + !needsClaim + ) { + if (didSkipRef.current) { + return; + } + didSkipRef.current = true; + props.onSkip(); + } + }, [ + claimQuery.isPending, + needsClaim, + props.onSkip, + snapshotQuery.data, + snapshotQuery.error, + snapshotQuery.isPending, + ]); + + const suggestions = useMemo(() => { + if (!snapshotQuery.data) return []; + return filterPeopleForTypeahead( + snapshotQuery.data.people, + query, + IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS, + ); + }, [query, snapshotQuery.data]); + + // No modal while loading, erroring, or not required — avoids a blank + // touch-stealing overlay on reconnect. + if (snapshotQuery.isPending || claimQuery.isPending) { + return null; + } + if (!needsClaim || !snapshotQuery.data?.enabled) { + return null; + } + + const submit = async (username: string) => { + const normalized = username.trim().toLowerCase(); + const exact = snapshotQuery.data?.people.find((person) => person.username === normalized); + if (!exact) { + setError("Pick a username from the server map."); + return; + } + setSubmitting(true); + setError(null); + try { + await claimCommand({ + environmentId, + input: { + username: IdentityUsername.make(exact.username), + method: "typeahead", + }, + }); + claimQuery.refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Claim failed"); + } finally { + setSubmitting(false); + } + }; + + const envLabel = props.environment.label.trim() || "this environment"; + + return ( + { + // Required claim — keep the modal. + }} + > + + + + Shared environment · {envLabel} + + Who are you? + + {envLabel} uses a closed identity map. Type at least{" "} + {IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS} characters, then choose a map match. + + { + setQuery(value); + setError(null); + }} + onSubmitEditing={() => void submit(query)} + className="mt-4 rounded-lg border border-border bg-background px-3 py-2.5 text-base text-foreground" + testID="identity-claim-input" + /> + {suggestions.length > 0 ? ( + + {suggestions.map((person) => ( + { + setQuery(person.username); + void submit(person.username); + }} + className="border-b border-border px-3 py-2.5 active:bg-subtle" + > + {person.username} + {person.name ? ( + {person.name} + ) : null} + + ))} + + ) : null} + {error ? {error} : null} + void submit(query)} + className="mt-4 items-center rounded-lg bg-primary px-4 py-2.5" + > + {submitting ? ( + + ) : ( + Save identity + )} + + + + + ); +} diff --git a/apps/mobile/src/features/identity/ParticipantStack.tsx b/apps/mobile/src/features/identity/ParticipantStack.tsx new file mode 100644 index 00000000000..b53956727cb --- /dev/null +++ b/apps/mobile/src/features/identity/ParticipantStack.tsx @@ -0,0 +1,133 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + claimPersonIdForEnvironment, + isClaimedNonStarterParticipant, +} from "@t3tools/client-runtime/state/identity"; +import type { ThreadParticipantSummary } from "@t3tools/contracts"; +import { View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { identityClaimPersonIdByEnvironmentAtom } from "../../state/identity"; +import { IdentityAvatar } from "./IdentityAvatar"; + +/** + * Creator micro-avatar + +N for thread list rows (mirrors web ParticipantStack). + * Long-press / accessibility label carries the expanded roster; dense RN lists + * skip hover popovers. + */ +export function ParticipantStack(props: { + readonly environmentId: string; + readonly participants: ReadonlyArray; + readonly className?: string; +}) { + const people = props.participants; + const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); + const claimPersonId = claimPersonIdForEnvironment( + claimPersonIdByEnvironment, + props.environmentId, + ); + const youParticipated = isClaimedNonStarterParticipant({ + claimPersonId, + participants: people, + }); + if (people.length === 0) return null; + + const lead = people[0]!; + const extras = people.slice(1); + const label = + extras.length === 0 + ? `Started by ${lead.username}` + : `Started by ${lead.username}, ${extras.length} other participant${extras.length === 1 ? "" : "s"}`; + const accessibleLabel = youParticipated ? `${label}. You participated` : label; + + return ( + + + {extras.length > 0 ? ( + + + +{extras.length} + + + ) : null} + + ); +} + +export function SourceChannelGlyph(props: { + readonly channel: string | null | undefined; + readonly className?: string; +}) { + if (!props.channel) return null; + const short = + props.channel === "desktop" + ? "D" + : props.channel === "web" + ? "W" + : props.channel === "mobile" + ? "M" + : props.channel === "discord" + ? "Δ" + : props.channel === "jira" + ? "J" + : props.channel === "github" + ? "G" + : props.channel.slice(0, 1).toUpperCase(); + return ( + + {short} + + ); +} + +/** Leading channel glyph + participant stack for a thread shell row. */ +export function ThreadIdentityLeading(props: { + readonly environmentId: string; + readonly originChannel?: string | null | undefined; + readonly participants?: ReadonlyArray | null | undefined; + readonly className?: string; +}) { + const participants = props.participants ?? []; + const channel = props.originChannel ?? participants[0]?.firstChannel ?? null; + if (!channel && participants.length === 0) return null; + return ( + + + + + ); +} diff --git a/apps/mobile/src/features/identity/identityClaimCandidate.ts b/apps/mobile/src/features/identity/identityClaimCandidate.ts new file mode 100644 index 00000000000..15fc4fc986a --- /dev/null +++ b/apps/mobile/src/features/identity/identityClaimCandidate.ts @@ -0,0 +1,6 @@ +export function resolveIdentityClaimCandidate( + candidates: readonly T[], + activeIndex: number, +): T | undefined { + return candidates[activeIndex]; +} diff --git a/apps/mobile/src/features/identity/ownershipFilterSurface.test.ts b/apps/mobile/src/features/identity/ownershipFilterSurface.test.ts new file mode 100644 index 00000000000..914673c6e65 --- /dev/null +++ b/apps/mobile/src/features/identity/ownershipFilterSurface.test.ts @@ -0,0 +1,31 @@ +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +function readSource(relativePath: string): string { + return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); +} + +describe("mobile ownership filter surface", () => { + it("keeps ownership controls wired in both phone and sidebar thread lists", () => { + const homeHeader = readSource("../home/HomeHeader.tsx"); + const homeRoute = readSource("../home/HomeRouteScreen.tsx"); + const sidebar = readSource("../threads/ThreadNavigationSidebar.tsx"); + const preferences = readSource("../../persistence/mobile-preferences.ts"); + const layout = readSource("../layout/AdaptiveWorkspaceLayout.tsx"); + + expect(homeHeader).toContain('title: "Ownership"'); + expect(homeHeader).toContain("onOwnershipFilterChange"); + expect(homeHeader).toContain("onOwnershipRelationChange"); + expect(homeRoute).toContain("ownershipFilteredThreads"); + expect(homeRoute).toContain("relation: listOptions.ownershipRelation"); + expect(sidebar).toContain("threadMatchesMine"); + expect(sidebar).toContain("ownershipFilter: options.ownershipFilter"); + expect(sidebar).toContain("ownershipRelation: options.ownershipRelation"); + // Device persistence — without these, Mine/Theirs resets on every launch. + expect(preferences).toContain("ownershipFilter"); + expect(preferences).toContain("ownershipRelation"); + expect(layout).toContain("storedOwnershipFilter"); + expect(layout).toContain("onStoreOwnershipFilter"); + }); +}); diff --git a/apps/mobile/src/features/identity/participantStackSurface.test.ts b/apps/mobile/src/features/identity/participantStackSurface.test.ts new file mode 100644 index 00000000000..4cdcb3cab7b --- /dev/null +++ b/apps/mobile/src/features/identity/participantStackSurface.test.ts @@ -0,0 +1,29 @@ +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +function readSource(relativePath: string): string { + return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); +} + +describe("mobile participation indicator surface", () => { + it("keeps the claimed-participant marker wired across mobile thread lists", () => { + const stack = readSource("./ParticipantStack.tsx"); + const listV1 = readSource("../threads/thread-list-items.tsx"); + const listV2 = readSource("../threads/thread-list-v2-items.tsx"); + const board = readSource("../board/BoardScreen.tsx"); + + expect(stack).toContain('testID={youParticipated ? "you-participated-indicator"'); + expect(stack).toContain("+{extras.length}"); + expect(stack).toContain('"border border-primary bg-primary/15"'); + expect(stack).not.toContain(">✓"); + expect(stack).toContain("highlighted={lead.personId === claimPersonId}"); + const avatar = readSource("./IdentityAvatar.tsx"); + expect(avatar).toContain('props.highlighted && "bg-primary"'); + expect(stack).toContain("isClaimedNonStarterParticipant"); + expect(stack).toContain("You participated"); + expect(listV1).toContain("environmentId={thread.environmentId}"); + expect(listV2).toContain("environmentId={thread.environmentId}"); + expect(board).toContain("environmentId={props.thread.environmentId}"); + }); +}); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c745818a7af..a57d8663527 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -42,12 +42,21 @@ import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; -import { HomeListOptionsProvider, resolveProjectGroupingMode } from "../home/home-list-options"; +import { + HomeListOptionsProvider, + resolveProjectGroupingMode, + type OwnershipFilter, + type OwnershipRelation, +} from "../home/home-list-options"; import { DEFAULT_HOME_THREAD_GROUPING, resolveHomeThreadGrouping, type HomeThreadGrouping, } from "../home/homeListMode"; +import { + resolveOwnershipFilter, + resolveOwnershipRelation, +} from "../../persistence/mobile-preferences"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -207,6 +216,18 @@ export function AdaptiveWorkspaceLayout(props: { }, [savePreferences], ); + const storeOwnershipFilter = useCallback( + (filter: OwnershipFilter) => { + savePreferences({ ownershipFilter: filter }); + }, + [savePreferences], + ); + const storeOwnershipRelation = useCallback( + (relation: OwnershipRelation) => { + savePreferences({ ownershipRelation: relation }); + }, + [savePreferences], + ); if (!AsyncResult.isSuccess(preferencesResult)) { return AsyncResult.isFailure(preferencesResult) ? ( @@ -217,6 +238,10 @@ export function AdaptiveWorkspaceLayout(props: { onStoreEnvironmentIds={storeEnvironmentIds} storedThreadGrouping={DEFAULT_HOME_THREAD_GROUPING} onStoreThreadGrouping={storeThreadGrouping} + storedOwnershipFilter="any" + onStoreOwnershipFilter={storeOwnershipFilter} + storedOwnershipRelation="both" + onStoreOwnershipRelation={storeOwnershipRelation} /> ) : null; } @@ -224,6 +249,8 @@ export function AdaptiveWorkspaceLayout(props: { const storedEnvironmentIds = (preferencesResult.value.selectedEnvironmentIds ?? []) as readonly EnvironmentId[]; const storedThreadGrouping = resolveHomeThreadGrouping(preferencesResult.value.threadGrouping); + const storedOwnershipFilter = resolveOwnershipFilter(preferencesResult.value); + const storedOwnershipRelation = resolveOwnershipRelation(preferencesResult.value); return ( ); } @@ -249,6 +280,10 @@ function AdaptiveWorkspaceLayoutContent( readonly onStoreEnvironmentIds: (ids: readonly EnvironmentId[]) => void; readonly storedThreadGrouping: HomeThreadGrouping; readonly onStoreThreadGrouping: (grouping: HomeThreadGrouping) => void; + readonly storedOwnershipFilter: OwnershipFilter; + readonly onStoreOwnershipFilter: (filter: OwnershipFilter) => void; + readonly storedOwnershipRelation: OwnershipRelation; + readonly onStoreOwnershipRelation: (relation: OwnershipRelation) => void; }, ) { const projectGroupingMode = props.projectGroupingMode; @@ -551,6 +586,10 @@ function AdaptiveWorkspaceLayoutContent( onStoreEnvironmentIds={props.onStoreEnvironmentIds} storedThreadGrouping={props.storedThreadGrouping} onStoreThreadGrouping={props.onStoreThreadGrouping} + storedOwnershipFilter={props.storedOwnershipFilter} + onStoreOwnershipFilter={props.onStoreOwnershipFilter} + storedOwnershipRelation={props.storedOwnershipRelation} + onStoreOwnershipRelation={props.onStoreOwnershipRelation} > diff --git a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts index 8770d96b124..820e1222243 100644 --- a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts +++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts @@ -1,16 +1,5 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; - -/** - * The patched mail-style toolbar is built natively from iOS 26 Liquid Glass - * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side - * silently drops the item and hides the navigation toolbar entirely. Screens - * that send it must fall back to standard search/toolbar primitives when this - * is false. - */ -export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; - type NativeMailSearchToolbarInput = Omit< HeaderBarButtonMailSearchToolbarItem, "type" | "useFallbackSearchField" diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 49adfe75cb2..f354bcd29ac 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -9,10 +9,19 @@ import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; +import { + ActivityIndicator, + Alert, + Linking, + Platform, + Pressable, + ScrollView, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { + type AtomCommandResult, isAtomCommandInterrupted, reportAtomCommandResult, settleAsyncResult, @@ -38,11 +47,6 @@ import { runtime } from "../../lib/runtime"; import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; -import { - type AppUpdateCheckState, - registerHiddenUpdateTap, - runAppUpdateCheck, -} from "../updates/app-updates"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -574,11 +578,12 @@ function BetaSettingsSection() { ); } +type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; + function AppSettingsSection() { const icon = useThemeColor("--color-icon"); - const [updateState, setUpdateState] = useState("idle"); + const [updateState, setUpdateState] = useState("idle"); const updateInFlight = useRef(false); - const hiddenUpdateTapCount = useRef(0); const version = Constants.expoConfig?.version ?? "0.0.0"; // Fall back to "production" to match resolveAppVariant in app.config.ts, so a @@ -586,11 +591,22 @@ function AppSettingsSection() { const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; const variantLabel = variant === "production" ? "" : capitalize(variant); const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; + // Which JS is actually running: the bundle shipped in the binary, or an OTA + // update downloaded on top of it. Surfacing this makes "am I even on the + // right build?" answerable at a glance. + const bundleLabel = Updates.isEnabled + ? Updates.isEmbeddedLaunch + ? "Embedded" + : Updates.updateId + ? `OTA ${Updates.updateId.slice(0, 7)}` + : null + : null; + const busy = updateState === "checking" || updateState === "downloading" || updateState === "restarting"; // "Up to date" is a transient acknowledgement, not a state worth persisting — - // return the version row to its normal, deliberately quiet state. + // drop back to the bundle label so the row keeps answering "what am I running?". useEffect(() => { if (updateState !== "current") return; const timer = setTimeout(() => setUpdateState("idle"), 3000); @@ -603,24 +619,12 @@ function AppSettingsSection() { if (updateInFlight.current) return; updateInFlight.current = true; try { - await runAppUpdateCheck({ - onFailure: (message) => Alert.alert("Update failed", message), - onStateChange: setUpdateState, - }); + await runUpdateCheck(setUpdateState); } finally { updateInFlight.current = false; } }, []); - const handleVersionPress = useCallback(() => { - if (!Updates.isEnabled || updateInFlight.current) return; - const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); - hiddenUpdateTapCount.current = tap.nextCount; - if (tap.shouldCheck) { - void checkForUpdate(); - } - }, [checkForUpdate]); - const statusLabel = updateState === "checking" ? "Checking…" @@ -630,7 +634,7 @@ function AppSettingsSection() { ? "Restarting…" : updateState === "current" ? "Up to date" - : null; + : bundleLabel; const versionRow = ( @@ -648,6 +652,21 @@ function AppSettingsSection() { {statusLabel} ) : null} + {Updates.isEnabled ? ( + + {busy ? ( + + ) : ( + + )} + + ) : null} ); @@ -657,10 +676,10 @@ function AppSettingsSection() { {Updates.isEnabled ? ( void checkForUpdate()} > {versionRow} @@ -671,6 +690,52 @@ function AppSettingsSection() { ); } +async function runUpdateCheck(setUpdateState: (state: UpdateCheckState) => void): Promise { + setUpdateState("checking"); + const check = await settlePromise(() => Updates.checkForUpdateAsync()); + if (check._tag === "Failure") { + reportUpdateFailure(check, "Could not check for updates."); + setUpdateState("idle"); + return; + } + // A rollback directive (`eas update:rollback`) arrives as isAvailable: false + // with isRollBackToEmbedded: true — there is nothing newer to install, but the + // running OTA still has to be dropped for the embedded bundle. + if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) { + setUpdateState("current"); + return; + } + + setUpdateState("downloading"); + const fetched = await settlePromise(() => Updates.fetchUpdateAsync()); + if (fetched._tag === "Failure") { + reportUpdateFailure(fetched, "Could not download the update."); + setUpdateState("idle"); + return; + } + // isNew is always false for a rollback, so it can't be the sole gate here either. + if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) { + setUpdateState("current"); + return; + } + + setUpdateState("restarting"); + // reloadAsync never resolves on success — the JS context is torn down — so + // reaching the failure branch below is the only way this returns. + const reloaded = await settlePromise(() => Updates.reloadAsync()); + if (reloaded._tag === "Failure") { + reportUpdateFailure(reloaded, "Downloaded, but could not restart the app."); + setUpdateState("idle"); + } +} + +function reportUpdateFailure(result: AtomCommandResult, fallback: string): void { + reportAtomCommandResult(result, { label: "app update check" }); + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + Alert.alert("Update failed", error instanceof Error ? error.message : fallback); +} + function capitalize(value: string): string { return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; } diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts index 06cb74e9467..9d5c7c0ee83 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -155,7 +155,11 @@ export function resolveProjectScriptTerminalId(input: { } export function projectScriptMenuLabel(script: ProjectScript): string { - return script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name; + const tags: string[] = []; + if (script.runOnWorktreeCreate) tags.push("setup"); + if (script.runOnWorktreeRemove === true) tags.push("teardown"); + if (script.runOnPrMerged === true) tags.push("pr-merged"); + return tags.length > 0 ? `${script.name} (${tags.join(", ")})` : script.name; } export function projectScriptMenuIcon(icon: ProjectScript["icon"]) { diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ea40b30daec..aeedb4d59f9 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -44,8 +44,7 @@ import { type ComposerDraft, type ComposerDraftWorkspaceSelection, } from "../../state/use-composer-drafts"; -import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; -import { resolveSelectableModelSelection } from "../../lib/modelOptions"; +import { useProjects } from "../../state/entities"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; @@ -94,9 +93,6 @@ export function NewTaskDraftScreen(props: { const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; const { connectedEnvironments } = useRemoteConnectionStatus(); - const selectedEnvironmentServerConfig = useEnvironmentServerConfig( - selectedProject?.environmentId ?? null, - ); const environmentConnected = selectedProject !== null && connectedEnvironments.find( @@ -829,14 +825,7 @@ export function NewTaskDraftScreen(props: { return; } const draft = getComposerDraftSnapshot(draftKey); - // Snapshot read keeps just-typed selector state; the availability gate - // still applies so a stored selection on a disabled provider falls back - // to the flow's resolved model. - const modelSelection = - resolveSelectableModelSelection( - selectedEnvironmentServerConfig, - draft.modelSelection ?? null, - ) ?? flow.selectedModel; + const modelSelection = draft.modelSelection ?? flow.selectedModel; const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; const selectedWorktreePath = @@ -888,10 +877,7 @@ export function NewTaskDraftScreen(props: { if (editingPendingTask) { flow.finishEditingPendingTask(); } else { - // Drop the workspace selection with the content: the next task should - // re-resolve mode/branch/origin from the server's configured defaults - // instead of resurrecting this task's picks. - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + clearComposerDraftContent(draftKey); } navigation.getParent()?.goBack(); return; @@ -949,7 +935,7 @@ export function NewTaskDraftScreen(props: { } flow.finishEditingPendingTask(); } else { - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + clearComposerDraftContent(draftKey); } navigation.dispatch( StackActions.replace("Thread", { diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index ff963a66401..0b2f038069e 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -3,6 +3,10 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { + claimPersonIdForEnvironment, + threadMatchesMine, +} from "@t3tools/client-runtime/state/identity"; import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, @@ -30,6 +34,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; +import { identityClaimPersonIdByEnvironmentAtom } from "../../state/identity"; import { resolveHideSettledOnProjects, resolveHideSettledOnRecent, @@ -241,11 +246,14 @@ function ThreadNavigationSidebarPane( options, toggleSelectedEnvironmentId, clearSelectedEnvironments, + setOwnershipFilter, + setOwnershipRelation, setListMode, setThreadGrouping, setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); + const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); const searchEnvironmentIds = useMemo(() => { const connectedIds = workspaceEnvironments .filter((environment) => environment.connectionState === "connected") @@ -368,12 +376,30 @@ function ThreadNavigationSidebarPane( ); const scopedThreads = useMemo( () => - selectedProjectRefs === null - ? threads - : threads.filter((thread) => - selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), - ), - [selectedProjectRefs, threads], + threads.filter( + (thread) => + (selectedProjectRefs === null || + selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId))) && + threadMatchesMine({ + claimPersonId: claimPersonIdForEnvironment( + claimPersonIdByEnvironment, + thread.environmentId, + ), + originPersonId: thread.originSource?.personId ?? null, + participantPersonIds: (thread.participantSummaries ?? []).map( + (participant) => participant.personId, + ), + mode: options.ownershipFilter, + relation: options.ownershipRelation, + }), + ), + [ + claimPersonIdByEnvironment, + options.ownershipFilter, + options.ownershipRelation, + selectedProjectRefs, + threads, + ], ); const scopedPendingTasks = useMemo( () => @@ -717,7 +743,7 @@ function ThreadNavigationSidebarPane( // Always partition settled into the slim tail (web V2 / classic Recent // shelf). Hide-settled must not erase that history on mobile. return buildThreadListV2Items({ - threads: threads.filter((thread) => thread.archivedAt === null), + threads: scopedThreads.filter((thread) => thread.archivedAt === null), selectedEnvironmentIds: options.selectedEnvironmentIds, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, @@ -739,7 +765,7 @@ function ThreadNavigationSidebarPane( settlementEnvironmentIds, snoozeEnvironmentIds, threadListV2Enabled, - threads, + scopedThreads, selectedProjectScope, ]); // Re-partition the moment the earliest snooze expires (clamped to the @@ -1313,6 +1339,8 @@ function ThreadNavigationSidebarPane( // light the "customized" state (sort options are hidden). const filterCustomized = options.selectedEnvironmentIds.length > 0 || + options.ownershipFilter !== "any" || + options.ownershipRelation !== "both" || selectedProjectKey !== null || options.threadGrouping !== "project" || (options.listMode === "threads" && @@ -1328,11 +1356,15 @@ function ThreadNavigationSidebarPane( projects: projectFilterOptions, selectedEnvironmentIds: options.selectedEnvironmentIds, selectedProjectKey, + ownershipFilter: options.ownershipFilter, + ownershipRelation: options.ownershipRelation, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, onClearEnvironments: clearSelectedEnvironments, onToggleEnvironment: toggleSelectedEnvironmentId, onProjectChange: setSelectedProjectKey, + onOwnershipFilterChange: setOwnershipFilter, + onOwnershipRelationChange: setOwnershipRelation, onProjectSortOrderChange: setProjectSortOrder, onThreadSortOrderChange: setThreadSortOrder, listOrganization, @@ -1352,6 +1384,8 @@ function ThreadNavigationSidebarPane( hideSettledThreads, listOrganization, options.listMode, + options.ownershipFilter, + options.ownershipRelation, options.projectSortOrder, options.selectedEnvironmentIds, options.threadGrouping, @@ -1359,6 +1393,8 @@ function ThreadNavigationSidebarPane( projectFilterOptions, selectedProjectKey, setHideSettledThreads, + setOwnershipFilter, + setOwnershipRelation, setProjectSortOrder, setThreadGrouping, setThreadSortOrder, diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index 4be4089a54d..f0e3f89c07d 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -11,7 +11,6 @@ import { import type { ReactNode } from "react"; import { Platform, useColorScheme } from "react-native"; -import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; @@ -36,9 +35,10 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { headerShadowVisible: false, headerShown: true, headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined, - ...getCompactBrandHeaderOptions({ fontSize: 18, fontWeight: "800" }), + headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? SCROLL_EDGE_EFFECTS : undefined, + title: "Threads", unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 63ed299eeed..b975b502141 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -7,7 +7,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; -import { Pressable, useColorScheme, useWindowDimensions, View } from "react-native"; +import { Platform, Pressable, useColorScheme, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import Svg, { Circle, Path } from "react-native-svg"; @@ -16,7 +16,6 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { ProviderUsageIcon } from "../../components/ProviderUsageIcon"; import { cn } from "../../lib/cn"; -import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import { useEnvironmentServerConfig } from "../../state/entities"; @@ -28,7 +27,8 @@ import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; import { composerDraftsAtom, hasComposerDraftMessage } from "../../state/use-composer-drafts"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { resolveThreadStatus } from "./threadPresentation"; +import { ThreadIdentityLeading } from "../identity/ParticipantStack"; +import { resolveSettledRowTimestamp, resolveThreadStatus } from "./threadPresentation"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; import { hasUsageMarker, @@ -46,9 +46,15 @@ import { useAtomValue } from "@effect/atom-react"; export type ThreadListVariant = "compact" | "sidebar"; /** Left inset that aligns compact secondary rows with the title column. */ -export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; +export const THREAD_LIST_COMPACT_INSET = 20; const SIDEBAR_ROW_RADIUS = 12; +const MONO_FONT = Platform.select({ + ios: "Menlo", + android: "monospace", + default: "monospace", +}); + function pullRequestTintColor( state: ThreadPr["state"], colorScheme: ReturnType, @@ -551,6 +557,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const timestamp = relativeTime( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ); + // Settled rows label by when the work ENDED, matching the shelf sort. + const settledTimestamp = relativeTime(resolveSettledRowTimestamp(thread)); const threadAccessibilityLabel = pr ? `${thread.title}, ${pr.accessibilityLabel}` : thread.title; const subtitleParts = [props.projectTitle, props.environmentLabel, thread.branch].filter( (part): part is string => Boolean(part), @@ -654,8 +662,111 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null; + // Project-grouped lists already show a favicon in the group header, so the + // slim row only leads with one where it also carries project context + // (recency / flat / Needs attention) — the same rule the subtitle uses. + const showSettledFavicon = Boolean(props.projectTitle) && props.projectCwd !== null; + + /** + * Settled threads are history, not inbox: they collapse to a single dimmed + * line so the active work above stays scannable. Status pill, subtitle, + * PR badge, provider icon and chevron all drop — a settled row is a title, + * a time, and a way back in. Matches the Thread List v2 settled tail + * (thread-list-v2-items.tsx) and web's settled shelf (Sidebar.tsx), so + * settled history reads the same in every list mode on every client. + */ + const settledRowContent = (close: () => void) => ( + setHovered(true)} + onHoverOut={compact ? undefined : () => setHovered(false)} + onPressIn={() => { + prefetchEnvironmentThread(thread.environmentId, thread.id); + }} + onPress={() => { + close(); + onSelectThread(thread); + }} + style={ + compact + ? ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + : ({ pressed }) => ({ + backgroundColor: selected + ? selectedBackgroundColor + : pressed || hovered + ? effectivePressedBackground + : backgroundColor, + borderRadius: SIDEBAR_ROW_RADIUS, + cursor: "pointer", + }) + } + > + + {showSettledFavicon ? ( + + + + ) : null} + + + + {thread.title} + + {hasDraft ? ( + + ) : null} + + {props.searchMatch ? ( + + ) : null} + + + {settledTimestamp} + + + + ); + const rowContent = (close: () => void) => - compact ? ( + isSettled ? ( + settledRowContent(close) + ) : compact ? ( ) : null} + {thread.title} @@ -765,6 +881,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { marker={showUsageDot ? (threadUsage?.marker ?? null) : null} /> ) : null} + + + 0 && - !thread.title.toLocaleLowerCase().includes(query) && + !threadMatchesAttributeQuery( + { + title: thread.title, + branch: thread.branch, + originSource: thread.originSource ?? null, + participantSummaries: thread.participantSummaries ?? [], + }, + query, + ) && input.matchedThreadKeys?.has( threadSearchMatchKey({ environmentId: thread.environmentId, diff --git a/apps/mobile/src/features/threads/threadPresentation.test.ts b/apps/mobile/src/features/threads/threadPresentation.test.ts new file mode 100644 index 00000000000..8e853ae6f4f --- /dev/null +++ b/apps/mobile/src/features/threads/threadPresentation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveSettledRowTimestamp } from "./threadPresentation"; + +const base = { + settledAt: null as string | null, + latestUserMessageAt: null as string | null, + updatedAt: "2026-01-01T00:00:00.000Z", + createdAt: "2025-12-01T00:00:00.000Z", +}; + +describe("resolveSettledRowTimestamp", () => { + it("prefers the explicit settle stamp", () => { + expect( + resolveSettledRowTimestamp({ + ...base, + settledAt: "2026-02-02T00:00:00.000Z", + latestUserMessageAt: "2026-01-15T00:00:00.000Z", + }), + ).toBe("2026-02-02T00:00:00.000Z"); + }); + + it("falls back to last user activity for auto-settled threads", () => { + expect( + resolveSettledRowTimestamp({ ...base, latestUserMessageAt: "2026-01-15T00:00:00.000Z" }), + ).toBe("2026-01-15T00:00:00.000Z"); + }); + + it("falls back to updatedAt when the thread has no user message", () => { + expect(resolveSettledRowTimestamp(base)).toBe("2026-01-01T00:00:00.000Z"); + }); + + it("orders rows the same way the settled shelf sorts them", () => { + // The shelf sorts by settledAt ?? latestUserMessageAt ?? updatedAt, so a + // freshly settled old thread must label ahead of a stale newer one. + const settledRecently = { + ...base, + settledAt: "2026-03-01T00:00:00.000Z", + latestUserMessageAt: "2025-06-01T00:00:00.000Z", + }; + const touchedRecently = { ...base, latestUserMessageAt: "2026-02-01T00:00:00.000Z" }; + + expect( + Date.parse(resolveSettledRowTimestamp(settledRecently)) > + Date.parse(resolveSettledRowTimestamp(touchedRecently)), + ).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index ab1b293a2dd..39b2c72726f 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -6,6 +6,21 @@ export function threadSortValue(thread: EnvironmentThreadShell): number { return Number.isNaN(candidate) ? 0 : candidate; } +/** + * The timestamp a settled row labels by: the settle stamp when the server + * recorded one (explicit settles), otherwise last activity. Mirrors the + * settled-shelf sort in HomeScreen / ThreadNavigationSidebar (and web's + * `resolveSettledTimestamp`) so a shelf reads in the order it is sorted. + */ +export function resolveSettledRowTimestamp( + thread: Pick< + EnvironmentThreadShell, + "settledAt" | "latestUserMessageAt" | "updatedAt" | "createdAt" + >, +): string { + return thread.settledAt ?? thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt; +} + export type ThreadStatusKind = | "pending-approval" | "awaiting-input" diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index f559545c04e..5c79b5b5eb8 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -65,6 +65,14 @@ export async function pickComposerImages(input: { readonly existingCount: number }; } + const permission = await imagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + return { + images: [], + error: "Allow photo library access to attach images.", + }; + } + const result = await imagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], allowsMultipleSelection: true, diff --git a/apps/mobile/src/mobileSurfaceExistence.test.ts b/apps/mobile/src/mobileSurfaceExistence.test.ts index 6380e5aea1c..a202eaa410d 100644 --- a/apps/mobile/src/mobileSurfaceExistence.test.ts +++ b/apps/mobile/src/mobileSurfaceExistence.test.ts @@ -27,6 +27,22 @@ describe("mobile surface existence (anti stack-drop)", () => { ); }); + it("renders settled threads as slim history rows in the classic thread lists", () => { + const listItems = readSrc("features/threads/thread-list-items.tsx"); + + // The settled branch must stay wired into the shared row renderer: a + // whole-file conflict resolve that keeps the helper but drops the branch + // would silently restore full-size settled rows. + expect(listItems).toContain('testID="thread-list-row-settled"'); + expect(listItems).toMatch(/isSettled \? \(\s*settledRowContent\(close\)/); + expect(listItems).toContain("resolveSettledRowTimestamp"); + // Slim chrome: dimmed favicon, one muted title line, no status pill. + expect(listItems).toMatch( + /testID="thread-list-row-settled"[\s\S]*?text-foreground-muted[\s\S]*?<\/Pressable>/, + ); + expect(listItems).toMatch(/settledRowContent[\s\S]*?opacity-40[\s\S]*?ProjectFavicon/); + }); + it("keys markdown nodes uniquely even when parser spans collide", () => { const nodeKey = NodeFS.readFileSync( NodePath.join(root, "../modules/t3-markdown-text/src/markdownNodeKey.ts"), diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index a524d515247..78c87119512 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -340,8 +340,7 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { return { type: "spacing", spacing: typeof child.props.width === "number" ? child.props.width : 8, - flexible: Boolean(child.props.flexible), - } as NativeStackHeaderItem; + }; } return null; @@ -352,11 +351,6 @@ function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { Children.forEach(children, (child) => { const item = convertToolbarChild(child); if (item) { - if (item.type === "spacing") { - // Native inserts spacing items at `index`, treating a missing index - // as 0 — which would move the spacer in front of earlier siblings. - (item as { index?: number }).index = items.length; - } items.push(item); } }); @@ -370,8 +364,7 @@ function NativeHeaderToolbarRoot(props: { const navigation = useNativeStackNavigation(); const items = useMemo(() => collectToolbarItems(props.children), [props.children]); - // Swap toolbar owners before paint so split and compact headers cannot clear each other. - useLayoutEffect(() => { + useEffect(() => { if (!navigation) { return; } @@ -447,7 +440,6 @@ function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) { NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel"; function NativeHeaderToolbarSpacer(_props: { - readonly flexible?: boolean; readonly sharesBackground?: boolean; readonly width?: number; }) { diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index e78f90a7db9..84d1c22084f 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -1,6 +1,5 @@ import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { requireNativeView } from "expo"; -import { TextInputWrapper } from "expo-paste-input"; import { useCallback, useEffect, @@ -10,13 +9,12 @@ import { useState, type Ref, } from "react"; -import type { NativeSyntheticEvent, ViewProps } from "react-native"; +import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; import { MOBILE_TYPOGRAPHY } from "../lib/typography"; -import { useNativePaste } from "../lib/useNativePaste"; import { useFontFamily } from "../lib/useFontFamily"; import { useThemeColor } from "../lib/useThemeColor"; import { @@ -119,7 +117,6 @@ export function ComposerEditor({ const skillBorder = useThemeColor("--color-inline-skill-border"); const skillText = useThemeColor("--color-inline-skill-foreground"); const fileTint = useThemeColor("--color-icon-muted"); - const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); useImperativeHandle( ref, @@ -224,63 +221,61 @@ export function ComposerEditor({ const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; const regularFontFamily = useFontFamily("regular"); return ( - - { - const acknowledgedEventCount = acceptNativeEvent( - event.nativeEvent.eventCount, - event.nativeEvent.value, - event.nativeEvent.selection, - ); - if (acknowledgedEventCount === false) return; - onChangeText(event.nativeEvent.value); - onSelectionChange?.(event.nativeEvent.selection); - setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); - }} - onComposerSelectionChange={(event) => { - const acknowledgedEventCount = acceptNativeEvent( - event.nativeEvent.eventCount, - event.nativeEvent.value, - event.nativeEvent.selection, - ); - if (acknowledgedEventCount === false) return; - onSelectionChange?.(event.nativeEvent.selection); - setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); - }} - onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} - onComposerFocus={onFocus} - onComposerBlur={onBlur} - /> - + } + onComposerChange={(event) => { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(event.nativeEvent.value); + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + setNativeEventSequence((sequence) => sequence + 1); + }} + onComposerSelectionChange={(event) => { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + setNativeEventSequence((sequence) => sequence + 1); + }} + onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} + onComposerFocus={onFocus} + onComposerBlur={onBlur} + /> ); } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 00fadfd5885..5486a12499b 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -58,6 +58,15 @@ export interface Preferences { * Device-local; survives restarts. Omitted = default project grouping. */ readonly threadGrouping?: "recency" | "project" | "none"; + /** + * Home/sidebar ownership filter (anyone / mine / theirs). Device-local so + * it survives app restarts — without this, Mine/Theirs resets on launch. + */ + readonly ownershipFilter?: "any" | "mine" | "theirs"; + /** + * Sub-filter for mine/theirs: created, participated, or both (default). + */ + readonly ownershipRelation?: "created" | "participated" | "both"; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -115,6 +124,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { hideSettledOnRecent?: boolean; hideSettledOnProjects?: boolean; threadGrouping?: "recency" | "project" | "none"; + ownershipFilter?: "any" | "mine" | "theirs"; + ownershipRelation?: "created" | "participated" | "both"; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -171,9 +182,49 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.threadGrouping = parsed.threadGrouping; } + if ( + parsed.ownershipFilter === "any" || + parsed.ownershipFilter === "mine" || + parsed.ownershipFilter === "theirs" + ) { + preferences.ownershipFilter = parsed.ownershipFilter; + } + if ( + parsed.ownershipRelation === "created" || + parsed.ownershipRelation === "participated" || + parsed.ownershipRelation === "both" + ) { + preferences.ownershipRelation = parsed.ownershipRelation; + } return preferences; } +/** Resolve stored ownership filter; default anyone when never chosen. */ +export function resolveOwnershipFilter(preferences: Preferences): "any" | "mine" | "theirs" { + if ( + preferences.ownershipFilter === "any" || + preferences.ownershipFilter === "mine" || + preferences.ownershipFilter === "theirs" + ) { + return preferences.ownershipFilter; + } + return "any"; +} + +/** Resolve mine/theirs relation sub-filter; default both. */ +export function resolveOwnershipRelation( + preferences: Preferences, +): "created" | "participated" | "both" { + if ( + preferences.ownershipRelation === "created" || + preferences.ownershipRelation === "participated" || + preferences.ownershipRelation === "both" + ) { + return preferences.ownershipRelation; + } + return "both"; +} + /** Resolve stored Threads grouping; default project when never chosen. */ export function resolveThreadGrouping(preferences: Preferences): "recency" | "project" | "none" { if ( diff --git a/apps/mobile/src/state/identity.ts b/apps/mobile/src/state/identity.ts new file mode 100644 index 00000000000..09cde6ff885 --- /dev/null +++ b/apps/mobile/src/state/identity.ts @@ -0,0 +1,28 @@ +import { createIdentityEnvironmentAtoms } from "@t3tools/client-runtime/state/identity"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; + +export const identityEnvironment = createIdentityEnvironmentAtoms(connectionAtomRuntime); + +const EMPTY_CLAIM_INPUT = {} as const; + +/** Session claim personId keyed by the thread's environment for ownership filters. */ +export const identityClaimPersonIdByEnvironmentAtom = Atom.make((get) => { + const catalog = get(environmentCatalog.catalogValueAtom); + const out = new Map(); + for (const environmentId of catalog.entries.keys()) { + const result = get( + identityEnvironment.sessionClaim({ + environmentId: environmentId as EnvironmentId, + input: EMPTY_CLAIM_INPUT, + }), + ); + const claimResult = Option.getOrNull(AsyncResult.value(result)); + out.set(environmentId, claimResult?.claim?.personId ?? null); + } + return out; +}).pipe(Atom.withLabel("mobile-identity-claim-person-by-environment")); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index fed97e81e08..fdabe67bc71 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -119,36 +119,6 @@ describe("mobile composer drafts", () => { }); }); - it("drops the workspace selection when clearing a sent new-task draft", () => { - const draftKey = "new-task:environment-1:project-1"; - const draft: ComposerDraft = { - text: "send this", - attachments: [], - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, - workspaceSelection: { - mode: "worktree", - branch: "main", - worktreePath: null, - startFromOrigin: false, - }, - }; - - expect( - clearComposerDraftContentState({ [draftKey]: draft }, draftKey, { - clearWorkspaceSelection: true, - }), - ).toEqual({ - [draftKey]: { - modelSelection: draft.modelSelection, - text: "", - attachments: [], - }, - }); - }); - it("reads the latest selector state synchronously for send", () => { const draftKey = "environment-1:thread-1"; const selectedDraft: ComposerDraft = { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 4c22f577184..185804680cf 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -381,18 +381,14 @@ export function updateComposerDraftSettings( export function clearComposerDraftContentState( current: Record, draftKey: string, - options?: { readonly clearWorkspaceSelection?: boolean }, ): Record { const existing = current[draftKey]; if (!existing) { return current; } - const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing; + const { importedShareIds: _importedShareIds, ...retained } = existing; const draft = { ...retained, - ...(options?.clearWorkspaceSelection || workspaceSelection === undefined - ? {} - : { workspaceSelection }), text: "", attachments: [], }; @@ -539,11 +535,8 @@ export async function restoreComposerDraftSnapshot( await persistenceQueue.run(() => writePersistedComposerDrafts(next)); } -export function clearComposerDraftContent( - draftKey: string, - options?: { readonly clearWorkspaceSelection?: boolean }, -): void { - updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); +export function clearComposerDraftContent(draftKey: string): void { + updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); } export function clearComposerDraft(draftKey: string): void { diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 329a937c3b7..e52d9dd17a2 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -2,13 +2,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; @@ -251,21 +249,12 @@ describe("AssetAccess", () => { prefix: "t3-asset-favicon-", }); const faviconPath = path.join(root, "favicon.svg"); - const initialFavicon = "a"; - const updatedFavicon = "b"; - expect(updatedFavicon).toHaveLength(initialFavicon.length); - yield* fileSystem.writeFileString(faviconPath, initialFavicon); + yield* fileSystem.writeFileString(faviconPath, ""); const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath); const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); - expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); - expect( - yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root }, - }), - ).toEqual(faviconResult); const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const faviconSeparatorIndex = faviconSuffix.indexOf("/"); expect( @@ -275,14 +264,6 @@ describe("AssetAccess", () => { ), ).toEqual({ kind: "file", path: canonicalFaviconPath }); - yield* fileSystem.writeFileString(faviconPath, updatedFavicon); - const updatedFaviconResult = yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root }, - }); - expect( - updatedFaviconResult.relativeUrl.slice(updatedFaviconResult.relativeUrl.lastIndexOf("/")), - ).not.toBe(faviconResult.relativeUrl.slice(faviconResult.relativeUrl.lastIndexOf("/"))); - yield* fileSystem.remove(faviconPath); const fallbackResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, @@ -299,31 +280,6 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); - it.effect("buckets project favicon expiry after content hashing", () => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-favicon-expiry-", - }); - yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), ""); - - const bucketMs = 30 * 60 * 1000; - yield* TestClock.setTime(bucketMs - 1); - const crossingCrypto = Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (algorithm, data) => - TestClock.adjust("2 millis").pipe(Effect.andThen(crypto.digest(algorithm, data))), - }); - const result = yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root }, - }).pipe(Effect.provideService(Crypto.Crypto, crossingCrypto)); - - expect(result.expiresAt).toBe(3 * bucketMs); - }).pipe(Effect.provide(testLayer)), - ); - it.effect("preserves structured project favicon resolution causes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 03568a0bc09..ac48f0198c9 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -21,9 +21,7 @@ import { } from "@t3tools/shared/filePreview"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -46,8 +44,6 @@ export const ASSET_ROUTE_PREFIX = "/api/assets"; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; -const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; -const PROJECT_FAVICON_VERSION_PREFIX = "v"; const PREVIEW_ASSET_EXTENSIONS = new Set([ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, @@ -173,7 +169,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; + const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; @@ -316,18 +312,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; - const canonicalFaviconPath = relativePath - ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconInspectionError({ - resource: input.resource, - cause, - }), - ), - ) - : null; - if (relativePath && !canonicalFaviconPath) { + if ( + relativePath && + !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + )) + ) { return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource, }); @@ -347,31 +343,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativePath, expiresAt, }; - if (relativePath && canonicalFaviconPath) { - const crypto = yield* Crypto.Crypto; - const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconInspectionError({ - resource: input.resource, - cause, - }), - ), - ); - const revision = yield* crypto.digest("SHA-256", faviconBytes).pipe( - Effect.map(Encoding.encodeHex), - Effect.mapError( - (cause) => - new AssetProjectFaviconInspectionError({ - resource: input.resource, - cause, - }), - ), - ); - fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`; - } else { - fileName = PROJECT_FAVICON_FALLBACK_MARKER; - } + fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; break; } } @@ -386,13 +358,6 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - if (claims.kind === "project-favicon") { - const issuedAt = yield* Clock.currentTimeMillis; - expiresAt = - (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * - PROJECT_FAVICON_TOKEN_BUCKET_MS; - claims = { ...claims, expiresAt }; - } const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; return { diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 228a2d2bb02..90cc4976c62 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -52,6 +52,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + [WS_METHODS.identityGetSnapshot]: AuthOrchestrationReadScope, + [WS_METHODS.identityGetSessionClaim]: AuthOrchestrationReadScope, + [WS_METHODS.identityClaim]: AuthOrchestrationOperateScope, + [WS_METHODS.identityClearClaim]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 5cb947ed87e..7a90b7fd0a9 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -42,8 +42,9 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import * as IdentityService from "./identity/IdentityService.ts"; -const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer, IdentityService.layer); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} const connectCli = makeCli({ cloudEnabled: true }); @@ -125,6 +126,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef }), ), Layer.provide(environmentAuthenticatedAuthLayer), + Layer.provide(IdentityService.layer), ); const appLayer = HttpRouter.serve(routesLayer, { disableListenLog: true, diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 1a0c46ab417..ef15286d7ca 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -16,8 +16,10 @@ import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; +import * as IdentityService from "./identity/IdentityService.ts"; -const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +// Identity is residual-free and required by the server command graph type. +const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer, IdentityService.layer); const connectPublicConfigMissingMessage = "T3 Connect commands are unavailable: this build is missing T3 Connect public configuration."; diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 2ea14b951fe..28b4f33f97c 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -6,9 +6,17 @@ import { VcsRepositoryDetectionError } from "@t3tools/contracts"; import * as GitManager from "./GitManager.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; +import * as ProjectLifecycleScriptRunner from "../project/ProjectLifecycleScriptRunner.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +const lifecycleScriptRunnerMock = Layer.mock( + ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner, +)({ + runWorktreeRemove: () => Effect.succeed({ status: "no-script" as const }), + runPrMerged: () => Effect.succeed({ status: "no-script" as const }), +}); + function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; }) { @@ -20,6 +28,7 @@ function makeLayer(input: { ), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), Layer.provide(Layer.mock(GitManager.GitManager)({})), + Layer.provide(lifecycleScriptRunnerMock), ); } @@ -100,6 +109,7 @@ describe("GitWorkflowService", () => { status, }), ), + Layer.provide(lifecycleScriptRunnerMock), ); return Effect.gen(function* () { diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index c5446943bd1..d8c210da3a1 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -31,6 +31,7 @@ import { } from "@t3tools/contracts"; import * as GitManager from "./GitManager.ts"; +import * as ProjectLifecycleScriptRunner from "../project/ProjectLifecycleScriptRunner.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -135,10 +136,42 @@ function nonRepositoryListRefs(): VcsListRefsResult { }; } +function lifecycleScriptToGitCommandError( + operation: string, + input: VcsRemoveWorktreeInput, + error: ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunnerError, +): GitCommandError { + if (error._tag === "ProjectLifecycleScriptFailedError") { + const detailParts = [ + error.message, + error.stderr.trim().length > 0 ? error.stderr.trim() : null, + error.stdout.trim().length > 0 ? error.stdout.trim() : null, + ].filter((part): part is string => part !== null); + return new GitCommandError({ + operation, + command: `lifecycle:${error.lifecycle}`, + cwd: input.path, + exitCode: error.exitCode ?? undefined, + failureKind: "unknown", + detail: detailParts.join("\n"), + cause: error, + }); + } + return new GitCommandError({ + operation, + command: `lifecycle:${error.lifecycle}`, + cwd: input.path, + failureKind: "unknown", + detail: error.message, + cause: error, + }); +} + export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; const gitManager = yield* GitManager.GitManager; + const lifecycleScriptRunner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( operation: string, @@ -322,7 +355,44 @@ export const make = Effect.gen(function* () { ), removeWorktree: (input) => ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( - Effect.andThen(git.removeWorktree(input)), + Effect.andThen( + Effect.gen(function* () { + // Prefer the PR associated with the worktree branch (status cwd = worktree path). + const associatedPr = yield* gitManager.remoteStatus({ cwd: input.path }).pipe( + Effect.map((remote) => remote?.pr ?? null), + Effect.orElseSucceed(() => null), + ); + + // Teardown must finish successfully before the worktree directory is removed. + // PR-merged lifecycle is a separate trigger (status transition), not part of remove. + yield* lifecycleScriptRunner + .runWorktreeRemove({ + projectCwd: input.cwd, + worktreePath: input.path, + pr: associatedPr + ? { + number: associatedPr.number, + url: associatedPr.url, + title: associatedPr.title, + baseRef: associatedPr.baseRef, + headRef: associatedPr.headRef, + state: associatedPr.state, + } + : null, + }) + .pipe( + Effect.mapError((error) => + lifecycleScriptToGitCommandError( + "GitWorkflowService.removeWorktree", + input, + error, + ), + ), + ); + + yield* git.removeWorktree(input); + }), + ), ), createRef: (input) => ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( diff --git a/apps/server/src/github/GitHubPrBridge.ts b/apps/server/src/github/GitHubPrBridge.ts index d833653fa21..60fb20b82df 100644 --- a/apps/server/src/github/GitHubPrBridge.ts +++ b/apps/server/src/github/GitHubPrBridge.ts @@ -29,6 +29,8 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; +import * as IdentityService from "../identity/IdentityService.ts"; +import { buildIntegrationSourceRef } from "../identity/stampSource.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -52,6 +54,7 @@ import { type GitHubPullRequestStackContext, stackBranchesForMatching, } from "./GitHubPullRequestStack.ts"; +import { classifyGitHubActorTrust } from "./githubActorTrust.ts"; const BUSY_RESPONSE = "This T3 thread is already working. Try again after the current turn finishes."; @@ -67,6 +70,8 @@ const PROVISION_FAILED_RESPONSE = "T3 could not open a thread for this pull request. Check the server logs for details."; const EMPTY_PROMPT_RESPONSE = "Provide a prompt after the mention. Conversation comments use the PR work thread; inline review reuses that discussion's session (first tag creates it). Override with `main-thread` or `sibling-thread`."; +const IDENTITY_DENIED_RESPONSE = + "Not authorized to run agent turns from this GitHub account. When the T3 identity map is enabled, only mapped operators (`github.login` / `github.id`) can invoke the bot — repository write access alone is not enough (including on public repos)."; const MAX_GITHUB_COMMENT_LENGTH = 65_536; const PERMISSION_RANK: Readonly> = { @@ -492,6 +497,7 @@ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngineService; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const providerRegistry = yield* ProviderRegistry; + const identity = yield* IdentityService.IdentityService; const crypto = yield* Crypto.Crypto; const provisionLock = yield* Semaphore.make(1); @@ -1212,6 +1218,38 @@ export const make = Effect.gen(function* () { return; } + // Closed-set identity map is required for agent turns (fail-closed when the + // map is off/empty). Public-repo write / outside collaborators cannot drive + // the host unless listed; permission floor alone is never enough. + const mapEnabled = yield* identity.isMapEnabled(); + const mapPeople = yield* identity.listMapPeople(); + const trust = classifyGitHubActorTrust({ + identityMapEnabled: mapEnabled, + actorId: input.invocation.actorId, + actorLogin: input.invocation.actorLogin, + people: mapPeople, + }); + if (trust.mode === "denied") { + yield* Effect.logWarning("Rejected GitHub PR invocation from unmapped identity", { + deliveryId: input.deliveryId, + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + actorId: input.invocation.actorId, + actorLogin: input.invocation.actorLogin, + reason: trust.reason, + }); + yield* finishDelivery(initial, IDENTITY_DENIED_RESPONSE, "rejected"); + return; + } + yield* Effect.logInfo("Classified GitHub actor trust", { + deliveryId: input.deliveryId, + actorLogin: input.invocation.actorLogin, + actorId: input.invocation.actorId, + mode: trust.mode, + reason: trust.reason, + personId: trust.person?.personId ?? null, + }); + const addAckReaction = input.invocation.commentSurface === "review" ? github.addReviewCommentReaction({ @@ -1357,6 +1395,20 @@ export const make = Effect.gen(function* () { const turnModelSelection = hasExplicitModelSelection ? yield* resolveGitHubModelSelection(turnInvocation, thread.modelSelection) : thread.modelSelection; + const sourcePeople = yield* identity.listMapPeople(); + const [repoOwner, repoName] = turnInvocation.repository.split("/"); + const source = buildIntegrationSourceRef({ + people: sourcePeople, + channel: "github", + platformId: String(turnInvocation.actorId), + displayName: turnInvocation.actorLogin, + location: { + owner: repoOwner ?? turnInvocation.repository, + repo: repoName ?? turnInvocation.repository, + number: turnInvocation.pullRequestNumber, + kind: "pr", + }, + }); const dispatched = yield* engine .dispatch({ type: "thread.turn.start", @@ -1376,6 +1428,7 @@ export const make = Effect.gen(function* () { titleSeed: turnInvocation.prompt.slice(0, 80) || "GitHub PR comment", runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, + source, createdAt: DateTime.formatIso(yield* DateTime.now), }) .pipe( diff --git a/apps/server/src/github/githubActorTrust.test.ts b/apps/server/src/github/githubActorTrust.test.ts new file mode 100644 index 00000000000..9932ec88f92 --- /dev/null +++ b/apps/server/src/github/githubActorTrust.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { classifyGitHubActorTrust, resolvePersonByGitHubActor } from "./githubActorTrust.ts"; + +const people = [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + github: { login: "patroza", id: "42661" }, + }, + { + personId: "julius", + username: "julius", + github: { login: "juliusmarminge" }, + }, +] as const; + +describe("resolvePersonByGitHubActor", () => { + it("prefers github id over login", () => { + const hit = resolvePersonByGitHubActor(people, { + actorId: 42661, + actorLogin: "someone-else", + }); + expect(hit?.person.username).toBe("patroza"); + expect(hit?.reason).toBe("mapped_github_id"); + }); + + it("falls back to login (case-insensitive)", () => { + const hit = resolvePersonByGitHubActor(people, { + actorId: 999, + actorLogin: "JuliusMarminge", + }); + expect(hit?.person.username).toBe("julius"); + expect(hit?.reason).toBe("mapped_github_login"); + }); + + it("returns null when unmapped", () => { + expect( + resolvePersonByGitHubActor(people, { actorId: 1, actorLogin: "random-user" }), + ).toBeNull(); + }); +}); + +describe("classifyGitHubActorTrust", () => { + it("denies agent access when the identity map is disabled (fail-closed)", () => { + expect( + classifyGitHubActorTrust({ + identityMapEnabled: false, + actorId: 1, + actorLogin: "stranger", + people: [], + }), + ).toEqual({ mode: "denied", person: null, reason: "identity_map_disabled" }); + }); + + it("trusts mapped github accounts for full agent turns", () => { + const byId = classifyGitHubActorTrust({ + identityMapEnabled: true, + actorId: "42661", + actorLogin: "patroza", + people, + }); + expect(byId.mode).toBe("full"); + expect(byId.reason).toBe("mapped_github_id"); + expect(byId.person?.username).toBe("patroza"); + }); + + it("denies unmapped actors when the map is on (public write is not enough)", () => { + expect( + classifyGitHubActorTrust({ + identityMapEnabled: true, + actorId: 42, + actorLogin: "drive-by-collaborator", + people, + }), + ).toMatchObject({ mode: "denied", reason: "unmapped_github_actor", person: null }); + }); + + it("denies missing actor fields when the map is on", () => { + expect( + classifyGitHubActorTrust({ + identityMapEnabled: true, + actorId: null, + actorLogin: "", + people, + }), + ).toMatchObject({ mode: "denied", reason: "missing_github_actor" }); + }); +}); diff --git a/apps/server/src/github/githubActorTrust.ts b/apps/server/src/github/githubActorTrust.ts new file mode 100644 index 00000000000..fef05c57129 --- /dev/null +++ b/apps/server/src/github/githubActorTrust.ts @@ -0,0 +1,81 @@ +/** + * GitHub actor trust relative to the closed-set identity map. + * + * Fail-closed: no configured map (or empty map) is treated like an unmapped + * actor — no agent turns. When the map is on, the actor must resolve to a + * mapped person (github id or login) — so public-repo write access or + * outside collaborators cannot drive the host unless listed. Collaborator + * permission floor is still enforced separately before this gate. + */ +import { + findPersonByGithubId, + findPersonByGithubLogin, + type IdentityMapPerson, +} from "@t3tools/shared/identityMap"; + +export type GitHubActorTrustMode = "full" | "denied"; + +export type GitHubActorTrustDecision = { + readonly mode: GitHubActorTrustMode; + readonly person: IdentityMapPerson | null; + readonly reason: + | "identity_map_disabled" + | "mapped_github_id" + | "mapped_github_login" + | "unmapped_github_actor" + | "missing_github_actor"; +}; + +export function resolvePersonByGitHubActor( + people: ReadonlyArray, + input: { + readonly actorId: number | string | null | undefined; + readonly actorLogin: string | null | undefined; + }, +): { + readonly person: IdentityMapPerson; + readonly reason: "mapped_github_id" | "mapped_github_login"; +} | null { + if (input.actorId !== null && input.actorId !== undefined && String(input.actorId).length > 0) { + const byId = findPersonByGithubId(people, input.actorId); + if (byId !== null) return { person: byId, reason: "mapped_github_id" }; + } + const login = input.actorLogin?.trim() ?? ""; + if (login.length > 0) { + const byLogin = findPersonByGithubLogin(people, login); + if (byLogin !== null) return { person: byLogin, reason: "mapped_github_login" }; + } + return null; +} + +/** + * Classify a GitHub mention actor for agent execution. + * + * - Map off / empty → denied (no agent; same as unmapped) + * - Map on + id/login in map → full + * - Map on + unmapped/missing → denied (no agent turn) + */ +export function classifyGitHubActorTrust(input: { + readonly identityMapEnabled: boolean; + readonly actorId: number | string | null | undefined; + readonly actorLogin: string | null | undefined; + readonly people: ReadonlyArray; +}): GitHubActorTrustDecision { + if (!input.identityMapEnabled) { + return { mode: "denied", person: null, reason: "identity_map_disabled" }; + } + const login = input.actorLogin?.trim() ?? ""; + const hasId = + input.actorId !== null && input.actorId !== undefined && String(input.actorId).length > 0; + if (!hasId && login.length === 0) { + return { mode: "denied", person: null, reason: "missing_github_actor" }; + } + const hit = resolvePersonByGitHubActor(input.people, { + actorId: input.actorId, + actorLogin: input.actorLogin, + }); + if (hit === null) { + return { mode: "denied", person: null, reason: "unmapped_github_actor" }; + } + return { mode: "full", person: hit.person, reason: hit.reason }; +} diff --git a/apps/server/src/identity/IdentityService.reload.test.ts b/apps/server/src/identity/IdentityService.reload.test.ts new file mode 100644 index 00000000000..682f8bcb6a3 --- /dev/null +++ b/apps/server/src/identity/IdentityService.reload.test.ts @@ -0,0 +1,124 @@ +// @effect-diagnostics nodeBuiltinImport:off +// Staging a real file on disk is the point of these tests: they cover the +// fs-backed reload path, so they use node:fs directly like IdentityService does. +import { AuthSessionId, IdentityUsername } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Duration from "effect/Duration"; +import * as TestClock from "effect/testing/TestClock"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as IdentityService from "./IdentityService.ts"; + +const TTL = IdentityService.IDENTITY_MAP_RELOAD_TTL_MS; + +const mapYaml = (usernames: ReadonlyArray) => + ["people:", ...usernames.map((u) => ` "id-${u}":\n username: ${u}\n name: ${u}`)].join( + "\n", + ); + +/** + * The source fingerprints on ino/size/mtime, so a rewrite inside the same clock + * millisecond could otherwise look unchanged. Stamp a strictly increasing mtime + * rather than reading wall-clock time (which the Effect lint rules disallow). + */ +let mtimeSeconds = 1_700_000_000; +function touch(file: string): void { + mtimeSeconds += 10; + NodeFS.utimesSync(file, mtimeSeconds, mtimeSeconds); +} + +/** Writes the map and points T3_IDENTITY_MAP_PATH at it; returns the path. */ +function stageMap(usernames: ReadonlyArray): string { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "identity-map-")); + const file = NodePath.join(dir, "identity-map.yaml"); + NodeFS.writeFileSync(file, mapYaml(usernames), "utf8"); + touch(file); + process.env.T3_IDENTITY_MAP_PATH = file; + return file; +} + +function rewrite(file: string, usernames: ReadonlyArray): void { + NodeFS.writeFileSync(file, mapYaml(usernames), "utf8"); + touch(file); +} + +describe("identity map reload", () => { + it.effect("applies an added person after the TTL, without a restart", () => + Effect.gen(function* () { + const file = stageMap(["patroza"]); + const source = IdentityService.makeFileSourceForTest(); + + const before = yield* source.current; + expect(before.people.map((p) => p.username)).toEqual(["patroza"]); + + rewrite(file, ["patroza", "micseg"]); + + // Still cached until the TTL elapses. + yield* TestClock.adjust(Duration.millis(TTL - 1)); + expect((yield* source.current).people).toHaveLength(1); + + yield* TestClock.adjust(Duration.millis(2)); + const after = yield* source.current; + expect(after.people.map((p) => p.username)).toEqual(["patroza", "micseg"]); + expect(after.enabled).toBe(true); + expect(after.healthy).toBe(true); + }), + ); + + it.effect("keeps the last good map when a re-read yields no people", () => + Effect.gen(function* () { + const file = stageMap(["patroza", "micseg"]); + const source = IdentityService.makeFileSourceForTest(); + expect((yield* source.current).people).toHaveLength(2); + + // Simulates a truncated or half-staged file. + NodeFS.writeFileSync(file, "", "utf8"); + touch(file); + + yield* TestClock.adjust(Duration.millis(TTL + 1)); + const degraded = yield* source.current; + expect(degraded.people).toHaveLength(2); + // The gate must stay on: enabled === false would turn it off entirely. + expect(degraded.enabled).toBe(true); + expect(degraded.healthy).toBe(false); + + // Recovers once the file is readable again. + rewrite(file, ["patroza", "micseg", "enricopolanski"]); + yield* TestClock.adjust(Duration.millis(TTL + 1)); + const recovered = yield* source.current; + expect(recovered.people).toHaveLength(3); + expect(recovered.healthy).toBe(true); + }), + ); + + it.effect("refuses operate for a removed person without deleting their claim", () => { + // Must be staged before the layer is built: the source loads at construction. + const file = stageMap(["patroza", "micseg"]); + return Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000cc"); + + yield* identity.claim(sessionId, { username: IdentityUsername.make("micseg") }); + expect(yield* identity.requireOperateClaim(sessionId)).not.toBeNull(); + + rewrite(file, ["patroza"]); + yield* TestClock.adjust(Duration.millis(TTL + 1)); + + // Operate is refused... + const refused = yield* identity.requireOperateClaim(sessionId).pipe(Effect.exit); + expect(Exit.isFailure(refused)).toBe(true); + + // ...but the claim was not destroyed, so re-adding the person restores it. + const stillThere = yield* identity.getSessionClaim(sessionId); + expect(stillThere.claim?.username).toBe("micseg"); + + rewrite(file, ["patroza", "micseg"]); + yield* TestClock.adjust(Duration.millis(TTL + 1)); + expect(yield* identity.requireOperateClaim(sessionId)).not.toBeNull(); + }).pipe(Effect.provide(IdentityService.layer)); + }); +}); diff --git a/apps/server/src/identity/IdentityService.test.ts b/apps/server/src/identity/IdentityService.test.ts new file mode 100644 index 00000000000..424298c305a --- /dev/null +++ b/apps/server/src/identity/IdentityService.test.ts @@ -0,0 +1,147 @@ +import { AuthSessionId, IdentityError, IdentityUsername } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; + +import * as IdentityService from "./IdentityService.ts"; + +const people = [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + jira: { accountId: "712020:pat-account" }, + }, + { + personId: "julius", + username: "julius", + name: "Julius", + }, +] as const; + +const TestLayer = IdentityService.layerWithPeople([...people]); + +const isIdentityError = (error: unknown): error is IdentityError => + typeof error === "object" && error !== null && "_tag" in error && error._tag === "IdentityError"; + +describe("IdentityService", () => { + it.effect("snapshot is enabled when people are present", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const snapshot = yield* identity.getSnapshot(); + expect(snapshot.enabled).toBe(true); + expect(snapshot.claimRequired).toBe(true); + expect(snapshot.people.map((person) => person.username)).toEqual(["patroza", "julius"]); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("rejects unknown claim targets", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000aa"); + const result = yield* identity + .claim(sessionId, { username: IdentityUsername.make("nobody") }) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = result.cause; + // Cause.fail path + const failures = + "failures" in error ? (error as { failures: ReadonlyArray }).failures : []; + const first = failures[0] ?? error; + // Prefer direct fail extraction via Cause.squash if available + void first; + } + const failed = yield* identity + .claim(sessionId, { username: IdentityUsername.make("nobody") }) + .pipe( + Effect.map(() => null as string | null), + Effect.catch((error) => Effect.succeed(isIdentityError(error) ? error.code : "other")), + ); + expect(failed).toBe("identity_unknown_person"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("claims, gates operate, and clears", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000bb"); + + const before = yield* identity.requireOperateClaim(sessionId).pipe( + Effect.map(() => null as string | null), + Effect.catch((error) => Effect.succeed(isIdentityError(error) ? error.code : "other")), + ); + expect(before).toBe("identity_claim_required"); + + const claimed = yield* identity.claim(sessionId, { + username: IdentityUsername.make("patroza"), + method: "typeahead", + }); + expect(claimed.claim.username).toBe("patroza"); + expect(claimed.claim.personId).toBe("patroza"); + + const allowed = yield* identity.requireOperateClaim(sessionId); + expect(allowed?.username).toBe("patroza"); + + const cleared = yield* identity.clearClaim(sessionId); + expect(cleared.cleared).toBe(true); + + const after = yield* identity.requireOperateClaim(sessionId).pipe( + Effect.map(() => null as string | null), + Effect.catch((error) => Effect.succeed(isIdentityError(error) ? error.code : "other")), + ); + expect(after).toBe("identity_claim_required"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("allows overwrite claim (settings switch)", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000cc"); + yield* identity.claim(sessionId, { username: IdentityUsername.make("patroza") }); + const next = yield* identity.claim(sessionId, { + username: IdentityUsername.make("julius"), + method: "settings", + }); + expect(next.claim.username).toBe("julius"); + expect(next.claim.method).toBe("settings"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("bot sessions skip the interactive operate claim gate", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000dd"); + const allowed = yield* identity.requireOperateClaim(sessionId, { + clientDeviceType: "bot", + }); + expect(allowed).toBeNull(); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("resolves mapped Jira account ids and reports map enabled", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + expect(yield* identity.isMapEnabled()).toBe(true); + const hit = yield* identity.resolveByJiraAccountId("accountid:712020:PAT-ACCOUNT"); + expect(hit?.username).toBe("patroza"); + const miss = yield* identity.resolveByJiraAccountId("712020:stranger"); + expect(miss).toBeNull(); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("non-bot sessions still require a claim when map is enabled", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000ee"); + const code = yield* identity + .requireOperateClaim(sessionId, { clientDeviceType: "desktop" }) + .pipe( + Effect.map(() => null as string | null), + Effect.catch((error) => Effect.succeed(isIdentityError(error) ? error.code : "other")), + ); + expect(code).toBe("identity_claim_required"); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/identity/IdentityService.ts b/apps/server/src/identity/IdentityService.ts new file mode 100644 index 00000000000..46b7aa44bac --- /dev/null +++ b/apps/server/src/identity/IdentityService.ts @@ -0,0 +1,490 @@ +// @effect-diagnostics preferSchemaOverJson:off +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalConsole:off +/** + * Closed-set identity map + per-session claims. + * + * Map: T3_IDENTITY_MAP_PATH only (explicit). Missing/empty at startup → feature + * off. Once enabled the file is re-checked on a TTL, so staged edits apply + * without a restart; a bad or empty re-read never disables an enabled map. + * Claims: `layerPersisted` (server) stores them in SQLite via + * SessionIdentityClaimRepository with the Ref as a read-through cache, so they + * survive a restart. The residual-free `layer` (CLI / tests) is Ref-only. + * + * Layer residual is empty so it can sit on the server graph without polluting + * CLI typecheck (SqlClient / ServerConfig leakage). + * + * v1 trust: interactive claim is map membership only (trusted-team ops). + */ +import * as NodeFS from "node:fs"; +import * as Clock from "effect/Clock"; +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 Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import { + AuthSessionId, + type AuthClientMetadataDeviceType, + IdentityClaimInput, + IdentityError, + IdentitySessionClaimResult, + IdentitySnapshot, + IdentityUsername, + PersonId, + SessionIdentityClaim, + type SessionIdentityClaimMethod, +} from "@t3tools/contracts"; +import { + parseIdentityMapDocument, + resolvePersonByJiraAccountId, + toIdentityPersonPublic, + type IdentityMapPerson, + IdentityMapParseError, +} from "@t3tools/shared/identityMap"; +import { parse as parseYamlString } from "yaml"; + +import { + SessionIdentityClaimRepository, + layer as sessionIdentityClaimRepositoryLayer, +} from "../persistence/SessionIdentityClaims.ts"; + +type ClaimRecord = { + readonly sessionId: AuthSessionId; + readonly personId: PersonId; + readonly username: IdentityUsername; + readonly claimedAt: string; + readonly method: SessionIdentityClaimMethod; +}; + +export class IdentityService extends Context.Service< + IdentityService, + { + readonly getSnapshot: () => Effect.Effect; + /** In-process map people for platform SourceRef resolution (GitHub/Jira/Discord). */ + readonly listMapPeople: () => Effect.Effect>; + readonly getSessionClaim: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly claim: ( + sessionId: AuthSessionId, + input: IdentityClaimInput, + ) => Effect.Effect<{ claim: SessionIdentityClaim }, IdentityError>; + readonly clearClaim: ( + sessionId: AuthSessionId, + ) => Effect.Effect<{ cleared: boolean }, IdentityError>; + /** + * When the identity map is enabled, interactive sessions must have claimed + * a person before orchestration operate. Returns the claim, or null when + * the map is off / the session is an integration bot. + * + * Bot sessions (`clientDeviceType: "bot"`) skip the claim gate: Discord/Jira + * share one long-lived auth session across many human senders, so a single + * session claim cannot impersonate each actor. Per-turn SourceRef stamping + * from the platform map is a separate path (not session claim). + */ + readonly requireOperateClaim: ( + sessionId: AuthSessionId, + options?: { + readonly clientDeviceType?: AuthClientMetadataDeviceType; + }, + ) => Effect.Effect; + /** + * Resolve a closed-set person from a Jira actor accountId. + * Returns null when the map is off, accountId is missing, or unmapped. + */ + readonly resolveByJiraAccountId: ( + accountId: string | null | undefined, + ) => Effect.Effect; + /** True when T3_IDENTITY_MAP_PATH loaded at least one person. */ + readonly isMapEnabled: () => Effect.Effect; + } +>()("t3/identity/IdentityService") {} + +function parseMapDocument(path: string, raw: string): ReadonlyArray { + const trimmed = raw.trim(); + if (trimmed.length === 0) return []; + let document: unknown; + if (path.endsWith(".json")) { + document = JSON.parse(trimmed) as unknown; + } else { + try { + document = JSON.parse(trimmed) as unknown; + } catch { + document = parseYamlString(trimmed) as unknown; + } + } + return parseIdentityMapDocument(document); +} + +function loadPeopleFromEnv(): ReadonlyArray { + const configured = process.env.T3_IDENTITY_MAP_PATH?.trim(); + if (configured === undefined || configured.length === 0) { + return []; + } + try { + if (!NodeFS.existsSync(configured)) { + console.error(`[identity] T3_IDENTITY_MAP_PATH not found: ${configured}`); + return []; + } + const raw = NodeFS.readFileSync(configured, "utf8"); + if (raw.trim().length === 0) { + console.error(`[identity] T3_IDENTITY_MAP_PATH is empty: ${configured}`); + return []; + } + const people = parseMapDocument(configured, raw); + if (people.length === 0) { + console.error(`[identity] T3_IDENTITY_MAP_PATH has no people: ${configured}`); + } + return people; + } catch (cause) { + const message = + cause instanceof IdentityMapParseError + ? cause.message + : cause instanceof Error + ? cause.message + : String(cause); + console.error(`[identity] failed to load map at ${configured}: ${message}`); + return []; + } +} + +/** How long a loaded map is served before the file is re-checked. */ +export const IDENTITY_MAP_RELOAD_TTL_MS = 60_000; + +type MapSnapshot = { + readonly people: ReadonlyArray; + readonly byUsername: ReadonlyMap; + readonly byPersonId: ReadonlyMap; + readonly enabled: boolean; + /** + * False while we are serving a stale snapshot because the last re-read failed + * (missing, truncated, or unparseable file). Callers must not take destructive + * action — notably evicting persisted claims — off an unhealthy snapshot. + */ + readonly healthy: boolean; +}; + +type IdentityMapSource = { readonly current: Effect.Effect }; + +function toSnapshot(people: ReadonlyArray, healthy: boolean): MapSnapshot { + return { + people, + byUsername: new Map(people.map((person) => [person.username, person] as const)), + byPersonId: new Map(people.map((person) => [person.personId, person] as const)), + enabled: people.length > 0, + healthy, + }; +} + +/** Cheap change detector: avoids re-parsing an untouched file every TTL. */ +function fingerprintFromEnv(): string | null { + const configured = process.env.T3_IDENTITY_MAP_PATH?.trim(); + if (configured === undefined || configured.length === 0) return null; + try { + const stat = NodeFS.statSync(configured); + return `${stat.ino}:${stat.size}:${stat.mtimeMs}`; + } catch { + return null; + } +} + +/** + * File-backed map with a TTL re-check, so operators can edit the staged map and + * have it apply without a server restart. + * + * Polls rather than watching: the map is delivered over virtiofs from the host, + * where inotify propagation is not something to depend on. + * + * Two safety rules, both about not letting a bad read do damage: + * - a reload that yields no people never disables an already-enabled map, since + * `enabled === false` turns the operate gate off entirely. Emptying the file + * is not the documented way to disable; removing T3_IDENTITY_MAP_PATH is. + * - a failed reload keeps serving the last good snapshot, marked unhealthy, and + * retries on the next TTL (the fingerprint is left unadvanced). + */ +function makeFileSource(options?: { readonly ttlMs?: number }): IdentityMapSource { + const ttlMs = options?.ttlMs ?? IDENTITY_MAP_RELOAD_TTL_MS; + + let snapshot = toSnapshot(loadPeopleFromEnv(), true); + let fingerprint = fingerprintFromEnv(); + let checkedAt: number | null = null; + let degraded = false; + + const current = Effect.gen(function* () { + const at = yield* Clock.currentTimeMillis; + if (checkedAt === null) { + checkedAt = at; + return snapshot; + } + if (at - checkedAt < ttlMs) return snapshot; + checkedAt = at; + + const nextFingerprint = fingerprintFromEnv(); + if (nextFingerprint !== null && nextFingerprint === fingerprint) return snapshot; + + const people = loadPeopleFromEnv(); + if (people.length === 0 && snapshot.enabled) { + if (!degraded) { + yield* Effect.logError( + "Identity map re-read produced no people; keeping the previous map and retrying", + ); + degraded = true; + } + snapshot = { ...snapshot, healthy: false }; + return snapshot; + } + + const changed = people.length !== snapshot.people.length || !snapshot.healthy; + fingerprint = nextFingerprint; + degraded = false; + snapshot = toSnapshot(people, true); + if (changed) { + yield* Effect.logInfo("Identity map reloaded", { people: people.length }); + } + return snapshot; + }); + + return { current }; +} + +function staticSource(people: ReadonlyArray): IdentityMapSource { + return { current: Effect.succeed(toSnapshot(people, true)) }; +} + +function toPublicPeople(people: ReadonlyArray) { + return people.map((person) => { + const pub = toIdentityPersonPublic(person); + return { + personId: PersonId.make(pub.personId), + username: IdentityUsername.make(pub.username), + ...(pub.name !== undefined ? { name: pub.name } : {}), + links: pub.links, + }; + }); +} + +type ClaimStore = { + readonly get: (sessionId: AuthSessionId) => Effect.Effect; + readonly put: (record: ClaimRecord) => Effect.Effect; + readonly remove: (sessionId: AuthSessionId) => Effect.Effect; +}; + +function makeService(source: IdentityMapSource, store: ClaimStore): IdentityService["Service"] { + const toPublicClaim = (record: ClaimRecord): SessionIdentityClaim => ({ + sessionId: record.sessionId, + personId: record.personId, + username: record.username, + claimedAt: record.claimedAt, + method: record.method, + }); + + return { + getSnapshot: () => + source.current.pipe( + Effect.map((snapshot) => ({ + enabled: snapshot.enabled, + claimRequired: snapshot.enabled, + people: toPublicPeople(snapshot.people), + })), + ), + + listMapPeople: () => source.current.pipe(Effect.map((snapshot) => snapshot.people)), + + getSessionClaim: (sessionId) => + store.get(sessionId).pipe( + Effect.map((record) => ({ + claim: record === null ? null : toPublicClaim(record), + })), + ), + + claim: (sessionId, input) => + Effect.gen(function* () { + const snapshot = yield* source.current; + if (!snapshot.enabled) { + return yield* new IdentityError({ + code: "identity_map_disabled", + message: "Identity map is not configured; claims are disabled.", + }); + } + + const person = + "personId" in input + ? (snapshot.byPersonId.get(input.personId) ?? null) + : (snapshot.byUsername.get(input.username) ?? null); + if (person === null) { + return yield* new IdentityError({ + code: "identity_unknown_person", + message: "That identity is not in the server identity map.", + }); + } + + const method: SessionIdentityClaimMethod = + ("method" in input && input.method !== undefined ? input.method : undefined) ?? + "typeahead"; + const claimedAt = yield* DateTime.now.pipe(Effect.map((dt) => DateTime.formatIso(dt))); + const record: ClaimRecord = { + sessionId, + personId: PersonId.make(person.personId), + username: IdentityUsername.make(person.username), + claimedAt, + method, + }; + yield* store.put(record); + return { claim: toPublicClaim(record) }; + }), + + clearClaim: (sessionId) => store.remove(sessionId).pipe(Effect.map((cleared) => ({ cleared }))), + + requireOperateClaim: (sessionId, options) => + Effect.gen(function* () { + const snapshot = yield* source.current; + if (!snapshot.enabled) return null; + // Integration bots: one auth session, many platform actors — not interactive claim. + if (options?.clientDeviceType === "bot") { + return null; + } + const existing = yield* store.get(sessionId); + if (existing === null) { + return yield* new IdentityError({ + code: "identity_claim_required", + message: + "Choose who you are (identity claim) before operating on this environment. Map membership only — trusted-team ops.", + }); + } + if ( + !snapshot.byPersonId.has(existing.personId) || + !snapshot.byUsername.has(existing.username) + ) { + // Deliberately does not delete the persisted claim. Refusing operate is + // the gate; deleting is only cleanup, and it is not reversible. Now that + // the map reloads under the server, a half-written file can parse as a + // valid map with a subset of people — deleting off that would destroy + // good claims. Membership is re-checked on every operate anyway, so a + // stale row grants nothing. + return yield* new IdentityError({ + code: "identity_unknown_person", + message: "Your identity claim is no longer in the server map. Claim again.", + }); + } + return toPublicClaim(existing); + }), + + resolveByJiraAccountId: (accountId) => + source.current.pipe( + Effect.map((snapshot) => + snapshot.enabled ? resolvePersonByJiraAccountId(snapshot.people, accountId) : null, + ), + ), + + isMapEnabled: () => source.current.pipe(Effect.map((snapshot) => snapshot.enabled)), + }; +} + +function makeMemoryStore(claimsRef: Ref.Ref>): ClaimStore { + return { + get: (sessionId) => + Ref.get(claimsRef).pipe(Effect.map((claims) => claims.get(sessionId) ?? null)), + put: (record) => + Ref.update(claimsRef, (claims) => { + const next = new Map(claims); + next.set(record.sessionId, record); + return next; + }), + remove: (sessionId) => + Ref.modify(claimsRef, (claims) => { + const had = claims.has(sessionId); + if (!had) return [false, claims] as const; + const next = new Map(claims); + next.delete(sessionId); + return [true, next] as const; + }), + }; +} + +export const make: Effect.Effect = Effect.gen(function* () { + const claimsRef = yield* Ref.make(new Map()); + const source = makeFileSource(); + const people = (yield* source.current).people; + if (people.length > 0) { + yield* Effect.logInfo("Identity map loaded", { + path: process.env.T3_IDENTITY_MAP_PATH ?? "", + people: people.length, + reloadTtlMs: IDENTITY_MAP_RELOAD_TTL_MS, + }); + } + return makeService(source, makeMemoryStore(claimsRef)); +}); + +/** Residual-free in-memory layer (CLI / tests without SQL). */ +export const layer = Layer.effect(IdentityService, make); + +/** + * Server layer: SQLite-backed claims with an in-memory cache. + * Requires SessionIdentityClaimRepository (SqlClient residual). + */ +export const layerPersisted = Layer.effect( + IdentityService, + Effect.gen(function* () { + const claimsRef = yield* Ref.make(new Map()); + const memory = makeMemoryStore(claimsRef); + const repository = yield* SessionIdentityClaimRepository; + const source = makeFileSource(); + const people = (yield* source.current).people; + if (people.length > 0) { + yield* Effect.logInfo("Identity map loaded", { + path: process.env.T3_IDENTITY_MAP_PATH ?? "", + people: people.length, + reloadTtlMs: IDENTITY_MAP_RELOAD_TTL_MS, + }); + } + + const store: ClaimStore = { + get: (sessionId) => + Effect.gen(function* () { + const cached = yield* memory.get(sessionId); + if (cached !== null) return cached; + const row = yield* repository + .getBySessionId(sessionId) + .pipe(Effect.catch(() => Effect.succeed(Option.none()))); + if (Option.isNone(row)) return null; + const record: ClaimRecord = { + sessionId: row.value.sessionId, + personId: row.value.personId, + username: row.value.username, + claimedAt: row.value.claimedAt, + method: row.value.method, + }; + yield* memory.put(record); + return record; + }), + put: (record) => + Effect.gen(function* () { + yield* memory.put(record); + yield* repository.upsert(record).pipe(Effect.catch(() => Effect.void)); + }), + remove: (sessionId) => + Effect.gen(function* () { + const cleared = yield* memory.remove(sessionId); + yield* repository.deleteBySessionId(sessionId).pipe(Effect.catch(() => Effect.void)); + return cleared; + }), + }; + + return makeService(source, store); + }), +).pipe(Layer.provide(sessionIdentityClaimRepositoryLayer)); + +/** Test helper: fixed people list. */ +export const layerWithPeople = (people: ReadonlyArray) => + Layer.effect( + IdentityService, + Effect.gen(function* () { + const claimsRef = yield* Ref.make(new Map()); + return makeService(staticSource(people), makeMemoryStore(claimsRef)); + }), + ); + +/** Test seam: file-backed source with an injectable clock and TTL. */ +export const makeFileSourceForTest = makeFileSource; diff --git a/apps/server/src/identity/stampSource.test.ts b/apps/server/src/identity/stampSource.test.ts new file mode 100644 index 00000000000..6356dc33332 --- /dev/null +++ b/apps/server/src/identity/stampSource.test.ts @@ -0,0 +1,144 @@ +import { + AuthSessionId, + CommandId, + IdentityUsername, + MessageId, + PersonId, + ThreadId, + type SessionIdentityClaim, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildIntegrationSourceRef, + sourceRefFromOperateClaim, + stampOrchestrationCommandSource, +} from "./stampSource.ts"; + +const claim: SessionIdentityClaim = { + sessionId: AuthSessionId.make("00000000-0000-4000-8000-0000000000aa"), + personId: PersonId.make("patroza"), + username: IdentityUsername.make("patroza"), + claimedAt: "2026-01-01T00:00:00.000Z", + method: "typeahead", +}; + +const people = [ + { + personId: "patroza", + username: "patroza", + name: "Patrick", + discord: { id: "95218063095377920" }, + github: { login: "patroza", id: "42661" }, + jira: { accountId: "jira-pat", email: "p@example.com" }, + }, +] as const; + +describe("sourceRefFromOperateClaim", () => { + it("maps desktop deviceType to channel", () => { + expect(sourceRefFromOperateClaim({ claim, clientDeviceType: "desktop" })).toEqual({ + channel: "desktop", + personId: "patroza", + username: "patroza", + }); + }); +}); + +describe("buildIntegrationSourceRef", () => { + it("resolves github actor to map person", () => { + const source = buildIntegrationSourceRef({ + people, + channel: "github", + platformId: "42661", + displayName: "patroza", + location: { owner: "pingdotgg", repo: "t3code", number: 1, kind: "pr" }, + }); + expect(source.channel).toBe("github"); + expect(source.personId).toBe("patroza"); + expect(source.username).toBe("patroza"); + expect(source.location?.number).toBe(1); + }); + + it("resolves discord snowflake", () => { + const source = buildIntegrationSourceRef({ + people, + channel: "discord", + platformId: "95218063095377920", + displayName: "patroza", + }); + expect(source.personId).toBe("patroza"); + expect(source.channel).toBe("discord"); + }); + + it("leaves person unset when unmapped", () => { + const source = buildIntegrationSourceRef({ + people, + channel: "jira", + platformId: "unknown-account", + displayName: "Ghost", + }); + expect(source.personId).toBeUndefined(); + expect(source.actor?.platformId).toBe("unknown-account"); + }); +}); + +describe("stampOrchestrationCommandSource", () => { + const baseCommand = { + type: "thread.turn.start" as const, + commandId: CommandId.make("00000000-0000-4000-8000-0000000000bb"), + threadId: ThreadId.make("00000000-0000-4000-8000-0000000000cc"), + message: { + messageId: MessageId.make("00000000-0000-4000-8000-0000000000dd"), + role: "user" as const, + text: "hello", + attachments: [], + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + createdAt: "2026-01-01T00:00:00.000Z", + }; + + it("stamps from claim for interactive clients", () => { + const stamped = stampOrchestrationCommandSource({ + claim, + clientDeviceType: "desktop", + command: baseCommand, + }); + expect(stamped.type).toBe("thread.turn.start"); + if (stamped.type === "thread.turn.start") { + expect(stamped.source).toEqual({ + channel: "desktop", + personId: "patroza", + username: "patroza", + }); + } + }); + + it("stamps from discord sourceHint for bot sessions", () => { + const stamped = stampOrchestrationCommandSource({ + claim: null, + clientDeviceType: "bot", + people, + command: { + ...baseCommand, + sourceHint: { + channel: "discord", + actor: { + platformId: "95218063095377920", + displayName: "patroza", + }, + location: { + guildId: "1083767712431480922", + channelId: "1532311945326235829", + }, + }, + }, + }); + if (stamped.type === "thread.turn.start") { + expect(stamped.source?.personId).toBe("patroza"); + expect(stamped.source?.channel).toBe("discord"); + expect(stamped.sourceHint).toBeUndefined(); + expect(stamped.source?.location?.guildId).toBe("1083767712431480922"); + } + }); +}); diff --git a/apps/server/src/identity/stampSource.ts b/apps/server/src/identity/stampSource.ts new file mode 100644 index 00000000000..0b43e7c4d5d --- /dev/null +++ b/apps/server/src/identity/stampSource.ts @@ -0,0 +1,198 @@ +/** + * Server-only: stamp SourceRef onto operate commands from session claim + * or platform sourceHint (Discord / GitHub / Jira bots and bridges). + * Never trusts client personId/username. + */ +import type { + AuthClientMetadataDeviceType, + ClientSourceHint, + OrchestrationCommand, + SessionIdentityClaim, + SourceChannel, + SourceRef, +} from "@t3tools/contracts"; +import { IdentityUsername, PersonId } from "@t3tools/contracts"; +import { + findPersonByDiscordId, + findPersonByGithubId, + findPersonByGithubLogin, + findPersonByJiraAccountId, + findPersonByJiraEmail, + type IdentityMapPerson, +} from "@t3tools/shared/identityMap"; +import { buildSourceRefFromClaim, resolveSourceChannel } from "@t3tools/shared/sourceAttribution"; + +export function sourceRefFromOperateClaim(input: { + readonly claim: SessionIdentityClaim; + readonly clientDeviceType?: AuthClientMetadataDeviceType | undefined; + readonly channelHint?: SourceChannel | undefined; +}): SourceRef { + const built = buildSourceRefFromClaim({ + personId: input.claim.personId, + username: input.claim.username, + channel: resolveSourceChannel({ + deviceType: input.clientDeviceType, + channelHint: input.channelHint, + }), + }); + return { + channel: built.channel, + personId: input.claim.personId, + username: input.claim.username, + }; +} + +/** + * Resolve a map person from a ClientSourceHint channel + actor. + * Unmapped actors still get a channel stamp without personId (v1 policy). + */ +export function resolvePersonFromSourceHint( + people: ReadonlyArray, + hint: ClientSourceHint, +): IdentityMapPerson | null { + const channel = hint.channel; + const actor = hint.actor; + const platformId = actor?.platformId?.trim() ?? ""; + const displayName = actor?.displayName?.trim() ?? ""; + + if (channel === "discord" && platformId.length > 0) { + return findPersonByDiscordId(people, platformId); + } + if (channel === "github") { + if (platformId.length > 0) { + const byId = findPersonByGithubId(people, platformId); + if (byId) return byId; + } + if (displayName.length > 0) { + return findPersonByGithubLogin(people, displayName); + } + // Some callers put login in platformId when numeric id is unknown. + if (platformId.length > 0 && !/^\d+$/u.test(platformId)) { + return findPersonByGithubLogin(people, platformId); + } + } + if (channel === "jira") { + if (platformId.length > 0) { + const byAccount = findPersonByJiraAccountId(people, platformId); + if (byAccount) return byAccount; + } + if (displayName.includes("@")) { + return findPersonByJiraEmail(people, displayName); + } + } + return null; +} + +export function sourceRefFromHintAndMap(input: { + readonly people: ReadonlyArray; + readonly hint: ClientSourceHint; + readonly clientDeviceType?: AuthClientMetadataDeviceType | undefined; +}): SourceRef { + const channel = resolveSourceChannel({ + deviceType: input.clientDeviceType, + channelHint: input.hint.channel, + }); + const person = resolvePersonFromSourceHint(input.people, input.hint); + const base: SourceRef = { + channel, + ...(input.hint.location !== undefined ? { location: input.hint.location } : {}), + ...(input.hint.actor !== undefined ? { actor: input.hint.actor } : {}), + }; + if (person === null) { + return base; + } + return { + ...base, + personId: PersonId.make(person.personId), + username: IdentityUsername.make(person.username), + }; +} + +/** + * Attach server-authored source to turn.start commands. + * Priority: session claim → sourceHint+map (integrations) → channel-only for bots. + */ +export function stampOrchestrationCommandSource(input: { + readonly command: OrchestrationCommand; + readonly claim: SessionIdentityClaim | null; + readonly clientDeviceType?: AuthClientMetadataDeviceType | undefined; + readonly people?: ReadonlyArray | undefined; +}): OrchestrationCommand { + if (input.command.type !== "thread.turn.start") { + return input.command; + } + + const hint = input.command.sourceHint; + let source: SourceRef | undefined; + + if (input.claim !== null) { + source = sourceRefFromOperateClaim({ + claim: input.claim, + clientDeviceType: input.clientDeviceType, + channelHint: hint?.channel, + }); + // Merge optional location/actor from hint (e.g. Discord guild) onto claim stamp. + if (hint?.location !== undefined || hint?.actor !== undefined) { + source = { + ...source, + ...(hint.location !== undefined ? { location: hint.location } : {}), + ...(hint.actor !== undefined ? { actor: hint.actor } : {}), + }; + } + } else if (hint !== undefined && (input.people?.length ?? 0) > 0) { + source = sourceRefFromHintAndMap({ + people: input.people ?? [], + hint, + clientDeviceType: input.clientDeviceType, + }); + } else if (hint !== undefined) { + // Map empty/off: still stamp channel + actor for provenance. + source = { + channel: resolveSourceChannel({ + deviceType: input.clientDeviceType, + channelHint: hint.channel, + }), + ...(hint.location !== undefined ? { location: hint.location } : {}), + ...(hint.actor !== undefined ? { actor: hint.actor } : {}), + }; + } else if (input.clientDeviceType === "bot") { + source = { channel: "bot" }; + } + + // Drop sourceHint from the command stored in the engine (source is authoritative). + const { sourceHint: _dropped, ...withoutHint } = input.command; + void _dropped; + if (source === undefined) { + return withoutHint as OrchestrationCommand; + } + return { + ...withoutHint, + source, + } as OrchestrationCommand; +} + +/** Build a full SourceRef for in-process bridges (GitHub/Jira) with map resolution. */ +export function buildIntegrationSourceRef(input: { + readonly people: ReadonlyArray; + readonly channel: SourceChannel; + readonly platformId?: string | null | undefined; + readonly displayName?: string | null | undefined; + readonly location?: SourceRef["location"]; +}): SourceRef { + const hint: ClientSourceHint = { + channel: input.channel, + ...(input.platformId || input.displayName + ? { + actor: { + ...(input.platformId ? { platformId: input.platformId } : {}), + ...(input.displayName ? { displayName: input.displayName } : {}), + }, + } + : {}), + ...(input.location !== undefined ? { location: input.location } : {}), + }; + return sourceRefFromHintAndMap({ + people: input.people, + hint, + }); +} diff --git a/apps/server/src/jira/JiraAppClient.ts b/apps/server/src/jira/JiraAppClient.ts index 0a89682ebd9..0afa3682767 100644 --- a/apps/server/src/jira/JiraAppClient.ts +++ b/apps/server/src/jira/JiraAppClient.ts @@ -15,11 +15,19 @@ export class JiraAppClient extends Context.Service< readonly body: string; /** * When set, create a threaded **reply** under this comment (Jira `parentId`). - * Only top-level comments accept children; nest under the thread root when the - * user wrote inside an existing reply thread. + * Only top-level comments accept children; the client resolves nested ids to the + * thread root via GET before posting. */ readonly parentCommentId?: string | null; - }) => Effect.Effect<{ readonly id: string } | null, never>; + /** + * Second parent to try if `parentCommentId` is rejected (e.g. root vs mention). + * Keeps the reply inline when the first parentId is invalid. + */ + readonly fallbackParentCommentId?: string | null; + /** @-mention this Jira user at the start of the reply (normal reply style). */ + readonly mentionAccountId?: string | null; + readonly mentionDisplayName?: string | null; + }) => Effect.Effect<{ readonly id: string; readonly parentId: string | null } | null, never>; /** * Best-effort reaction on a comment (👀). Jira Cloud support varies; returns the emoji id * when the site accepted the reaction, otherwise null. @@ -39,6 +47,12 @@ export class JiraAppClient extends Context.Service< const CommentResponse = Schema.Struct({ id: Schema.Union([Schema.String, Schema.Number]), + parentId: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Null])), +}); + +const CommentGetResponse = Schema.Struct({ + id: Schema.Union([Schema.String, Schema.Number]), + parentId: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Null])), }); export const make = Effect.gen(function* () { @@ -54,22 +68,105 @@ export const make = Effect.gen(function* () { return request.pipe(HttpClientRequest.setHeader("authorization", `Basic ${token}`)); }; + /** + * Jira only allows children under **root** comments. Nesting under a reply returns 400 + * ("Parent comment not found, and no child comments exist"). Resolve any comment id to + * the thread root via GET `/rest/api/3/issue/{key}/comment/{id}` (`parentId` or self). + */ + const resolveThreadRootCommentId = ( + issueKey: string, + commentId: string, + ): Effect.Effect => + Effect.gen(function* () { + const trimmed = commentId.trim(); + if (trimmed.length === 0) return null; + if (!config.enabled) return trimmed; + + const url = `${config.baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment/${encodeURIComponent(trimmed)}`; + const request = authorize( + HttpClientRequest.get(url).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.setHeader("user-agent", "t3-code-jira-bridge"), + ), + ); + const response = yield* httpClient.execute(request).pipe( + Effect.tapError((cause) => + Effect.logWarning("Jira comment GET for thread root failed", { + issueKey, + commentId: trimmed, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + if (response === null) return trimmed; + + return yield* HttpClientResponse.matchStatus(response, { + "2xx": (success) => + HttpClientResponse.schemaBodyJson(CommentGetResponse)(success).pipe( + Effect.map((parsed) => { + const parentRaw = parsed.parentId; + if (parentRaw === undefined || parentRaw === null) return String(parsed.id); + const parent = String(parentRaw).trim(); + return parent.length > 0 ? parent : String(parsed.id); + }), + Effect.tap((rootId) => + rootId !== trimmed + ? Effect.logInfo("Resolved Jira nested comment to thread root", { + issueKey, + commentId: trimmed, + threadRootId: rootId, + }) + : Effect.void, + ), + Effect.tapError((cause) => + Effect.logWarning("Jira comment GET decode failed; using id as root candidate", { + issueKey, + commentId: trimmed, + cause, + }), + ), + Effect.orElseSucceed(() => trimmed), + ), + orElse: (failed) => + Effect.gen(function* () { + const detail = yield* failed.text.pipe(Effect.orElseSucceed(() => "")); + yield* Effect.logWarning("Jira comment GET rejected; using id as root candidate", { + issueKey, + commentId: trimmed, + status: failed.status, + detail: detail.slice(0, 300), + }); + return trimmed; + }), + }); + }); + const addIssueComment = Effect.fn("JiraAppClient.addIssueComment")(function* (input: { readonly issueKey: string; readonly body: string; readonly parentCommentId?: string | null; + readonly fallbackParentCommentId?: string | null; + readonly mentionAccountId?: string | null; + readonly mentionDisplayName?: string | null; }) { if (!config.enabled) return null; const url = `${config.baseUrl}/rest/api/3/issue/${encodeURIComponent(input.issueKey)}/comment`; - const parentCommentId = input.parentCommentId?.trim() || null; - const payload: Record = { body: plainTextToAdf(input.body) }; - // Undocumented but supported on Jira Cloud: parentId threads the comment under a root. - if (parentCommentId !== null) { - payload.parentId = parentCommentId; - } + const adfBody = plainTextToAdf(input.body, { + mention: input.mentionAccountId + ? { + accountId: input.mentionAccountId, + displayName: input.mentionDisplayName, + } + : null, + }); + + /** Jira accepts parentId as string or number; prefer numeric when pure digits. */ + const parentIdValue = (raw: string): string | number => + /^\d+$/u.test(raw) ? Number(raw) : raw; - const postOnce = (body: Record) => { + const postOnce = (body: Record, parentForLog: string | null) => { const request = authorize( HttpClientRequest.post(url).pipe( HttpClientRequest.acceptJson, @@ -81,7 +178,7 @@ export const make = Effect.gen(function* () { Effect.tapError((cause) => Effect.logWarning("Jira comment create request failed", { issueKey: input.issueKey, - parentCommentId, + parentCommentId: parentForLog, cause, }), ), @@ -89,74 +186,115 @@ export const make = Effect.gen(function* () { ); }; - const decodeSuccess = (success: HttpClientResponse.HttpClientResponse) => + const decodeSuccess = ( + success: HttpClientResponse.HttpClientResponse, + parentForLog: string | null, + ) => HttpClientResponse.schemaBodyJson(CommentResponse)(success).pipe( - Effect.map((parsed) => ({ id: String(parsed.id) })), + Effect.map((parsed) => { + const parentRaw = parsed.parentId; + const parentId = + parentRaw === undefined || parentRaw === null ? null : String(parentRaw).trim() || null; + return { id: String(parsed.id), parentId }; + }), Effect.tapError((cause) => Effect.logWarning("Jira comment create response decode failed", { issueKey: input.issueKey, - parentCommentId, + parentCommentId: parentForLog, cause, }), ), Effect.orElseSucceed(() => null), ); - let response = yield* postOnce(payload); - if (response === null) return null; + type Attempt = + | { readonly _tag: "ok"; readonly id: string; readonly parentId: string | null } + | { readonly _tag: "retry_next" } + | { readonly _tag: "failed" }; - const first = yield* HttpClientResponse.matchStatus(response, { - "2xx": (success) => - decodeSuccess(success).pipe( - Effect.map((parsed) => - parsed === null ? { _tag: "failed" as const } : { _tag: "ok" as const, id: parsed.id }, - ), - ), - orElse: (failed) => - Effect.gen(function* () { - const detail = yield* failed.text.pipe(Effect.orElseSucceed(() => "")); - // Threading can fail if parentId is invalid / not a root; fall back to top-level once. - if (parentCommentId !== null && (failed.status === 400 || failed.status === 404)) { - yield* Effect.logWarning( - "Jira threaded comment rejected; retrying as top-level comment", - { + const tryPost = (parentCommentId: string | null): Effect.Effect => + Effect.gen(function* () { + const payload: Record = { body: adfBody }; + // Jira Cloud: parentId threads under a **root** comment only. + if (parentCommentId !== null) { + payload.parentId = parentIdValue(parentCommentId); + } + const response = yield* postOnce(payload, parentCommentId); + if (response === null) return { _tag: "failed" as const }; + + return yield* HttpClientResponse.matchStatus(response, { + "2xx": (success) => + decodeSuccess(success, parentCommentId).pipe( + Effect.map((parsed) => + parsed === null + ? ({ _tag: "failed" } as const) + : ({ _tag: "ok", id: parsed.id, parentId: parsed.parentId } as const), + ), + ), + orElse: (failed) => + Effect.gen(function* () { + const detail = yield* failed.text.pipe(Effect.orElseSucceed(() => "")); + // Threading can fail if parentId is invalid / not a root — try next parent. + if (parentCommentId !== null && (failed.status === 400 || failed.status === 404)) { + yield* Effect.logWarning("Jira threaded comment rejected; trying next parent", { + issueKey: input.issueKey, + parentCommentId, + status: failed.status, + detail: detail.slice(0, 500), + }); + return { _tag: "retry_next" as const }; + } + yield* Effect.logWarning("Jira comment create rejected", { issueKey: input.issueKey, parentCommentId, status: failed.status, detail: detail.slice(0, 500), - }, - ); - return { _tag: "retry_flat" as const }; - } - yield* Effect.logWarning("Jira comment create rejected", { - issueKey: input.issueKey, - parentCommentId, - status: failed.status, - detail: detail.slice(0, 500), - }); - return { _tag: "failed" as const }; - }), - }); + }); + return { _tag: "failed" as const }; + }), + }); + }); + + const primary = input.parentCommentId?.trim() || null; + const secondary = input.fallbackParentCommentId?.trim() || null; + + // Resolve nested mention/reply ids to thread roots before posting. Live probe on SA-421: + // parentId=child → 400; parentId=root → 200 with parentId set. + const resolvedRoots: string[] = []; + for (const candidate of [primary, secondary]) { + if (candidate === null) continue; + const root = yield* resolveThreadRootCommentId(input.issueKey, candidate); + if (root !== null && !resolvedRoots.includes(root)) { + resolvedRoots.push(root); + } + } - if (first._tag === "ok") return { id: first.id }; - if (first._tag !== "retry_flat") return null; + // Prefer inline reply under resolved roots; top-level only as last resort. + const parents: Array = [...resolvedRoots, null]; - response = yield* postOnce({ body: plainTextToAdf(input.body) }); - if (response === null) return null; - return yield* HttpClientResponse.matchStatus(response, { - "2xx": (success) => decodeSuccess(success), - orElse: (failed) => - Effect.gen(function* () { - const detail = yield* failed.text.pipe(Effect.orElseSucceed(() => "")); - yield* Effect.logWarning("Jira comment create rejected", { + for (const parent of parents) { + const result = yield* tryPost(parent); + if (result._tag === "ok") { + if (parent !== null && result.parentId === null) { + // API accepted body but ignored parentId (e.g. wrong shape). Loud so we notice. + yield* Effect.logError("Jira comment created without parentId despite request", { issueKey: input.issueKey, - parentCommentId: null, - status: failed.status, - detail: detail.slice(0, 500), + requestedParentId: parent, + createdCommentId: result.id, }); - return null; - }), - }); + } else if (parent !== null) { + yield* Effect.logInfo("Posted Jira inline threaded reply", { + issueKey: input.issueKey, + parentId: result.parentId ?? parent, + createdCommentId: result.id, + }); + } + return { id: result.id, parentId: result.parentId }; + } + if (result._tag === "failed") return null; + // retry_next → continue + } + return null; }); /** diff --git a/apps/server/src/jira/JiraDeliveryStore.ts b/apps/server/src/jira/JiraDeliveryStore.ts index 73b94eb42b6..8fed2fa9e86 100644 --- a/apps/server/src/jira/JiraDeliveryStore.ts +++ b/apps/server/src/jira/JiraDeliveryStore.ts @@ -23,6 +23,9 @@ export const JiraDelivery = Schema.Struct({ responseCommentId: Schema.NullOr(Schema.String), /** Emoji id for ack reaction on the source comment (e.g. 1f440), when supported. */ acknowledgmentEmojiId: Schema.optional(Schema.NullOr(Schema.String)), + /** Jira accountId of the human who mentioned the bot (for inline @ replies). */ + actorAccountId: Schema.optional(Schema.NullOr(Schema.String)), + actorDisplayName: Schema.optional(Schema.NullOr(Schema.String)), threadId: Schema.NullOr(Schema.String), previousTurnId: Schema.NullOr(Schema.String), userMessageId: Schema.NullOr(Schema.String), @@ -41,6 +44,8 @@ export type StoredJiraDelivery = { readonly commentSurface: "issue" | "reply"; readonly responseCommentId: string | null; readonly acknowledgmentEmojiId: string | null; + readonly actorAccountId: string | null; + readonly actorDisplayName: string | null; readonly threadId: ThreadId | null; readonly previousTurnId: TurnId | null; readonly userMessageId: string | null; @@ -76,6 +81,8 @@ export const make = Effect.gen(function* () { (delivery): StoredJiraDelivery => ({ ...delivery, acknowledgmentEmojiId: delivery.acknowledgmentEmojiId ?? null, + actorAccountId: delivery.actorAccountId ?? null, + actorDisplayName: delivery.actorDisplayName ?? null, threadId: delivery.threadId as ThreadId | null, previousTurnId: delivery.previousTurnId as TurnId | null, targetTurnId: delivery.targetTurnId as TurnId | null, diff --git a/apps/server/src/jira/JiraIssueBridge.ts b/apps/server/src/jira/JiraIssueBridge.ts index f665d76777c..24df2df4e86 100644 --- a/apps/server/src/jira/JiraIssueBridge.ts +++ b/apps/server/src/jira/JiraIssueBridge.ts @@ -5,8 +5,10 @@ import { ProjectId, ThreadId, type OrchestrationThread, + type SourceRef, type TurnId, } from "@t3tools/contracts"; +import type { IdentityMapPerson } from "@t3tools/shared/identityMap"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -22,6 +24,8 @@ import { githubFinalAnswerWithStats, resolveGitHubBridgeTurnOutcome, } from "../github/GitHubPrBridge.ts"; +import * as IdentityService from "../identity/IdentityService.ts"; +import { buildIntegrationSourceRef } from "../identity/stampSource.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; @@ -37,41 +41,58 @@ import { resolveT3ProjectIdForJiraKey, } from "./JiraAppConfig.ts"; import { JiraDeliveryStore, type StoredJiraDelivery } from "./JiraDeliveryStore.ts"; -import { resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; +import { resolveDiscordLinkForJiraIssue, resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; +import { classifyJiraActorTrust, type JiraActorTrustDecision } from "./jiraActorTrust.ts"; +import { + formatDiscordJiraContextNote, + postDiscordChannelMessage, + resolveDiscordBotToken, +} from "./jiraDiscordContext.ts"; import { buildJiraTurnPrompt, type JiraIssueInvocation } from "./JiraWebhookPayload.ts"; const NOT_LINKED_RESPONSE = "not yet linked. No T3 thread lists this issue, and auto-create could not pick a project (set T3CODE_JIRA_PROJECT_MAP for this Jira key, T3CODE_JIRA_DEFAULT_PROJECT_ID, or ensure exactly one T3 project exists)."; const CREATE_DISABLED_RESPONSE = - "not yet linked. Auto-create is disabled (T3CODE_JIRA_AUTO_CREATE_THREAD=false); link this issue from Discord/T3 or enable auto-create."; + "not yet linked. Auto-create is disabled; link this issue from Chat or enable auto-create."; const AMBIGUOUS_RESPONSE = - "Multiple T3 threads are linked to this Jira issue, so the bot could not pick which one to use."; -const BUSY_RESPONSE = - "This T3 thread is already working. Try again after the current turn finishes."; + "Multiple chat threads are linked to this Jira issue, so the bot could not pick which one to use."; const FAILED_RESPONSE = - "T3 could not complete this request. Check the linked T3 thread for details."; + "Could not complete this request. Check the linked chat thread for details."; const EMPTY_PROMPT_RESPONSE = "Provide a prompt after the mention (for example: `@omegent investigate the packing failure`)."; const CREATE_FAILED_RESPONSE = - "T3 could not create a thread for this Jira issue. Check server logs or link an existing thread."; + "Could not create a chat thread for this Jira issue. Check server logs or link an existing thread."; +/** Untrusted-actor replies: short, no product jargon; @-mention is applied separately in ADF. */ +const CONTEXT_UNAUTHORIZED_RESPONSE = + "You're not currently authorized to run agent work from Jira. Please ask a team member who is authorized to take this forward."; +const CONTEXT_NOTED_RESPONSE = + "Thanks — noted for the team. You're not currently authorized to run agent work from Jira, so this was filed as context only. An authorized teammate can pick it up."; +const CONTEXT_FAILED_RESPONSE = + "You're not currently authorized to run agent work from Jira, and I couldn't file this as context either. Please ping an authorized teammate."; const MAX_JIRA_COMMENT_LENGTH = 32_000; +function jiraSourceRef( + invocation: JiraIssueInvocation, + people: ReadonlyArray, +): SourceRef { + return buildIntegrationSourceRef({ + people, + channel: "jira", + platformId: invocation.actorAccountId, + displayName: invocation.actorDisplayName, + location: { + ...(invocation.projectKey ? { projectKey: invocation.projectKey } : {}), + issueKey: invocation.issueKey, + }, + }); +} + export function formatJiraComment(body: string): string { const trimmed = body.trim(); if (trimmed.length <= MAX_JIRA_COMMENT_LENGTH) return trimmed; return `${trimmed.slice(0, MAX_JIRA_COMMENT_LENGTH - 20)}\n\n…(truncated)`; } -function isThreadBusy(thread: OrchestrationThread): boolean { - const latest = thread.latestTurn; - if (latest?.state === "running") return true; - const session = thread.session; - if (session === null) return false; - return ( - session.activeTurnId !== null && (session.status === "running" || session.status === "starting") - ); -} - export class JiraIssueBridge extends Context.Service< JiraIssueBridge, { @@ -89,21 +110,50 @@ const make = Effect.gen(function* () { const jira = yield* JiraAppClient; const engine = yield* OrchestrationEngineService; const projection = yield* ProjectionSnapshotQuery; + const identity = yield* IdentityService.IdentityService; const fileSystem = yield* FileSystem.FileSystem; const crypto = yield* Crypto.Crypto; const createLock = yield* Semaphore.make(1); + const resolveActorTrust = (invocation: JiraIssueInvocation) => + Effect.gen(function* () { + const people = yield* identity.listMapPeople(); + const mapEnabled = yield* identity.isMapEnabled(); + return classifyJiraActorTrust({ + identityMapEnabled: mapEnabled, + actorAccountId: invocation.actorAccountId, + people, + }) satisfies JiraActorTrustDecision; + }); + /** - * Post a bridge response as a **threaded reply** when possible. - * Uses delivery.replyToCommentId (thread root, or the mention itself when top-level). - * Jira only allows children under top-level comments — not under existing replies. + * Post a bridge response as an **inline threaded reply** under the user's mention. + * + * Parent candidates (JiraAppClient resolves each to the **thread root** via GET — + * Jira rejects nesting under a child comment with 400): + * 1. `sourceCommentId` — the triggering mention (reply next to the user) + * 2. `replyToCommentId` — webhook parent / root when present and different + * + * Never intentionally posts a bare top-level comment first; flat fallback is only + * if every resolved parentId is rejected (see JiraAppClient). */ - const postComment = (delivery: StoredJiraDelivery, body: string) => - jira.addIssueComment({ + const postComment = (delivery: StoredJiraDelivery, body: string) => { + const mentionParent = delivery.sourceCommentId.trim(); + const replyParent = delivery.replyToCommentId.trim(); + // Prefer the mention itself so we always answer that comment's thread; client + // walks parentId up to the root when the mention is already a nested reply. + const parentCommentId = mentionParent.length > 0 ? mentionParent : null; + return jira.addIssueComment({ issueKey: delivery.issueKey, body: formatJiraComment(body), - parentCommentId: delivery.replyToCommentId || delivery.sourceCommentId || null, + parentCommentId, + fallbackParentCommentId: + replyParent.length > 0 && replyParent !== mentionParent ? replyParent : null, + // Normal Jira reply style: @ the human who triggered the bot. + mentionAccountId: delivery.actorAccountId, + mentionDisplayName: delivery.actorDisplayName, }); + }; const updateDelivery = (delivery: StoredJiraDelivery, patch: Partial) => DateTime.now.pipe( @@ -381,6 +431,8 @@ const make = Effect.gen(function* () { commentSurface: input.invocation.commentSurface, responseCommentId: null, acknowledgmentEmojiId: null, + actorAccountId: input.invocation.actorAccountId, + actorDisplayName: input.invocation.actorDisplayName, threadId: null, previousTurnId: null, userMessageId: null, @@ -450,12 +502,94 @@ const make = Effect.gen(function* () { return; } + const trust = yield* resolveActorTrust(input.invocation); + yield* Effect.logInfo("Classified Jira actor trust", { + deliveryId: input.deliveryId, + issueKey: input.invocation.issueKey, + actorAccountId: input.invocation.actorAccountId, + mode: trust.mode, + reason: trust.reason, + personId: trust.person?.personId ?? null, + }); + const link = yield* resolveLinkedThreadId(input.invocation.issueKey); if (link._tag === "ambiguous") { yield* finishDelivery(acknowledged, AMBIGUOUS_RESPONSE, "rejected"); return; } + // Untrusted actors: optional chat context note only (no agent). Never auto-creates. + // Requires a unique chat-linked issue in links.json when filing context. + if (trust.mode === "context-only") { + const linksPath = config.discordLinksPath; + if (linksPath === null || linksPath.length === 0) { + yield* finishDelivery(acknowledged, CONTEXT_UNAUTHORIZED_RESPONSE, "rejected"); + return; + } + const linksRaw = yield* fileSystem + .readFileString(linksPath) + .pipe(Effect.orElseSucceed(() => "")); + const discordLink = resolveDiscordLinkForJiraIssue({ + issueKey: input.invocation.issueKey, + linksJson: linksRaw, + }); + if (discordLink._tag === "unlinked" || discordLink._tag === "ambiguous") { + yield* finishDelivery(acknowledged, CONTEXT_UNAUTHORIZED_RESPONSE, "rejected"); + return; + } + + const token = yield* Effect.promise(() => resolveDiscordBotToken()); + if (token === null) { + yield* finishDelivery(acknowledged, CONTEXT_FAILED_RESPONSE, "rejected"); + return; + } + + const requester = + input.invocation.actorDisplayName ?? input.invocation.actorAccountId ?? "unknown"; + const content = formatDiscordJiraContextNote({ + issueKey: input.invocation.issueKey, + requester, + prompt: input.invocation.prompt, + commentUrl: input.invocation.commentUrl, + }); + const posted = yield* Effect.promise(() => + postDiscordChannelMessage({ + token, + channelId: discordLink.discordThreadId, + content, + }) + .then((message) => ({ _tag: "ok" as const, message })) + .catch((cause) => ({ _tag: "err" as const, cause })), + ); + if (posted._tag === "err") { + yield* Effect.logError("Failed to post Jira context-only note to Discord", { + deliveryId: input.deliveryId, + issueKey: input.invocation.issueKey, + discordThreadId: discordLink.discordThreadId, + cause: posted.cause, + }); + yield* finishDelivery(acknowledged, CONTEXT_FAILED_RESPONSE, "rejected"); + return; + } + + yield* Effect.logInfo("Posted Jira context-only note to Discord (no agent run)", { + deliveryId: input.deliveryId, + issueKey: input.invocation.issueKey, + discordThreadId: discordLink.discordThreadId, + t3ThreadId: discordLink.t3ThreadId, + discordMessageId: posted.message.id, + }); + const notedDelivery: StoredJiraDelivery = { + ...acknowledged, + threadId: + discordLink.t3ThreadId !== null + ? (discordLink.t3ThreadId as ThreadId) + : acknowledged.threadId, + }; + yield* finishDelivery(notedDelivery, CONTEXT_NOTED_RESPONSE, "completed"); + return; + } + let thread: OrchestrationThread; if (link._tag === "linked") { const snapshot = yield* projection @@ -477,7 +611,7 @@ const make = Effect.gen(function* () { thread = snapshot.value; } } else { - // unlinked — join-or-create + // unlinked — join-or-create (trusted actors only) if (!config.enabled || !config.autoCreateThread) { yield* finishDelivery(acknowledged, CREATE_DISABLED_RESPONSE, "rejected"); return; @@ -499,11 +633,9 @@ const make = Effect.gen(function* () { }) .pipe(Effect.ignore); - if (isThreadBusy(thread)) { - yield* finishDelivery({ ...acknowledged, threadId: thread.id }, BUSY_RESPONSE, "completed"); - return; - } - + // Always dispatch. When the thread is mid-turn, orchestration queues the + // message (thread.message-queued) — do not short-circuit with a busy reply. + // Response posting stays async via forkDetach(bridgeTurn) below. const commandId = CommandId.make(yield* crypto.randomUUIDv4); const messageId = MessageId.make(yield* crypto.randomUUIDv4); const processing: StoredJiraDelivery = { @@ -522,8 +654,12 @@ const make = Effect.gen(function* () { threadId: thread.id, issueKey: input.invocation.issueKey, userMessageId: messageId, + trustMode: trust.mode, + personId: trust.person?.personId ?? null, }); + const mapPeople = yield* identity.listMapPeople(); + const source = jiraSourceRef(input.invocation, mapPeople); const dispatched = yield* engine .dispatch({ type: "thread.turn.start", @@ -539,6 +675,7 @@ const make = Effect.gen(function* () { titleSeed: input.invocation.prompt.slice(0, 80) || "Jira comment", runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, + source, createdAt: DateTime.formatIso(yield* DateTime.now), }) .pipe( diff --git a/apps/server/src/jira/JiraThreadLookup.ts b/apps/server/src/jira/JiraThreadLookup.ts index 57b49714ba7..80b4c2467d9 100644 --- a/apps/server/src/jira/JiraThreadLookup.ts +++ b/apps/server/src/jira/JiraThreadLookup.ts @@ -3,6 +3,8 @@ * * Preferred resolution is the server-native {@link ThreadWorkItemStore}. This helper remains * for migration/fallback when Discord still holds associations that have not been imported yet. + * + * Discord destinations (for untrusted context notes) also come from the same links.json. */ import type { ThreadId } from "@t3tools/contracts"; @@ -11,6 +13,8 @@ import * as Schema from "effect/Schema"; const DiscordThreadLink = Schema.Struct({ discordThreadId: Schema.optional(Schema.String), t3ThreadId: Schema.String, + channelId: Schema.optional(Schema.String), + guildId: Schema.optional(Schema.String), status: Schema.optional(Schema.String), jiraIssueKeys: Schema.optional(Schema.Array(Schema.String)), }); @@ -27,26 +31,47 @@ export type JiraThreadLookupResult = | { readonly _tag: "ambiguous"; readonly threadIds: ReadonlyArray } | { readonly _tag: "linked"; readonly threadId: ThreadId }; -export function resolveThreadIdForJiraIssue(input: { - readonly issueKey: string; - readonly linksJson: string; -}): JiraThreadLookupResult { - const issueKey = input.issueKey.trim().toUpperCase(); - if (issueKey.length === 0) return { _tag: "unlinked" }; +export type JiraDiscordLinkLookupResult = + | { readonly _tag: "unlinked" } + | { + readonly _tag: "ambiguous"; + readonly discordThreadIds: ReadonlyArray; + } + | { + readonly _tag: "linked"; + readonly discordThreadId: string; + readonly t3ThreadId: string | null; + readonly channelId: string | null; + readonly guildId: string | null; + }; + +function activeLinksWithIssue( + linksJson: string, + issueKeyRaw: string, +): ReadonlyArray { + const issueKey = issueKeyRaw.trim().toUpperCase(); + if (issueKey.length === 0) return []; let links: ReadonlyArray; try { - links = decodeLinksFile(input.linksJson).links; + links = decodeLinksFile(linksJson).links; } catch { - return { _tag: "unlinked" }; + return []; } - const matches = new Set(); - for (const link of links) { - if (link.status !== undefined && link.status !== "active") continue; + return links.filter((link) => { + if (link.status !== undefined && link.status !== "active") return false; const keys = link.jiraIssueKeys ?? []; - const hit = keys.some((key) => key.trim().toUpperCase() === issueKey); - if (!hit) continue; + return keys.some((key) => key.trim().toUpperCase() === issueKey); + }); +} + +export function resolveThreadIdForJiraIssue(input: { + readonly issueKey: string; + readonly linksJson: string; +}): JiraThreadLookupResult { + const matches = new Set(); + for (const link of activeLinksWithIssue(input.linksJson, input.issueKey)) { const threadId = link.t3ThreadId.trim(); if (threadId.length > 0) matches.add(threadId); } @@ -61,3 +86,31 @@ export function resolveThreadIdForJiraIssue(input: { const [only] = matches; return { _tag: "linked", threadId: only as ThreadId }; } + +/** + * Resolve the Discord thread to post untrusted Jira context into. + * Requires a unique active links.json row with both the issue key and a discordThreadId. + */ +export function resolveDiscordLinkForJiraIssue(input: { + readonly issueKey: string; + readonly linksJson: string; +}): JiraDiscordLinkLookupResult { + const withDiscord = activeLinksWithIssue(input.linksJson, input.issueKey).filter( + (link) => (link.discordThreadId?.trim().length ?? 0) > 0, + ); + if (withDiscord.length === 0) return { _tag: "unlinked" }; + if (withDiscord.length > 1) { + return { + _tag: "ambiguous", + discordThreadIds: withDiscord.map((link) => link.discordThreadId!.trim()), + }; + } + const only = withDiscord[0]!; + return { + _tag: "linked", + discordThreadId: only.discordThreadId!.trim(), + t3ThreadId: only.t3ThreadId.trim() || null, + channelId: only.channelId?.trim() || null, + guildId: only.guildId?.trim() || null, + }; +} diff --git a/apps/server/src/jira/JiraWebhook.test.ts b/apps/server/src/jira/JiraWebhook.test.ts index fc98198a3c6..78a82364705 100644 --- a/apps/server/src/jira/JiraWebhook.test.ts +++ b/apps/server/src/jira/JiraWebhook.test.ts @@ -8,7 +8,7 @@ import { resolveT3ProjectIdForJiraKey, } from "./JiraAppConfig.ts"; import { formatJiraComment } from "./JiraIssueBridge.ts"; -import { resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; +import { resolveDiscordLinkForJiraIssue, resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; import { classifyWebhookBodyFailure, previewWebhookBody, @@ -222,6 +222,45 @@ describe("parseJiraCommentInvocation", () => { }); }); + it("accepts REST-style comment.parentId for threaded replies", () => { + const invocation = parseJiraCommentInvocation( + webhook("@omegent continue", { + comment: { + id: "10800", + body: "@omegent continue", + parentId: 71399, + author: { accountId: "user-1", displayName: "Ada", accountType: "atlassian" }, + }, + }), + "omegent", + ); + expect(invocation).toMatchObject({ + commentId: "10800", + replyToCommentId: "71399", + commentSurface: "reply", + }); + }); + + it("ignores empty Automation parent.id and treats the mention as top-level", () => { + const invocation = parseJiraCommentInvocation( + webhook("@omegent investigate packing", { + comment: { + id: "71413", + body: "@omegent investigate packing", + parent: { id: "" }, + parentId: null, + author: { accountId: "user-1", displayName: "Ada", accountType: "atlassian" }, + }, + }), + "omegent", + ); + expect(invocation).toMatchObject({ + commentId: "71413", + replyToCommentId: "71413", + commentSurface: "issue", + }); + }); + it("uses the mention itself as replyTo when the comment is top-level", () => { const invocation = parseJiraCommentInvocation( webhook("@omegent investigate packing"), @@ -321,11 +360,13 @@ describe("Jira thread lookup", () => { links: [ { t3ThreadId: "thread-a", + discordThreadId: "discord-a", status: "active", jiraIssueKeys: ["SA-402", "SA-409"], }, { t3ThreadId: "thread-b", + discordThreadId: "discord-b", status: "tombstone", jiraIssueKeys: ["SA-402"], }, @@ -335,6 +376,13 @@ describe("Jira thread lookup", () => { _tag: "linked", threadId: "thread-a", }); + expect(resolveDiscordLinkForJiraIssue({ issueKey: "SA-402", linksJson })).toEqual({ + _tag: "linked", + discordThreadId: "discord-a", + t3ThreadId: "thread-a", + channelId: null, + guildId: null, + }); }); it("reports unlinked and ambiguous cases", () => { @@ -356,6 +404,28 @@ describe("Jira thread lookup", () => { }), }), ).toMatchObject({ _tag: "ambiguous" }); + + expect( + resolveDiscordLinkForJiraIssue({ + issueKey: "SA-1", + linksJson: JSON.stringify({ + links: [ + { + t3ThreadId: "a", + discordThreadId: "d1", + status: "active", + jiraIssueKeys: ["SA-1"], + }, + { + t3ThreadId: "b", + discordThreadId: "d2", + status: "active", + jiraIssueKeys: ["SA-1"], + }, + ], + }), + }), + ).toMatchObject({ _tag: "ambiguous" }); }); }); @@ -366,6 +436,36 @@ describe("Jira helpers", () => { expect(isJiraProjectAllowed(new Set(["SA"]), "CFG")).toBe(false); expect(isJiraProjectAllowed(new Set(), "ANY")).toBe(true); expect(plainTextToAdf("hello\n\nworld").content).toHaveLength(2); + // Live Jira comments use bare accountId in attrs.id (no accountid: prefix). + const withMention = plainTextToAdf("hello", { + mention: { accountId: "6331c32307a27ebeff15d19d", displayName: "Armin Gebhardt" }, + }); + expect(withMention.content[0]?.content[0]).toMatchObject({ + type: "mention", + attrs: { + id: "6331c32307a27ebeff15d19d", + text: "@Armin Gebhardt", + accessLevel: "", + }, + }); + expect(withMention.content[0]?.content[1]).toMatchObject({ + type: "text", + text: " hello", + }); + // Wiki/Automation form still normalizes to bare accountId for outbound ADF. + const fromWikiForm = plainTextToAdf("ping", { + mention: { + accountId: "accountid:712020:187d3a46-cef9-4fcd-881b-b66f1a7e56ab", + displayName: "Omegent", + }, + }); + expect(fromWikiForm.content[0]?.content[0]).toMatchObject({ + type: "mention", + attrs: { + id: "712020:187d3a46-cef9-4fcd-881b-b66f1a7e56ab", + text: "@Omegent", + }, + }); expect(formatJiraComment(" ok ")).toBe("ok"); }); diff --git a/apps/server/src/jira/JiraWebhookPayload.ts b/apps/server/src/jira/JiraWebhookPayload.ts index 22033b50326..d3950142063 100644 --- a/apps/server/src/jira/JiraWebhookPayload.ts +++ b/apps/server/src/jira/JiraWebhookPayload.ts @@ -29,15 +29,24 @@ export const JiraCommentWebhook = Schema.Struct({ updated: Schema.optional(Schema.String), /** * Present when the comment is a reply in a threaded discussion (when Jira provides it). - * String or number depending on payload shape. + * String or number depending on payload shape. Empty `id` (Automation blank fields) + * is treated as missing by `parentCommentIdFromPayload`. */ parent: Schema.optional( Schema.Union([ - Schema.Struct({ id: Schema.Union([Schema.String, Schema.Number]) }), + Schema.Struct({ + id: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Null])), + }), Schema.String, Schema.Number, + Schema.Null, ]), ), + /** + * REST list/get shape uses top-level `parentId` (number|string|null). Accept it on + * webhooks/Automation so nested mentions resolve without an extra GET when present. + */ + parentId: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Null])), jsdPublic: Schema.optional(Schema.Boolean), }), issue: Schema.Struct({ @@ -310,12 +319,17 @@ export function projectKeyFromIssueKey(issueKey: string): string { return match?.[1] ?? issueKey.split("-")[0]?.toUpperCase() ?? ""; } -function parentCommentId(parent: JiraCommentWebhook["comment"]["parent"]): string | null { +function parentCommentIdFromPayload(comment: JiraCommentWebhook["comment"]): string | null { + // Prefer REST-style parentId (what GET /comment returns and Automation can mirror). + const fromParentId = asStringId(comment.parentId ?? null); + if (fromParentId !== null) return fromParentId; + + const parent = comment.parent; if (parent === undefined || parent === null) return null; if (typeof parent === "string" || typeof parent === "number") { return asStringId(parent); } - return asStringId(parent.id); + return asStringId(parent.id ?? null); } function isBotAuthor(author: JiraWebhookUser | undefined): boolean { @@ -356,7 +370,7 @@ export function parseJiraCommentInvocation( const commentId = asStringId(payload.comment.id); if (commentId === null) return null; - const parentId = parentCommentId(payload.comment.parent); + const parentId = parentCommentIdFromPayload(payload.comment); const replyToCommentId = parentId ?? commentId; const commentSurface: JiraCommentSurface = parentId !== null ? "reply" : "issue"; const projectKey = @@ -384,10 +398,9 @@ export function parseJiraCommentInvocation( }; } -export function buildJiraTurnPrompt(invocation: JiraIssueInvocation): string { +function jiraPromptHeaderLines(invocation: JiraIssueInvocation): Array { const requester = invocation.actorDisplayName ?? invocation.actorAccountId ?? "unknown"; - const isUpdate = invocation.webhookEvent === "comment_updated"; - const lines = [ + return [ "", "", isUpdate @@ -445,26 +466,92 @@ function simplePromptFingerprint(prompt: string): string { return (hash >>> 0).toString(16).padStart(8, "0"); } -/** Minimal ADF document from plain text paragraphs (API v3 comment body). */ -export function plainTextToAdf(text: string): { +export type JiraAdfMention = { + readonly accountId: string; + readonly displayName?: string | null | undefined; +}; + +type AdfInlineNode = + | { readonly type: "text"; readonly text: string } + | { + readonly type: "mention"; + readonly attrs: { + readonly id: string; + readonly text: string; + readonly accessLevel: string; + }; + }; + +type AdfParagraph = { + readonly type: "paragraph"; + readonly content: ReadonlyArray; +}; + +export type JiraAdfDocument = { readonly type: "doc"; readonly version: 1; - readonly content: ReadonlyArray<{ - readonly type: "paragraph"; - readonly content: ReadonlyArray<{ readonly type: "text"; readonly text: string }>; - }>; -} { + readonly content: ReadonlyArray; +}; + +/** + * Normalize a Jira Cloud account id for an ADF **mention** node `attrs.id`. + * + * Live GET `/rest/api/3/issue/{key}/comment` returns bare Atlassian account ids + * (e.g. `6331c32307a27ebeff15d19d` or `712020:uuid`) — **not** the wiki form + * `accountid:…`. Official ADF docs: attrs.id = “Atlassian Account ID”. + * Strip a leading `accountid:` if present so inbound wiki/Automation forms still work. + */ +export function jiraMentionAccountId(accountId: string): string { + const trimmed = accountId.trim(); + if (trimmed.length === 0) return trimmed; + return trimmed.replace(/^accountid:/iu, ""); +} + +/** + * Minimal ADF document from plain text paragraphs (API v3 comment body). + * Optional leading @mention of the human requester (normal Jira reply style). + * + * Mention shape (matches real comments on this site): + * `{ type: "mention", attrs: { id: "", text: "@Name", accessLevel: "" } }` + */ +export function plainTextToAdf( + text: string, + options?: { readonly mention?: JiraAdfMention | null | undefined }, +): JiraAdfDocument { const paragraphs = text .split(/\n{2,}/u) .map((block) => block.trim()) .filter((block) => block.length > 0); const blocks = paragraphs.length > 0 ? paragraphs : [text.trim() || " "]; + const mention = options?.mention; + const accountId = jiraMentionAccountId(mention?.accountId?.trim() ?? ""); + const display = mention?.displayName?.trim().replace(/^@/u, "") || accountId; + const mentionNode: AdfInlineNode | null = + accountId.length > 0 + ? { + type: "mention", + attrs: { + id: accountId, + text: `@${display}`, + accessLevel: "", + }, + } + : null; + return { type: "doc", version: 1, - content: blocks.map((block) => ({ - type: "paragraph" as const, - content: [{ type: "text" as const, text: block }], - })), + content: blocks.map((block, index) => { + if (index === 0 && mentionNode !== null) { + return { + type: "paragraph" as const, + content: [mentionNode, { type: "text" as const, text: ` ${block}` }], + }; + } + return { + type: "paragraph" as const, + content: [{ type: "text" as const, text: block }], + }; + }), }; } diff --git a/apps/server/src/jira/jiraActorTrust.test.ts b/apps/server/src/jira/jiraActorTrust.test.ts new file mode 100644 index 00000000000..e699ac40565 --- /dev/null +++ b/apps/server/src/jira/jiraActorTrust.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + classifyJiraActorTrust, + normalizeJiraAccountId, + resolvePersonByJiraAccountId, +} from "./jiraActorTrust.ts"; + +const people = [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + jira: { accountId: "712020:abc-trusted" }, + }, + { + personId: "julius", + username: "julius", + jira: { accountId: "accountid:712020:def-other" }, + }, +] as const; + +describe("normalizeJiraAccountId", () => { + it("strips accountid: prefix and lowercases", () => { + expect(normalizeJiraAccountId("accountid:712020:ABC")).toBe("712020:abc"); + expect(normalizeJiraAccountId("712020:ABC")).toBe("712020:abc"); + }); + + it("returns null for empty", () => { + expect(normalizeJiraAccountId(null)).toBeNull(); + expect(normalizeJiraAccountId(" ")).toBeNull(); + }); +}); + +describe("resolvePersonByJiraAccountId", () => { + it("matches mapped account ids with prefix variants", () => { + expect(resolvePersonByJiraAccountId(people, "712020:abc-trusted")?.username).toBe("patroza"); + expect(resolvePersonByJiraAccountId(people, "accountid:712020:ABC-TRUSTED")?.username).toBe( + "patroza", + ); + expect(resolvePersonByJiraAccountId(people, "712020:def-other")?.username).toBe("julius"); + }); + + it("returns null when unmapped", () => { + expect(resolvePersonByJiraAccountId(people, "unknown")).toBeNull(); + }); +}); + +describe("classifyJiraActorTrust", () => { + it("denies agent access when the identity map is disabled (fail-closed)", () => { + expect( + classifyJiraActorTrust({ + identityMapEnabled: false, + actorAccountId: "stranger", + people: [], + }), + ).toEqual({ mode: "context-only", person: null, reason: "identity_map_disabled" }); + }); + + it("trusts mapped Jira account ids for full agent turns", () => { + const decision = classifyJiraActorTrust({ + identityMapEnabled: true, + actorAccountId: "712020:abc-trusted", + people, + }); + expect(decision.mode).toBe("full"); + expect(decision.reason).toBe("mapped_jira_account"); + expect(decision.person?.username).toBe("patroza"); + }); + + it("restricts unmapped and missing account ids to context-only", () => { + expect( + classifyJiraActorTrust({ + identityMapEnabled: true, + actorAccountId: "712020:stranger", + people, + }), + ).toMatchObject({ mode: "context-only", reason: "unmapped_jira_account", person: null }); + + expect( + classifyJiraActorTrust({ + identityMapEnabled: true, + actorAccountId: null, + people, + }), + ).toMatchObject({ mode: "context-only", reason: "missing_jira_account_id", person: null }); + }); +}); diff --git a/apps/server/src/jira/jiraActorTrust.ts b/apps/server/src/jira/jiraActorTrust.ts new file mode 100644 index 00000000000..9d7808cfe5b --- /dev/null +++ b/apps/server/src/jira/jiraActorTrust.ts @@ -0,0 +1,53 @@ +/** + * Jira actor trust relative to the closed-set identity map. + * + * Fail-closed: no configured map (or empty map) is treated like an unmapped + * actor — no agent turns. Only people with a mapped Jira accountId may run the + * agent; everyone else may only append context to an already-linked thread. + */ +import { + normalizeJiraAccountId, + resolvePersonByJiraAccountId, + type IdentityMapPerson, +} from "@t3tools/shared/identityMap"; + +export type JiraActorTrustMode = "full" | "context-only"; + +export type JiraActorTrustDecision = { + readonly mode: JiraActorTrustMode; + /** Mapped person when trusted; null when untrusted. */ + readonly person: IdentityMapPerson | null; + readonly reason: + | "identity_map_disabled" + | "mapped_jira_account" + | "unmapped_jira_account" + | "missing_jira_account_id"; +}; + +export { normalizeJiraAccountId, resolvePersonByJiraAccountId }; + +/** + * Classify a Jira mention actor for agent execution. + * + * - Map off / empty → context-only (no agent; same as unmapped) + * - Map on + accountId in map → full + * - Map on + missing/unmapped accountId → context-only + */ +export function classifyJiraActorTrust(input: { + readonly identityMapEnabled: boolean; + readonly actorAccountId: string | null | undefined; + readonly people: ReadonlyArray; +}): JiraActorTrustDecision { + if (!input.identityMapEnabled) { + return { mode: "context-only", person: null, reason: "identity_map_disabled" }; + } + const normalized = normalizeJiraAccountId(input.actorAccountId); + if (normalized === null) { + return { mode: "context-only", person: null, reason: "missing_jira_account_id" }; + } + const person = resolvePersonByJiraAccountId(input.people, input.actorAccountId); + if (person === null) { + return { mode: "context-only", person: null, reason: "unmapped_jira_account" }; + } + return { mode: "full", person, reason: "mapped_jira_account" }; +} diff --git a/apps/server/src/jira/jiraDiscordContext.test.ts b/apps/server/src/jira/jiraDiscordContext.test.ts new file mode 100644 index 00000000000..dd96703a667 --- /dev/null +++ b/apps/server/src/jira/jiraDiscordContext.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { formatDiscordJiraContextNote } from "./jiraDiscordContext.ts"; + +describe("formatDiscordJiraContextNote", () => { + it("formats a short context note", () => { + const text = formatDiscordJiraContextNote({ + issueKey: "SA-402", + requester: "Ada", + prompt: "packing fails on stage", + commentUrl: "https://example.atlassian.net/browse/SA-402?focusedCommentId=1", + }); + expect(text).toContain("SA-402"); + expect(text).toContain("Ada"); + expect(text).toContain("no agent run"); + expect(text).toContain("packing fails on stage"); + expect(text).toContain("focusedCommentId=1"); + }); + + it("truncates long prompts to Discord limits", () => { + const prompt = "x".repeat(3000); + const text = formatDiscordJiraContextNote({ + issueKey: "SA-1", + requester: "Bob", + prompt, + }); + expect(text.length).toBeLessThanOrEqual(2000); + expect(text).toContain("…(truncated)"); + }); +}); diff --git a/apps/server/src/jira/jiraDiscordContext.ts b/apps/server/src/jira/jiraDiscordContext.ts new file mode 100644 index 00000000000..946666bc5c6 --- /dev/null +++ b/apps/server/src/jira/jiraDiscordContext.ts @@ -0,0 +1,84 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalFetchInEffect:off +/** + * Minimal Discord post helper for untrusted Jira context notes. + * Intentionally small — does not depend on MCP tools or the full Discord bot. + */ +import * as NodeFSP from "node:fs/promises"; + +const DISCORD_API_BASE_URL = "https://discord.com/api/v10"; +const DISCORD_DEFAULT_ENV_FILE = "/run/secrets/discord-bot.env"; + +function extractEnvAssignment(raw: string, key: string): string | undefined { + for (const line of raw.split(/\r?\n/u)) { + if (!line.startsWith(`${key}=`)) continue; + const value = line.slice(key.length + 1).trim(); + if (value.length === 0) return undefined; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; + } + return undefined; +} + +export async function resolveDiscordBotToken(): Promise { + const fromEnv = process.env.DISCORD_BOT_TOKEN?.trim(); + if (fromEnv) return fromEnv; + try { + const envFile = await NodeFSP.readFile(DISCORD_DEFAULT_ENV_FILE, "utf8"); + const fromFile = extractEnvAssignment(envFile, "DISCORD_BOT_TOKEN")?.trim(); + return fromFile && fromFile.length > 0 ? fromFile : null; + } catch { + return null; + } +} + +/** Discord message body length limit (UTF-16 code units; we treat as chars). */ +const DISCORD_CONTENT_MAX = 2000; + +export function formatDiscordJiraContextNote(input: { + readonly issueKey: string; + readonly requester: string; + readonly prompt: string; + readonly commentUrl?: string | null; +}): string { + const header = `**Jira context** (no agent run) · \`${input.issueKey}\` · ${input.requester}`; + const link = input.commentUrl ? `\n${input.commentUrl}` : ""; + const body = input.prompt.trim(); + const combined = `${header}${link}\n\n${body}`; + if (combined.length <= DISCORD_CONTENT_MAX) return combined; + const budget = DISCORD_CONTENT_MAX - header.length - link.length - 20; + const clipped = body.slice(0, Math.max(0, budget)); + return `${header}${link}\n\n${clipped}\n…(truncated)`; +} + +export async function postDiscordChannelMessage(input: { + readonly token: string; + readonly channelId: string; + readonly content: string; +}): Promise<{ readonly id: string; readonly channelId: string }> { + const url = `${DISCORD_API_BASE_URL}/channels/${input.channelId}/messages`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bot ${input.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: input.content, + allowed_mentions: { parse: [] as string[] }, + }), + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Discord create message failed (${response.status}): ${body}`); + } + const json = (await response.json()) as { id?: string; channel_id?: string }; + return { + id: json.id ?? "", + channelId: json.channel_id ?? input.channelId, + }; +} diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index d929012fc57..af47ae338d5 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -198,8 +198,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); - assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); - assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); assert.equal(defaultsByCommand.get("board.open"), "mod+t"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 53f329e8512..18a78fe3623 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -547,24 +547,19 @@ const make = Effect.gen(function* () { }); } - // Startup backfill must never evict persisted user rules: append only - // the defaults that fit and skip the rest. - const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length); - const defaultsToAppend = missingDefaults.slice(0, availableSlots); - const skippedDefaults = missingDefaults.slice(availableSlots); - if (skippedDefaults.length > 0) { - yield* Effect.logWarning("skipping default keybinding backfill at max entries", { + const nextConfig = [...customConfig, ...missingDefaults]; + const cappedConfig = + nextConfig.length > MAX_KEYBINDINGS_COUNT + ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) + : nextConfig; + if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { + yield* Effect.logWarning("truncating keybindings config to max entries", { path: keybindingsConfigPath, maxEntries: MAX_KEYBINDINGS_COUNT, - commands: skippedDefaults.map((rule) => rule.command), }); } - if (defaultsToAppend.length === 0) { - yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); - return; - } - yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]); + yield* writeConfigAtomically(cappedConfig); yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); }), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index beb1991e21e..c1f504618b4 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -459,6 +459,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.make("thread-archive"), requestId: CommandId.make("cmd-thread-archive-title-regeneration"), title: "Stale generated title", + createdAt: now(), }), ); expect( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f95882f1ea2..fd2fab744ea 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -566,6 +566,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ]); let latestUserMessageAt: string | null = null; + // Rebuild origin + participants from projected user messages so shell stays + // consistent after resync/replay (not only first-write stamps). + type ParticipantRow = NonNullable<(typeof existingRow.value)["participantSummaries"]>[number]; + const rebuiltParticipants: Array = []; + let rebuiltOrigin = existingRow.value.originSource ?? null; for (const message of messages) { if ( message.role === "user" && @@ -573,7 +578,53 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ) { latestUserMessageAt = message.createdAt; } + if (message.role !== "user" || message.source === undefined) { + continue; + } + if (rebuiltOrigin === null || rebuiltOrigin === undefined) { + rebuiltOrigin = message.source; + } + if (message.source.personId !== undefined && message.source.username !== undefined) { + const personId = message.source.personId; + const existingParticipantIndex = rebuiltParticipants.findIndex( + (entry) => entry.personId === personId, + ); + if (existingParticipantIndex === -1) { + rebuiltParticipants.push({ + personId, + username: message.source.username, + firstChannel: message.source.channel, + channels: [message.source.channel], + firstParticipatedAt: message.createdAt, + }); + } else { + const existingParticipant = rebuiltParticipants[existingParticipantIndex]!; + if (!existingParticipant.channels?.includes(message.source.channel)) { + rebuiltParticipants[existingParticipantIndex] = { + ...existingParticipant, + channels: [ + ...(existingParticipant.channels ?? + (existingParticipant.firstChannel === undefined + ? [] + : [existingParticipant.firstChannel])), + message.source.channel, + ], + }; + } + } + } + } + // Origin person first when present. + if (rebuiltOrigin?.personId !== undefined) { + const originId = rebuiltOrigin.personId; + rebuiltParticipants.sort((left, right) => { + if (left.personId === originId) return -1; + if (right.personId === originId) return 1; + return left.firstParticipatedAt.localeCompare(right.firstParticipatedAt); + }); } + const originSource = rebuiltOrigin; + const participantSummaries = rebuiltParticipants; const pendingApprovalCount = pendingApprovals.filter( (approval) => approval.status === "pending", @@ -590,6 +641,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pendingApprovalCount, pendingUserInputCount, hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0, + originSource: originSource ?? null, + participantSummaries, }); }); @@ -937,6 +990,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), isStreaming: event.payload.streaming, + ...(event.payload.source !== undefined + ? { source: event.payload.source } + : previousMessage?.source !== undefined + ? { source: previousMessage.source } + : {}), createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index b687082eec7..1c02b15f770 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -26,7 +26,9 @@ import { type OrchestrationThreadShell, ModelSelection, ProjectId, + SourceRef, ThreadId, + ThreadParticipantSummary, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; @@ -77,6 +79,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + source: Schema.NullOr(Schema.fromJsonString(Schema.NullOr(SourceRef))), }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; @@ -89,6 +92,11 @@ const ProjectionQueuedMessageDbRowSchema = ProjectionQueuedMessage.mapFields( const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + // JSON columns may be SQL NULL or the string "null" from JSON.stringify(null). + originSource: Schema.NullOr(Schema.fromJsonString(Schema.NullOr(SourceRef))), + participantSummaries: Schema.NullOr( + Schema.fromJsonString(Schema.NullOr(Schema.Array(ThreadParticipantSummary))), + ), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -462,7 +470,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + origin_source_json AS "originSource", + participant_summaries_json AS "participantSummaries" FROM projection_threads ORDER BY created_at ASC, thread_id ASC `, @@ -496,7 +506,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + origin_source_json AS "originSource", + participant_summaries_json AS "participantSummaries" FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NULL @@ -532,7 +544,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + origin_source_json AS "originSource", + participant_summaries_json AS "participantSummaries" FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NOT NULL @@ -553,6 +567,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source_json AS "source", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -1065,7 +1080,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + origin_source_json AS "originSource", + participant_summaries_json AS "participantSummaries" FROM projection_threads WHERE thread_id = ${threadId} AND deleted_at IS NULL @@ -1104,6 +1121,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source_json AS "source", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -1497,6 +1515,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ...(row.attachments !== null ? { attachments: row.attachments } : {}), turnId: row.turnId, streaming: row.isStreaming === 1, + ...(row.source !== null && row.source !== undefined + ? { source: row.source } + : {}), createdAt: row.createdAt, updatedAt: row.updatedAt, }); @@ -1644,6 +1665,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { hasMoreActivities: false, checkpoints: checkpointsByThread.get(row.threadId) ?? [], session: sessionsByThread.get(row.threadId) ?? null, + ...(row.originSource !== null && row.originSource !== undefined + ? { originSource: row.originSource } + : {}), + ...(row.participantSummaries !== null && row.participantSummaries !== undefined + ? { participantSummaries: row.participantSummaries } + : {}), })); const snapshot = { @@ -2032,6 +2059,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { hasPendingApprovals: row.pendingApprovalCount > 0, hasPendingUserInput: row.pendingUserInputCount > 0, hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + ...(row.originSource !== null && row.originSource !== undefined + ? { originSource: row.originSource } + : {}), + ...(row.participantSummaries !== null && + row.participantSummaries !== undefined + ? { participantSummaries: row.participantSummaries } + : {}), } satisfies OrchestrationThreadShell) : Result.failVoid, ), @@ -2171,6 +2205,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { hasPendingApprovals: row.pendingApprovalCount > 0, hasPendingUserInput: row.pendingUserInputCount > 0, hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + ...(row.originSource !== null && row.originSource !== undefined + ? { originSource: row.originSource } + : {}), + ...(row.participantSummaries !== null && row.participantSummaries !== undefined + ? { participantSummaries: row.participantSummaries } + : {}), }), ), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", @@ -2473,6 +2513,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, hasPendingUserInput: threadRow.value.pendingUserInputCount > 0, hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0, + ...(threadRow.value.originSource !== null && threadRow.value.originSource !== undefined + ? { originSource: threadRow.value.originSource } + : {}), + ...(threadRow.value.participantSummaries !== null && + threadRow.value.participantSummaries !== undefined + ? { participantSummaries: threadRow.value.participantSummaries } + : {}), } satisfies OrchestrationThreadShell); }); @@ -2593,6 +2640,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text: row.text, turnId: row.turnId, streaming: row.isStreaming === 1, + ...(row.source !== null && row.source !== undefined ? { source: row.source } : {}), createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -2627,6 +2675,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { completedAt: row.completedAt, })), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + ...(threadRow.value.originSource !== null && threadRow.value.originSource !== undefined + ? { originSource: threadRow.value.originSource } + : {}), + ...(threadRow.value.participantSummaries !== null && + threadRow.value.participantSummaries !== undefined + ? { participantSummaries: threadRow.value.participantSummaries } + : {}), }; return Option.some( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 88d38d88664..b33c247559d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -945,6 +945,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, requestId: input.requestId, ...(input.title !== undefined ? { title: input.title } : {}), + createdAt: yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)), }); }); const clearInterruptedThreadTitleRegenerations = Effect.fn( diff --git a/apps/server/src/orchestration/decider.titleRegeneration.test.ts b/apps/server/src/orchestration/decider.titleRegeneration.test.ts index dbf67acc14d..bf75ca52b88 100644 --- a/apps/server/src/orchestration/decider.titleRegeneration.test.ts +++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts @@ -52,6 +52,7 @@ it.layer(NodeServices.layer)("title regeneration decider", (it) => { threadId: ThreadId.make("thread-1"), requestId: CommandId.make("cmd-old-regeneration-request"), title: "Generated title", + createdAt: UPDATED_AT, }, readModel, }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 012bbca6ce5..29f2f8caa32 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -4,6 +4,7 @@ import { type OrchestrationEvent, type OrchestrationQueuedMessage, type OrchestrationThread, + type SourceRef, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -239,6 +240,8 @@ interface TurnStartMessageInput { readonly modelSelection?: OrchestrationQueuedMessage["modelSelection"]; readonly titleSeed?: string; readonly sourceProposedPlan?: OrchestrationQueuedMessage["sourceProposedPlan"]; + /** Server-stamped SourceRef for this user message (absent when identity off). */ + readonly source?: SourceRef; } /** @@ -276,6 +279,7 @@ const planTurnStartEvents = Effect.fn("planTurnStartEvents")(function* ({ attachments: message.attachments, turnId: null, streaming: false, + ...(message.source !== undefined ? { source: message.source } : {}), createdAt: occurredAt, updatedAt: occurredAt, }, @@ -402,6 +406,7 @@ const planQueuedMessageDispatch = Effect.fn("planQueuedMessageDispatch")(functio ? { modelSelection: queuedMessage.modelSelection } : {}), ...(sourcePlanStillValid ? { sourceProposedPlan } : {}), + ...(queuedMessage.source !== undefined ? { source: queuedMessage.source } : {}), }, occurredAt, }); @@ -1047,6 +1052,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? { modelSelection: command.modelSelection } : {}), ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), + ...(command.source !== undefined ? { source: command.source } : {}), queuedAt: command.createdAt, }, }; @@ -1064,6 +1070,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}), ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), + ...(command.source !== undefined ? { source: command.source } : {}), }, occurredAt: command.createdAt, }); diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 9d7272ac8b5..0ce742dadbe 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -2,6 +2,8 @@ import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, EnvironmentHttpApi, + type EnvironmentRequestInvalidReason, + IdentityError, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -16,10 +18,25 @@ import { failEnvironmentNotFound, requireEnvironmentScope, } from "../auth/http.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; import { GrokTranscriptResync } from "../externalSessions/GrokTranscriptResync.ts"; +import * as IdentityService from "../identity/IdentityService.ts"; +import { stampOrchestrationCommandSource } from "../identity/stampSource.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +const identityErrorToHttpReason = (error: IdentityError): EnvironmentRequestInvalidReason => { + switch (error.code) { + case "identity_claim_required": + case "identity_claim_missing": + return "identity_claim_required"; + case "identity_unknown_person": + return "identity_unknown_person"; + default: + return "identity_map_invalid"; + } +}; + export const orchestrationHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "orchestration", @@ -27,6 +44,8 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; const grokTranscriptResync = yield* GrokTranscriptResync; + const identity = yield* IdentityService.IdentityService; + const sessions = yield* SessionStore.SessionStore; return handlers .handle( @@ -89,10 +108,33 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( "dispatch", Effect.fn("environment.orchestration.dispatch")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); - yield* requireEnvironmentScope(AuthOrchestrationOperateScope); - const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( - Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), + const session = yield* requireEnvironmentScope(AuthOrchestrationOperateScope); + const clientDeviceType = yield* sessions.listActive().pipe( + Effect.map( + (active) => + active.find((entry) => entry.sessionId === session.sessionId)?.client.deviceType, + ), + Effect.orElseSucceed(() => undefined), ); + const operateClaim = yield* identity + .requireOperateClaim( + session.sessionId, + clientDeviceType !== undefined ? { clientDeviceType } : {}, + ) + .pipe( + Effect.catchTag("IdentityError", (error) => + failEnvironmentInvalidRequest(identityErrorToHttpReason(error)), + ), + ); + const mapPeople = yield* identity.listMapPeople(); + const normalizedCommand = stampOrchestrationCommandSource({ + command: yield* normalizeDispatchCommand(args.payload).pipe( + Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), + ), + claim: operateClaim, + clientDeviceType, + people: mapPeople, + }); return yield* orchestrationEngine .dispatch(normalizedCommand) .pipe( diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 36748f247c3..6ce6f9b5d18 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -4,7 +4,10 @@ import { OrchestrationMessage, OrchestrationSession, OrchestrationThread, + type SourceRef, + type ThreadParticipantSummary, } from "@t3tools/contracts"; +import { mergeParticipantSummaries, nextOriginSource } from "@t3tools/shared/sourceAttribution"; import * as Effect from "effect/Effect"; import * as HashMap from "effect/HashMap"; import * as HashSet from "effect/HashSet"; @@ -487,6 +490,7 @@ export function projectEvent( ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), turnId: payload.turnId, streaming: payload.streaming, + ...(payload.source !== undefined ? { source: payload.source } : {}), createdAt: payload.createdAt, updatedAt: payload.updatedAt, }, @@ -523,17 +527,64 @@ export function projectEvent( ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + // Preserve source on first write; streaming deltas don't re-stamp. + ...(entry.source === undefined && message.source !== undefined + ? { source: message.source } + : {}), } : entry, ) : [...thread.messages, message]; const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); + const messageSource: SourceRef | undefined = + existingMessage === undefined + ? message.source + : (existingMessage.source ?? message.source); + const nextOrigin = nextOriginSource({ + current: thread.originSource ?? null, + messageSource, + role: message.role, + }); + // Preserve branded SourceRef from the event when setting origin. + const originSource: SourceRef | null | undefined = + nextOrigin === null || nextOrigin === undefined + ? nextOrigin + : messageSource !== undefined && + (thread.originSource === undefined || thread.originSource === null) + ? messageSource + : (thread.originSource ?? null); + const existingSummaries = thread.participantSummaries ?? []; + const participantSummaries: ReadonlyArray = + message.role === "user" && + messageSource?.personId !== undefined && + messageSource.username !== undefined + ? (mergeParticipantSummaries({ + existing: existingSummaries, + source: { + personId: messageSource.personId, + username: messageSource.username, + channel: messageSource.channel, + }, + participatedAt: message.createdAt, + originPersonId: originSource?.personId ?? null, + }).map((entry) => ({ + personId: entry.personId as ThreadParticipantSummary["personId"], + username: entry.username as ThreadParticipantSummary["username"], + ...(entry.name !== undefined ? { name: entry.name } : {}), + ...(entry.firstChannel !== undefined ? { firstChannel: entry.firstChannel } : {}), + ...(entry.channels !== undefined ? { channels: [...entry.channels] } : {}), + firstParticipatedAt: entry.firstParticipatedAt, + })) as ReadonlyArray) + : existingSummaries; + return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { messages: cappedMessages, updatedAt: event.occurredAt, + ...(originSource !== undefined ? { originSource } : {}), + ...(participantSummaries.length > 0 ? { participantSummaries } : {}), }), }; }); @@ -561,6 +612,7 @@ export function projectEvent( ...(payload.sourceProposedPlan !== undefined ? { sourceProposedPlan: payload.sourceProposedPlan } : {}), + ...(payload.source !== undefined ? { source: payload.source } : {}), queuedAt: payload.queuedAt, }, ]; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 71919166886..368eb0d2f6b 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ChatAttachment } from "@t3tools/contracts"; +import { ChatAttachment, SourceRef } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -21,6 +21,8 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + // JSON column may be SQL NULL or the string "null" from JSON.stringify(null). + source: Schema.NullOr(Schema.fromJsonString(Schema.NullOr(SourceRef))), }), ); @@ -37,6 +39,7 @@ function toProjectionThreadMessage( createdAt: row.createdAt, updatedAt: row.updatedAt, ...(row.attachments !== null ? { attachments: row.attachments } : {}), + ...(row.source !== null && row.source !== undefined ? { source: row.source } : {}), }; } @@ -48,6 +51,8 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { execute: (row) => { const nextAttachmentsJson = row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + // Avoid JSON.stringify(null) → "null" which fails SourceRef decode. + const nextSourceJson = row.source != null ? JSON.stringify(row.source) : null; return sql` INSERT INTO projection_thread_messages ( message_id, @@ -57,6 +62,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json, is_streaming, + source_json, created_at, updated_at ) @@ -75,6 +81,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ) ), ${row.isStreaming ? 1 : 0}, + COALESCE( + ${nextSourceJson}, + ( + SELECT source_json + FROM projection_thread_messages + WHERE message_id = ${row.messageId} + ) + ), ${row.createdAt}, ${row.updatedAt} ) @@ -89,6 +103,10 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { projection_thread_messages.attachments_json ), is_streaming = excluded.is_streaming, + source_json = COALESCE( + excluded.source_json, + projection_thread_messages.source_json + ), created_at = excluded.created_at, updated_at = excluded.updated_at `; @@ -108,6 +126,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source_json AS "source", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -129,6 +148,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source_json AS "source", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 7bbe78ea3b7..c6876e6e6a3 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -2,6 +2,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; @@ -15,15 +16,53 @@ import { ProjectionThreadWorktreeReference, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ModelSelection, SourceRef, ThreadParticipantSummary } from "@t3tools/contracts"; +// JSON columns may be SQL NULL or the string "null" (from JSON.stringify(null)). +// Decode with NullOr inside fromJsonString so both forms become null. const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + originSource: Schema.NullOr(Schema.fromJsonString(Schema.NullOr(SourceRef))), + participantSummaries: Schema.NullOr( + Schema.fromJsonString(Schema.NullOr(Schema.Array(ThreadParticipantSummary))), + ), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; +function toProjectionThread(row: ProjectionThreadDbRow): ProjectionThread { + return { + threadId: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurnId: row.latestTurnId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + latestUserMessageAt: row.latestUserMessageAt, + pendingApprovalCount: row.pendingApprovalCount, + pendingUserInputCount: row.pendingUserInputCount, + hasActionableProposedPlan: row.hasActionableProposedPlan, + deletedAt: row.deletedAt, + ...(row.originSource !== null && row.originSource !== undefined + ? { originSource: row.originSource } + : {}), + ...(row.participantSummaries !== null && row.participantSummaries !== undefined + ? { participantSummaries: row.participantSummaries } + : {}), + }; +} + const makeProjectionThreadRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -54,7 +93,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, - deleted_at + deleted_at, + origin_source_json, + participant_summaries_json ) VALUES ( ${row.threadId}, @@ -79,7 +120,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, ${row.hasActionableProposedPlan}, - ${row.deletedAt} + ${row.deletedAt}, + ${row.originSource != null ? JSON.stringify(row.originSource) : null}, + ${row.participantSummaries != null ? JSON.stringify(row.participantSummaries) : null} ) ON CONFLICT (thread_id) DO UPDATE SET @@ -104,7 +147,15 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, - deleted_at = excluded.deleted_at + deleted_at = excluded.deleted_at, + origin_source_json = COALESCE( + excluded.origin_source_json, + projection_threads.origin_source_json + ), + participant_summaries_json = COALESCE( + excluded.participant_summaries_json, + projection_threads.participant_summaries_json + ) `, }); @@ -136,7 +187,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + origin_source_json AS "originSource", + participant_summaries_json AS "participantSummaries" FROM projection_threads WHERE thread_id = ${threadId} `, @@ -170,7 +223,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + origin_source_json AS "originSource", + participant_summaries_json AS "participantSummaries" FROM projection_threads WHERE project_id = ${projectId} ORDER BY created_at ASC, thread_id ASC @@ -211,11 +266,15 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const getById: ProjectionThreadRepositoryShape["getById"] = (input) => getProjectionThreadRow(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), + Effect.map((row) => + Option.isNone(row) ? Option.none() : Option.some(toProjectionThread(row.value)), + ), ); const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => listProjectionThreadRows(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), + Effect.map((rows) => rows.map(toProjectionThread)), ); const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 3be51de130a..237d98cf770 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -49,6 +49,8 @@ import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0036 from "./Migrations/036_ProjectionQueuedMessages.ts"; +import Migration0037 from "./Migrations/037_SessionIdentityClaims.ts"; +import Migration0038 from "./Migrations/038_ProjectionThreadSourceAttribution.ts"; import Migration0039 from "./Migrations/039_RepairProjectionThreadTitleRegeneration.ts"; /** @@ -98,6 +100,8 @@ export const migrationEntries = [ [34, "ProjectionThreadsSnoozed", Migration0034], [35, "ProjectionThreadTitleRegeneration", Migration0035], [36, "ProjectionQueuedMessages", Migration0036], + [37, "SessionIdentityClaims", Migration0037], + [38, "ProjectionThreadSourceAttribution", Migration0038], [39, "RepairProjectionThreadTitleRegeneration", Migration0039], ] as const; diff --git a/apps/server/src/persistence/Migrations/037_SessionIdentityClaims.ts b/apps/server/src/persistence/Migrations/037_SessionIdentityClaims.ts new file mode 100644 index 00000000000..b778ee3bcb3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/037_SessionIdentityClaims.ts @@ -0,0 +1,19 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS session_identity_claims ( + session_id TEXT PRIMARY KEY NOT NULL, + person_id TEXT NOT NULL, + username TEXT NOT NULL, + claimed_at TEXT NOT NULL, + method TEXT NOT NULL + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_session_identity_claims_person + ON session_identity_claims(person_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/038_ProjectionThreadSourceAttribution.ts b/apps/server/src/persistence/Migrations/038_ProjectionThreadSourceAttribution.ts new file mode 100644 index 00000000000..9da2f972805 --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_ProjectionThreadSourceAttribution.ts @@ -0,0 +1,36 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Source attribution denormalized onto projected messages and thread shells. + * JSON columns stay optional for legacy rows (NULL = absent). + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const messageColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + if (!messageColumns.some((column) => column.name === "source_json")) { + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN source_json TEXT + `; + } + + const threadColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!threadColumns.some((column) => column.name === "origin_source_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN origin_source_json TEXT + `; + } + if (!threadColumns.some((column) => column.name === "participant_summaries_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN participant_summaries_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff320256..af7f3cfcdd7 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -10,6 +10,7 @@ import { ChatAttachment, MessageId, OrchestrationMessageRole, + SourceRef, ThreadId, TurnId, IsoDateTime, @@ -29,6 +30,8 @@ export const ProjectionThreadMessage = Schema.Struct({ text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), isStreaming: Schema.Boolean, + /** Server-authored provenance; absent on legacy / assistant rows. */ + source: Schema.optional(SourceRef), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index c8206272db0..2948e33d500 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -14,7 +14,9 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + SourceRef, ThreadId, + ThreadParticipantSummary, TurnId, } from "@t3tools/contracts"; import * as Option from "effect/Option"; @@ -48,6 +50,10 @@ export const ProjectionThread = Schema.Struct({ pendingUserInputCount: NonNegativeInt, hasActionableProposedPlan: NonNegativeInt, deletedAt: Schema.NullOr(IsoDateTime), + /** First user-message SourceRef; null/absent on legacy threads. */ + originSource: Schema.optional(Schema.NullOr(SourceRef)), + /** Ordered participant stack data for list UI. */ + participantSummaries: Schema.optional(Schema.Array(ThreadParticipantSummary)), }); export type ProjectionThread = typeof ProjectionThread.Type; diff --git a/apps/server/src/persistence/SessionIdentityClaims.ts b/apps/server/src/persistence/SessionIdentityClaims.ts new file mode 100644 index 00000000000..b7eafb4b4c3 --- /dev/null +++ b/apps/server/src/persistence/SessionIdentityClaims.ts @@ -0,0 +1,177 @@ +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 Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { + AuthSessionId, + IdentityUsername, + PersonId, + SessionIdentityClaimMethod, +} from "@t3tools/contracts"; + +import { + PersistenceDecodeError, + PersistenceSqlError, + type PersistenceErrorCorrelation, +} from "./Errors.ts"; + +export const SessionIdentityClaimRecord = Schema.Struct({ + sessionId: AuthSessionId, + personId: PersonId, + username: IdentityUsername, + claimedAt: Schema.String, + method: SessionIdentityClaimMethod, +}); +export type SessionIdentityClaimRecord = typeof SessionIdentityClaimRecord.Type; + +type ClaimRepoError = PersistenceSqlError | PersistenceDecodeError; + +export class SessionIdentityClaimRepository extends Context.Service< + SessionIdentityClaimRepository, + { + readonly getBySessionId: ( + sessionId: AuthSessionId, + ) => Effect.Effect, ClaimRepoError>; + readonly upsert: (record: SessionIdentityClaimRecord) => Effect.Effect; + readonly deleteBySessionId: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + } +>()("t3/persistence/SessionIdentityClaims/SessionIdentityClaimRepository") {} + +const toError = + (sqlOp: string, decodeOp: string, correlation?: PersistenceErrorCorrelation) => + (cause: unknown): ClaimRepoError => + Schema.isSchemaError(cause) + ? PersistenceDecodeError.fromSchemaError(decodeOp, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOp, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); + +const ClaimDbRow = Schema.Struct({ + sessionId: AuthSessionId, + personId: PersonId, + username: IdentityUsername, + claimedAt: Schema.String, + method: SessionIdentityClaimMethod, +}); + +const decodeClaim = Schema.decodeUnknownEffect(ClaimDbRow); + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const getRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ sessionId: AuthSessionId }), + Result: Schema.Struct({ + sessionId: Schema.String, + personId: Schema.Unknown, + username: Schema.Unknown, + claimedAt: Schema.Unknown, + method: Schema.Unknown, + }), + execute: ({ sessionId }) => + sql` + SELECT + session_id AS "sessionId", + person_id AS "personId", + username AS "username", + claimed_at AS "claimedAt", + method AS "method" + FROM session_identity_claims + WHERE session_id = ${sessionId} + `, + }); + + const upsertRow = SqlSchema.void({ + Request: SessionIdentityClaimRecord, + execute: (input) => + sql` + INSERT INTO session_identity_claims ( + session_id, + person_id, + username, + claimed_at, + method + ) + VALUES ( + ${input.sessionId}, + ${input.personId}, + ${input.username}, + ${input.claimedAt}, + ${input.method} + ) + ON CONFLICT(session_id) DO UPDATE SET + person_id = excluded.person_id, + username = excluded.username, + claimed_at = excluded.claimed_at, + method = excluded.method + `, + }); + + const deleteRow = SqlSchema.void({ + Request: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ sessionId }) => + sql` + DELETE FROM session_identity_claims + WHERE session_id = ${sessionId} + `, + }); + + return { + getBySessionId: (sessionId) => + getRow({ sessionId }).pipe( + Effect.mapError( + toError( + "SessionIdentityClaimRepository.getBySessionId:query", + "SessionIdentityClaimRepository.getBySessionId:decode", + { sessionId }, + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeClaim(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "SessionIdentityClaimRepository.getBySessionId:decode", + cause, + { sessionId }, + ), + ), + Effect.map((decoded) => Option.some(decoded)), + ), + }), + ), + ), + upsert: (record) => + upsertRow(record).pipe( + Effect.mapError( + toError( + "SessionIdentityClaimRepository.upsert:query", + "SessionIdentityClaimRepository.upsert:encode", + { sessionId: record.sessionId }, + ), + ), + ), + deleteBySessionId: (sessionId) => + deleteRow({ sessionId }).pipe( + Effect.mapError( + toError( + "SessionIdentityClaimRepository.deleteBySessionId:query", + "SessionIdentityClaimRepository.deleteBySessionId:encode", + { sessionId }, + ), + ), + Effect.as(true), + ), + } satisfies SessionIdentityClaimRepository["Service"]; +}); + +export const layer = Layer.effect(SessionIdentityClaimRepository, make); diff --git a/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts b/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts new file mode 100644 index 00000000000..398270a2e17 --- /dev/null +++ b/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ProjectLifecycleScriptRunner from "./ProjectLifecycleScriptRunner.ts"; + +const okProcessOutput = ( + overrides: Partial = {}, +): ProcessRunner.ProcessRunOutput => ({ + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + ...overrides, +}); + +const isLifecycleFailed = Schema.is(ProjectLifecycleScriptRunner.ProjectLifecycleScriptFailedError); + +const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({ + id: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/repo/project", + defaultModelSelection: null, + scripts, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +const makeProjectionSnapshotQueryLayer = ( + project: OrchestrationProject | null, + options?: { readonly worktreePath?: string }, +) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: project ? [project] : [], + threads: + project && options?.worktreePath + ? [ + { + id: "thread-1" as never, + projectId: project.id, + title: "Thread", + modelSelection: { + instanceId: "codex" as never, + model: "gpt", + }, + runtimeMode: "full-access" as never, + interactionMode: "default" as never, + branch: null, + worktreePath: options.worktreePath, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }, + ] + : [], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.succeed( + project && workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), + ), + getProjectShellById: (projectId) => + Effect.succeed(project && projectId === project.id ? Option.some(project) : Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getSessionStopContextById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), + getThreadLifecycleById: () => Effect.die("unused"), + }); + +const makeProcessRunnerLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"]) => + Layer.succeed(ProcessRunner.ProcessRunner, { run }); + +const testLayer = ( + project: OrchestrationProject | null, + run: ProcessRunner.ProcessRunner["Service"]["run"], + options?: { readonly worktreePath?: string }, +) => + ProjectLifecycleScriptRunner.layer.pipe( + Layer.provideMerge(makeProjectionSnapshotQueryLayer(project, options)), + Layer.provideMerge(makeProcessRunnerLayer(run)), + ); + +describe("ProjectLifecycleScriptRunner", () => { + it.effect("returns no-script when no teardown script exists", () => { + const run = vi.fn(() => Effect.die("unexpected run")); + const project = makeProject([]); + + return Effect.gen(function* () { + const runner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + const result = yield* runner.runWorktreeRemove({ + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + }); + expect(result).toEqual({ status: "no-script" }); + expect(run).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(project, run))); + }); + + it.effect("returns no-script when no project can be resolved", () => { + const run = vi.fn(() => Effect.die("unexpected run")); + return Effect.gen(function* () { + const runner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + const result = yield* runner.runWorktreeRemove({ + projectCwd: "/missing", + worktreePath: "/repo/worktrees/a", + }); + expect(result).toEqual({ status: "no-script" }); + expect(run).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(null, run))); + }); + + it.effect("runs the worktree-remove script and waits for a successful exit", () => { + const run = vi.fn(() => Effect.succeed(okProcessOutput({ stdout: "cleaned\n" }))); + const project = makeProject([ + { + id: "teardown", + name: "Teardown", + command: "echo cleaned", + icon: "configure", + runOnWorktreeCreate: false, + runOnWorktreeRemove: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + const result = yield* runner.runWorktreeRemove({ + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + }); + + expect(result).toMatchObject({ + status: "completed", + scriptId: "teardown", + scriptName: "Teardown", + lifecycle: "worktree-remove", + exitCode: 0, + }); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + command: "sh", + args: ["-c", "echo cleaned"], + cwd: "/repo/worktrees/a", + env: expect.objectContaining({ + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + T3CODE_LIFECYCLE: "worktree-remove", + }), + }), + ); + }).pipe(Effect.provide(testLayer(project, run))); + }); + + it.effect("fails when the lifecycle script exits non-zero", () => { + const run = vi.fn(() => + Effect.succeed( + okProcessOutput({ + stderr: "still running\n", + code: ChildProcessSpawner.ExitCode(2), + }), + ), + ); + const project = makeProject([ + { + id: "teardown", + name: "Teardown", + command: "exit 2", + icon: "configure", + runOnWorktreeCreate: false, + runOnWorktreeRemove: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + const error = yield* runner + .runWorktreeRemove({ + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + }) + .pipe(Effect.flip); + + expect(isLifecycleFailed(error)).toBe(true); + if (isLifecycleFailed(error)) { + expect(error.exitCode).toBe(2); + expect(error.scriptName).toBe("Teardown"); + expect(error.stderr).toContain("still running"); + } + }).pipe(Effect.provide(testLayer(project, run))); + }); + + it.effect("runs the pr-merged lifecycle script when requested", () => { + const run = vi.fn(() => Effect.succeed(okProcessOutput())); + const project = makeProject([ + { + id: "merged", + name: "On merge", + command: "echo reaped", + icon: "configure", + runOnWorktreeCreate: false, + runOnPrMerged: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + const result = yield* runner.runPrMerged({ + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + pr: { + number: 42, + url: "https://github.com/org/repo/pull/42", + title: "Ship it", + baseRef: "main", + headRef: "feature/x", + state: "merged", + }, + }); + expect(result.status).toBe("completed"); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + T3CODE_LIFECYCLE: "pr-merged", + T3CODE_PR: "https://github.com/org/repo/pull/42", + T3CODE_PR_NUMBER: "42", + T3CODE_PR_URL: "https://github.com/org/repo/pull/42", + T3CODE_PR_TITLE: "Ship it", + T3CODE_PR_BASE_REF: "main", + T3CODE_PR_HEAD_REF: "feature/x", + T3CODE_PR_STATE: "merged", + }), + }), + ); + }).pipe(Effect.provide(testLayer(project, run))); + }); + + it.effect( + "resolves the project via thread worktree path when cwd is not the workspace root", + () => { + const run = vi.fn(() => Effect.succeed(okProcessOutput())); + const project = makeProject([ + { + id: "merged", + name: "On merge", + command: "echo reaped", + icon: "configure", + runOnWorktreeCreate: false, + runOnPrMerged: true, + }, + ]); + const worktreePath = "/repo/worktrees/a"; + + return Effect.gen(function* () { + const runner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + const result = yield* runner.runPrMerged({ + worktreePath, + }); + expect(result.status).toBe("completed"); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: worktreePath, + env: expect.objectContaining({ + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: worktreePath, + }), + }), + ); + }).pipe(Effect.provide(testLayer(project, run, { worktreePath }))); + }, + ); +}); diff --git a/apps/server/src/project/ProjectLifecycleScriptRunner.ts b/apps/server/src/project/ProjectLifecycleScriptRunner.ts new file mode 100644 index 00000000000..1d7f3702809 --- /dev/null +++ b/apps/server/src/project/ProjectLifecycleScriptRunner.ts @@ -0,0 +1,271 @@ +/** + * ProjectLifecycleScriptRunner - runs blocking project lifecycle scripts + * (worktree remove / PR merged) via a real process, waiting for exit before + * the caller continues (e.g. `git worktree remove`). + * + * Unlike setup scripts (fire-and-forget in a terminal), teardown must finish + * and succeed before filesystem teardown proceeds. + * + * @module ProjectLifecycleScriptRunner + */ +import { type ProjectScript, ProjectId } from "@t3tools/contracts"; +import { + prMergedProjectScript, + projectLifecycleRuntimeEnv, + type ProjectLifecycleKind, + type ProjectLifecyclePrAssociation, + worktreeRemoveProjectScript, +} from "@t3tools/shared/projectScripts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +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 Schema from "effect/Schema"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProcessRunner from "../processRunner.ts"; + +const LIFECYCLE_SCRIPT_TIMEOUT = "10 minutes"; +const LIFECYCLE_SCRIPT_MAX_OUTPUT_BYTES = 1_024 * 1_024; + +export interface ProjectLifecycleScriptRunnerResultNoScript { + readonly status: "no-script"; +} + +export interface ProjectLifecycleScriptRunnerResultCompleted { + readonly status: "completed"; + readonly scriptId: string; + readonly scriptName: string; + readonly lifecycle: ProjectLifecycleKind; + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; +} + +export type ProjectLifecycleScriptRunnerResult = + | ProjectLifecycleScriptRunnerResultNoScript + | ProjectLifecycleScriptRunnerResultCompleted; + +export interface ProjectLifecycleScriptRunnerInput { + readonly projectId?: string; + readonly projectCwd?: string; + readonly worktreePath: string; + /** Linked/associated PR or MR for this lifecycle run, when known. */ + readonly pr?: ProjectLifecyclePrAssociation | null; +} + +export class ProjectLifecycleScriptOperationError extends Schema.TaggedErrorClass()( + "ProjectLifecycleScriptOperationError", + { + lifecycle: Schema.Literals(["worktree-remove", "pr-merged"]), + projectId: Schema.optional(Schema.String), + projectCwd: Schema.optional(Schema.String), + worktreePath: Schema.String, + operation: Schema.Literals(["resolveProject", "runScript"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Project lifecycle script operation '${this.operation}' failed for '${this.lifecycle}' in '${this.worktreePath}'.`; + } +} + +export class ProjectLifecycleScriptFailedError extends Schema.TaggedErrorClass()( + "ProjectLifecycleScriptFailedError", + { + lifecycle: Schema.Literals(["worktree-remove", "pr-merged"]), + projectId: Schema.optional(Schema.String), + projectCwd: Schema.optional(Schema.String), + worktreePath: Schema.String, + scriptId: Schema.String, + scriptName: Schema.String, + exitCode: Schema.NullOr(Schema.Number), + timedOut: Schema.Boolean, + stdout: Schema.String, + stderr: Schema.String, + }, +) { + override get message(): string { + if (this.timedOut) { + return `Lifecycle script '${this.scriptName}' (${this.lifecycle}) timed out in '${this.worktreePath}'.`; + } + const code = this.exitCode === null ? "unknown" : String(this.exitCode); + return `Lifecycle script '${this.scriptName}' (${this.lifecycle}) exited with code ${code} in '${this.worktreePath}'.`; + } +} + +export const ProjectLifecycleScriptRunnerError = Schema.Union([ + ProjectLifecycleScriptOperationError, + ProjectLifecycleScriptFailedError, +]); +export type ProjectLifecycleScriptRunnerError = typeof ProjectLifecycleScriptRunnerError.Type; + +export class ProjectLifecycleScriptRunner extends Context.Service< + ProjectLifecycleScriptRunner, + { + readonly runWorktreeRemove: ( + input: ProjectLifecycleScriptRunnerInput, + ) => Effect.Effect; + readonly runPrMerged: ( + input: ProjectLifecycleScriptRunnerInput, + ) => Effect.Effect; + } +>()("t3/project/ProjectLifecycleScriptRunner") {} + +const selectScript = ( + lifecycle: ProjectLifecycleKind, + scripts: readonly ProjectScript[], +): ProjectScript | null => + lifecycle === "worktree-remove" + ? worktreeRemoveProjectScript(scripts) + : prMergedProjectScript(scripts); + +export const make = Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const processRunner = yield* ProcessRunner.ProcessRunner; + const platform = yield* HostProcessPlatform; + const hostEnvironment = yield* HostProcessEnvironment; + + const resolveProject = Effect.fn("ProjectLifecycleScriptRunner.resolveProject")(function* ( + input: ProjectLifecycleScriptRunnerInput, + lifecycle: ProjectLifecycleKind, + ) { + const errorContext = { + lifecycle, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + }; + const mapResolveError = (cause: unknown) => + new ProjectLifecycleScriptOperationError({ + ...errorContext, + operation: "resolveProject", + cause, + }); + + if (input.projectId) { + const projectById = yield* projectionSnapshotQuery + .getProjectShellById(ProjectId.make(input.projectId)) + .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(mapResolveError)); + if (projectById) { + return projectById; + } + } + + const candidateRoots = [input.projectCwd, input.worktreePath].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); + for (const root of candidateRoots) { + const projectByRoot = yield* projectionSnapshotQuery + .getActiveProjectByWorkspaceRoot(root) + .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(mapResolveError)); + if (projectByRoot) { + return projectByRoot; + } + } + + // Status cwd is often a worktree path that is not the project workspace root. + // Resolve via thread shells that link this path. + const shell = yield* projectionSnapshotQuery + .getShellSnapshot() + .pipe(Effect.mapError(mapResolveError)); + const thread = shell.threads.find( + (entry) => entry.worktreePath !== null && entry.worktreePath === input.worktreePath, + ); + if (!thread) { + return null; + } + return ( + shell.projects.find((project) => project.id === thread.projectId) ?? + (yield* projectionSnapshotQuery + .getProjectShellById(thread.projectId) + .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(mapResolveError))) + ); + }); + + const runLifecycle = Effect.fn("ProjectLifecycleScriptRunner.runLifecycle")(function* ( + input: ProjectLifecycleScriptRunnerInput, + lifecycle: ProjectLifecycleKind, + ) { + const errorContext = { + lifecycle, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + }; + + const project = yield* resolveProject(input, lifecycle); + if (!project) { + // Bare VCS removes (no T3 project) skip lifecycle scripts rather than failing. + return { status: "no-script" } as const; + } + const script = selectScript(lifecycle, project.scripts); + if (!script) { + return { status: "no-script" } as const; + } + + const lifecycleEnv = projectLifecycleRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: input.worktreePath, + lifecycle, + pr: input.pr ?? null, + }); + const env: NodeJS.ProcessEnv = { + ...hostEnvironment, + ...lifecycleEnv, + }; + + const isWindows = platform === "win32"; + const output = yield* processRunner + .run({ + command: isWindows ? "cmd.exe" : "sh", + args: isWindows ? ["/d", "/s", "/c", script.command] : ["-c", script.command], + cwd: input.worktreePath, + env, + timeout: LIFECYCLE_SCRIPT_TIMEOUT, + outputMode: "truncate", + maxOutputBytes: LIFECYCLE_SCRIPT_MAX_OUTPUT_BYTES, + truncatedMarker: "\n…[truncated]\n", + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectLifecycleScriptOperationError({ + ...errorContext, + operation: "runScript", + cause, + }), + ), + ); + + if (output.timedOut || output.code === null || output.code !== 0) { + return yield* new ProjectLifecycleScriptFailedError({ + ...errorContext, + scriptId: script.id, + scriptName: script.name, + exitCode: output.code, + timedOut: output.timedOut, + stdout: output.stdout, + stderr: output.stderr, + }); + } + + return { + status: "completed", + scriptId: script.id, + scriptName: script.name, + lifecycle, + exitCode: output.code, + stdout: output.stdout, + stderr: output.stderr, + } as const; + }); + + return ProjectLifecycleScriptRunner.of({ + runWorktreeRemove: (input) => runLifecycle(input, "worktree-remove"), + runPrMerged: (input) => runLifecycle(input, "pr-merged"), + }); +}); + +export const layer = Layer.effect(ProjectLifecycleScriptRunner, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 1e5339d89cf..368381ce6ee 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -80,6 +80,7 @@ import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; import { makeRoutesLayer } from "./server.ts"; +import * as IdentityService from "./identity/IdentityService.ts"; import { resolveAvailableEditorsForConfig } from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GrokTranscriptResync from "./externalSessions/GrokTranscriptResync.ts"; @@ -107,6 +108,7 @@ import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; +import * as ProjectLifecycleScriptRunner from "./project/ProjectLifecycleScriptRunner.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -535,10 +537,19 @@ const buildAppUnderTest = (options?: { Layer.provide(T3ProjectFileLoader.layer), ), ); + const projectLifecycleScriptRunnerLayer = Layer.mock( + ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner, + )({ + runWorktreeRemove: () => Effect.succeed({ status: "no-script" as const }), + runPrMerged: () => Effect.succeed({ status: "no-script" as const }), + }); const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), Layer.provideMerge(gitVcsDriverLayer), Layer.provideMerge(gitManagerLayer), + // Merge so VcsStatusBroadcaster (which also depends on this service) can + // be provided from the same gitWorkflowLayer output. + Layer.provideMerge(projectLifecycleScriptRunnerLayer), ); const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( Layer.provide(vcsDriverRegistryLayer), @@ -570,15 +581,20 @@ const buildAppUnderTest = (options?: { disableListenLog: true, disableLogger: true, }).pipe( + // Identity gate is off when map is empty; still must be provided for WS/HTTP. + // Merged with keybindings so Layer.pipe stays under the 20-arg limit. Layer.provide( - Layer.mock(Keybindings.Keybindings)({ - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], + Layer.mergeAll( + IdentityService.layerWithPeople([]), + Layer.mock(Keybindings.Keybindings)({ + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + ...options?.layers?.keybindings, }), - streamChanges: Stream.empty, - ...options?.layers?.keybindings, - }), + ), ), Layer.provide( Layer.mock(ProviderRegistry.ProviderRegistry)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d89279a14cf..f97802d9a91 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,8 +1,6 @@ import { EnvironmentHttpApi } from "@t3tools/contracts"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Schedule from "effect/Schedule"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -77,11 +75,13 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; +import * as ProjectLifecycleScriptRunner from "./project/ProjectLifecycleScriptRunner.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import * as IdentityService from "./identity/IdentityService.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { connectHttpApiLayer, @@ -257,6 +257,11 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +/** Durable identity claims — residual-free once Persistence/SqlClient is in the graph. */ +const IdentityLayerLive = IdentityService.layerPersisted.pipe( + Layer.provideMerge(PersistenceLayerLive), +); + const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); @@ -276,6 +281,10 @@ const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(TextGeneration.layer), ); +const ProjectLifecycleScriptRunnerLayerLive = ProjectLifecycleScriptRunner.layer.pipe( + Layer.provide(ProcessRunner.layer), +); + const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitManagerLayerLive), Layer.provideMerge(GitVcsDriver.layer), @@ -284,6 +293,7 @@ const GitLayerLive = Layer.empty.pipe( const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( Layer.provideMerge(VcsDriverRegistryLayerLive), Layer.provideMerge(GitLayerLive), + Layer.provideMerge(ProjectLifecycleScriptRunnerLayerLive), ); const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.layer.pipe( @@ -307,6 +317,7 @@ const GitHubAppDependenciesLive = Layer.mergeAll( const GitHubPrBridgeLive = GitHubPrBridge.layer.pipe( Layer.provideMerge(GitHubAppDependenciesLive), Layer.provideMerge(ThreadWorkItemStoreLive), + Layer.provideMerge(IdentityLayerLive), ); const JiraAppDependenciesLive = Layer.mergeAll(JiraAppClient.layer, JiraDeliveryStore.layer).pipe( @@ -317,6 +328,8 @@ const JiraIssueBridgeLive = JiraIssueBridge.layer.pipe( Layer.provideMerge(JiraAppDependenciesLive), // Prefer the instance already provided by GitHubPrBridgeLive when merged below. Layer.provideMerge(ThreadWorkItemStoreLive), + // Closed-set map for trusted vs context-only Jira actors. + Layer.provideMerge(IdentityLayerLive), ); const VcsLayerLive = Layer.empty.pipe( @@ -493,6 +506,8 @@ export const makeRoutesLayer = Layer.mergeAll( ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe( + // IdentityService is residual here — tests provide layerWithPeople / memory; + // makeServerLayer provides layerPersisted (SQLite claims) once SqlClient is live. Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), Layer.provide(browserApiCorsLayer), @@ -620,25 +635,7 @@ export const makeServerLayer = Layer.unwrap( yield* Effect.forkScoped( Effect.sleep("250 millis").pipe( Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), - // On reboot this races NIC/DNS bring-up, so back off exponentially - // (capped at 30s) instead of burning all retries in a second. - // Bounded overall so a permanently broken setup still surfaces the - // warning below. Bad-request/unauthorized/conflict are - // deterministic failures (malformed origin, not linked yet, linked - // to a different cloud account) that no amount of retrying - // converges. - Effect.retry({ - while: (error) => - error._tag !== "EnvironmentHttpBadRequestError" && - error._tag !== "EnvironmentHttpUnauthorizedError" && - error._tag !== "EnvironmentHttpConflictError", - schedule: Schedule.exponential("1 second").pipe( - Schedule.modifyDelay(({ duration }) => - Effect.succeed(Duration.min(duration, Duration.seconds(30))), - ), - Schedule.upTo({ duration: "10 minutes" }), - ), - }), + Effect.retry({ times: 4 }), Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), Effect.catch((cause) => Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { @@ -662,6 +659,8 @@ export const makeServerLayer = Layer.unwrap( return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive), + // Durable claims for HTTP/WS routes (residual-free; SQL from Persistence). + Layer.provideMerge(IdentityLayerLive), Layer.provideMerge(serverRelayBrokerTracingLayer), Layer.provideMerge(HttpResponseCompressionLive), Layer.provideMerge(HttpServerLive), diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 3474c6f522a..aab69de6a12 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -153,50 +153,6 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () }).pipe(Effect.provide(layer)); }); -it.effect("invalidates origin remote cache when a driver mutation adds origin", () => - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - - const before = yield* driver.statusDetailsLocal(cwd); - assert.equal(before.hasOriginRemote, false); - - yield* driver.ensureRemote({ cwd, preferredName: "origin", url: remote }); - - const after = yield* driver.statusDetailsLocal(cwd); - assert.equal(after.hasOriginRemote, true); - }).pipe(Effect.provide(TestLayer)), -); - -it.effect("re-reads origin remote status after cache TTL expiry and bypassed invalidation", () => - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - - // First call caches hasOriginRemote = false (5-min TTL) - assert.equal((yield* driver.statusDetailsLocal(cwd)).hasOriginRemote, false); - - // Add origin via raw git (bypasses invalidation hook) - yield* git(cwd, ["remote", "add", "origin", remote]); - - // Cache still has the stale false (TTL not yet expired) - const stillCached = yield* driver.statusDetailsLocal(cwd); - assert.equal(stillCached.hasOriginRemote, false); - - // Advance past the 5-minute TTL so the cache entry expires - yield* TestClock.adjust("6 minutes"); - - // After expiry, the next call re-executes and picks up the remote - const afterExpiry = yield* driver.statusDetailsLocal(cwd); - assert.equal(afterExpiry.hasOriginRemote, true); - }).pipe(Effect.provide(TestLayer)), -); it.effect("coalesces concurrent ref pages into one repository snapshot", () => Effect.scoped( Effect.gen(function* () { @@ -619,6 +575,7 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => }), ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); + it.layer(TestLayer)("GitVcsDriver core integration", (it) => { describe("process environment", () => { it.effect("preserves the caller locale for general Git subprocesses", () => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index daa07e30bbf..190e9ec69a5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -70,8 +70,6 @@ const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64; const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); -const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); -const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", @@ -1186,63 +1184,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return Cache.get(refresh ? repositoryPathsRefreshCache : repositoryPathsCache, cacheKey); }; - const defaultBranchCache = yield* Cache.makeWith( - (gitCommonDir: string) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fetchCwd = - path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return yield* executeGit( - "GitVcsDriver.statusDetails.defaultBranch", - fetchCwd, - ["--git-dir", gitCommonDir, "symbolic-ref", "refs/remotes/origin/HEAD"], - { allowNonZeroExit: true }, - ).pipe( - Effect.map((result) => { - if (result.exitCode !== 0) return null; - return parseDefaultBranchFromRemoteHeadRef(result.stdout, "origin"); - }), - ); - }), - { - capacity: 2_048, - timeToLive: Exit.match({ - onSuccess: () => STATUS_DEFAULT_BRANCH_CACHE_TTL, - onFailure: () => Duration.zero, - }), - }, - ); - const originExistsCache = yield* Cache.makeWith( - (gitCommonDir: string) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fetchCwd = - path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return yield* executeGit( - "GitVcsDriver.statusDetails.originExists", - fetchCwd, - ["--git-dir", gitCommonDir, "remote", "get-url", "origin"], - { allowNonZeroExit: true }, - ).pipe(Effect.map((result) => result.exitCode === 0)); - }), - { - capacity: 2_048, - timeToLive: Exit.match({ - onSuccess: () => STATUS_ORIGIN_EXISTS_CACHE_TTL, - onFailure: () => Duration.zero, - }), - }, - ); - const invalidateStatusStaticCaches = (cwd: string) => - Effect.gen(function* () { - const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( - Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), - ); - const cacheKey = repositoryPaths?.gitCommonDir ?? normalizeRepositoryPathsCacheKey(cwd); - yield* Cache.invalidate(defaultBranchCache, cacheKey); - yield* Cache.invalidate(originExistsCache, cacheKey); - }); - const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { const repositoryPaths = yield* resolveRepositoryPaths(cwd); if (repositoryPaths !== null) { @@ -1641,11 +1582,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } - const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( - Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), - ); - const statusCacheKey = repositoryPaths?.gitCommonDir; - const [numstatStdout, defaultBranch, hasPrimaryRemote] = yield* Effect.all( + const [numstatStdout, defaultRefResult, hasPrimaryRemote] = yield* Effect.all( [ executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", @@ -1702,16 +1639,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }), ), - statusCacheKey - ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) - : resolveDefaultBranchName(cwd, "origin").pipe(Effect.orElseSucceed(() => null)), - statusCacheKey - ? Cache.get(originExistsCache, statusCacheKey).pipe(Effect.orElseSucceed(() => false)) - : originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), + executeGit( + "GitVcsDriver.statusDetails.defaultRef", + cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { allowNonZeroExit: true }, + ), + originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), ], { concurrency: "unbounded" }, ); const statusStdout = statusResult.stdout; + const defaultBranch = + defaultRefResult.exitCode === 0 + ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; let refName: string | null = null; let upstreamRef: string | null = null; @@ -2960,14 +2902,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: string, effect: Effect.Effect, ): Effect.Effect => - effect.pipe( - Effect.ensuring( - Effect.all([ - invalidateListRefsSnapshot(cwd).pipe(Effect.ignore), - invalidateStatusStaticCaches(cwd).pipe(Effect.ignore), - ]), - ), - ); + effect.pipe(Effect.ensuring(invalidateListRefsSnapshot(cwd).pipe(Effect.ignore))); const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( input, ) => diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 4ea2885dc72..9fbf9767d7f 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -26,6 +26,14 @@ import { GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as ProjectLifecycleScriptRunner from "../project/ProjectLifecycleScriptRunner.ts"; + +const lifecycleScriptRunnerMock = Layer.mock( + ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner, +)({ + runWorktreeRemove: () => Effect.succeed({ status: "no-script" as const }), + runPrMerged: () => Effect.succeed({ status: "no-script" as const }), +}); const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); @@ -79,6 +87,7 @@ function makeTestLayer(state: { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(lifecycleScriptRunnerMock), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -221,6 +230,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(lifecycleScriptRunnerMock), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -329,6 +339,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(lifecycleScriptRunnerMock), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: (input) => @@ -492,6 +503,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(lifecycleScriptRunnerMock), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -696,6 +708,120 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); }); + it("detects open→merged PR transitions only", () => { + const open = remoteStatusWithPr; + const merged = { + ...remoteStatusWithPr, + pr: { ...remoteStatusWithPr.pr!, state: "merged" as const }, + }; + assert.isTrue(VcsStatusBroadcaster.didChangeRequestBecomeMerged(open, merged)); + assert.isFalse(VcsStatusBroadcaster.didChangeRequestBecomeMerged(null, merged)); + assert.isFalse(VcsStatusBroadcaster.didChangeRequestBecomeMerged(merged, merged)); + assert.isFalse(VcsStatusBroadcaster.didChangeRequestBecomeMerged(open, open)); + assert.isFalse( + VcsStatusBroadcaster.didChangeRequestBecomeMerged(open, { + ...merged, + pr: { ...merged.pr!, number: 9999 }, + }), + ); + }); + + it.effect("runs pr-merged lifecycle once when remote status transitions open→merged", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr as VcsStatusRemoteResult | null, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + const prMergedCalls: Array<{ + cwd: string; + prNumber?: number; + prUrl?: string; + prTitle?: string; + }> = []; + + return Effect.gen(function* () { + const prMergedRan = yield* Deferred.make(); + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner)({ + runWorktreeRemove: () => Effect.succeed({ status: "no-script" as const }), + runPrMerged: (input) => + Effect.gen(function* () { + prMergedCalls.push({ + cwd: input.worktreePath, + ...(input.pr + ? { + prNumber: input.pr.number, + prUrl: input.pr.url, + ...(input.pr.title ? { prTitle: input.pr.title } : {}), + } + : {}), + }); + yield* Deferred.succeed(prMergedRan, undefined).pipe(Effect.ignore); + return { status: "no-script" as const }; + }), + }), + ), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + invalidateStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + state.remoteInvalidationCalls += 1; + }), + }), + ), + ); + + yield* Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.refreshStatus("/repo"); + assert.equal(prMergedCalls.length, 0); + + state.currentRemoteStatus = { + ...remoteStatusWithPr, + pr: { ...remoteStatusWithPr.pr!, state: "merged" }, + }; + yield* broadcaster.refreshStatus("/repo"); + yield* Deferred.await(prMergedRan); + assert.equal(prMergedCalls.length, 1); + assert.deepStrictEqual(prMergedCalls[0], { + cwd: "/repo", + prNumber: 2978, + prUrl: "https://github.com/pingdotgg/t3code/pull/2978", + prTitle: "[codex] Rewrite client connection architecture", + }); + + // Stay merged — do not re-fire. + yield* broadcaster.refreshStatus("/repo"); + assert.equal(prMergedCalls.length, 1); + }).pipe(Effect.provide(testLayer)); + }); + }); + it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { assert.equal( Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), @@ -752,6 +878,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => false)), + Layer.provide(lifecycleScriptRunnerMock), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -805,6 +932,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(lifecycleScriptRunnerMock), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index d8233ca321d..14b219bc2d8 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -23,6 +23,7 @@ import type { import { mergeGitStatusParts } from "@t3tools/shared/git"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as ProjectLifecycleScriptRunner from "../project/ProjectLifecycleScriptRunner.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); /** @@ -163,6 +164,27 @@ export function remoteRefreshFailureDelay( return Duration.max(configuredInterval, cappedBackoff); } +/** + * True only when we observed an open PR on this cwd and it later became merged. + * Skips first-seen already-merged PRs so server restarts / initial polls do not re-fire. + */ +export function didChangeRequestBecomeMerged( + previous: VcsStatusRemoteResult | null | undefined, + next: VcsStatusRemoteResult | null, +): boolean { + if (next?.pr?.state !== "merged") { + return false; + } + if (previous?.pr?.state !== "open") { + return false; + } + return previous.pr.number === next.pr.number; +} + +function prMergedLifecycleKey(cwd: string, prNumber: number): string { + return `${cwd}::${prNumber}`; +} + export class VcsStatusBroadcaster extends Context.Service< VcsStatusBroadcaster, { @@ -192,6 +214,7 @@ const normalizeCwd = (cwd: string) => export const make = Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; + const lifecycleScriptRunner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; const fs = yield* FileSystem.FileSystem; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), @@ -212,6 +235,8 @@ export const make = Effect.gen(function* () { * this so remotes never pile up on top of an in-flight sweep. */ const listSweepMutex = yield* Semaphore.make(1); + // One fire per cwd+PR number for this server process. + const prMergedLifecycleFiredRef = yield* Ref.make(new Set()); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( cwd: string, @@ -219,6 +244,65 @@ export const make = Effect.gen(function* () { return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); }); + const maybeRunPrMergedLifecycle = Effect.fn("VcsStatusBroadcaster.maybeRunPrMergedLifecycle")( + function* ( + cwd: string, + previousRemote: VcsStatusRemoteResult | null | undefined, + nextRemote: VcsStatusRemoteResult | null, + ) { + if (!didChangeRequestBecomeMerged(previousRemote, nextRemote) || !nextRemote?.pr) { + return; + } + const pr = nextRemote.pr; + const fireKey = prMergedLifecycleKey(cwd, pr.number); + const shouldFire = yield* Ref.modify(prMergedLifecycleFiredRef, (fired) => { + if (fired.has(fireKey)) { + return [false, fired] as const; + } + const next = new Set(fired); + next.add(fireKey); + return [true, next] as const; + }); + if (!shouldFire) { + return; + } + + yield* lifecycleScriptRunner + .runPrMerged({ + projectCwd: cwd, + worktreePath: cwd, + pr: { + number: pr.number, + url: pr.url, + title: pr.title, + baseRef: pr.baseRef, + headRef: pr.headRef, + state: pr.state, + }, + }) + .pipe( + Effect.tap((result) => + result.status === "completed" + ? Effect.logInfo("VcsStatusBroadcaster pr-merged lifecycle completed", { + cwd, + prNumber: pr.number, + scriptId: result.scriptId, + scriptName: result.scriptName, + }) + : Effect.void, + ), + Effect.catch((error) => + Effect.logWarning("VcsStatusBroadcaster pr-merged lifecycle failed", { + cwd, + prNumber: pr.number, + cause: error, + }), + ), + Effect.forkIn(broadcasterScope), + ); + }, + ); + const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { const nextLocal = { @@ -255,16 +339,24 @@ export const make = Effect.gen(function* () { fingerprint: fingerprintStatusPart(remote), value: remote, } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const { previousRemote, shouldPublish } = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); nextCache.set(cwd, { ...previous, remote: nextRemote, }); - return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + return [ + { + previousRemote: previous.remote?.value, + shouldPublish: previous.remote?.fingerprint !== nextRemote.fingerprint, + }, + nextCache, + ] as const; }); + yield* maybeRunPrMergedLifecycle(cwd, previousRemote, remote); + if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { cwd, @@ -293,7 +385,7 @@ export const make = Effect.gen(function* () { fingerprint: fingerprintStatusPart(remote), value: remote, } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const { previousRemote, shouldPublish } = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); nextCache.set(cwd, { @@ -301,12 +393,18 @@ export const make = Effect.gen(function* () { remote: nextRemote, }); return [ - previous.local?.fingerprint !== nextLocal.fingerprint || - previous.remote?.fingerprint !== nextRemote.fingerprint, + { + previousRemote: previous.remote?.value, + shouldPublish: + previous.local?.fingerprint !== nextLocal.fingerprint || + previous.remote?.fingerprint !== nextRemote.fingerprint, + }, nextCache, ] as const; }); + yield* maybeRunPrMergedLifecycle(cwd, previousRemote, remote); + if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { cwd, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f03b13046b7..c8168750a92 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -108,6 +108,8 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; +import * as IdentityService from "./identity/IdentityService.ts"; +import { stampOrchestrationCommandSource } from "./identity/stampSource.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as HostResourceProbe from "./diagnostics/HostResourceProbe.ts"; @@ -438,6 +440,7 @@ const makeWsRpcLayer = ( yield* SourceControlRepositoryService.SourceControlRepositoryService; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; + const identity = yield* IdentityService.IdentityService; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; @@ -1205,11 +1208,51 @@ const makeWsRpcLayer = ( .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); return WsRpcGroup.of({ + [WS_METHODS.identityGetSnapshot]: () => + observeRpcEffect(WS_METHODS.identityGetSnapshot, identity.getSnapshot()), + [WS_METHODS.identityGetSessionClaim]: () => + observeRpcEffect( + WS_METHODS.identityGetSessionClaim, + identity.getSessionClaim(currentSessionId), + ), + [WS_METHODS.identityClaim]: (payload) => + observeRpcEffect(WS_METHODS.identityClaim, identity.claim(currentSessionId, payload)), + [WS_METHODS.identityClearClaim]: () => + observeRpcEffect(WS_METHODS.identityClearClaim, identity.clearClaim(currentSessionId)), [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { - const normalizedCommand = yield* normalizeDispatchCommand(command); + // Resolve deviceType so bot/integration sessions skip the interactive claim gate. + // Discord/Jira share one bot session across many humans; session claim cannot impersonate. + const clientDeviceType = yield* sessions.listActive().pipe( + Effect.map( + (active) => + active.find((entry) => entry.sessionId === currentSessionId)?.client.deviceType, + ), + Effect.orElseSucceed(() => undefined), + ); + const operateClaim = yield* identity + .requireOperateClaim( + currentSessionId, + clientDeviceType !== undefined ? { clientDeviceType } : {}, + ) + .pipe( + Effect.mapError( + (error) => + new OrchestrationDispatchCommandError({ + message: error.message, + code: error.code, + }), + ), + ); + const mapPeople = yield* identity.listMapPeople(); + const normalizedCommand = stampOrchestrationCommandSource({ + command: yield* normalizeDispatchCommand(command), + claim: operateClaim, + clientDeviceType, + people: mapPeople, + }); const shouldStopSessionAfterArchive = normalizedCommand.type === "thread.archive" ? yield* projectionSnapshotQuery diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index a245cbc54db..22ff986afbd 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -145,14 +145,7 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayEnvironmentLinkProofInvalidError": return `Relay rejected the environment link proof (${error.reason}).`; case "RelayEnvironmentConnectNotAuthorizedError": - // "Not authorized" covers non-auth causes too; surface the reason so a - // missing link doesn't read as a credential problem. - if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; - } - return error.reason - ? `Relay rejected the environment connection request (${error.reason}).` - : "Relay rejected the environment connection request."; + return "Relay rejected the environment connection request."; case "RelayEnvironmentEndpointUnavailableError": return `Relay could not reach the environment endpoint (${error.reason}).`; case "RelayEnvironmentEndpointTimedOutError": diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 625bf3377f2..9b9c84a3a1b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import { CheckIcon, ChevronRightIcon, @@ -56,8 +57,6 @@ import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; -import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; -import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; import { @@ -93,6 +92,27 @@ import { import { resolveDiscoveredServerUrl, resolveNavigableUrl } from "../browser/browserTargetResolver"; import { useAssetUrl } from "../assets/assetUrls"; +class CodeHighlightErrorBoundary extends React.Component< + { fallback: ReactNode; children: ReactNode }, + { hasError: boolean } +> { + constructor(props: { fallback: ReactNode; children: ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + override render() { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } +} + interface ChatMarkdownProps { text: string; cwd: string | undefined; @@ -128,6 +148,7 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); +const highlighterPromiseCache = new Map>(); function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); @@ -278,6 +299,27 @@ function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } +function getHighlighterPromise(language: string): Promise { + const cached = highlighterPromiseCache.get(language); + if (cached) return cached; + + const promise = getSharedHighlighter({ + themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + langs: [language as SupportedLanguages], + preferredHighlighter: "shiki-js", + }).catch((err) => { + highlighterPromiseCache.delete(language); + if (language === "text") { + // "text" itself failed — Shiki cannot initialize at all, surface the error + throw err; + } + // Language not supported by Shiki — fall back to "text" + return getHighlighterPromise("text"); + }); + highlighterPromiseCache.set(language, promise); + return promise; +} + function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap; } @@ -667,7 +709,7 @@ function UncachedShikiCodeBlock({ cacheKey, isStreaming, }: UncachedShikiCodeBlockProps) { - const highlighter = use(getSyntaxHighlighterPromise(language)); + const highlighter = use(getHighlighterPromise(language)); const highlightedHtml = useMemo(() => { try { return highlighter.codeToHtml(code, { lang: language, theme: themeName }); @@ -1566,7 +1608,7 @@ function ChatMarkdown({ fenceTitle={fenceTitle} theme={resolvedTheme} > - {children}}> + {children}}> {children}}> - + ); }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 744eeb52e68..d9bc14cbbc9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -72,6 +72,10 @@ import { import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import { + isIdentityClaimRequiredMessage, + requestIdentityClaimGate, +} from "./identity/IdentityClaimGate"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; @@ -167,6 +171,7 @@ import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybinding import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { buildProjectScript, + clearConflictingLifecycleFlags, commandForProjectScript, nextProjectScriptId, projectScriptIdFromCommand, @@ -2036,75 +2041,46 @@ function ChatViewContent(props: ChatViewProps) { ); const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; - const updateRunning = serverUpdateState.status === "running"; - const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; - const environmentReconnecting = - unavailableConnection !== null && - (unavailableConnection.phase === "connecting" || - unavailableConnection.phase === "reconnecting"); - // Reconnecting to a version-skewed server with no update in flight - // usually means the server is restarting mid-update and a refresh wiped - // the in-memory update state. Fold the reconnect and version banners - // into one calm line instead of stacking "Failed to connect" on - // "versions differ". A failed update never folds: its error and retry - // action must stay visible. - const reconnectingThroughVersionSkew = - serverUpdateState.status === "idle" && environmentReconnecting && versionMismatch !== null; - // While an update runs, transient connect blips are expected (the server - // restarts) and the update banner already shows progress. Hard failure - // phases still surface so the Reconnect action stays reachable. - const suppressUnavailableBanner = updateRunning && environmentReconnecting; - if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { - if (reconnectingThroughVersionSkew) { - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: "default", - icon: ( -