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/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..4ddbab08684 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -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,6 +27,7 @@ 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 { ThreadIdentityLeading } from "../identity/ParticipantStack"; import { resolveThreadStatus } from "./threadPresentation"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; import { @@ -46,7 +46,7 @@ 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; function pullRequestTintColor( @@ -694,6 +694,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { marker={showUsageDot ? (threadUsage?.marker ?? null) : null} /> ) : null} + {thread.title} @@ -765,6 +770,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/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/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/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/server.test.ts b/apps/server/src/server.test.ts index 1e5339d89cf..fc23ef6867e 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"; @@ -570,15 +571,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..afd389ba6ea 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"; @@ -82,6 +80,7 @@ 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 +256,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), ); @@ -307,6 +311,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 +322,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 +500,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 +629,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 +653,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/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..fb2f10f3cb0 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"; @@ -2036,75 +2040,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: ( - + } + /> + + {thread.title} + + + )} {hasDraft ? : null} {prStatus && pr ? ( @@ -2317,12 +2334,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec clicked === "settle" ? await settleThread(threadRef) : await unsettleThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); + const message = error instanceof Error ? error.message : "An error occurred."; + if (isIdentityClaimRequiredMessage(message)) { + requestIdentityClaimGate(threadRef.environmentId); + } toastManager.add( stackedThreadToast({ type: "error", title: clicked === "settle" ? "Failed to settle thread" : "Failed to un-settle thread", - description: error instanceof Error ? error.message : "An error occurred.", + description: message, }), ); } @@ -3267,12 +3288,16 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { : await props.unsettleThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); + const message = error instanceof Error ? error.message : "An error occurred."; + if (isIdentityClaimRequiredMessage(message)) { + requestIdentityClaimGate(threadRef.environmentId); + } toastManager.add( stackedThreadToast({ type: "error", title: clicked === "settle" ? "Failed to settle thread" : "Failed to un-settle thread", - description: error instanceof Error ? error.message : "An error occurred.", + description: message, }), ); } @@ -3462,14 +3487,27 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { onBlur={() => void commitRename()} /> ) : ( - - {thread.title} - + <> + + + + {thread.title} + + )} {hasDraft ? : null} @@ -3582,7 +3620,20 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { onBlur={() => void commitRename()} /> ) : ( - {thread.title} + <> + + + {thread.title} + )} {hasDraft ? : null} {prStatus && pr ? ( @@ -5597,7 +5648,11 @@ export default function Sidebar() { .downloadUpdate() .then((result) => { if (result.completed) { - showDesktopUpdateDownloadedToast(bridge, result.state); + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 280d38c4034..27fdd60b771 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -110,6 +110,19 @@ import { import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; +import { ParticipantStack, SourceChannelGlyph } from "./identity/ParticipantStack"; +import { + isIdentityClaimRequiredMessage, + requestIdentityClaimGate, +} from "./identity/IdentityClaimGate"; +import { + claimPersonIdForEnvironment, + DEFAULT_OWNERSHIP_RELATION, + isOwnershipRelation, + threadMatchesMine, + type OwnershipRelation, +} from "@t3tools/client-runtime/state/identity"; +import { identityClaimPersonIdByEnvironmentAtom } from "../state/identity"; import { SETTLED_TAIL_INITIAL_COUNT, SETTLED_TAIL_PAGE_COUNT, @@ -805,8 +818,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { "opacity-70 transition-opacity hover:opacity-100", ); + const participants = thread.participantSummaries ?? []; + const originChannel = thread.originSource?.channel ?? participants[0]?.firstChannel ?? null; + const title = (
+ {!isRenaming ? ( + <> + + + + ) : null} {isRenaming ? ( (() => { + try { + const raw = window.localStorage.getItem("t3.sidebar.ownershipFilter"); + if (raw === "mine" || raw === "theirs" || raw === "any") return raw; + } catch { + // ignore + } + return "any"; + }); + const [ownershipRelation, setOwnershipRelation] = useState(() => { + try { + const raw = window.localStorage.getItem("t3.sidebar.ownershipRelation"); + if (isOwnershipRelation(raw)) return raw; + } catch { + // ignore + } + return DEFAULT_OWNERSHIP_RELATION; + }); + // Per-environment claims (not primary-only): smart has no map while t3vm does. + const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); + const listOptionsActive = !isAllEnvironmentsSelected(selectedEnvironmentIds) || storedThreadGrouping !== DEFAULT_WEB_THREAD_GROUPING || settledRecencyHeadersEnabled !== DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS || - settledShelfExpanded !== DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED; + settledShelfExpanded !== DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED || + ownershipFilter !== "any" || + ownershipRelation !== DEFAULT_OWNERSHIP_RELATION; const orderedProjects = useMemo( () => orderItemsByPreferredIds({ @@ -1611,7 +1656,19 @@ export default function SidebarV2() { thread.archivedAt === null && matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds) && (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) && + threadMatchesMine({ + claimPersonId: claimPersonIdForEnvironment( + claimPersonIdByEnvironment, + thread.environmentId, + ), + originPersonId: thread.originSource?.personId ?? null, + participantPersonIds: (thread.participantSummaries ?? []).map( + (participant) => participant.personId, + ), + mode: ownershipFilter, + relation: ownershipRelation, + }), ); const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; @@ -1661,7 +1718,10 @@ export default function SidebarV2() { }, [ autoSettleAfterDays, changeRequestStateByKey, + claimPersonIdByEnvironment, nowMinute, + ownershipFilter, + ownershipRelation, scopedProjectKeys, selectedEnvironmentIds, serverConfigs, @@ -1977,11 +2037,15 @@ export default function SidebarV2() { // Never navigate away from a thread that did not settle. if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); + const message = error instanceof Error ? error.message : "An error occurred."; + if (isIdentityClaimRequiredMessage(message)) { + requestIdentityClaimGate(threadRef.environmentId); + } toastManager.add( stackedThreadToast({ type: "error", title: "Failed to settle thread", - description: error instanceof Error ? error.message : "An error occurred.", + description: message, }), ); } @@ -2757,6 +2821,82 @@ export default function SidebarV2() { + +
+ Ownership +
+ { + if (value !== "any" && value !== "mine" && value !== "theirs") return; + setOwnershipFilter(value); + try { + window.localStorage.setItem("t3.sidebar.ownershipFilter", value); + } catch { + // ignore + } + }} + > + {( + [ + ["any", "Anyone"], + ["mine", "Mine"], + ["theirs", "Theirs"], + ] as const + ).map(([value, label]) => ( + + {label} + + ))} + +
+ {ownershipFilter === "mine" || ownershipFilter === "theirs" ? ( + <> + + +
+ {ownershipFilter === "mine" ? "Mine includes" : "Theirs includes"} +
+ { + if (!isOwnershipRelation(value)) return; + setOwnershipRelation(value); + try { + window.localStorage.setItem("t3.sidebar.ownershipRelation", value); + } catch { + // ignore + } + }} + > + {( + [ + ["both", "Created or participated"], + ["created", "Created"], + ["participated", "Participated"], + ] as const + ).map(([value, label]) => ( + + {label} + + ))} + +
+ + ) : null} +
Settled shelf diff --git a/apps/web/src/components/board/BoardView.tsx b/apps/web/src/components/board/BoardView.tsx index b148d812736..6544783b220 100644 --- a/apps/web/src/components/board/BoardView.tsx +++ b/apps/web/src/components/board/BoardView.tsx @@ -28,6 +28,10 @@ import { useNavigate } from "@tanstack/react-router"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + isIdentityClaimRequiredMessage, + requestIdentityClaimGate, +} from "../identity/IdentityClaimGate"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; import { isElectron } from "../../env"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; @@ -119,16 +123,24 @@ interface BoardThreadGitContext { } /** Error toast for a failed thread action; interruptions and successes are silent. */ -function reportThreadActionFailure(result: AtomCommandResult, title: string) { +function reportThreadActionFailure( + result: AtomCommandResult, + title: string, + environmentId?: EnvironmentId | null, +) { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) { return; } const error = squashAtomCommandFailure(result); + const message = error instanceof Error ? error.message : "An error occurred."; + if (isIdentityClaimRequiredMessage(message)) { + requestIdentityClaimGate(environmentId); + } toastManager.add( stackedThreadToast({ type: "error", title, - description: error instanceof Error ? error.message : "An error occurred.", + description: message, }), ); } @@ -763,6 +775,7 @@ function BoardContent() { reportThreadActionFailure( result, clicked === "settle" ? "Failed to settle thread" : "Failed to un-settle thread", + threadRef.environmentId, ); return; } diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 75d81aa03da..697bbdb9062 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -25,7 +25,7 @@ const exitTransitionStyle = { export interface ComposerBannerStackItem { readonly id: string; - readonly variant: "default" | "error" | "info" | "success" | "warning"; + readonly variant: "error" | "info" | "success" | "warning"; readonly icon: ReactNode; readonly title: ReactNode; readonly description?: ReactNode; diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 6593acd8e1f..a9f910ec064 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -476,13 +476,7 @@ export const TraitsPicker = memo(function TraitsPicker({ }); const fastModeIcon = showFastModeIcon ? ( <> - + Fast mode on ) : null; diff --git a/apps/web/src/components/identity/IdentityAvatar.tsx b/apps/web/src/components/identity/IdentityAvatar.tsx new file mode 100644 index 00000000000..cee49d7b440 --- /dev/null +++ b/apps/web/src/components/identity/IdentityAvatar.tsx @@ -0,0 +1,45 @@ +import { identityAvatar } from "@t3tools/shared/identityAvatar"; +import { cn } from "~/lib/utils"; + +export function IdentityAvatar(props: { + readonly personId?: string | null | undefined; + readonly username?: string | null | undefined; + readonly name?: string | null | undefined; + readonly size?: "micro" | "sm" | "md"; + readonly className?: string; + readonly highlighted?: boolean; + /** `null` suppresses the native title when a parent owns richer tooltip content. */ + readonly title?: string | null; +}) { + const model = identityAvatar({ + personId: props.personId, + username: props.username, + name: props.name, + }); + const sizeClass = + props.size === "md" + ? "size-7 text-[11px]" + : props.size === "sm" + ? "size-6 text-[10px]" + : "size-3.5 text-[8px]"; + + const title = props.title === null ? undefined : (props.title ?? model.label); + + return ( + + {model.initials} + + ); +} diff --git a/apps/web/src/components/identity/IdentityClaimGate.tsx b/apps/web/src/components/identity/IdentityClaimGate.tsx new file mode 100644 index 00000000000..11710c4556e --- /dev/null +++ b/apps/web/src/components/identity/IdentityClaimGate.tsx @@ -0,0 +1,377 @@ +import { + filterPeopleForTypeahead, + identityClaimRequired, +} from "@t3tools/client-runtime/state/identity"; +import { + IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS, + IdentityUsername, + type EnvironmentId, + type IdentityPersonPublic, +} from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { useActiveEnvironmentId } from "../../state/entities"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { identityEnvironment } from "../../state/identity"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { IdentityAvatar } from "./IdentityAvatar"; + +/** + * Force-open the claim modal (e.g. after a dispatch error). Optionally target + * the environment that rejected the operate (critical for multi-env: primary + * smart has no map while secondary t3vm requires claim). + */ +let forceClaimOpen = false; +let forceClaimEnvironmentId: EnvironmentId | null = null; +const forceClaimListeners = new Set<() => void>(); + +export function requestIdentityClaimGate(environmentId?: EnvironmentId | null): void { + forceClaimOpen = true; + forceClaimEnvironmentId = environmentId ?? null; + for (const listener of forceClaimListeners) { + listener(); + } +} + +function useForceClaimState(): { + readonly open: boolean; + readonly environmentId: EnvironmentId | null; +} { + const [state, setState] = useState({ + open: forceClaimOpen, + environmentId: forceClaimEnvironmentId, + }); + useEffect(() => { + const listener = () => + setState({ open: forceClaimOpen, environmentId: forceClaimEnvironmentId }); + forceClaimListeners.add(listener); + return () => { + forceClaimListeners.delete(listener); + }; + }, []); + return state; +} + +function clearForceClaimOpen(): void { + forceClaimOpen = false; + forceClaimEnvironmentId = null; + for (const listener of forceClaimListeners) { + listener(); + } +} + +/** + * Full-screen "Who are you?" gate when *any* connected environment has a + * closed identity map and this auth session has not claimed there yet. + * + * Multi-env: primary may be smart (no map) while secondary is t3vm (map on). + * Gate every environment that requires a claim, not only primary/active. + */ +export function IdentityClaimGate() { + const activeEnvironmentId = useActiveEnvironmentId(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { environments } = useEnvironments(); + const force = useForceClaimState(); + + const orderedEnvironmentIds = useMemo(() => { + const ids: EnvironmentId[] = []; + const add = (id: EnvironmentId | null | undefined) => { + if (id !== null && id !== undefined && !ids.includes(id)) { + ids.push(id); + } + }; + // Forced env first so settle/send errors open the right dialog. + add(force.environmentId); + add(activeEnvironmentId); + add(primaryEnvironmentId); + for (const environment of environments) { + add(environment.environmentId); + } + return ids; + }, [activeEnvironmentId, environments, force.environmentId, primaryEnvironmentId]); + + if (orderedEnvironmentIds.length === 0) { + return null; + } + + // One gate body per env (hooks). Only the first that needs claim / force + // renders a modal (others return null). + return ( + <> + {orderedEnvironmentIds.map((environmentId) => { + const label = + environments.find((env) => env.environmentId === environmentId)?.label ?? null; + const forceOpen = + force.open && (force.environmentId === null || force.environmentId === environmentId); + return ( + + ); + })} + + ); +} + +function IdentityClaimGateForEnvironment(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string | null; + readonly forceOpen: boolean; +}) { + const target = useMemo( + () => ({ environmentId: props.environmentId, input: {} as const }), + [props.environmentId], + ); + const snapshotQuery = useEnvironmentQuery(identityEnvironment.snapshot(target)); + const claimQuery = useEnvironmentQuery(identityEnvironment.sessionClaim(target)); + const claimCommand = useAtomCommand(identityEnvironment.claim, { + label: "identity-claim", + reportFailure: true, + }); + + const needsClaim = identityClaimRequired(snapshotQuery.data, claimQuery.data); + const [query, setQuery] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const suggestions = useMemo(() => { + if (!snapshotQuery.data) return [] as ReadonlyArray; + return filterPeopleForTypeahead( + snapshotQuery.data.people, + query, + IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS, + ); + }, [query, snapshotQuery.data]); + + // Show when: map requires claim, or user forced open after a dispatch error. + // Keep showing while loading if forceOpen (so the error path isn't silent). + const showGate = + needsClaim || + (props.forceOpen && (needsClaim || snapshotQuery.isPending || snapshotQuery.data !== null)) || + (props.forceOpen && snapshotQuery.error !== null); + + if (!showGate) { + return null; + } + + // Still loading map/claim — block operate with a clear panel, not a toast. + if (snapshotQuery.isPending && snapshotQuery.data === null) { + return ( +
+
+

+ Checking identity… +

+

+ Loading this server’s identity map before you can send turns. +

+
+
+ ); + } + + if (snapshotQuery.error !== null && snapshotQuery.data === null) { + return ( +
+
+

+ Could not load identity +

+

{snapshotQuery.error}

+
+ +
+
+
+ ); + } + + if (!snapshotQuery.data?.enabled) { + // Map off on *this* env (e.g. smart). Do NOT clear forceOpen — a sibling + // env (t3vm) may still need the claim dialog. + return null; + } + + if (!needsClaim) { + // Already claimed on this env; clear force only when we targeted it. + if (props.forceOpen) clearForceClaimOpen(); + return null; + } + + const snapshot = snapshotQuery.data; + const envLabel = props.environmentLabel?.trim() || "this environment"; + + const submitUsername = async (username: string) => { + const normalized = username.trim().toLowerCase(); + if (normalized.length === 0) { + setError("Type your username, then pick a match from the list."); + return; + } + const exact = snapshot.people.find((person) => person.username === normalized); + if (!exact) { + setError("That identity is not on this server’s map. Keep typing to see matches."); + return; + } + setSubmitting(true); + setError(null); + try { + const result = await claimCommand({ + environmentId: props.environmentId, + input: { + username: IdentityUsername.make(exact.username), + method: "typeahead", + }, + }); + if (result._tag === "Failure") { + setError("Could not claim identity on this server."); + return; + } + claimQuery.refresh(); + snapshotQuery.refresh(); + clearForceClaimOpen(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not claim identity."); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+

+ Shared environment · {envLabel} +

+

+ Who are you? +

+

+ {envLabel} uses a closed identity + map. Type at least {IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS} characters of your username (for + example pat + …), then choose a match. Free-form names are not allowed. +

+ + + { + setQuery(event.currentTarget.value); + setError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submitUsername(query); + } + }} + /> + + {suggestions.length > 0 ? ( +
    + {suggestions.map((person) => ( +
  • + +
  • + ))} +
+ ) : query.trim().length >= IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS ? ( +

No map matches for that query.

+ ) : ( +

+ Type {IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS}+ characters to search the map ( + {snapshot.people.length} people listed). +

+ )} + + {error ?

{error}

: null} + +
+ +
+
+
+ ); +} + +/** Detect dispatch / operate failures that mean the user must claim. */ +export function isIdentityClaimRequiredMessage(message: string | null | undefined): boolean { + if (!message) return false; + const lower = message.toLowerCase(); + return ( + lower.includes("identity_claim_required") || + lower.includes("choose who you are") || + lower.includes("identity claim") + ); +} diff --git a/apps/web/src/components/identity/ParticipantStack.logic.test.ts b/apps/web/src/components/identity/ParticipantStack.logic.test.ts new file mode 100644 index 00000000000..2f9e4af99ba --- /dev/null +++ b/apps/web/src/components/identity/ParticipantStack.logic.test.ts @@ -0,0 +1,28 @@ +import { IdentityUsername, PersonId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { participantDisplayLabel } from "./ParticipantStack.logic"; + +describe("participantDisplayLabel", () => { + it("shows all devices for one person in first-seen order", () => { + expect( + participantDisplayLabel({ + personId: PersonId.make("patroza"), + username: IdentityUsername.make("patroza"), + firstChannel: "desktop", + channels: ["desktop", "discord"], + firstParticipatedAt: "2026-07-30T12:00:00.000Z", + }), + ).toBe("patroza@desktop,discord"); + }); + + it("supports summaries persisted before channel lists were added", () => { + expect( + participantDisplayLabel({ + personId: PersonId.make("patroza"), + username: IdentityUsername.make("patroza"), + firstChannel: "discord", + firstParticipatedAt: "2026-07-30T12:00:00.000Z", + }), + ).toBe("patroza@discord"); + }); +}); diff --git a/apps/web/src/components/identity/ParticipantStack.logic.ts b/apps/web/src/components/identity/ParticipantStack.logic.ts new file mode 100644 index 00000000000..0a2a9024810 --- /dev/null +++ b/apps/web/src/components/identity/ParticipantStack.logic.ts @@ -0,0 +1,7 @@ +import type { ThreadParticipantSummary } from "@t3tools/contracts"; + +export function participantDisplayLabel(person: ThreadParticipantSummary): string { + const channels = + person.channels ?? (person.firstChannel === undefined ? [] : [person.firstChannel]); + return channels.length === 0 ? person.username : `${person.username}@${channels.join(",")}`; +} diff --git a/apps/web/src/components/identity/ParticipantStack.tsx b/apps/web/src/components/identity/ParticipantStack.tsx new file mode 100644 index 00000000000..979e6a2d4c1 --- /dev/null +++ b/apps/web/src/components/identity/ParticipantStack.tsx @@ -0,0 +1,139 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + claimPersonIdForEnvironment, + isClaimedNonStarterParticipant, +} from "@t3tools/client-runtime/state/identity"; +import type { ThreadParticipantSummary } from "@t3tools/contracts"; +import { identityClaimPersonIdByEnvironmentAtom } from "../../state/identity"; +import { IdentityAvatar } from "./IdentityAvatar"; +import { participantDisplayLabel } from "./ParticipantStack.logic"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "~/lib/utils"; + +/** + * Creator face + +N extras for thread list rows. + * Hover/focus expands remaining participants (design: participant stack). + */ +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; + + const stack = ( + + + {extras.length > 0 ? ( + + +{extras.length} + + ) : null} + + ); + + return ( + + + + {people.map((person) => ( + + + + {participantDisplayLabel(person)} + {person.personId === claimPersonId ? ( + · You + ) : 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} + + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index ca45ec74ecd..30619db4fde 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1445,6 +1445,7 @@ function SavedBackendListRow({
@@ -3021,6 +3022,7 @@ export function ConnectionsSettings() { primaryServerUpdateState.status !== "idle" ? ( ) : primaryVersionMismatch ? ( diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index c5c35ad4d35..f59226bb5a8 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -14,7 +14,6 @@ import { shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "../desktopUpdate.logic"; -import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Separator } from "../ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -81,7 +80,11 @@ export function SidebarUpdatePill() { .downloadUpdate() .then((result) => { if (result.completed) { - showDesktopUpdateDownloadedToast(bridge, result.state); + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); diff --git a/apps/web/src/forkSurfaceExistence.test.ts b/apps/web/src/forkSurfaceExistence.test.ts index 0a4d03a10d1..98990a0b463 100644 --- a/apps/web/src/forkSurfaceExistence.test.ts +++ b/apps/web/src/forkSurfaceExistence.test.ts @@ -130,4 +130,47 @@ describe("fork surface existence (anti stack-drop)", () => { expect(chips).toContain('aria-label="Edit queued message"'); expect(chips).toContain("Steer: send now, interrupting the current step"); }); + + it("identity claim gate and participant stack surfaces exist", () => { + const gate = readSrc("components/identity/IdentityClaimGate.tsx"); + expect(gate).toContain('data-testid="identity-claim-gate"'); + expect(gate).toContain("Who are you?"); + expect(gate).toContain("identity-claim-suggestions"); + expect(gate).toContain("Save identity"); + expect(gate).toContain("requestIdentityClaimGate"); + expect(gate).toContain("isIdentityClaimRequiredMessage"); + // Multi-env: claim gate must not only target primary (smart-without-map + t3vm). + expect(gate).toContain("forceClaimEnvironmentId"); + expect(gate).toContain("orderedEnvironmentIds"); + const stack = readSrc("components/identity/ParticipantStack.tsx"); + expect(stack).toContain('data-testid="participant-stack"'); + expect(stack).toContain('data-testid="participant-stack-popup"'); + expect(stack).toContain(" { shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", ); - assert.strictEqual( - shortcutLabelForCommand(DEFAULT_BINDINGS, "filePicker.toggle", "MacIntel"), - "⌘P", - ); - assert.strictEqual( - shortcutLabelForCommand(DEFAULT_BINDINGS, "projectSearch.toggle", "MacIntel"), - "⇧⌘F", - ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), "Ctrl+Shift+M", @@ -542,40 +524,6 @@ describe("chat/editor shortcuts", () => { ); }); - it("matches filePicker.toggle shortcut outside terminal focus", () => { - assert.strictEqual( - resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { terminalFocus: false }, - }), - "filePicker.toggle", - ); - assert.notStrictEqual( - resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { terminalFocus: true }, - }), - "filePicker.toggle", - ); - }); - - it("matches projectSearch.toggle shortcut outside terminal focus", () => { - assert.strictEqual( - resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { terminalFocus: false }, - }), - "projectSearch.toggle", - ); - assert.notStrictEqual( - resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { terminalFocus: true }, - }), - "projectSearch.toggle", - ); - }); - it("matches diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c07ac97e5fa..32f2120b131 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -19,6 +19,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { OmegentDeepLinkCoordinator } from "../components/OmegentDeepLinkCoordinator"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; +import { IdentityClaimGate } from "../components/identity/IdentityClaimGate"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { hasThreadDeepLinkIntent } from "../deepLinkStore"; import { Button } from "../components/ui/button"; @@ -139,6 +140,10 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {/* Claim gate: primary auth OR hosted-static (paired remotes still need identity). */} + {primaryEnvironmentAuthenticated || authGateState.status === "hosted-static" ? ( + + ) : null} {appShell} diff --git a/apps/web/src/state/identity.ts b/apps/web/src/state/identity.ts new file mode 100644 index 00000000000..2f808489cba --- /dev/null +++ b/apps/web/src/state/identity.ts @@ -0,0 +1,34 @@ +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 per connected environment. + * + * Ownership filters must key by the *thread's* environment, not primary. + * Desktop primary=smart (no map) while secondary=t3vm (claimed) must still + * Mine-filter t3vm threads correctly. + */ +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("web-identity-claim-person-by-environment")); diff --git a/docs/user/jira-issue-conversations.md b/docs/user/jira-issue-conversations.md index 5688d9681ca..35cd68e887b 100644 --- a/docs/user/jira-issue-conversations.md +++ b/docs/user/jira-issue-conversations.md @@ -181,9 +181,35 @@ are logged and never block the turn. Responses are posted as issue comments authored by the service account, preferably as a **threaded reply** under the triggering mention (REST body field `parentId` — supported on Jira -Cloud even though it is lightly documented). When the user mentioned the bot inside an existing -reply thread, the bridge parents under that thread’s **root** (Jira rejects nesting under a child). -If threading is rejected (invalid parent), the bridge falls back once to a top-level comment. +Cloud even though it is lightly documented). The body **@-mentions** the human requester via an ADF `mention` node: + +```json +{ + "type": "mention", + "attrs": { + "id": "", + "text": "@Display Name", + "accessLevel": "" + } +} +``` + +`attrs.id` is the **bare** account id from the webhook author (e.g. `6331c323…` or +`712020:uuid`) — the same shape returned by GET comment bodies on this site — not the wiki +`[~accountid:…]` form and not plain `@Name` text alone. + +**Inline threading (required for reliable replies):** Jira Cloud only accepts children under a +**root** comment. POST with `parentId` set to a nested reply id returns **400** +(`Parent comment not found, and no child comments exist`) — the bridge used to fall back flat. +Before posting, `JiraAppClient` GETs `/rest/api/3/issue/{key}/comment/{id}` and uses that +comment’s `parentId` when set (else the id itself). Webhooks should send either `comment.parentId` +(REST shape) or `comment.parent.id` when known; empty Automation fields are ignored. Only if every +resolved root is rejected does the bridge fall back to a top-level comment (logged as error when +the create response lacks `parentId`). + +**Busy threads:** follow-up mentions always dispatch `thread.turn.start`. Orchestration queues the +message while a turn is running; the bridge does **not** post an “already working” short-circuit. +The reply still posts asynchronously when the turn completes (`bridgeTurn` is fork-detached). Prefer Markdown converted to a minimal ADF document for API v3. Do not @-spam watchers unless the agent explicitly mentions users. diff --git a/infra/relay/README.md b/infra/relay/README.md index 0085c9c5b6b..114d5e9b07f 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -1,7 +1,7 @@ # T3 Connect Relay -> [!NOTE] -> Sign in to T3 Connect from the app under Settings > Connections. +> [!WARNING] +> T3 Connect is currently in private beta. Join the waitlist in the app under Settings > T3 Connect. The relay is the hosted control plane for T3 Connect. It helps clients discover and connect to remote environments, manages the cloud-side records needed for those connections, and delivers @@ -9,7 +9,7 @@ optional mobile notifications and Live Activities. The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects, regular API and WebSocket traffic goes directly between that client and the selected environment. -See the [T3 Connect architecture overview](../../docs/internals/t3-code-connect-auth-flow.html) for the larger system +See the [T3 Connect architecture overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the larger system design. ## Responsibilities @@ -25,7 +25,7 @@ The relay currently owns: - Persisting relay state and exposing relay-specific traces for diagnostics. The environment server and relay have separate credentials and trust boundaries. Read -[Environment Authentication Profile](../../docs/internals/environment-auth.md) before changing token, +[Environment Authentication Profile](../../docs/environment-auth.md) before changing token, credential, or authorization behavior. ## Code Map @@ -159,8 +159,8 @@ and hosted web builds. See: -- [T3 Connect Clerk Setup](../../docs/internals/t3-connect.md) for Clerk keys, JWT templates, and sign-up restrictions +- [T3 Connect Clerk Setup](../../docs/cloud/t3-connect-clerk.md) for Clerk keys, JWT templates, and waitlist setup. -- [Relay Observability](../../docs/operations/relay-observability.md) for deployment tracing and diagnostics. -- [T3 Connect Architecture Overview](../../docs/internals/t3-code-connect-auth-flow.html) for the full link, +- [Relay Observability](../../docs/relay-observability.md) for deployment tracing and diagnostics. +- [T3 Connect Architecture Overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the full link, connect, endpoint, and notification flows. diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index d840f809e5a..db662aee94d 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -13,7 +13,6 @@ import { RelayEnvironmentMintResponse, RelayEnvironmentMintResponseProofPayload, RelayCloudMintCredentialProofPayload, - RelayEnvironmentConnectNotAuthorizedReason, type RelayEnvironmentConnectResponse, type RelayEnvironmentStatusResponse, } from "@t3tools/contracts/relay"; @@ -45,8 +44,21 @@ import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; import * as RelayConfiguration from "../Config.ts"; import { isManagedEndpointHostname } from "../deploymentConfig.ts"; +export const EnvironmentConnectNotAuthorizedReason = Schema.Literals([ + "client_proof_key_thumbprint_missing", + "environment_link_not_found", + "endpoint_provider_not_managed", + "managed_endpoint_allocation_not_found", + "managed_endpoint_base_domain_not_configured", + "managed_endpoint_allocation_not_ready", + "managed_endpoint_hostname_invalid", + "managed_endpoint_mismatch", +]); +export type EnvironmentConnectNotAuthorizedReason = + typeof EnvironmentConnectNotAuthorizedReason.Type; + function environmentConnectNotAuthorizedReasonMessage( - reason: RelayEnvironmentConnectNotAuthorizedReason, + reason: EnvironmentConnectNotAuthorizedReason, ): string { switch (reason) { case "client_proof_key_thumbprint_missing": @@ -73,7 +85,7 @@ export class EnvironmentConnectNotAuthorized extends Schema.TaggedErrorClass + EnvironmentConnectNotAuthorized: (_error, traceId) => new RelayEnvironmentConnectNotAuthorizedError({ code: "environment_connect_not_authorized", - reason: error.reason, traceId, }), EnvironmentMintRequestFailed: (_error, traceId) => @@ -821,10 +820,9 @@ export const dpopClientApi = HttpApiBuilder.group( }, mapRelayCommonApiErrors("invalid_dpop"), mapErrorTags({ - EnvironmentConnectNotAuthorized: (error, traceId) => + EnvironmentConnectNotAuthorized: (_error, traceId) => new RelayEnvironmentConnectNotAuthorizedError({ code: "environment_connect_not_authorized", - reason: error.reason, traceId, }), EnvironmentMintRequestFailed: (_error, traceId) => diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 6889fe64248..695a92dc772 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -47,6 +47,10 @@ "types": "./src/state/aiUsagePresentation.ts", "default": "./src/state/aiUsagePresentation.ts" }, + "./state/identity": { + "types": "./src/state/identity.ts", + "default": "./src/state/identity.ts" + }, "./state/auth": { "types": "./src/state/auth.ts", "default": "./src/state/auth.ts" diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 147843eb589..21f25c75699 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -336,40 +336,6 @@ describe("RpcSessionFactory", () => { }), ); - it.effect("reaches ready when a newer server sends unknown config members", () => - Effect.gen(function* () { - const { factory, sockets } = yield* makeFactory(); - const session = yield* factory.connect(PREPARED); - const readyFiber = yield* Effect.forkChild(session.ready); - const socket = yield* awaitSocket(sockets); - socket.open(); - - const shortcut = { - key: "p", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }; - yield* completeInitialConfig(socket, { - ...ENCODED_SERVER_CONFIG, - keybindings: [ - { command: "someFuture.toggle", shortcut }, - { command: "terminal.toggle", shortcut }, - ], - issues: [{ kind: "keybindings.future-issue", message: "From a newer server" }], - availableEditors: ["some-future-editor", "zed"], - }); - yield* Fiber.join(readyFiber); - - const config = yield* session.initialConfig; - expect(config.keybindings).toEqual([{ command: "terminal.toggle", shortcut }]); - expect(config.issues).toEqual([]); - expect(config.availableEditors).toEqual(["zed"]); - }), - ); - it.effect("uses the legacy config RPC for probes when the server lacks the capability", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/identity.test.ts b/packages/client-runtime/src/state/identity.test.ts new file mode 100644 index 00000000000..306f81cc0f4 --- /dev/null +++ b/packages/client-runtime/src/state/identity.test.ts @@ -0,0 +1,351 @@ +import { IdentityUsername, PersonId, type ThreadParticipantSummary } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + claimPersonIdForEnvironment, + filterPeopleForTypeahead, + identityClaimRequired, + isClaimedNonStarterParticipant, + threadMatchesMine, +} from "./identity.ts"; + +describe("identityClaimRequired", () => { + it("is false when identity is off", () => { + expect( + identityClaimRequired({ enabled: false, claimRequired: false, people: [] }, { claim: null }), + ).toBe(false); + }); + + it("is true when map enabled and no claim", () => { + expect( + identityClaimRequired( + { + enabled: true, + claimRequired: true, + people: [ + { + personId: "patroza" as never, + username: "patroza" as never, + links: {}, + }, + ], + }, + { claim: null }, + ), + ).toBe(true); + }); +}); + +describe("filterPeopleForTypeahead", () => { + const people = [ + { + personId: "patroza" as never, + username: "patroza" as never, + name: "Patrick Roza", + links: {}, + }, + { + personId: "julius" as never, + username: "julius" as never, + name: "Julius", + links: {}, + }, + ]; + + it("requires min chars", () => { + expect(filterPeopleForTypeahead(people, "pa", 3)).toEqual([]); + }); + + it("matches username and name after min chars", () => { + expect(filterPeopleForTypeahead(people, "pat", 3).map((p) => p.username)).toEqual(["patroza"]); + expect(filterPeopleForTypeahead(people, "roza", 3).map((p) => p.username)).toEqual(["patroza"]); + }); +}); + +describe("threadMatchesMine", () => { + it("filters mine vs theirs when threads are person-attributed", () => { + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "patroza", + mode: "mine", + }), + ).toBe(true); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + mode: "mine", + }), + ).toBe(false); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + mode: "theirs", + }), + ).toBe(true); + }); + + it("treats unattributed threads as mine (legacy / channel-only / identity off)", () => { + // No origin or participants — old threads, { channel: "desktop" } only, etc. + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: null, + participantPersonIds: [], + mode: "mine", + }), + ).toBe(true); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: null, + participantPersonIds: [], + mode: "theirs", + }), + ).toBe(false); + // Identity-disabled env: no claim, no person tags → Mine still shows work. + expect( + threadMatchesMine({ + claimPersonId: null, + originPersonId: undefined, + participantPersonIds: undefined, + mode: "mine", + }), + ).toBe(true); + expect( + threadMatchesMine({ + claimPersonId: null, + originPersonId: undefined, + mode: "theirs", + }), + ).toBe(false); + }); + + it("treats attributed threads without a session claim as theirs", () => { + expect( + threadMatchesMine({ + claimPersonId: null, + originPersonId: "julius", + mode: "mine", + }), + ).toBe(false); + expect( + threadMatchesMine({ + claimPersonId: null, + originPersonId: "julius", + mode: "theirs", + }), + ).toBe(true); + }); + + it("includes participant-only matches as mine", () => { + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + participantPersonIds: ["patroza"], + mode: "mine", + }), + ).toBe(true); + }); + + it("supports created / participated / both relation sub-filters", () => { + // Mine: created is origin only; participated is join-without-start + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "patroza", + participantPersonIds: ["julius"], + mode: "mine", + relation: "created", + }), + ).toBe(true); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + participantPersonIds: ["patroza"], + mode: "mine", + relation: "created", + }), + ).toBe(false); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + participantPersonIds: ["patroza"], + mode: "mine", + relation: "participated", + }), + ).toBe(true); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "patroza", + participantPersonIds: [], + mode: "mine", + relation: "participated", + }), + ).toBe(false); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + participantPersonIds: ["patroza"], + mode: "mine", + relation: "both", + }), + ).toBe(true); + + // Theirs never includes threads I joined — Created is not wider than Both + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + participantPersonIds: ["patroza"], + mode: "theirs", + relation: "created", + }), + ).toBe(false); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + participantPersonIds: ["patroza"], + mode: "theirs", + relation: "both", + }), + ).toBe(false); + // Foreign thread: others started → theirs for created and both + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + participantPersonIds: ["alice"], + mode: "theirs", + relation: "created", + }), + ).toBe(true); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + participantPersonIds: ["alice"], + mode: "theirs", + relation: "both", + }), + ).toBe(true); + // Foreign with participants only (no origin): participated ⊆ both, not created + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: null, + participantPersonIds: ["julius"], + mode: "theirs", + relation: "created", + }), + ).toBe(false); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: null, + participantPersonIds: ["julius"], + mode: "theirs", + relation: "participated", + }), + ).toBe(true); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: null, + participantPersonIds: ["julius"], + mode: "theirs", + relation: "both", + }), + ).toBe(true); + + // Fully unattributed only under mine + both + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: null, + participantPersonIds: [], + mode: "mine", + relation: "created", + }), + ).toBe(false); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: null, + participantPersonIds: [], + mode: "mine", + relation: "both", + }), + ).toBe(true); + }); +}); + +describe("claimPersonIdForEnvironment", () => { + it("returns the claim for the thread environment only", () => { + const map = new Map([ + ["smart", null], + ["t3vm", "patroza"], + ]); + expect(claimPersonIdForEnvironment(map, "t3vm")).toBe("patroza"); + expect(claimPersonIdForEnvironment(map, "smart")).toBeNull(); + expect(claimPersonIdForEnvironment(map, "missing")).toBeNull(); + }); +}); + +describe("isClaimedNonStarterParticipant", () => { + const participants = [ + { + personId: PersonId.make("joshua"), + username: IdentityUsername.make("joshuadima"), + firstChannel: "discord", + firstParticipatedAt: "2026-07-30T12:00:00.000Z", + }, + { + personId: PersonId.make("patroza"), + username: IdentityUsername.make("patroza"), + firstChannel: "desktop", + firstParticipatedAt: "2026-07-30T12:01:00.000Z", + }, + ] satisfies ReadonlyArray; + + it("marks a claimed person hidden among later participants", () => { + expect( + isClaimedNonStarterParticipant({ + claimPersonId: "PATROZA", + participants, + }), + ).toBe(true); + }); + + it("does not redundantly mark the visible starter", () => { + expect( + isClaimedNonStarterParticipant({ + claimPersonId: "joshua", + participants, + }), + ).toBe(false); + }); + + it("does not mark an unclaimed or absent person", () => { + expect( + isClaimedNonStarterParticipant({ + claimPersonId: null, + participants, + }), + ).toBe(false); + expect( + isClaimedNonStarterParticipant({ + claimPersonId: "someone-else", + participants, + }), + ).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/identity.ts b/packages/client-runtime/src/state/identity.ts new file mode 100644 index 00000000000..f5af55bb837 --- /dev/null +++ b/packages/client-runtime/src/state/identity.ts @@ -0,0 +1,196 @@ +/** + * Per-environment session identity (closed-set claim against the server map). + */ +import { + WS_METHODS, + type IdentityClaimInput, + type IdentitySnapshot, + type IdentitySessionClaimResult, + type SessionIdentityClaim, + type ThreadParticipantSummary, +} from "@t3tools/contracts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { Atom } from "effect/unstable/reactivity"; +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; + +export function createIdentityEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const snapshot = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "identity-snapshot", + tag: WS_METHODS.identityGetSnapshot, + staleTimeMs: 30_000, + idleTtlMs: 60_000, + }); + + const sessionClaim = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "identity-session-claim", + tag: WS_METHODS.identityGetSessionClaim, + staleTimeMs: 5_000, + idleTtlMs: 60_000, + }); + + const claim = createEnvironmentRpcCommand(runtime, { + label: "identity-claim", + tag: WS_METHODS.identityClaim, + }); + + const clearClaim = createEnvironmentRpcCommand(runtime, { + label: "identity-clear-claim", + tag: WS_METHODS.identityClearClaim, + }); + + return { + snapshot, + sessionClaim, + claim, + clearClaim, + }; +} + +export type IdentityEnvironmentAtoms = ReturnType; + +export function identityClaimRequired( + snapshot: IdentitySnapshot | null | undefined, + claimResult: IdentitySessionClaimResult | null | undefined, +): boolean { + if (snapshot === null || snapshot === undefined) return false; + if (!snapshot.enabled || !snapshot.claimRequired) return false; + return claimResult?.claim == null; +} + +export function filterPeopleForTypeahead( + people: IdentitySnapshot["people"], + query: string, + minChars: number, +): IdentitySnapshot["people"] { + const q = query.trim().toLowerCase(); + if (q.length < minChars) return []; + return people.filter((person) => { + if (person.username.includes(q)) return true; + if (person.name?.toLowerCase().includes(q)) return true; + return false; + }); +} + +/** + * Sub-filter for Mine / Theirs. Always a *refinement* of the mode: + * - `both` (default) — created or participated (widest for that mode) + * - `created` — starter/origin role only + * - `participated` — joined without being the starter + * + * For a given mode, `both` is always a superset of `created` and of + * `participated`. Looking only at one field for Theirs used to invert that + * (Created could show more than Created-or-participated). + */ +export type OwnershipRelation = "created" | "participated" | "both"; + +export const DEFAULT_OWNERSHIP_RELATION: OwnershipRelation = "both"; + +export function isOwnershipRelation(value: unknown): value is OwnershipRelation { + return value === "created" || value === "participated" || value === "both"; +} + +/** + * Match a thread for Mine / Theirs ownership filters. + * + * **Mine** (relation `both`, default) includes: + * - threads the claim person started or later joined + * - threads with **no person attribution** (channel-only stamps, identity off, + * legacy) — treated as "ours" so filters stay useful offline of a map + * + * **Mine** + `created` / `participated` narrows to that role only (unattributed + * threads are not included). + * + * **Theirs** is attributed threads the claim person is not on. Relation then + * narrows by how *others* appear: origin set (`created`), non-starter + * participants (`participated`), or either (`both`). + */ +export function threadMatchesMine(input: { + readonly claimPersonId: string | null | undefined; + readonly originPersonId?: string | null | undefined; + readonly participantPersonIds?: ReadonlyArray | null | undefined; + readonly mode: "mine" | "theirs" | "any"; + /** Defaults to `both` (created or participated). */ + readonly relation?: OwnershipRelation; +}): boolean { + if (input.mode === "any") return true; + + const relation = input.relation ?? DEFAULT_OWNERSHIP_RELATION; + const origin = input.originPersonId?.trim().toLowerCase() ?? ""; + const participants = new Set(); + for (const id of input.participantPersonIds ?? []) { + const personId = id?.trim().toLowerCase() ?? ""; + if (personId.length > 0) participants.add(personId); + } + + const fullyUnattributed = origin.length === 0 && participants.size === 0; + // Unattributed stays under Mine only for the default "both" relation. + if (fullyUnattributed) { + return input.mode === "mine" && relation === "both"; + } + + const claimId = input.claimPersonId?.trim().toLowerCase() ?? ""; + const fullPeople = new Set(participants); + if (origin.length > 0) fullPeople.add(origin); + + // No session claim: attributed work is someone else's. + if (claimId.length === 0) { + if (input.mode === "mine") return false; + return matchesTheirsRelation({ relation, origin, participants }); + } + + const createdByMe = origin.length > 0 && origin === claimId; + // Joined without starting. Origin counts as created, not participated. + const participatedByMe = !createdByMe && fullPeople.has(claimId); + const involved = createdByMe || participatedByMe; + + if (input.mode === "mine") { + if (relation === "created") return createdByMe; + if (relation === "participated") return participatedByMe; + return involved; + } + + // Theirs: not on the thread at all, then refine by others' roles. + if (involved) return false; + return matchesTheirsRelation({ relation, origin, participants }); +} + +function matchesTheirsRelation(input: { + readonly relation: OwnershipRelation; + readonly origin: string; + readonly participants: ReadonlySet; +}): boolean { + const othersCreated = input.origin.length > 0; + // Participant list may restate the origin; any non-empty roster is enough + // for "someone participated" when we already know the claim is not on it. + const othersParticipated = input.participants.size > 0; + if (input.relation === "created") return othersCreated; + if (input.relation === "participated") return othersParticipated; + return othersCreated || othersParticipated; +} + +/** Whether the claimed person participated after someone else started the thread. */ +export function isClaimedNonStarterParticipant(input: { + readonly claimPersonId: string | null | undefined; + readonly participants: ReadonlyArray; +}): boolean { + const claimId = input.claimPersonId?.trim().toLowerCase() ?? ""; + if (claimId.length === 0) return false; + return input.participants + .slice(1) + .some((participant) => participant.personId.trim().toLowerCase() === claimId); +} + +/** Look up the claim person for a thread's environment (multi-env clients). */ +export function claimPersonIdForEnvironment( + claimPersonIdByEnvironment: ReadonlyMap, + environmentId: string, +): string | null { + const value = claimPersonIdByEnvironment.get(environmentId); + if (value === undefined || value === null) return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export type { IdentityClaimInput, IdentitySnapshot, SessionIdentityClaim }; diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 11ab7df36d7..553491c4b1b 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -4,7 +4,9 @@ import { CheckpointRef, CommandId, EventId, + IdentityUsername, MessageId, + PersonId, ProjectId, ProviderInstanceId, ThreadId, @@ -304,6 +306,41 @@ describe("applyThreadDetailEvent", () => { } }); + it("preserves server-authored source attribution on live messages", () => { + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 7, + occurredAt: "2026-04-01T06:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.message-sent", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("msg-sourced"), + role: "user", + text: "Sent from desktop", + turnId: null, + streaming: false, + source: { + channel: "desktop", + personId: PersonId.make("patroza"), + username: IdentityUsername.make("patroza"), + }, + createdAt: "2026-04-01T06:01:00.000Z", + updatedAt: "2026-04-01T06:01:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages[0]?.source).toEqual({ + channel: "desktop", + personId: "patroza", + username: "patroza", + }); + } + }); + it("appends text for streaming messages", () => { const threadWithMessage: OrchestrationThread = { ...baseThread, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 69a2964a8e6..3b6dc3e6f9a 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -288,6 +288,7 @@ export function applyThreadDetailEvent( ...(event.payload.attachments !== undefined ? { attachments: event.payload.attachments } : {}), + ...(event.payload.source !== undefined ? { source: event.payload.source } : {}), turnId: event.payload.turnId, streaming: event.payload.streaming, createdAt: event.payload.createdAt, @@ -312,6 +313,9 @@ export function applyThreadDetailEvent( ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(entry.source === undefined && message.source !== undefined + ? { source: message.source } + : {}), }, ) : Arr.append(thread.messages, message); @@ -389,6 +393,7 @@ export function applyThreadDetailEvent( ...(event.payload.sourceProposedPlan !== undefined ? { sourceProposedPlan: event.payload.sourceProposedPlan } : {}), + ...(event.payload.source !== undefined ? { source: event.payload.source } : {}), queuedAt: event.payload.queuedAt, }; return { diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2d40dad60cc..f95fb82808a 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -56,6 +56,9 @@ export const EnvironmentRequestInvalidReason = Schema.Literals([ "invalid_scope", "scope_not_granted", "invalid_command", + "identity_claim_required", + "identity_unknown_person", + "identity_map_invalid", ]); export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type; diff --git a/packages/contracts/src/identity.test.ts b/packages/contracts/src/identity.test.ts new file mode 100644 index 00000000000..5c7d50231bf --- /dev/null +++ b/packages/contracts/src/identity.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { + IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS, + IDENTITY_HANDLE_SOFT_MAX_LENGTH, + ClientSourceHint, + IdentityClaimInput, + IdentityError, + IdentityPersonPublic, + IdentitySnapshot, + IdentityUsername, + PersonId, + SessionIdentityClaim, + SourceRef, + ThreadParticipantSummary, +} from "./identity.ts"; +import { AuthSessionId } from "./baseSchemas.ts"; + +const decodeUsername = Schema.decodeUnknownSync(IdentityUsername); +const decodePersonId = Schema.decodeUnknownSync(PersonId); +const decodeSourceRef = Schema.decodeUnknownSync(SourceRef); +const decodeClientHint = Schema.decodeUnknownSync(ClientSourceHint); +const decodeSnapshot = Schema.decodeUnknownSync(IdentitySnapshot); +const decodeClaimInput = Schema.decodeUnknownSync(IdentityClaimInput); +const decodeClaim = Schema.decodeUnknownSync(SessionIdentityClaim); +const decodePerson = Schema.decodeUnknownSync(IdentityPersonPublic); +const decodeParticipant = Schema.decodeUnknownSync(ThreadParticipantSummary); + +describe("IdentityUsername / PersonId handles", () => { + it.each(["a", "pat", "patroza", "a_b-c", "julius", "user.name", "x1"])("accepts %s", (value) => { + expect(decodeUsername(value)).toBe(value.toLowerCase()); + expect(decodePersonId(value)).toBe(value.toLowerCase()); + }); + + it("normalizes case to lowercase", () => { + expect(decodeUsername("PatRoza")).toBe("patroza"); + expect(decodePersonId("PatRoza")).toBe("patroza"); + }); + + it("accepts usernames longer than 16 chars within soft max", () => { + const long = `a${"b".repeat(40)}`; + expect(decodeUsername(long)).toBe(long); + }); + + it.each([ + ["empty", ""], + ["spaces", "pat roza"], + ["control char", "foo\nbar"], + ["leading dash", "-pat"], + ["leading underscore", "_pat"], + ["at-sign", "pat@roza"], + ["leading dot", ".pat"], + ])("rejects %s", (_label, value) => { + expect(() => decodeUsername(value)).toThrow(); + expect(() => decodePersonId(value)).toThrow(); + }); + + it("rejects past soft max", () => { + expect(() => decodeUsername("a".repeat(IDENTITY_HANDLE_SOFT_MAX_LENGTH + 1))).toThrow(); + }); + + it("exports typeahead threshold of 3 characters", () => { + expect(IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS).toBe(3); + }); +}); + +describe("SourceRef vs ClientSourceHint", () => { + it("decodes a server stamp with person", () => { + const parsed = decodeSourceRef({ + channel: "desktop", + personId: "patroza", + username: "patroza", + }); + expect(parsed.channel).toBe("desktop"); + expect(parsed.personId).toBe("patroza"); + }); + + it("client hint has no person fields", () => { + const hint = decodeClientHint({ + channel: "discord", + location: { guildId: "1", channelId: "2" }, + actor: { platformId: "9", displayName: "Patrick" }, + }); + expect(hint.channel).toBe("discord"); + expect("personId" in hint).toBe(false); + }); + + it("rejects unknown channel", () => { + expect(() => decodeSourceRef({ channel: "irc" })).toThrow(); + }); +}); + +describe("IdentitySnapshot + claim", () => { + it("decodes an enabled map snapshot", () => { + const parsed = decodeSnapshot({ + enabled: true, + claimRequired: true, + people: [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + links: { + discordId: "95218063095377920", + githubLogin: "patroza", + }, + }, + ], + }); + expect(parsed.enabled).toBe(true); + expect(parsed.people[0]?.username).toBe("patroza"); + }); + + it("defaults empty links on person", () => { + const person = decodePerson({ + personId: PersonId.make("julius"), + username: "julius", + }); + expect(person.links).toEqual({}); + }); + + it("accepts claim by username or personId with optional method", () => { + expect(decodeClaimInput({ username: "patroza" })).toEqual({ username: "patroza" }); + expect(decodeClaimInput({ personId: "patroza", method: "settings" })).toEqual({ + personId: "patroza", + method: "settings", + }); + }); + + it("decodes a session claim", () => { + const claim = decodeClaim({ + sessionId: AuthSessionId.make("00000000-0000-4000-8000-000000000001"), + personId: "patroza", + username: "patroza", + claimedAt: "2026-07-30T12:00:00.000Z", + method: "typeahead", + }); + expect(claim.method).toBe("typeahead"); + }); + + it("decodes participant summary", () => { + const row = decodeParticipant({ + personId: "patroza", + username: "patroza", + firstChannel: "discord", + channels: ["discord", "desktop"], + firstParticipatedAt: "2026-07-30T12:00:00.000Z", + }); + expect(row.firstChannel).toBe("discord"); + expect(row.channels).toEqual(["discord", "desktop"]); + }); + + it("constructs IdentityError codes", () => { + const err = new IdentityError({ + code: "identity_unknown_person", + message: "not in map", + }); + expect(err.code).toBe("identity_unknown_person"); + }); +}); diff --git a/packages/contracts/src/identity.ts b/packages/contracts/src/identity.ts new file mode 100644 index 00000000000..e9d578ac4af --- /dev/null +++ b/packages/contracts/src/identity.ts @@ -0,0 +1,210 @@ +/** + * Session identity + message/thread source attribution. + * + * Closed-set people come from a server identity map file. Interactive clients + * claim a map person on their auth session; free-form usernames are rejected. + * + * Trust note (v1): interactive claim is **map membership only** — any paired + * session can claim any listed person. That is intentional for trusted-team + * shared environments, not anti-impersonation. “Mine” is claim-based and + * spoofable by peers with a session. Discord/Jira auto-claim binds via platform id. + * + * See docs/architecture/source-and-identity.md + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; +import { AuthSessionId, TrimmedNonEmptyString, IsoDateTime } from "./baseSchemas.ts"; + +// ── Username / person ────────────────────────────────────────── + +/** + * Soft max for wire abuse only — not a product length rule. + * Charset keeps handles safe for `user@channel` display and logs. + */ +export const IDENTITY_HANDLE_SOFT_MAX_LENGTH = 128; + +/** Minimum typed characters before the claim UI shows map suggestions. */ +export const IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS = 3; + +/** + * Handle charset: leading alnum, then alnum / `.` / `_` / `-`. + * No spaces or control chars. No minimum length product rule (single char OK). + */ +export const IDENTITY_HANDLE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; + +const normalizeHandle = (value: string) => value.trim().toLowerCase(); + +const IdentityHandleString = TrimmedNonEmptyString.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(normalizeHandle(value)), + encode: (value) => Effect.succeed(value), + }), + ), +).check( + Schema.isMaxLength(IDENTITY_HANDLE_SOFT_MAX_LENGTH), + Schema.isPattern(IDENTITY_HANDLE_PATTERN), +); + +export const IdentityUsername = IdentityHandleString.pipe(Schema.brand("IdentityUsername")); +export type IdentityUsername = typeof IdentityUsername.Type; + +/** Same normalization as username so mine/theirs compares stay case-stable. */ +export const PersonId = IdentityHandleString.pipe(Schema.brand("PersonId")); +export type PersonId = typeof PersonId.Type; + +// ── Channels / SourceRef ─────────────────────────────────────── + +export const SourceChannel = Schema.Literals([ + "desktop", + "vscode", + "web", + "mobile", + "discord", + "github", + "jira", + "slack", + "teams", + "bot", + "unknown", +]); +export type SourceChannel = typeof SourceChannel.Type; + +export const SourceLocation = Schema.Struct({ + guildId: Schema.optionalKey(TrimmedNonEmptyString), + channelId: Schema.optionalKey(TrimmedNonEmptyString), + threadId: Schema.optionalKey(TrimmedNonEmptyString), + owner: Schema.optionalKey(TrimmedNonEmptyString), + repo: Schema.optionalKey(TrimmedNonEmptyString), + number: Schema.optionalKey(Schema.Int), + kind: Schema.optionalKey(Schema.Literals(["pr", "issue"])), + projectKey: Schema.optionalKey(TrimmedNonEmptyString), + issueKey: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type SourceLocation = typeof SourceLocation.Type; + +export const SourceActor = Schema.Struct({ + platformId: Schema.optionalKey(TrimmedNonEmptyString), + displayName: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type SourceActor = typeof SourceActor.Type; + +/** + * Client may only hint non-person fields. Server stamps person from the + * session claim (or platform map for bots). Never trust client personId/username. + */ +export const ClientSourceHint = Schema.Struct({ + channel: Schema.optionalKey(SourceChannel), + location: Schema.optionalKey(SourceLocation), + actor: Schema.optionalKey(SourceActor), +}); +export type ClientSourceHint = typeof ClientSourceHint.Type; + +/** + * Server-authored provenance for a user-originated message / thread origin. + * personId/username absent only when an external actor is unmapped. + */ +export const SourceRef = Schema.Struct({ + channel: SourceChannel, + personId: Schema.optionalKey(PersonId), + username: Schema.optionalKey(IdentityUsername), + location: Schema.optionalKey(SourceLocation), + actor: Schema.optionalKey(SourceActor), +}); +export type SourceRef = typeof SourceRef.Type; + +/** Ordered participant on a thread shell (origin first when known). */ +export const ThreadParticipantSummary = Schema.Struct({ + personId: PersonId, + username: IdentityUsername, + name: Schema.optionalKey(TrimmedNonEmptyString), + firstChannel: Schema.optionalKey(SourceChannel), + channels: Schema.optionalKey(Schema.Array(SourceChannel)), + firstParticipatedAt: IsoDateTime, +}); +export type ThreadParticipantSummary = typeof ThreadParticipantSummary.Type; + +// ── Public identity map (client-safe) ────────────────────────── + +export const IdentityPlatformLinkPublic = Schema.Struct({ + discordId: Schema.optionalKey(TrimmedNonEmptyString), + discordUsername: Schema.optionalKey(TrimmedNonEmptyString), + githubLogin: Schema.optionalKey(TrimmedNonEmptyString), + jiraAccountId: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type IdentityPlatformLinkPublic = typeof IdentityPlatformLinkPublic.Type; + +export const IdentityPersonPublic = Schema.Struct({ + personId: PersonId, + username: IdentityUsername, + name: Schema.optionalKey(TrimmedNonEmptyString), + links: IdentityPlatformLinkPublic.pipe(Schema.withDecodingDefault(Effect.succeed({}))), +}); +export type IdentityPersonPublic = typeof IdentityPersonPublic.Type; + +/** + * Snapshot of the closed identity set. + * v1: `claimRequired === enabled` (both true when map has people). + * Full people[] is intentional roster share for typeahead (not privacy isolation). + */ +export const IdentitySnapshot = Schema.Struct({ + /** False when map file missing/empty — no claim gate. */ + enabled: Schema.Boolean, + people: Schema.Array(IdentityPersonPublic), + /** v1 always equals `enabled`. */ + claimRequired: Schema.Boolean, +}); +export type IdentitySnapshot = typeof IdentitySnapshot.Type; + +export const SessionIdentityClaimMethod = Schema.Literals([ + "typeahead", + "settings", + "auto-discord", + "auto-jira", + "bootstrap", +]); +export type SessionIdentityClaimMethod = typeof SessionIdentityClaimMethod.Type; + +export const SessionIdentityClaim = Schema.Struct({ + sessionId: AuthSessionId, + personId: PersonId, + username: IdentityUsername, + claimedAt: IsoDateTime, + method: SessionIdentityClaimMethod, +}); +export type SessionIdentityClaim = typeof SessionIdentityClaim.Type; + +export const IdentityClaimInput = Schema.Union([ + Schema.Struct({ + personId: PersonId, + method: Schema.optionalKey(Schema.Literals(["typeahead", "settings", "bootstrap"])), + }), + Schema.Struct({ + username: IdentityUsername, + method: Schema.optionalKey(Schema.Literals(["typeahead", "settings", "bootstrap"])), + }), +]); +export type IdentityClaimInput = typeof IdentityClaimInput.Type; + +export const IdentityClaimResult = Schema.Struct({ + claim: SessionIdentityClaim, +}); +export type IdentityClaimResult = typeof IdentityClaimResult.Type; + +export const IdentitySessionClaimResult = Schema.Struct({ + claim: Schema.NullOr(SessionIdentityClaim), +}); +export type IdentitySessionClaimResult = typeof IdentitySessionClaimResult.Type; + +export class IdentityError extends Schema.TaggedErrorClass()("IdentityError", { + code: Schema.Literals([ + "identity_map_disabled", + "identity_unknown_person", + "identity_claim_required", + "identity_claim_missing", + "identity_map_invalid", + ]), + message: Schema.String, +}) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d7064a7cf62..579815e8ad4 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,5 +1,6 @@ export * from "./baseSchemas.ts"; export * from "./auth.ts"; +export * from "./identity.ts"; export * from "./environment.ts"; export * from "./environmentHttp.ts"; export * from "./relayClient.ts"; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index ecf7afa0610..cc35dc4e415 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -659,11 +659,13 @@ it.effect("accepts an internal title regeneration completion", () => threadId: "thread-1", requestId: "cmd-title-regenerate", title: "Updated title", + createdAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.type, "thread.title.regeneration.complete"); if (parsed.type === "thread.title.regeneration.complete") { assert.strictEqual(parsed.requestId, "cmd-title-regenerate"); assert.strictEqual(parsed.title, "Updated title"); + assert.strictEqual(parsed.createdAt, "2026-01-01T00:00:00.000Z"); } }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b9440e12051..5601fd7b90d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -22,6 +22,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { ClientSourceHint, SourceRef, ThreadParticipantSummary } from "./identity.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -234,6 +235,8 @@ export const OrchestrationMessage = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + /** Server-authored provenance; absent on legacy / assistant messages. */ + source: Schema.optional(SourceRef), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -361,6 +364,8 @@ export const OrchestrationQueuedMessage = Schema.Struct({ attachments: Schema.Array(ChatAttachment), modelSelection: Schema.optional(ModelSelection), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + /** Server-stamped at enqueue; preserved when the queue drains to message-sent. */ + source: Schema.optional(SourceRef), queuedAt: IsoDateTime, }); export type OrchestrationQueuedMessage = typeof OrchestrationQueuedMessage.Type; @@ -420,6 +425,9 @@ export const OrchestrationThread = Schema.Struct({ hasMoreActivities: Schema.optional(Schema.Boolean), checkpoints: Schema.Array(OrchestrationCheckpointSummary), session: Schema.NullOr(OrchestrationSession), + originSource: Schema.optional(Schema.NullOr(SourceRef)), + // Optional without default so legacy fixtures omit the field; clients use ?? []. + participantSummaries: Schema.optional(Schema.Array(ThreadParticipantSummary)), }); export type OrchestrationThread = typeof OrchestrationThread.Type; @@ -470,6 +478,13 @@ export const OrchestrationThreadShell = Schema.Struct({ hasPendingApprovals: Schema.Boolean, hasPendingUserInput: Schema.Boolean, hasActionableProposedPlan: Schema.Boolean, + /** First user message SourceRef; null/absent on legacy threads. */ + originSource: Schema.optional(Schema.NullOr(SourceRef)), + /** + * Distinct people on user messages: origin person first, then first-participation order. + * Used for creator + +N participant stack. Absent on legacy shells (clients use ?? []). + */ + participantSummaries: Schema.optional(Schema.Array(ThreadParticipantSummary)), }); export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; @@ -739,6 +754,17 @@ export const ThreadTurnStartCommand = Schema.Struct({ ), bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + /** + * Server-authored only. Gate layer stamps from session claim + deviceType + * or resolves platform actors from `sourceHint` (bots / integrations). + * Clients must not send trusted person fields. + */ + source: Schema.optional(SourceRef), + /** + * Non-person hints from trusted integrations (Discord bot, etc.). + * Server resolves personId via the identity map; never trusts client person fields. + */ + sourceHint: Schema.optional(ClientSourceHint), createdAt: IsoDateTime, }); @@ -758,6 +784,8 @@ const ClientThreadTurnStartCommand = Schema.Struct({ interactionMode: ProviderInteractionMode, bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + /** Platform actor/location only — server stamps person from the identity map. */ + sourceHint: Schema.optional(ClientSourceHint), createdAt: IsoDateTime, }); @@ -982,6 +1010,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ threadId: ThreadId, requestId: CommandId, title: Schema.optional(TrimmedNonEmptyString), + createdAt: IsoDateTime, }); /** @@ -1184,6 +1213,8 @@ export const ThreadMessageSentPayload = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + /** Server-authored only; clients must not invent person fields. */ + source: Schema.optional(SourceRef), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -1195,6 +1226,8 @@ export const ThreadMessageQueuedPayload = Schema.Struct({ attachments: Schema.Array(ChatAttachment), modelSelection: Schema.optional(ModelSelection), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + /** Server-stamped provenance for the queued user message. */ + source: Schema.optional(SourceRef), queuedAt: IsoDateTime, }); @@ -1704,6 +1737,8 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass()( "RelayEnvironmentConnectNotAuthorizedError", { code: Schema.Literal("environment_connect_not_authorized"), - // Optional so responses from relays deployed before the reason was - // threaded through still decode. - reason: Schema.optional(RelayEnvironmentConnectNotAuthorizedReason), traceId: TrimmedNonEmptyString, }, { httpApiStatus: 403 }, ) { override get message(): string { - return this.reason - ? `Relay environment connection is not authorized: ${this.reason}` - : "Relay environment connection is not authorized"; + return "Relay environment connection is not authorized"; } } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ad7afc32277..3b2f894c038 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -8,6 +8,13 @@ import { AuthAccessStreamEvent, EnvironmentAuthorizationError, } from "./auth.ts"; +import { + IdentityClaimInput, + IdentityClaimResult, + IdentityError, + IdentitySessionClaimResult, + IdentitySnapshot, +} from "./identity.ts"; import { BackgroundPolicySnapshot, ClientActivityReportInput, @@ -259,6 +266,12 @@ export const WS_METHODS = { cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", + // Session identity (closed-set map claim) + identityGetSnapshot: "identity.getSnapshot", + identityGetSessionClaim: "identity.getSessionClaim", + identityClaim: "identity.claim", + identityClearClaim: "identity.clearClaim", + // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", @@ -426,6 +439,30 @@ export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRela stream: true, }); +export const WsIdentityGetSnapshotRpc = Rpc.make(WS_METHODS.identityGetSnapshot, { + payload: Schema.Struct({}), + success: IdentitySnapshot, + error: Schema.Union([IdentityError, EnvironmentAuthorizationError]), +}); + +export const WsIdentityGetSessionClaimRpc = Rpc.make(WS_METHODS.identityGetSessionClaim, { + payload: Schema.Struct({}), + success: IdentitySessionClaimResult, + error: Schema.Union([IdentityError, EnvironmentAuthorizationError]), +}); + +export const WsIdentityClaimRpc = Rpc.make(WS_METHODS.identityClaim, { + payload: IdentityClaimInput, + success: IdentityClaimResult, + error: Schema.Union([IdentityError, EnvironmentAuthorizationError]), +}); + +export const WsIdentityClearClaimRpc = Rpc.make(WS_METHODS.identityClearClaim, { + payload: Schema.Struct({}), + success: Schema.Struct({ cleared: Schema.Boolean }), + error: Schema.Union([IdentityError, EnvironmentAuthorizationError]), +}); + export const WsServerReportClientActivityRpc = Rpc.make(WS_METHODS.serverReportClientActivity, { payload: ClientActivityReportInput, error: EnvironmentAuthorizationError, @@ -880,6 +917,10 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetBackgroundPolicyRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, + WsIdentityGetSnapshotRpc, + WsIdentityGetSessionClaimRpc, + WsIdentityClaimRpc, + WsIdentityClearClaimRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 078e9fcbf33..c906f86f4dc 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,11 +1,9 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { ServerConfig, ServerProvider, ServerUpsertKeybindingResult } from "./server.ts"; +import { ServerProvider } from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); -const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); -const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors); describe("ServerProvider", () => { it("defaults capability arrays when decoding provider snapshots", () => { @@ -74,25 +72,3 @@ describe("ServerProvider", () => { expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); }); }); - -describe("server config forward compatibility", () => { - it("drops config issues with kinds this build does not know", () => { - const parsed = decodeUpsertKeybindingResult({ - keybindings: [], - issues: [ - { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 }, - { kind: "keybindings.future-issue", message: "From a newer server" }, - ], - }); - - expect(parsed.issues).toEqual([ - { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 }, - ]); - }); - - it("drops editor ids this build does not know", () => { - const parsed = decodeAvailableEditors(["zed", "some-future-editor", "vscode"]); - - expect(parsed).toEqual(["zed", "vscode"]); - }); -}); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index cff26e0a3ba..6ddfa3cdb67 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -3,7 +3,6 @@ import * as Schema from "effect/Schema"; import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts"; import { ServerAuthDescriptor } from "./auth.ts"; import { - ForwardCompatibleArray, IsoDateTime, NonNegativeInt, PositiveInt, @@ -39,9 +38,7 @@ export const ServerConfigIssue = Schema.Union([ ]); export type ServerConfigIssue = typeof ServerConfigIssue.Type; -// Issue kinds grow over time; older clients must not fail the whole config -// decode over a kind they cannot render. -const ServerConfigIssues = ForwardCompatibleArray(ServerConfigIssue); +const ServerConfigIssues = Schema.Array(ServerConfigIssue); export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]); export type ServerProviderState = typeof ServerProviderState.Type; @@ -420,9 +417,7 @@ export const ServerConfig = Schema.Struct({ keybindings: ResolvedKeybindingsConfig, issues: ServerConfigIssues, providers: ServerProviders, - // Editor ids grow over time; drop ones this build does not know rather than - // failing the whole config decode. - availableEditors: ForwardCompatibleArray(EditorId), + availableEditors: Schema.Array(EditorId), observability: ServerObservability, settings: ServerSettings, /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ diff --git a/packages/shared/package.json b/packages/shared/package.json index 4214d57b953..ff115b6e9b7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -99,6 +99,22 @@ "types": "./src/String.ts", "import": "./src/String.ts" }, + "./identityAvatar": { + "types": "./src/identityAvatar.ts", + "import": "./src/identityAvatar.ts" + }, + "./identityMap": { + "types": "./src/identityMap.ts", + "import": "./src/identityMap.ts" + }, + "./sourceAttribution": { + "types": "./src/sourceAttribution.ts", + "import": "./src/sourceAttribution.ts" + }, + "./threadAttributeSearch": { + "types": "./src/threadAttributeSearch.ts", + "import": "./src/threadAttributeSearch.ts" + }, "./projectScripts": { "types": "./src/projectScripts.ts", "import": "./src/projectScripts.ts" diff --git a/packages/shared/src/identityAvatar.test.ts b/packages/shared/src/identityAvatar.test.ts new file mode 100644 index 00000000000..d7480c89a24 --- /dev/null +++ b/packages/shared/src/identityAvatar.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + identityAvatar, + identityAvatarColors, + identityInitials, + IDENTITY_AVATAR_PALETTE, +} from "./identityAvatar.ts"; + +describe("identityInitials", () => { + it("uses two words from display name", () => { + expect(identityInitials({ username: "patroza", name: "Patrick Roza" })).toBe("PR"); + }); + + it("uses first two letters of a single name token", () => { + expect(identityInitials({ name: "Julius" })).toBe("JU"); + }); + + it("falls back to username", () => { + expect(identityInitials({ username: "patroza" })).toBe("PA"); + }); + + it("handles short username", () => { + expect(identityInitials({ username: "ab" })).toBe("AB"); + expect(identityInitials({ username: "x" })).toBe("X"); + }); + + it("returns ? when empty", () => { + expect(identityInitials({})).toBe("?"); + expect(identityInitials({ username: " ", name: "" })).toBe("?"); + }); + + it("handles CJK name without surrogate splits", () => { + expect(identityInitials({ name: "田中 太郎" })).toBe("田太"); + }); + + it("handles CJK username", () => { + expect(identityInitials({ username: "田中" })).toBe("田中"); + }); + + it("skips emoji-only name to username when possible", () => { + // emoji has no L/N letters — falls through to code points of name + const initials = identityInitials({ name: "😀😀", username: "pat" }); + expect(initials.length).toBeGreaterThan(0); + expect(initials).not.toMatch(/[\uD800-\uDFFF]/u); + }); +}); + +describe("identityAvatarColors", () => { + it("is deterministic for the same seed", () => { + expect(identityAvatarColors("patroza")).toEqual(identityAvatarColors("patroza")); + }); + + it("varies across different seeds when possible", () => { + const a = identityAvatarColors("patroza"); + const b = identityAvatarColors("julius"); + expect(IDENTITY_AVATAR_PALETTE).toContainEqual(a); + expect(IDENTITY_AVATAR_PALETTE).toContainEqual(b); + }); +}); + +describe("identityAvatar", () => { + it("combines initials, label, and colors", () => { + const avatar = identityAvatar({ + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + }); + expect(avatar.initials).toBe("PR"); + expect(avatar.label).toBe("Patrick Roza"); + expect(avatar.backgroundColor).toMatch(/^#/); + expect(avatar.color).toBe("#FFFFFF"); + }); + + it("keeps color seed on personId when username changes", () => { + const a = identityAvatar({ personId: "p1", username: "old" }); + const b = identityAvatar({ personId: "p1", username: "new" }); + expect(a.backgroundColor).toBe(b.backgroundColor); + }); +}); diff --git a/packages/shared/src/identityAvatar.ts b/packages/shared/src/identityAvatar.ts new file mode 100644 index 00000000000..ab817efe3b0 --- /dev/null +++ b/packages/shared/src/identityAvatar.ts @@ -0,0 +1,122 @@ +/** + * Deterministic micro-avatars from identity usernames (initials + color). + * + * Pure presentation helpers for web/mobile — no network, no assets. + * Real photo URLs can replace these later; seed stays `personId` / `username`. + * + * See docs/architecture/source-and-identity.md + */ + +export type IdentityAvatarColors = { + readonly backgroundColor: string; + readonly color: string; +}; + +export type IdentityAvatarModel = IdentityAvatarColors & { + /** 1–2 uppercase letters for the chip. */ + readonly initials: string; + /** Accessible label, usually the username or display name. */ + readonly label: string; +}; + +/** + * Fixed palette (background + readable foreground). Indexed by a stable hash of + * the person key so the same user always gets the same chip across clients. + * Colors are slightly muted so dense lists stay calm on dark/light UIs. + */ +export const IDENTITY_AVATAR_PALETTE: ReadonlyArray = [ + { backgroundColor: "#3B5BDB", color: "#FFFFFF" }, + { backgroundColor: "#0CA678", color: "#FFFFFF" }, + { backgroundColor: "#E67700", color: "#FFFFFF" }, + { backgroundColor: "#9C36B5", color: "#FFFFFF" }, + { backgroundColor: "#0B7285", color: "#FFFFFF" }, + { backgroundColor: "#C2255C", color: "#FFFFFF" }, + { backgroundColor: "#2F9E44", color: "#FFFFFF" }, + { backgroundColor: "#364FC7", color: "#FFFFFF" }, + { backgroundColor: "#D9480F", color: "#FFFFFF" }, + { backgroundColor: "#5F3DC4", color: "#FFFFFF" }, + { backgroundColor: "#087F5B", color: "#FFFFFF" }, + { backgroundColor: "#A61E4D", color: "#FFFFFF" }, +] as const; + +/** FNV-1a 32-bit — fast, stable, no deps. Not part of the public chip API. */ +function hashIdentitySeed(seed: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < seed.length; i++) { + hash ^= seed.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +export function identityAvatarColors(seed: string): IdentityAvatarColors { + const index = hashIdentitySeed(seed) % IDENTITY_AVATAR_PALETTE.length; + return IDENTITY_AVATAR_PALETTE[index]!; +} + +/** First up to `count` Unicode code points (not UTF-16 units). */ +function takeCodePoints(value: string, count: number): string { + const points: Array = []; + for (const point of value) { + if (point.trim().length === 0) continue; + points.push(point); + if (points.length >= count) break; + } + return points.join(""); +} + +/** + * Initials from display name when present, otherwise username. + * Uses code points so non-BMP / CJK handles do not split surrogates. + * - "Patrick Roza" → "PR" + * - "patroza" → "PA" + * - "田中" → "田中" + * - empty → "?" + */ +export function identityInitials(input: { + readonly username?: string | null | undefined; + readonly name?: string | null | undefined; +}): string { + const name = input.name?.trim() ?? ""; + if (name.length > 0) { + const words = name.replace(/[_-]+/gu, " ").split(/\s+/u).filter(Boolean); + if (words.length >= 2) { + const a = takeCodePoints(words[0]!, 1); + const b = takeCodePoints(words[1]!, 1); + const pair = `${a}${b}`; + if (pair.length > 0) return pair.toLocaleUpperCase(); + } + if (words.length === 1) { + const two = takeCodePoints(words[0]!, 2); + if (two.length > 0) return two.toLocaleUpperCase(); + } + } + + const username = input.username?.trim() ?? ""; + if (username.length === 0) return "?"; + // Prefer letter/number-like code points; fall back to raw username points. + const alnumLike = [...username].filter((ch) => /[\p{L}\p{N}]/u.test(ch)).join(""); + const source = alnumLike.length > 0 ? alnumLike : username; + const two = takeCodePoints(source, 2); + return two.length > 0 ? two.toLocaleUpperCase() : "?"; +} + +/** + * Build a micro-avatar model. Prefer `personId` as color seed when available so + * renames keep the same chip; fall back to username. + */ +export function identityAvatar(input: { + readonly personId?: string | null | undefined; + readonly username?: string | null | undefined; + readonly name?: string | null | undefined; +}): IdentityAvatarModel { + const username = input.username?.trim() ?? ""; + const name = input.name?.trim() ?? ""; + const seed = (input.personId?.trim() || username || name || "?").toLowerCase(); + const colors = identityAvatarColors(seed); + return { + initials: identityInitials({ username, name }), + label: name.length > 0 ? name : username.length > 0 ? username : "Unknown", + ...colors, + }; +} diff --git a/packages/shared/src/identityMap.lookup.test.ts b/packages/shared/src/identityMap.lookup.test.ts new file mode 100644 index 00000000000..475e2490627 --- /dev/null +++ b/packages/shared/src/identityMap.lookup.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + findPersonByDiscordId, + findPersonByGithubId, + findPersonByGithubLogin, + findPersonByJiraAccountId, + findPersonByJiraEmail, + parseIdentityMapDocument, +} from "./identityMap.ts"; + +const people = parseIdentityMapDocument({ + people: { + patroza: { + username: "patroza", + name: "Patrick", + discord: { id: "95218063095377920" }, + github: { login: "patroza", id: "42661" }, + jira: { accountId: "jira-pat", email: "patrick@example.com" }, + }, + julius: { + username: "julius", + github: { login: "juliusmarminge" }, + }, + }, +}); + +describe("identity map platform lookups", () => { + it("finds by discord id", () => { + expect(findPersonByDiscordId(people, "95218063095377920")?.username).toBe("patroza"); + expect(findPersonByDiscordId(people, "0")).toBeNull(); + }); + + it("finds by github login and id", () => { + expect(findPersonByGithubLogin(people, "Patroza")?.username).toBe("patroza"); + expect(findPersonByGithubId(people, 42661)?.username).toBe("patroza"); + }); + + it("finds by jira account and email", () => { + expect(findPersonByJiraAccountId(people, "jira-pat")?.username).toBe("patroza"); + expect(findPersonByJiraEmail(people, "patrick@example.com")?.username).toBe("patroza"); + }); +}); diff --git a/packages/shared/src/identityMap.test.ts b/packages/shared/src/identityMap.test.ts new file mode 100644 index 00000000000..dda0239d851 --- /dev/null +++ b/packages/shared/src/identityMap.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + IdentityMapParseError, + normalizeJiraAccountId, + parseIdentityMapDocument, + resolvePersonByJiraAccountId, +} from "./identityMap.ts"; + +describe("parseIdentityMapDocument", () => { + it("parses people map with usernames", () => { + const people = parseIdentityMapDocument({ + people: { + patroza: { + username: "patroza", + name: "Patrick Roza", + discord: { id: "95218063095377920" }, + github: { login: "patroza", id: "42661" }, + }, + julius: { + username: "Julius", + name: "Julius", + }, + }, + }); + expect(people).toHaveLength(2); + expect(people[0]?.username).toBe("patroza"); + expect(people[1]?.username).toBe("julius"); + expect(people[1]?.personId).toBe("julius"); + }); + + it("rejects free-form invalid usernames", () => { + expect(() => + parseIdentityMapDocument({ + people: [{ username: "pat roza", name: "Bad" }], + }), + ).toThrow(IdentityMapParseError); + }); + + it("rejects duplicate usernames", () => { + expect(() => + parseIdentityMapDocument({ + people: [ + { username: "a", personId: "a" }, + { username: "a", personId: "b" }, + ], + }), + ).toThrow(/duplicate username/); + }); + + it("returns empty for empty document", () => { + expect(parseIdentityMapDocument({})).toEqual([]); + expect(parseIdentityMapDocument({ people: [] })).toEqual([]); + }); + + it("resolves people by Jira accountId", () => { + const people = parseIdentityMapDocument({ + people: { + patroza: { + username: "patroza", + jira: { accountId: "712020:abc" }, + }, + }, + }); + expect(normalizeJiraAccountId("accountid:712020:ABC")).toBe("712020:abc"); + expect(resolvePersonByJiraAccountId(people, "712020:abc")?.username).toBe("patroza"); + expect(resolvePersonByJiraAccountId(people, "nope")).toBeNull(); + }); +}); diff --git a/packages/shared/src/identityMap.ts b/packages/shared/src/identityMap.ts new file mode 100644 index 00000000000..9ba48aedf29 --- /dev/null +++ b/packages/shared/src/identityMap.ts @@ -0,0 +1,314 @@ +/** + * Parse closed-set identity map documents (YAML/JSON). + * Shared by server (and later Discord bot) so ops keep one file format. + * + * See docs/architecture/source-and-identity.md + */ +import * as Schema from "effect/Schema"; + +export type IdentityMapDiscordRef = { + readonly id: string; + readonly username?: string | undefined; +}; + +export type IdentityMapGitHubRef = { + readonly login: string; + readonly id?: string | undefined; + readonly email?: string | undefined; + readonly name?: string | undefined; +}; + +export type IdentityMapJiraRef = { + readonly accountId?: string | undefined; + readonly email?: string | undefined; + readonly displayName?: string | undefined; +}; + +export type IdentityMapPerson = { + readonly personId: string; + readonly username: string; + readonly name?: string | undefined; + readonly discord?: IdentityMapDiscordRef | undefined; + readonly github?: IdentityMapGitHubRef | undefined; + readonly jira?: IdentityMapJiraRef | undefined; +}; + +export class IdentityMapParseError extends Error { + readonly _tag = "IdentityMapParseError"; + readonly pathLabel: string; + constructor(pathLabel: string, message: string) { + super(message); + this.name = "IdentityMapParseError"; + this.pathLabel = pathLabel; + } +} + +const HANDLE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; +const HANDLE_MAX = 128; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeHandle(value: string, field: string, label: string): string { + const normalized = value.trim().toLowerCase(); + if ( + normalized.length === 0 || + normalized.length > HANDLE_MAX || + !HANDLE_PATTERN.test(normalized) + ) { + throw new IdentityMapParseError( + label, + `${field} must be a non-empty handle (max ${HANDLE_MAX}, pattern ${HANDLE_PATTERN}): got ${JSON.stringify(value)}`, + ); + } + return normalized; +} + +function asDiscordSnowflake(value: unknown): string | undefined { + const raw = asNonEmptyString(value); + if (raw === undefined) return undefined; + if (!/^\d{1,32}$/u.test(raw)) return undefined; + return raw; +} + +function normalizeLogin(value: unknown): string | undefined { + const raw = asNonEmptyString(value); + if (raw === undefined) return undefined; + const login = raw.replace(/^@/u, "").trim(); + if (login.length === 0) return undefined; + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u.test(login)) return undefined; + return login; +} + +function parsePerson(raw: unknown, indexLabel: string, keyHint?: string): IdentityMapPerson { + if (!isRecord(raw)) { + throw new IdentityMapParseError(indexLabel, "person entry must be an object"); + } + + const discordNested = isRecord(raw.discord) ? raw.discord : undefined; + const githubNested = isRecord(raw.github) ? raw.github : undefined; + const jiraNested = isRecord(raw.jira) ? raw.jira : undefined; + + const usernameRaw = + asNonEmptyString(raw.username) ?? + asNonEmptyString(raw.userName) ?? + (keyHint !== undefined && !/^\d+$/u.test(keyHint) ? keyHint : undefined); + if (usernameRaw === undefined) { + throw new IdentityMapParseError(indexLabel, 'missing required "username"'); + } + const username = normalizeHandle(usernameRaw, "username", indexLabel); + + const personIdRaw = asNonEmptyString(raw.personId) ?? asNonEmptyString(raw.id) ?? username; + const personId = normalizeHandle(personIdRaw, "personId", indexLabel); + + const name = asNonEmptyString(raw.name); + + const discordId = + asDiscordSnowflake(discordNested?.id) ?? + asDiscordSnowflake(raw.discordId) ?? + asDiscordSnowflake(raw.discord_id) ?? + (keyHint !== undefined ? asDiscordSnowflake(keyHint) : undefined); + const discordUsername = + asNonEmptyString(discordNested?.username) ?? + asNonEmptyString(raw.discordUsername) ?? + asNonEmptyString(raw.discord_username); + + const githubLogin = + normalizeLogin(githubNested?.login) ?? + normalizeLogin(raw.githubLogin) ?? + normalizeLogin(raw.github_login) ?? + normalizeLogin(raw.github); + const githubId = + asNonEmptyString(githubNested?.id)?.replace(/\D/gu, "") || + asNonEmptyString(raw.githubId)?.replace(/\D/gu, "") || + asNonEmptyString(raw.github_id)?.replace(/\D/gu, "") || + undefined; + const githubEmail = + asNonEmptyString(githubNested?.email) ?? + asNonEmptyString(raw.githubEmail) ?? + asNonEmptyString(raw.github_email); + const githubName = + asNonEmptyString(githubNested?.name) ?? + asNonEmptyString(raw.githubName) ?? + asNonEmptyString(raw.github_name); + + const jiraAccountId = + asNonEmptyString(jiraNested?.accountId) ?? + asNonEmptyString(raw.jiraAccountId) ?? + asNonEmptyString(raw.jira_account_id); + const jiraEmail = + asNonEmptyString(jiraNested?.email) ?? + asNonEmptyString(raw.jiraEmail) ?? + asNonEmptyString(raw.jira_email); + const jiraDisplayName = + asNonEmptyString(jiraNested?.displayName) ?? + asNonEmptyString(raw.jiraDisplayName) ?? + asNonEmptyString(raw.jira_display_name); + + return { + personId, + username, + ...(name !== undefined ? { name } : {}), + ...(discordId !== undefined + ? { + discord: { + id: discordId, + ...(discordUsername !== undefined ? { username: discordUsername } : {}), + }, + } + : {}), + ...(githubLogin !== undefined + ? { + github: { + login: githubLogin, + ...(githubId !== undefined && githubId.length > 0 ? { id: githubId } : {}), + ...(githubEmail !== undefined ? { email: githubEmail } : {}), + ...(githubName !== undefined ? { name: githubName } : {}), + }, + } + : {}), + ...(jiraAccountId !== undefined || jiraEmail !== undefined + ? { + jira: { + ...(jiraAccountId !== undefined ? { accountId: jiraAccountId } : {}), + ...(jiraEmail !== undefined ? { email: jiraEmail } : {}), + ...(jiraDisplayName !== undefined ? { displayName: jiraDisplayName } : {}), + }, + } + : {}), + }; +} + +/** + * Parse identity map document object (already JSON/YAML-parsed). + */ +export function parseIdentityMapDocument(document: unknown): ReadonlyArray { + if (document === null || document === undefined) return []; + if (!isRecord(document)) { + throw new IdentityMapParseError("root", "Identity map root must be an object."); + } + + const peopleNode = document.people; + let people: ReadonlyArray; + + if (Array.isArray(peopleNode)) { + people = peopleNode.map((entry, index) => parsePerson(entry, `[${index}]`)); + } else if (isRecord(peopleNode)) { + people = Object.entries(peopleNode).map(([key, value]) => + parsePerson(value, `people["${key}"]`, key), + ); + } else { + const reserved = new Set(["version", "schema", "$schema"]); + const entries = Object.entries(document).filter(([key]) => !reserved.has(key)); + if (entries.length === 0) return []; + people = entries.map(([key, value]) => parsePerson(value, `["${key}"]`, key)); + } + + const usernames = new Set(); + const personIds = new Set(); + for (const person of people) { + if (usernames.has(person.username)) { + throw new IdentityMapParseError(person.username, `duplicate username "${person.username}"`); + } + if (personIds.has(person.personId)) { + throw new IdentityMapParseError(person.personId, `duplicate personId "${person.personId}"`); + } + usernames.add(person.username); + personIds.add(person.personId); + } + + return people; +} + +export function toIdentityPersonPublic(person: IdentityMapPerson) { + return { + personId: person.personId, + username: person.username, + ...(person.name !== undefined ? { name: person.name } : {}), + links: { + ...(person.discord?.id !== undefined ? { discordId: person.discord.id } : {}), + ...(person.discord?.username !== undefined + ? { discordUsername: person.discord.username } + : {}), + ...(person.github?.login !== undefined ? { githubLogin: person.github.login } : {}), + ...(person.jira?.accountId !== undefined ? { jiraAccountId: person.jira.accountId } : {}), + }, + }; +} + +/** Closed-set platform lookups (case-insensitive where appropriate). */ +export function findPersonByDiscordId( + people: ReadonlyArray, + discordId: string, +): IdentityMapPerson | null { + const id = discordId.trim(); + if (id.length === 0) return null; + return people.find((person) => person.discord?.id === id) ?? null; +} + +export function findPersonByGithubLogin( + people: ReadonlyArray, + login: string, +): IdentityMapPerson | null { + const normalized = login.trim().replace(/^@/u, "").toLowerCase(); + if (normalized.length === 0) return null; + return people.find((person) => person.github?.login.toLowerCase() === normalized) ?? null; +} + +export function findPersonByGithubId( + people: ReadonlyArray, + githubId: string | number, +): IdentityMapPerson | null { + const id = String(githubId).replace(/\D/gu, ""); + if (id.length === 0) return null; + return people.find((person) => person.github?.id === id) ?? null; +} + +/** Normalize Atlassian account ids for map lookup (`accountid:` prefix, case). */ +export function normalizeJiraAccountId(accountId: string | null | undefined): string | null { + if (accountId === null || accountId === undefined) return null; + const trimmed = accountId.trim(); + if (trimmed.length === 0) return null; + const withoutPrefix = trimmed.replace(/^accountid:/iu, ""); + return withoutPrefix.length > 0 ? withoutPrefix.toLowerCase() : null; +} + +/** Resolve a closed-set person by Jira Cloud accountId (prefix/case-insensitive). */ +export function resolvePersonByJiraAccountId( + people: ReadonlyArray, + accountId: string | null | undefined, +): IdentityMapPerson | null { + const normalized = normalizeJiraAccountId(accountId); + if (normalized === null) return null; + for (const person of people) { + const mapped = normalizeJiraAccountId(person.jira?.accountId); + if (mapped !== null && mapped === normalized) return person; + } + return null; +} + +export function findPersonByJiraAccountId( + people: ReadonlyArray, + accountId: string, +): IdentityMapPerson | null { + return resolvePersonByJiraAccountId(people, accountId); +} + +export function findPersonByJiraEmail( + people: ReadonlyArray, + email: string, +): IdentityMapPerson | null { + const normalized = email.trim().toLowerCase(); + if (normalized.length === 0) return null; + return people.find((person) => person.jira?.email?.toLowerCase() === normalized) ?? null; +} + +/** Schema re-export helper for tests that want branded contracts after parse. */ +export const IdentityMapPersonCount = Schema.Number; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 21f8dd47dc5..81793bcf88b 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -35,8 +35,6 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, - { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, - { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, { key: "mod+t", command: "board.open" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts index 4a4d16da0b3..c808a9b7c51 100644 --- a/packages/shared/src/schemaJson.test.ts +++ b/packages/shared/src/schemaJson.test.ts @@ -57,15 +57,6 @@ Done.`), expect(() => decodeLenientJson('{ "enabled": true,, }')).toThrow(); }); - it("preserves commas before brackets inside string values", () => { - // A comma inside a string value that happens to precede `}`/`]` must not - // be stripped as if it were a trailing comma. - expect(decodeLenientJson('{"note":"a,]"}')).toEqual({ note: "a,]" }); - expect(decodeLenientJson('{"list":["x,}"]}')).toEqual({ list: ["x,}"] }); - // Genuine trailing commas are still removed. - expect(decodeLenientJson('{"values":[1, 2,],}')).toEqual({ values: [1, 2] }); - }); - it("formats schema failures with paths without exposing invalid values", () => { const decodeCredential = decodeJsonResult(Schema.Struct({ token: Schema.Number })); const decoded = decodeCredential('{"token":"credential=secret-value"}'); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 77b1fa5d548..04d26d9c229 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -190,14 +190,8 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""), ); - // Strip trailing commas before `}` or `]`. The alternation preserves quoted - // strings so a comma inside a string value (e.g. `{"note":"a,]"}`) is not - // mistaken for a trailing comma and removed. - stripped = stripped.replace( - /("(?:[^"\\]|\\.)*")|,(\s*[}\]])/g, - (match, stringLiteral: string | undefined, bracket: string | undefined) => - stringLiteral ? match : (bracket ?? ""), - ); + // Strip trailing commas before `}` or `]`. + stripped = stripped.replace(/,(\s*[}\]])/g, "$1"); return decodeJsonString(stripped).pipe( Effect.map(Option.some), diff --git a/packages/shared/src/semver.test.ts b/packages/shared/src/semver.test.ts index ed3e1896aaf..8cbbc150fc9 100644 --- a/packages/shared/src/semver.test.ts +++ b/packages/shared/src/semver.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - compareSemverVersions, - normalizeSemverVersion, - parseSemver, - satisfiesSemverRange, -} from "./semver.ts"; +import { compareSemverVersions, normalizeSemverVersion, satisfiesSemverRange } from "./semver.ts"; describe("semver helpers", () => { it("matches supported range groups", () => { @@ -23,25 +18,6 @@ describe("semver helpers", () => { expect(normalizeSemverVersion("2.1")).toBe("2.1.0"); }); - it("normalizes and parses shorthand major-only versions", () => { - expect(normalizeSemverVersion("20")).toBe("20.0.0"); - expect(normalizeSemverVersion("v18")).toBe("v18.0.0"); - expect(normalizeSemverVersion("20-rc.1")).toBe("20.0.0-rc.1"); - expect(parseSemver("20")).toEqual({ major: 20, minor: 0, patch: 0, prerelease: [] }); - }); - - it("compares shorthand versions numerically instead of lexically", () => { - // Regression: "20" vs "9" previously fell back to string comparison, which - // ordered "20" before "9" ("2" < "9"). - expect(compareSemverVersions("20", "9")).toBeGreaterThan(0); - expect(compareSemverVersions("18", "18.0.0")).toBe(0); - }); - - it("still rejects non-numeric shorthand and keeps empty input empty", () => { - expect(parseSemver("abc")).toBeNull(); - expect(normalizeSemverVersion("")).toBe(""); - }); - it("compares prerelease versions before stable versions", () => { expect(compareSemverVersions("2.1.111-beta.1", "2.1.111")).toBeLessThan(0); }); diff --git a/packages/shared/src/semver.ts b/packages/shared/src/semver.ts index a765b065fc6..1a73e33042f 100644 --- a/packages/shared/src/semver.ts +++ b/packages/shared/src/semver.ts @@ -17,12 +17,7 @@ export function normalizeSemverVersion(version: string): string { } } - // Pad shorthand versions ("20" or "20.1") up to three segments so major-only - // and minor-only inputs parse and compare numerically. This matches - // satisfiesSemverRange, which already treats a missing minor/patch as 0. The - // length > 0 guard keeps empty/garbage input empty (parseSemver still - // rejects it), and inputs with more than three segments are left untouched. - while (segments.length > 0 && segments.length < 3) { + if (segments.length === 2) { segments.push("0"); } diff --git a/packages/shared/src/sourceAttribution.test.ts b/packages/shared/src/sourceAttribution.test.ts new file mode 100644 index 00000000000..ce8f831673e --- /dev/null +++ b/packages/shared/src/sourceAttribution.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildSourceRefFromClaim, + mergeParticipantSummaries, + nextOriginSource, + resolveSourceChannel, + sourceChannelFromDeviceType, +} from "./sourceAttribution.ts"; + +describe("sourceChannelFromDeviceType", () => { + it("maps known device types", () => { + expect(sourceChannelFromDeviceType("desktop")).toBe("desktop"); + expect(sourceChannelFromDeviceType("mobile")).toBe("mobile"); + expect(sourceChannelFromDeviceType("tablet")).toBe("mobile"); + expect(sourceChannelFromDeviceType("bot")).toBe("bot"); + expect(sourceChannelFromDeviceType("unknown")).toBe("unknown"); + expect(sourceChannelFromDeviceType(undefined)).toBe("unknown"); + }); +}); + +describe("resolveSourceChannel", () => { + it("accepts the VS Code integration channel hint", () => { + expect(resolveSourceChannel({ deviceType: "desktop", channelHint: "vscode" })).toBe("vscode"); + }); + + it("prefers explicit channel hints", () => { + expect(resolveSourceChannel({ deviceType: "desktop", channelHint: "discord" })).toBe("discord"); + }); + + it("falls back to device type", () => { + expect(resolveSourceChannel({ deviceType: "mobile" })).toBe("mobile"); + }); +}); + +describe("buildSourceRefFromClaim", () => { + it("stamps person fields from claim", () => { + expect( + buildSourceRefFromClaim({ + personId: "patroza", + username: "patroza", + channel: "desktop", + }), + ).toEqual({ + channel: "desktop", + personId: "patroza", + username: "patroza", + }); + }); +}); + +describe("nextOriginSource", () => { + it("sets origin from first user source only", () => { + const first = buildSourceRefFromClaim({ + personId: "patroza", + username: "patroza", + channel: "web", + }); + const second = buildSourceRefFromClaim({ + personId: "julius", + username: "julius", + channel: "desktop", + }); + expect(nextOriginSource({ current: null, messageSource: first, role: "user" })).toEqual(first); + expect(nextOriginSource({ current: first, messageSource: second, role: "user" })).toEqual( + first, + ); + expect(nextOriginSource({ current: null, messageSource: first, role: "assistant" })).toBeNull(); + }); +}); + +describe("mergeParticipantSummaries", () => { + it("appends distinct people and keeps origin first", () => { + const origin = { + personId: "patroza", + username: "patroza", + firstChannel: "discord" as const, + firstParticipatedAt: "2026-01-01T00:00:00.000Z", + }; + const merged = mergeParticipantSummaries({ + existing: [origin], + source: { personId: "julius", username: "julius", channel: "desktop" }, + participatedAt: "2026-01-01T00:01:00.000Z", + originPersonId: "patroza", + }); + expect(merged.map((entry) => entry.personId)).toEqual(["patroza", "julius"]); + }); + + it("ignores unmapped sources and folds a person's channels into one summary", () => { + const existing = [ + { + personId: "patroza", + username: "patroza", + firstChannel: "discord" as const, + firstParticipatedAt: "2026-01-01T00:00:00.000Z", + }, + ]; + expect( + mergeParticipantSummaries({ + existing, + source: { channel: "discord" }, + participatedAt: "2026-01-01T00:01:00.000Z", + }), + ).toEqual(existing); + const folded = mergeParticipantSummaries({ + existing, + source: { personId: "patroza", username: "patroza", channel: "desktop" }, + participatedAt: "2026-01-01T00:02:00.000Z", + }); + expect(folded).toHaveLength(1); + expect(folded[0]?.channels).toEqual(["discord", "desktop"]); + expect( + mergeParticipantSummaries({ + existing: folded, + source: { personId: "patroza", username: "patroza", channel: "desktop" }, + participatedAt: "2026-01-01T00:03:00.000Z", + }), + ).toBe(folded); + }); +}); diff --git a/packages/shared/src/sourceAttribution.ts b/packages/shared/src/sourceAttribution.ts new file mode 100644 index 00000000000..ec9cbd8284e --- /dev/null +++ b/packages/shared/src/sourceAttribution.ts @@ -0,0 +1,176 @@ +/** + * Server-side SourceRef helpers and thread participant denormalization. + * + * Clients never invent personId/username — those come from session claim or + * platform map resolution. Channel is derived from auth client device type + * (or an explicit SourceChannel for integrations). + * + * See docs/architecture/source-and-identity.md + */ +import type { AuthClientMetadataDeviceType, SourceChannel } from "@t3tools/contracts"; + +export type SourceRefLike = { + readonly channel: SourceChannel; + readonly personId?: string | undefined; + readonly username?: string | undefined; + readonly location?: { + readonly guildId?: string | undefined; + readonly channelId?: string | undefined; + readonly threadId?: string | undefined; + readonly owner?: string | undefined; + readonly repo?: string | undefined; + readonly number?: number | undefined; + readonly kind?: "pr" | "issue" | undefined; + readonly projectKey?: string | undefined; + readonly issueKey?: string | undefined; + }; + readonly actor?: { + readonly platformId?: string | undefined; + readonly displayName?: string | undefined; + }; +}; + +/** Map auth client deviceType → SourceChannel. */ +export function sourceChannelFromDeviceType( + deviceType: AuthClientMetadataDeviceType | undefined | null, +): SourceChannel { + switch (deviceType) { + case "desktop": + return "desktop"; + case "mobile": + case "tablet": + return "mobile"; + case "bot": + return "bot"; + case "unknown": + case undefined: + case null: + return "unknown"; + default: { + const _exhaustive: never = deviceType; + void _exhaustive; + return "unknown"; + } + } +} + +/** + * Prefer an explicit channel (ClientSourceHint / integration) when present; + * otherwise derive from session deviceType. Web is not a deviceType today — + * browser clients often report desktop; accept explicit "web" when hinted. + */ +export function resolveSourceChannel(input: { + readonly deviceType?: AuthClientMetadataDeviceType | null | undefined; + readonly channelHint?: SourceChannel | null | undefined; +}): SourceChannel { + if (input.channelHint !== undefined && input.channelHint !== null) { + return input.channelHint; + } + return sourceChannelFromDeviceType(input.deviceType); +} + +export function buildSourceRefFromClaim(input: { + readonly personId: string; + readonly username: string; + readonly channel: SourceChannel; + readonly location?: SourceRefLike["location"]; + readonly actor?: SourceRefLike["actor"]; +}): SourceRefLike { + return { + channel: input.channel, + personId: input.personId, + username: input.username, + ...(input.location !== undefined ? { location: input.location } : {}), + ...(input.actor !== undefined ? { actor: input.actor } : {}), + }; +} + +export type ParticipantSummaryLike = { + readonly personId: string; + readonly username: string; + readonly name?: string | undefined; + readonly firstChannel?: SourceChannel | undefined; + readonly channels?: ReadonlyArray | undefined; + readonly firstParticipatedAt: string; +}; + +/** + * Merge a user message SourceRef into ordered participant summaries. + * Origin person stays first when already present; new people append by + * first-participation time (caller passes chronological events). + */ +export function mergeParticipantSummaries(input: { + readonly existing: ReadonlyArray; + readonly source: { + readonly personId?: string | undefined; + readonly username?: string | undefined; + readonly channel: SourceChannel; + readonly name?: string | undefined; + }; + readonly participatedAt: string; + readonly originPersonId?: string | null | undefined; +}): ReadonlyArray { + const personId = input.source.personId; + if (personId === undefined || personId === null || personId.length === 0) { + return input.existing; + } + const username = input.source.username; + if (username === undefined || username === null || username.length === 0) { + return input.existing; + } + + const existingIndex = input.existing.findIndex((entry) => entry.personId === personId); + if (existingIndex !== -1) { + const existingEntry = input.existing[existingIndex]!; + const channels = + existingEntry.channels ?? + (existingEntry.firstChannel === undefined ? [] : [existingEntry.firstChannel]); + if (channels.includes(input.source.channel)) { + return input.existing; + } + return input.existing.map((entry, index) => + index === existingIndex + ? { + ...entry, + channels: [...channels, input.source.channel], + } + : entry, + ); + } + + const nextEntry: ParticipantSummaryLike = { + personId, + username, + ...(input.source.name !== undefined ? { name: input.source.name } : {}), + firstChannel: input.source.channel, + channels: [input.source.channel], + firstParticipatedAt: input.participatedAt, + }; + + const originId = input.originPersonId ?? null; + if (originId !== null && personId === originId) { + return [nextEntry, ...input.existing]; + } + + // Keep origin lead if present, then append by first-seen order. + if (originId !== null) { + const origin = input.existing.find((entry) => entry.personId === originId); + const rest = input.existing.filter((entry) => entry.personId !== originId); + if (origin !== undefined) { + return [origin, ...rest, nextEntry]; + } + } + + return [...input.existing, nextEntry]; +} + +/** First user-message SourceRef becomes origin when none set yet. */ +export function nextOriginSource(input: { + readonly current: SourceRefLike | null | undefined; + readonly messageSource: SourceRefLike | undefined; + readonly role: string; +}): SourceRefLike | null | undefined { + if (input.role !== "user") return input.current; + if (input.current !== undefined && input.current !== null) return input.current; + return input.messageSource ?? input.current ?? null; +} diff --git a/packages/shared/src/threadAttributeSearch.test.ts b/packages/shared/src/threadAttributeSearch.test.ts new file mode 100644 index 00000000000..9ba392600e3 --- /dev/null +++ b/packages/shared/src/threadAttributeSearch.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildThreadAttributeSearchTerms, + threadAttributeSearchMatches, + threadMatchesAttributeQuery, +} from "./threadAttributeSearch.ts"; + +const sample = { + title: "Fix gate SA-123 for multi-user claims", + branch: "pr/4521-identity-search", + originSource: { + channel: "discord" as const, + personId: "patroza", + username: "patroza", + location: { + issueKey: "SA-123", + number: 4521, + kind: "pr" as const, + }, + }, + participantSummaries: [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + firstChannel: "discord" as const, + }, + { + personId: "julius", + username: "julius", + firstChannel: "desktop" as const, + }, + ], + extraTerms: ["t3-code"], +}; + +describe("buildThreadAttributeSearchTerms", () => { + it("includes identity handles and channels", () => { + const terms = buildThreadAttributeSearchTerms(sample); + expect(terms).toEqual( + expect.arrayContaining([ + "patroza", + "@patroza", + "patroza@discord", + "@discord", + "discord", + "julius", + "@julius", + "julius@desktop", + "@desktop", + "desktop", + "patrick roza", + ]), + ); + }); + + it("includes PR and Jira tokens", () => { + const terms = buildThreadAttributeSearchTerms(sample); + expect(terms).toEqual( + expect.arrayContaining(["#4521", "4521", "pr/4521", "pr-4521", "sa-123"]), + ); + }); + + it("includes title and branch", () => { + const terms = buildThreadAttributeSearchTerms(sample); + expect(terms).toEqual( + expect.arrayContaining(["fix gate sa-123 for multi-user claims", "pr/4521-identity-search"]), + ); + }); +}); + +describe("threadMatchesAttributeQuery", () => { + it.each([ + ["@patroza"], + ["patroza@discord"], + ["@desktop"], + ["#4521"], + ["4521"], + ["SA-123"], + ["sa-123"], + ["julius"], + ["multi-user"], + ])("matches %s", (query) => { + expect(threadMatchesAttributeQuery(sample, query)).toBe(true); + }); + + it("rejects unrelated queries", () => { + expect(threadMatchesAttributeQuery(sample, "@theo")).toBe(false); + expect(threadMatchesAttributeQuery(sample, "#9999")).toBe(false); + expect(threadMatchesAttributeQuery(sample, "ZZ-1")).toBe(false); + }); + + it("empty query matches all", () => { + expect(threadMatchesAttributeQuery(sample, " ")).toBe(true); + }); +}); + +describe("threadAttributeSearchMatches", () => { + it("matches partial username prefixes", () => { + const terms = buildThreadAttributeSearchTerms(sample); + expect(threadAttributeSearchMatches(terms, "@patr")).toBe(true); + }); +}); diff --git a/packages/shared/src/threadAttributeSearch.ts b/packages/shared/src/threadAttributeSearch.ts new file mode 100644 index 00000000000..481bb74db82 --- /dev/null +++ b/packages/shared/src/threadAttributeSearch.ts @@ -0,0 +1,208 @@ +/** + * Search terms and match helpers for thread attributes beyond title/branch: + * identity handles (`@user`, `user@channel`, `@channel`), PR numbers (`#123`), + * and Jira keys (`SA-123`). + * + * Pure string helpers for web/mobile command palette and list filters. + * See docs/architecture/source-and-identity.md + */ + +export type ThreadAttributeSourceLike = { + readonly channel?: string | null | undefined; + readonly personId?: string | null | undefined; + readonly username?: string | null | undefined; + readonly location?: + | { + readonly number?: number | null | undefined; + readonly issueKey?: string | null | undefined; + readonly kind?: string | null | undefined; + } + | null + | undefined; +}; + +export type ThreadAttributeParticipantLike = { + readonly personId?: string | null | undefined; + readonly username?: string | null | undefined; + readonly name?: string | null | undefined; + readonly firstChannel?: string | null | undefined; +}; + +export type ThreadAttributeSearchInput = { + readonly title?: string | null | undefined; + readonly branch?: string | null | undefined; + readonly originSource?: ThreadAttributeSourceLike | null | undefined; + readonly participantSummaries?: ReadonlyArray | null | undefined; + /** Additional free-form terms (project title, etc.). */ + readonly extraTerms?: ReadonlyArray | null | undefined; +}; + +/** Jira-style issue keys: PROJ-123, SA-49, … */ +const JIRA_KEY_PATTERN = /\b([A-Za-z][A-Za-z0-9]+-\d+)\b/g; +/** Explicit PR markers in free text / branch names. */ +const PR_HASH_PATTERN = /#(\d+)\b/g; +const PR_SLUG_PATTERN = /\b(?:pr|pull)[-_/]?(\d+)\b/gi; + +function addTerm(into: Set, raw: string | null | undefined): void { + if (raw === null || raw === undefined) return; + const trimmed = raw.trim().toLowerCase(); + if (trimmed.length === 0) return; + into.add(trimmed); +} + +function addPersonTerms( + into: Set, + person: { + readonly username?: string | null | undefined; + readonly personId?: string | null | undefined; + readonly name?: string | null | undefined; + readonly channel?: string | null | undefined; + }, +): void { + const username = person.username?.trim().toLowerCase() ?? ""; + const personId = person.personId?.trim().toLowerCase() ?? ""; + const channel = person.channel?.trim().toLowerCase() ?? ""; + const name = person.name?.trim().toLowerCase() ?? ""; + + if (username.length > 0) { + addTerm(into, username); + addTerm(into, `@${username}`); + if (channel.length > 0) { + addTerm(into, `${username}@${channel}`); + } + } + if (personId.length > 0 && personId !== username) { + addTerm(into, personId); + addTerm(into, `@${personId}`); + if (channel.length > 0) { + addTerm(into, `${personId}@${channel}`); + } + } + if (name.length > 0) { + addTerm(into, name); + } +} + +function addChannelTerms(into: Set, channel: string | null | undefined): void { + const normalized = channel?.trim().toLowerCase() ?? ""; + if (normalized.length === 0) return; + addTerm(into, normalized); + addTerm(into, `@${normalized}`); +} + +function addPrNumber(into: Set, value: number | string): void { + const digits = String(value).replace(/\D/g, ""); + if (digits.length === 0) return; + addTerm(into, digits); + addTerm(into, `#${digits}`); + addTerm(into, `pr-${digits}`); + addTerm(into, `pr/${digits}`); +} + +function extractFromText(into: Set, text: string | null | undefined): void { + if (text === null || text === undefined || text.trim().length === 0) return; + const source = text; + + for (const match of source.matchAll(JIRA_KEY_PATTERN)) { + const key = match[1]; + if (key !== undefined) addTerm(into, key); + } + for (const match of source.matchAll(PR_HASH_PATTERN)) { + const n = match[1]; + if (n !== undefined) addPrNumber(into, n); + } + for (const match of source.matchAll(PR_SLUG_PATTERN)) { + const n = match[1]; + if (n !== undefined) addPrNumber(into, n); + } +} + +/** + * Build a deduped, lowercased bag of search terms for a thread. + * Suitable for command-palette `searchTerms` and list filters. + */ +export function buildThreadAttributeSearchTerms( + input: ThreadAttributeSearchInput, +): ReadonlyArray { + const terms = new Set(); + + addTerm(terms, input.title); + addTerm(terms, input.branch); + if (input.branch !== null && input.branch !== undefined && input.branch.trim().length > 0) { + addTerm(terms, `#${input.branch.trim()}`); + } + + extractFromText(terms, input.title); + extractFromText(terms, input.branch); + + const origin = input.originSource ?? null; + if (origin !== null) { + addChannelTerms(terms, origin.channel); + addPersonTerms(terms, { + username: origin.username, + personId: origin.personId, + channel: origin.channel, + }); + if (origin.location?.number !== undefined && origin.location.number !== null) { + addPrNumber(terms, origin.location.number); + } + if (origin.location?.issueKey) { + addTerm(terms, origin.location.issueKey); + } + } + + for (const participant of input.participantSummaries ?? []) { + addPersonTerms(terms, { + username: participant.username, + personId: participant.personId, + name: participant.name, + channel: participant.firstChannel, + }); + addChannelTerms(terms, participant.firstChannel); + } + + for (const extra of input.extraTerms ?? []) { + addTerm(terms, extra); + extractFromText(terms, extra); + } + + return [...terms]; +} + +/** + * Whether any search term matches the query (substring, case-insensitive). + * Query is normalized the same way as terms (trim + lower). + */ +export function threadAttributeSearchMatches(terms: ReadonlyArray, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase().replace(/\s+/g, " "); + if (normalizedQuery.length === 0) return true; + if (terms.length === 0) return false; + + // Direct term substring (covers @user, user@channel, #123, sa-123, title words). + for (const term of terms) { + if (term.includes(normalizedQuery) || normalizedQuery.includes(term)) { + // Prefer: query is a prefix/substring of a term (user typed partial handle). + if (term.includes(normalizedQuery)) return true; + } + } + + // Joined haystack for multi-word title queries. + const haystack = terms.join(" "); + if (haystack.includes(normalizedQuery)) return true; + + // `#42` vs bare `42` already both in terms when PR-linked. + // `@desktop` is stored as both `desktop` and `@desktop`. + return false; +} + +/** + * Convenience: build terms and match in one call. + */ +export function threadMatchesAttributeQuery( + input: ThreadAttributeSearchInput, + query: string, +): boolean { + const normalizedQuery = query.trim(); + if (normalizedQuery.length === 0) return true; + return threadAttributeSearchMatches(buildThreadAttributeSearchTerms(input), normalizedQuery); +} diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index f7854675351..9c04c7e9dd1 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - This host-side fixture creates an isolated local T3 environment. +// @effect-diagnostics nodeBuiltinImport:off globalDate:off - This host-side fixture creates an isolated local T3 environment. import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; @@ -387,48 +387,6 @@ function insertThread( .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt); } -const SEEDED_PROJECTION_TABLES = [ - "projection_pending_approvals", - "projection_thread_proposed_plans", - "projection_thread_activities", - "projection_thread_messages", - "projection_thread_sessions", - "projection_turns", - "projection_threads", - "projection_projects", - "projection_state", -] as const; - -function hasSeedableSchema(dbPath: string): boolean { - let database: NodeSqlite.DatabaseSync; - try { - database = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); - } catch { - return false; - } - try { - const row = database - .prepare( - `SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN (${SEEDED_PROJECTION_TABLES.map(() => "?").join(", ")})`, - ) - .get(...SEEDED_PROJECTION_TABLES) as { count: number }; - return row.count === SEEDED_PROJECTION_TABLES.length; - } catch { - return false; - } finally { - database.close(); - } -} - -async function waitForSeedableSchema(dbPath: string, timeoutMs = 60_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (hasSeedableSchema(dbPath)) return; - await new Promise((resolve) => setTimeout(resolve, 250)); - } - throw new Error(`The environment server did not migrate ${dbPath} within ${timeoutMs}ms.`); -} - function seedDatabase( dbPath: string, workspaceRoots: ReadonlyMap, @@ -443,7 +401,17 @@ function seedDatabase( const database = new NodeSqlite.DatabaseSync(dbPath, { timeout: 30_000 }); try { database.exec("BEGIN IMMEDIATE"); - for (const table of SEEDED_PROJECTION_TABLES) { + for (const table of [ + "projection_pending_approvals", + "projection_thread_proposed_plans", + "projection_thread_activities", + "projection_thread_messages", + "projection_thread_sessions", + "projection_turns", + "projection_threads", + "projection_projects", + "projection_state", + ]) { database.exec(`DELETE FROM ${table}`); } const insertProject = database.prepare( @@ -626,9 +594,6 @@ export async function seedShowcaseEnvironment(input: { }); }), ); - // The environment server begins listening before it finishes migrating the - // database, so wait for the schema before deleting from and reseeding it. - await waitForSeedableSchema(dbPath); seedDatabase(dbPath, workspaceRoots, projects, threads, now); const terminalDirectory = NodePath.join(input.baseDir, "userdata", "logs", "terminals"); diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index 4643c06ccfd..ee2fd325257 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -124,13 +124,12 @@ const config: ShowcaseConfig = { simulator: "iPad Pro 13-inch (M5)", simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB", appearance: "dark", - orientation: "landscape", scenes: ["thread", "terminal", "review", "threads", "environments"], storeAsset: { store: "apple", directory: "apple/ipad-13", - width: 2752, - height: 2064, + width: 2064, + height: 2752, minimumUploadCount: 1, maximumUploadCount: 10, }, diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index 16fb3e230bb..a0deadbef65 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -216,18 +216,17 @@ it("configures every default device with an exact upload-ready store target", () assert.deepStrictEqual( showcaseConfig.devices.map((device) => [ device.id, - device.platform === "ios" ? (device.orientation ?? "portrait") : null, device.storeAsset.directory, device.storeAsset.width, device.storeAsset.height, ]), [ - ["iphone-6.9", "portrait", "apple/iphone-6.9", 1320, 2868], - ["iphone-6.5", "portrait", "apple/iphone-6.5", 1284, 2778], - ["ipad-13", "landscape", "apple/ipad-13", 2752, 2064], - ["pixel", null, "google-play/phone", 1080, 1920], - ["android-tablet-7", null, "google-play/tablet-7", 1080, 1920], - ["android-tablet-10", null, "google-play/tablet-10", 1440, 2560], + ["iphone-6.9", "apple/iphone-6.9", 1320, 2868], + ["iphone-6.5", "apple/iphone-6.5", 1284, 2778], + ["ipad-13", "apple/ipad-13", 2064, 2752], + ["pixel", "google-play/phone", 1080, 1920], + ["android-tablet-7", "google-play/tablet-7", 1080, 1920], + ["android-tablet-10", "google-play/tablet-10", 1440, 2560], ], ); });