From 37d6208d81c7bcf6b026455535707ceed43a4b60 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 1 Apr 2026 13:51:07 -0700 Subject: [PATCH 1/7] Normalize sidebar thread data for faster updates - derive sidebar rows from thread summaries and indexed ids - move status pill logic off full thread activity scans --- apps/web/src/components/Sidebar.logic.test.ts | 78 +- apps/web/src/components/Sidebar.logic.ts | 39 +- apps/web/src/components/Sidebar.tsx | 754 +++++++++--------- apps/web/src/routes/__root.tsx | 62 +- apps/web/src/store.test.ts | 9 + apps/web/src/store.ts | 277 ++++++- apps/web/src/storeSelectors.ts | 24 +- apps/web/src/types.ts | 18 + 8 files changed, 771 insertions(+), 490 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d4cd25db4c..844b01bb37 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -45,10 +45,12 @@ describe("hasUnseenCompletion", () => { it("returns true when a thread completed after its last visit", () => { expect( hasUnseenCompletion({ + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, interactionMode: "default", latestTurn: makeLatestTurn(), lastVisitedAt: "2026-03-09T10:04:00.000Z", - proposedPlans: [], session: null, }), ).toBe(true); @@ -259,17 +261,14 @@ describe("getVisibleSidebarThreadIds", () => { expect( getVisibleSidebarThreadIds([ { - renderedThreads: [ - { id: ThreadId.makeUnsafe("thread-12") }, - { id: ThreadId.makeUnsafe("thread-11") }, - { id: ThreadId.makeUnsafe("thread-10") }, + renderedThreadIds: [ + ThreadId.makeUnsafe("thread-12"), + ThreadId.makeUnsafe("thread-11"), + ThreadId.makeUnsafe("thread-10"), ], }, { - renderedThreads: [ - { id: ThreadId.makeUnsafe("thread-8") }, - { id: ThreadId.makeUnsafe("thread-6") }, - ], + renderedThreadIds: [ThreadId.makeUnsafe("thread-8"), ThreadId.makeUnsafe("thread-6")], }, ]), ).toEqual([ @@ -286,17 +285,14 @@ describe("getVisibleSidebarThreadIds", () => { getVisibleSidebarThreadIds([ { shouldShowThreadPanel: false, - renderedThreads: [ - { id: ThreadId.makeUnsafe("thread-hidden-2") }, - { id: ThreadId.makeUnsafe("thread-hidden-1") }, + renderedThreadIds: [ + ThreadId.makeUnsafe("thread-hidden-2"), + ThreadId.makeUnsafe("thread-hidden-1"), ], }, { shouldShowThreadPanel: true, - renderedThreads: [ - { id: ThreadId.makeUnsafe("thread-12") }, - { id: ThreadId.makeUnsafe("thread-11") }, - ], + renderedThreadIds: [ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")], }, ]), ).toEqual([ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")]); @@ -337,10 +333,12 @@ describe("isContextMenuPointerDown", () => { describe("resolveThreadStatusPill", () => { const baseThread = { + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, interactionMode: "plan" as const, latestTurn: null, lastVisitedAt: undefined, - proposedPlans: [], session: { provider: "codex" as const, status: "running" as const, @@ -353,9 +351,11 @@ describe("resolveThreadStatusPill", () => { it("shows pending approval before all other statuses", () => { expect( resolveThreadStatusPill({ - thread: baseThread, - hasPendingApprovals: true, - hasPendingUserInput: true, + thread: { + ...baseThread, + hasPendingApprovals: true, + hasPendingUserInput: true, + }, }), ).toMatchObject({ label: "Pending Approval", pulse: false }); }); @@ -363,9 +363,10 @@ describe("resolveThreadStatusPill", () => { it("shows awaiting input when plan mode is blocked on user answers", () => { expect( resolveThreadStatusPill({ - thread: baseThread, - hasPendingApprovals: false, - hasPendingUserInput: true, + thread: { + ...baseThread, + hasPendingUserInput: true, + }, }), ).toMatchObject({ label: "Awaiting Input", pulse: false }); }); @@ -374,8 +375,6 @@ describe("resolveThreadStatusPill", () => { expect( resolveThreadStatusPill({ thread: baseThread, - hasPendingApprovals: false, - hasPendingUserInput: false, }), ).toMatchObject({ label: "Working", pulse: true }); }); @@ -385,26 +384,14 @@ describe("resolveThreadStatusPill", () => { resolveThreadStatusPill({ thread: { ...baseThread, + hasActionableProposedPlan: true, latestTurn: makeLatestTurn(), - proposedPlans: [ - { - id: "plan-1" as never, - turnId: "turn-1" as never, - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - planMarkdown: "# Plan", - implementedAt: null, - implementationThreadId: null, - }, - ], session: { ...baseThread.session, status: "ready", orchestrationStatus: "ready", }, }, - hasPendingApprovals: false, - hasPendingUserInput: false, }), ).toMatchObject({ label: "Plan Ready", pulse: false }); }); @@ -415,25 +402,12 @@ describe("resolveThreadStatusPill", () => { thread: { ...baseThread, latestTurn: makeLatestTurn(), - proposedPlans: [ - { - id: "plan-1" as never, - turnId: "turn-1" as never, - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - planMarkdown: "# Plan", - implementedAt: "2026-03-09T10:06:00.000Z", - implementationThreadId: "thread-implement" as never, - }, - ], session: { ...baseThread.session, status: "ready", orchestrationStatus: "ready", }, }, - hasPendingApprovals: false, - hasPendingUserInput: false, }), ).toMatchObject({ label: "Completed", pulse: false }); }); @@ -452,8 +426,6 @@ describe("resolveThreadStatusPill", () => { orchestrationStatus: "ready", }, }, - hasPendingApprovals: false, - hasPendingUserInput: false, }), ).toMatchObject({ label: "Completed", pulse: false }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 16c7752f74..5f9b244a50 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,12 +1,8 @@ import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import type { Thread } from "../types"; +import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; -import { - findLatestProposedPlan, - hasActionableProposedPlan, - isLatestTurnSettled, -} from "../session-logic"; +import { isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; @@ -47,8 +43,13 @@ const THREAD_STATUS_PRIORITY: Record = { }; type ThreadStatusInput = Pick< - Thread, - "interactionMode" | "latestTurn" | "proposedPlans" | "session" + SidebarThreadSummary, + | "hasActionableProposedPlan" + | "hasPendingApprovals" + | "hasPendingUserInput" + | "interactionMode" + | "latestTurn" + | "session" > & { lastVisitedAt?: string | undefined; }; @@ -192,15 +193,11 @@ export function orderItemsByPreferredIds(input: { export function getVisibleSidebarThreadIds( renderedProjects: readonly { shouldShowThreadPanel?: boolean; - renderedThreads: readonly { - id: TThreadId; - }[]; + renderedThreadIds: readonly TThreadId[]; }[], ): TThreadId[] { return renderedProjects.flatMap((renderedProject) => - renderedProject.shouldShowThreadPanel === false - ? [] - : renderedProject.renderedThreads.map((thread) => thread.id), + renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds, ); } @@ -273,12 +270,10 @@ export function resolveThreadRowClassName(input: { export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; - hasPendingApprovals: boolean; - hasPendingUserInput: boolean; }): ThreadStatusPill | null { - const { hasPendingApprovals, hasPendingUserInput, thread } = input; + const { thread } = input; - if (hasPendingApprovals) { + if (thread.hasPendingApprovals) { return { label: "Pending Approval", colorClass: "text-amber-600 dark:text-amber-300/90", @@ -287,7 +282,7 @@ export function resolveThreadStatusPill(input: { }; } - if (hasPendingUserInput) { + if (thread.hasPendingUserInput) { return { label: "Awaiting Input", colorClass: "text-indigo-600 dark:text-indigo-300/90", @@ -315,12 +310,10 @@ export function resolveThreadStatusPill(input: { } const hasPlanReadyPrompt = - !hasPendingUserInput && + !thread.hasPendingUserInput && thread.interactionMode === "plan" && isLatestTurnSettled(thread.latestTurn, thread.session) && - hasActionableProposedPlan( - findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null), - ); + thread.hasActionableProposedPlan; if (hasPlanReadyPrompt) { return { label: "Plan Ready", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c819ce81cd..27ce7378e2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -18,8 +18,13 @@ import { useMemo, useRef, useState, + type Dispatch, + type KeyboardEvent, type MouseEvent, + type MutableRefObject, type PointerEvent, + type ReactNode, + type SetStateAction, } from "react"; import { useShallow } from "zustand/react/shallow"; import { @@ -55,6 +60,7 @@ import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, @@ -64,14 +70,12 @@ import { threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; -import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitStatusQueryOptions } from "../lib/gitReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -124,7 +128,8 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import type { Project, Thread } from "../types"; +import { useSidebarThreadSummaryById } from "../storeSelectors"; +import type { Project } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -140,79 +145,9 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; -type SidebarThreadSnapshot = Pick< - Thread, - | "activities" - | "archivedAt" - | "branch" - | "createdAt" - | "id" - | "interactionMode" - | "latestTurn" - | "projectId" - | "proposedPlans" - | "session" - | "title" - | "updatedAt" - | "worktreePath" -> & { - lastVisitedAt?: string | undefined; - latestUserMessageAt: string | null; -}; - type SidebarProjectSnapshot = Project & { expanded: boolean; }; - -const sidebarThreadSnapshotCache = new WeakMap< - Thread, - { lastVisitedAt?: string | undefined; snapshot: SidebarThreadSnapshot } ->(); - -function getLatestUserMessageAt(thread: Thread): string | null { - let latestUserMessageAt: string | null = null; - - for (const message of thread.messages) { - if (message.role !== "user") { - continue; - } - if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) { - latestUserMessageAt = message.createdAt; - } - } - - return latestUserMessageAt; -} - -function toSidebarThreadSnapshot( - thread: Thread, - lastVisitedAt: string | undefined, -): SidebarThreadSnapshot { - const cached = sidebarThreadSnapshotCache.get(thread); - if (cached && cached.lastVisitedAt === lastVisitedAt) { - return cached.snapshot; - } - - const snapshot: SidebarThreadSnapshot = { - id: thread.id, - projectId: thread.projectId, - title: thread.title, - interactionMode: thread.interactionMode, - session: thread.session, - createdAt: thread.createdAt, - updatedAt: thread.updatedAt, - archivedAt: thread.archivedAt, - latestTurn: thread.latestTurn, - lastVisitedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - activities: thread.activities, - proposedPlans: thread.proposedPlans, - latestUserMessageAt: getLatestUserMessageAt(thread), - }; - sidebarThreadSnapshotCache.set(thread, { lastVisitedAt, snapshot }); - return snapshot; -} interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -309,6 +244,301 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { return null; } +interface SidebarThreadRowProps { + threadId: ThreadId; + orderedProjectThreadIds: readonly ThreadId[]; + routeThreadId: ThreadId | null; + selectedThreadIds: ReadonlySet; + showThreadJumpHints: boolean; + jumpLabel: string | null; + appSettingsConfirmThreadArchive: boolean; + renamingThreadId: ThreadId | null; + renamingTitle: string; + setRenamingTitle: (title: string) => void; + renamingInputRef: MutableRefObject; + renamingCommittedRef: MutableRefObject; + confirmingArchiveThreadId: ThreadId | null; + setConfirmingArchiveThreadId: Dispatch>; + confirmArchiveButtonRefs: MutableRefObject>; + handleThreadClick: ( + event: MouseEvent, + threadId: ThreadId, + orderedProjectThreadIds: readonly ThreadId[], + ) => void; + navigateToThread: (threadId: ThreadId) => void; + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; + handleThreadContextMenu: ( + threadId: ThreadId, + position: { x: number; y: number }, + ) => Promise; + clearSelection: () => void; + commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise; + cancelRename: () => void; + attemptArchiveThread: (threadId: ThreadId) => Promise; + openPrLink: (event: MouseEvent, prUrl: string) => void; + pr: ThreadPr | null; +} + +function SidebarThreadRow(props: SidebarThreadRowProps) { + const thread = useSidebarThreadSummaryById(props.threadId); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); + const runningTerminalIds = useTerminalStateStore( + (state) => + selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, + ); + + if (!thread) { + return null; + } + + const isActive = props.routeThreadId === thread.id; + const isSelected = props.selectedThreadIds.has(thread.id); + const isHighlighted = isActive || isSelected; + const isThreadRunning = + thread.session?.status === "running" && thread.session.activeTurnId != null; + const threadStatus = resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt, + }, + }); + const prStatus = prStatusIndicator(props.pr); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; + const threadMetaClassName = isConfirmingArchive + ? "pointer-events-none opacity-0" + : !isThreadRunning + ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" + : "pointer-events-none"; + + return ( + { + props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + }} + onBlurCapture={(event) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } + props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + }); + }} + > + } + size="sm" + isActive={isActive} + data-testid={`thread-row-${thread.id}`} + className={`${resolveThreadRowClassName({ + isActive, + isSelected, + })} relative isolate`} + onClick={(event) => { + props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + props.navigateToThread(thread.id); + }} + onContextMenu={(event) => { + event.preventDefault(); + if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) { + void props.handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + } else { + if (props.selectedThreadIds.size > 0) { + props.clearSelection(); + } + void props.handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + } + }} + > +
+ {prStatus && ( + + { + props.openPrLink(event, prStatus.url); + }} + > + + + } + /> + {prStatus.tooltip} + + )} + {threadStatus && } + {props.renamingThreadId === thread.id ? ( + { + if (element && props.renamingInputRef.current !== element) { + props.renamingInputRef.current = element; + element.focus(); + element.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={props.renamingTitle} + onChange={(event) => props.setRenamingTitle(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + props.renamingCommittedRef.current = true; + void props.commitRename(thread.id, props.renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + props.renamingCommittedRef.current = true; + props.cancelRename(); + } + }} + onBlur={() => { + if (!props.renamingCommittedRef.current) { + void props.commitRename(thread.id, props.renamingTitle, thread.title); + } + }} + onClick={(event) => event.stopPropagation()} + /> + ) : ( + {thread.title} + )} +
+
+ {terminalStatus && ( + + + + )} +
+ {isConfirmingArchive ? ( + + ) : !isThreadRunning ? ( + props.appSettingsConfirmThreadArchive ? ( +
+ +
+ ) : ( + + + +
+ } + /> + Archive + + ) + ) : null} + + {props.showThreadJumpHints && props.jumpLabel ? ( + + {props.jumpLabel} + + ) : ( + + {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} + + )} + +
+ +
+
+ ); +} + function T3Wordmark() { return ( React.ReactNode; + children: (handleProps: SortableProjectHandleProps) => ReactNode; }) { const { attributes, @@ -436,7 +666,8 @@ function SortableProjectItem({ export default function Sidebar() { const projects = useStore((store) => store.projects); - const serverThreads = useStore((store) => store.threads); + const sidebarThreadsById = useStore((store) => store.sidebarThreadsById); + const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( useShallow((store) => ({ projectExpandedById: store.projectExpandedById, @@ -512,13 +743,7 @@ export default function Sidebar() { })), [orderedProjects, projectExpandedById], ); - const threads = useMemo( - () => - serverThreads.map((thread) => - toSidebarThreadSnapshot(thread, threadLastVisitedAtById[thread.id]), - ), - [serverThreads, threadLastVisitedAtById], - ); + const sidebarThreads = useMemo(() => Object.values(sidebarThreadsById), [sidebarThreadsById]); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -538,12 +763,12 @@ export default function Sidebar() { ); const threadGitTargets = useMemo( () => - threads.map((thread) => ({ + sidebarThreads.map((thread) => ({ threadId: thread.id, branch: thread.branch, cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, })), - [projectCwdById, threads], + [projectCwdById, sidebarThreads], ); const threadGitStatusCwds = useMemo( () => [ @@ -584,7 +809,7 @@ export default function Sidebar() { return map; }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); - const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { + const openPrLink = useCallback((event: MouseEvent, prUrl: string) => { event.preventDefault(); event.stopPropagation(); @@ -624,7 +849,10 @@ export default function Sidebar() { const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { const latestThread = sortThreadsForSidebar( - threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), + (threadIdsByProjectId[projectId] ?? []) + .map((threadId) => sidebarThreadsById[threadId]) + .filter((thread): thread is NonNullable => thread !== undefined) + .filter((thread) => thread.archivedAt === null), appSettings.sidebarThreadSortOrder, )[0]; if (!latestThread) return; @@ -634,7 +862,7 @@ export default function Sidebar() { params: { threadId: latestThread.id }, }); }, - [appSettings.sidebarThreadSortOrder, navigate, threads], + [appSettings.sidebarThreadSortOrder, navigate, sidebarThreadsById, threadIdsByProjectId], ); const addProjectFromPath = useCallback( @@ -830,7 +1058,7 @@ export default function Sidebar() { async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; - const thread = threads.find((t) => t.id === threadId); + const thread = sidebarThreadsById[threadId]; if (!thread) return; const threadWorkspacePath = thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; @@ -893,7 +1121,7 @@ export default function Sidebar() { deleteThread, markThreadUnread, projectCwdById, - threads, + sidebarThreadsById, ], ); @@ -915,7 +1143,7 @@ export default function Sidebar() { if (clicked === "mark-unread") { for (const id of ids) { - const thread = threads.find((candidate) => candidate.id === id); + const thread = sidebarThreadsById[id]; markThreadUnread(id, thread?.latestTurn?.completedAt); } clearSelection(); @@ -947,7 +1175,7 @@ export default function Sidebar() { markThreadUnread, removeFromSelection, selectedThreadIds, - threads, + sidebarThreadsById, ], ); @@ -1023,8 +1251,8 @@ export default function Sidebar() { } if (clicked !== "delete") return; - const projectThreads = threads.filter((thread) => thread.projectId === projectId); - if (projectThreads.length > 0) { + const projectThreadIds = threadIdsByProjectId[projectId] ?? []; + if (projectThreadIds.length > 0) { toastManager.add({ type: "warning", title: "Project is not empty", @@ -1063,7 +1291,7 @@ export default function Sidebar() { copyPathToClipboard, getDraftThreadByProjectId, projects, - threads, + threadIdsByProjectId, ], ); @@ -1151,8 +1379,8 @@ export default function Sidebar() { ); const visibleThreads = useMemo( - () => threads.filter((thread) => thread.archivedAt === null), - [threads], + () => sidebarThreads.filter((thread) => thread.archivedAt === null), + [sidebarThreads], ); const sortedProjects = useMemo( () => @@ -1163,22 +1391,22 @@ export default function Sidebar() { const renderedProjects = useMemo( () => sortedProjects.map((project) => { + const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) => + resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt: threadLastVisitedAtById[thread.id], + }, + }); const projectThreads = sortThreadsForSidebar( - visibleThreads.filter((thread) => thread.projectId === project.id), + (threadIdsByProjectId[project.id] ?? []) + .map((threadId) => sidebarThreadsById[threadId]) + .filter((thread): thread is NonNullable => thread !== undefined) + .filter((thread) => thread.archivedAt === null), appSettings.sidebarThreadSortOrder, ); - const threadStatuses = new Map( - projectThreads.map((thread) => [ - thread.id, - resolveThreadStatusPill({ - thread, - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - }), - ]), - ); const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => threadStatuses.get(thread.id) ?? null), + projectThreads.map((thread) => resolveProjectThreadStatus(thread)), ); const activeThreadId = routeThreadId ?? undefined; const isThreadListExpanded = expandedThreadListsByProject.has(project.id); @@ -1198,12 +1426,12 @@ export default function Sidebar() { previewLimit: THREAD_PREVIEW_LIMIT, }); const hiddenThreadStatus = resolveProjectStatusIndicator( - hiddenThreads.map((thread) => threadStatuses.get(thread.id) ?? null), + hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), ); const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreads = pinnedCollapsedThread - ? [pinnedCollapsedThread] - : visibleProjectThreads; + const renderedThreadIds = pinnedCollapsedThread + ? [pinnedCollapsedThread.id] + : visibleProjectThreads.map((thread) => thread.id); const showEmptyThreadState = project.expanded && projectThreads.length === 0; return { @@ -1212,9 +1440,7 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, - projectThreads, - threadStatuses, - renderedThreads, + renderedThreadIds, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -1225,7 +1451,9 @@ export default function Sidebar() { expandedThreadListsByProject, routeThreadId, sortedProjects, - visibleThreads, + sidebarThreadsById, + threadIdsByProjectId, + threadLastVisitedAtById, ], ); const visibleSidebarThreadIds = useMemo( @@ -1266,7 +1494,7 @@ export default function Sidebar() { terminalOpen: routeTerminalOpen, }); - const onWindowKeyDown = (event: KeyboardEvent) => { + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { updateThreadJumpHintsVisibility( shouldShowThreadJumpHints(event, keybindings, { platform, @@ -1314,7 +1542,7 @@ export default function Sidebar() { navigateToThread(targetThreadId); }; - const onWindowKeyUp = (event: KeyboardEvent) => { + const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { updateThreadJumpHintsVisibility( shouldShowThreadJumpHints(event, keybindings, { platform, @@ -1357,263 +1585,11 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, - projectThreads, - threadStatuses, - renderedThreads, + renderedThreadIds, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, } = renderedProject; - const renderThreadRow = (thread: (typeof projectThreads)[number]) => { - const isActive = routeThreadId === thread.id; - const isSelected = selectedThreadIds.has(thread.id); - const isHighlighted = isActive || isSelected; - const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; - const isThreadRunning = - thread.session?.status === "running" && thread.session.activeTurnId != null; - const threadStatus = threadStatuses.get(thread.id) ?? null; - const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, - ); - const isConfirmingArchive = confirmingArchiveThreadId === thread.id && !isThreadRunning; - const threadMetaClassName = isConfirmingArchive - ? "pointer-events-none opacity-0" - : !isThreadRunning - ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" - : "pointer-events-none"; - - return ( - { - setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); - }} - onBlurCapture={(event) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; - } - setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); - }); - }} - > - } - size="sm" - isActive={isActive} - data-testid={`thread-row-${thread.id}`} - className={`${resolveThreadRowClassName({ - isActive, - isSelected, - })} relative isolate`} - onClick={(event) => { - handleThreadClick(event, thread.id, orderedProjectThreadIds); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(thread.id); - }} - onContextMenu={(event) => { - event.preventDefault(); - if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (selectedThreadIds.size > 0) { - clearSelection(); - } - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - } - }} - > -
- {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - - } - /> - {prStatus.tooltip} - - )} - {threadStatus && } - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } - }} - className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(thread.id, renamingTitle, thread.title); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename(thread.id, renamingTitle, thread.title); - } - }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - {thread.title} - )} -
-
- {terminalStatus && ( - - - - )} -
- {isConfirmingArchive ? ( - - ) : !isThreadRunning ? ( - appSettings.confirmThreadArchive ? ( -
- -
- ) : ( - - - -
- } - /> - Archive - - ) - ) : null} - - {showThreadJumpHints && jumpLabel ? ( - - {jumpLabel} - - ) : ( - - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} - - )} - -
- - - - ); - }; - return ( <>
@@ -1711,7 +1687,37 @@ export default function Sidebar() {
) : null} - {shouldShowThreadPanel && renderedThreads.map((thread) => renderThreadRow(thread))} + {shouldShowThreadPanel && + renderedThreadIds.map((threadId) => ( + + ))} {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( @@ -1752,7 +1758,7 @@ export default function Sidebar() { } const handleProjectTitleClick = useCallback( - (event: React.MouseEvent, projectId: ProjectId) => { + (event: MouseEvent, projectId: ProjectId) => { if (suppressProjectClickForContextMenuRef.current) { suppressProjectClickForContextMenuRef.current = false; event.preventDefault(); @@ -1780,7 +1786,7 @@ export default function Sidebar() { ); const handleProjectTitleKeyDown = useCallback( - (event: React.KeyboardEvent, projectId: ProjectId) => { + (event: KeyboardEvent, projectId: ProjectId) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); if (dragInProgressRef.current) { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 368c595a4b..99c3ad4e6a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -153,6 +153,40 @@ function errorDetails(error: unknown): string { } } +function coalesceOrchestrationUiEvents( + events: ReadonlyArray, +): OrchestrationEvent[] { + if (events.length < 2) { + return [...events]; + } + + const coalesced: OrchestrationEvent[] = []; + for (const event of events) { + const previous = coalesced.at(-1); + if ( + previous?.type === "thread.message-sent" && + event.type === "thread.message-sent" && + previous.payload.threadId === event.payload.threadId && + previous.payload.messageId === event.payload.messageId + ) { + coalesced[coalesced.length - 1] = { + ...event, + payload: { + ...event.payload, + attachments: event.payload.attachments ?? previous.payload.attachments, + createdAt: previous.payload.createdAt, + text: previous.payload.text + event.payload.text, + }, + }; + continue; + } + + coalesced.push(event); + } + + return coalesced; +} + function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); const syncServerReadModel = useStore((store) => store.syncServerReadModel); @@ -269,6 +303,8 @@ function EventRouter() { disposedRef.current = false; const recovery = createOrchestrationRecoveryCoordinator(); let needsProviderInvalidation = false; + const pendingDomainEvents: OrchestrationEvent[] = []; + let flushPendingDomainEventsScheduled = false; const reconcileSnapshotDerivedState = () => { const threads = useStore.getState().threads; @@ -316,6 +352,7 @@ function EventRouter() { } const batchEffects = deriveOrchestrationBatchEffects(nextEvents); + const uiEvents = coalesceOrchestrationUiEvents(nextEvents); const needsProjectUiSync = nextEvents.some( (event) => event.type === "project.created" || @@ -328,7 +365,7 @@ function EventRouter() { void queryInvalidationThrottler.maybeExecute(); } - applyOrchestrationEvents(nextEvents); + applyOrchestrationEvents(uiEvents); if (needsProjectUiSync) { const projects = useStore.getState().projects; syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); @@ -357,6 +394,23 @@ function EventRouter() { removeTerminalState(threadId); } }; + const flushPendingDomainEvents = () => { + flushPendingDomainEventsScheduled = false; + if (disposed || pendingDomainEvents.length === 0) { + return; + } + + const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); + applyEventBatch(events); + }; + const schedulePendingDomainEventFlush = () => { + if (flushPendingDomainEventsScheduled) { + return; + } + + flushPendingDomainEventsScheduled = true; + queueMicrotask(flushPendingDomainEvents); + }; const recoverFromSequenceGap = async (): Promise => { if (!recovery.beginReplayRecovery("sequence-gap")) { @@ -410,10 +464,12 @@ function EventRouter() { const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { const action = recovery.classifyDomainEvent(event.sequence); if (action === "apply") { - applyEventBatch([event]); + pendingDomainEvents.push(event); + schedulePendingDomainEventFlush(); return; } if (action === "recover") { + flushPendingDomainEvents(); void recoverFromSequenceGap(); } }); @@ -434,6 +490,8 @@ function EventRouter() { disposed = true; disposedRef.current = true; needsProviderInvalidation = false; + flushPendingDomainEventsScheduled = false; + pendingDomainEvents.length = 0; queryInvalidationThrottler.cancel(); unsubDomainEvent(); unsubTerminalEvent(); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 6e909b38f0..f0d42d8b1b 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -47,6 +47,9 @@ function makeThread(overrides: Partial = {}): Thread { } function makeState(thread: Thread): AppState { + const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { + [thread.projectId]: [thread.id], + }; return { projects: [ { @@ -61,6 +64,8 @@ function makeState(thread: Thread): AppState { }, ], threads: [thread], + sidebarThreadsById: {}, + threadIdsByProjectId, bootstrapComplete: true, }; } @@ -271,6 +276,8 @@ describe("store read model sync", () => { }, ], threads: [], + sidebarThreadsById: {}, + threadIdsByProjectId: {}, bootstrapComplete: true, }; const readModel: OrchestrationReadModel = { @@ -361,6 +368,8 @@ describe("incremental orchestration updates", () => { }, ], threads: [], + sidebarThreadsById: {}, + threadIdsByProjectId: {}, bootstrapComplete: true, }; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index eff6a6fd07..a98e56383f 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -2,6 +2,7 @@ import { type OrchestrationEvent, type OrchestrationMessage, type OrchestrationProposedPlan, + type ProjectId, type ProviderKind, ThreadId, type OrchestrationReadModel, @@ -12,25 +13,36 @@ import { } from "@t3tools/contracts"; import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; -import { type ChatMessage, type Project, type Thread } from "./types"; +import { + findLatestProposedPlan, + hasActionableProposedPlan, + derivePendingApprovals, + derivePendingUserInputs, +} from "./session-logic"; +import { type ChatMessage, type Project, type SidebarThreadSummary, type Thread } from "./types"; // ── State ──────────────────────────────────────────────────────────── export interface AppState { projects: Project[]; threads: Thread[]; + sidebarThreadsById: Record; + threadIdsByProjectId: Record; bootstrapComplete: boolean; } const initialState: AppState = { projects: [], threads: [], + sidebarThreadsById: {}, + threadIdsByProjectId: {}, bootstrapComplete: false, }; const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; const MAX_THREAD_PROPOSED_PLANS = 200; const MAX_THREAD_ACTIVITIES = 500; +const EMPTY_THREAD_IDS: ThreadId[] = []; // ── Pure helpers ────────────────────────────────────────────────────── @@ -180,6 +192,127 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec }; } +function getLatestUserMessageAt( + messages: ReadonlyArray, +): string | null { + let latestUserMessageAt: string | null = null; + + for (const message of messages) { + if (message.role !== "user") { + continue; + } + if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) { + latestUserMessageAt = message.createdAt; + } + } + + return latestUserMessageAt; +} + +function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { + return { + id: thread.id, + projectId: thread.projectId, + title: thread.title, + interactionMode: thread.interactionMode, + session: thread.session, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + latestTurn: thread.latestTurn, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestUserMessageAt: getLatestUserMessageAt(thread.messages), + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + hasActionableProposedPlan: hasActionableProposedPlan( + findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null), + ), + }; +} + +function sidebarThreadSummariesEqual( + left: SidebarThreadSummary | undefined, + right: SidebarThreadSummary, +): boolean { + return ( + left !== undefined && + left.id === right.id && + left.projectId === right.projectId && + left.title === right.title && + left.interactionMode === right.interactionMode && + left.session === right.session && + left.createdAt === right.createdAt && + left.archivedAt === right.archivedAt && + left.updatedAt === right.updatedAt && + left.latestTurn === right.latestTurn && + left.branch === right.branch && + left.worktreePath === right.worktreePath && + left.latestUserMessageAt === right.latestUserMessageAt && + left.hasPendingApprovals === right.hasPendingApprovals && + left.hasPendingUserInput === right.hasPendingUserInput && + left.hasActionableProposedPlan === right.hasActionableProposedPlan + ); +} + +function appendThreadIdByProjectId( + threadIdsByProjectId: Record, + projectId: ProjectId, + threadId: ThreadId, +): Record { + const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; + if (existingThreadIds.includes(threadId)) { + return threadIdsByProjectId; + } + return { + ...threadIdsByProjectId, + [projectId]: [...existingThreadIds, threadId], + }; +} + +function removeThreadIdByProjectId( + threadIdsByProjectId: Record, + projectId: ProjectId, + threadId: ThreadId, +): Record { + const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; + if (!existingThreadIds.includes(threadId)) { + return threadIdsByProjectId; + } + const nextThreadIds = existingThreadIds.filter( + (existingThreadId) => existingThreadId !== threadId, + ); + if (nextThreadIds.length === existingThreadIds.length) { + return threadIdsByProjectId; + } + if (nextThreadIds.length === 0) { + const nextThreadIdsByProjectId = { ...threadIdsByProjectId }; + delete nextThreadIdsByProjectId[projectId]; + return nextThreadIdsByProjectId; + } + return { + ...threadIdsByProjectId, + [projectId]: nextThreadIds, + }; +} + +function buildThreadIdsByProjectId(threads: ReadonlyArray): Record { + const threadIdsByProjectId: Record = {}; + for (const thread of threads) { + const existingThreadIds = threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS; + threadIdsByProjectId[thread.projectId] = [...existingThreadIds, thread.id]; + } + return threadIdsByProjectId; +} + +function buildSidebarThreadsById( + threads: ReadonlyArray, +): Record { + return Object.fromEntries( + threads.map((thread) => [thread.id, buildSidebarThreadSummary(thread)]), + ); +} + function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { if (status === "error") { return "error" as const; @@ -398,6 +531,46 @@ function attachmentPreviewRoutePath(attachmentId: string): string { return `/attachments/${encodeURIComponent(attachmentId)}`; } +function updateThreadState( + state: AppState, + threadId: ThreadId, + updater: (thread: Thread) => Thread, +): AppState { + let updatedThread: Thread | null = null; + const threads = updateThread(state.threads, threadId, (thread) => { + const nextThread = updater(thread); + if (nextThread !== thread) { + updatedThread = nextThread; + } + return nextThread; + }); + if (threads === state.threads || updatedThread === null) { + return state; + } + + const nextSummary = buildSidebarThreadSummary(updatedThread); + const previousSummary = state.sidebarThreadsById[threadId]; + const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) + ? state.sidebarThreadsById + : { + ...state.sidebarThreadsById, + [threadId]: nextSummary, + }; + + if (sidebarThreadsById === state.sidebarThreadsById) { + return { + ...state, + threads, + }; + } + + return { + ...state, + threads, + sidebarThreadsById, + }; +} + // ── Pure state transition functions ──────────────────────────────────── export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { @@ -405,10 +578,14 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea .filter((project) => project.deletedAt === null) .map(mapProject); const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); + const sidebarThreadsById = buildSidebarThreadsById(threads); + const threadIdsByProjectId = buildThreadIdsByProjectId(threads); return { ...state, projects, threads, + sidebarThreadsById, + threadIdsByProjectId, bootstrapComplete: true, }; } @@ -489,34 +666,68 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve const threads = existing ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) : [...state.threads, nextThread]; - return { ...state, threads }; + const nextSummary = buildSidebarThreadSummary(nextThread); + const previousSummary = state.sidebarThreadsById[nextThread.id]; + const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) + ? state.sidebarThreadsById + : { + ...state.sidebarThreadsById, + [nextThread.id]: nextSummary, + }; + const threadIdsByProjectId = appendThreadIdByProjectId( + state.threadIdsByProjectId, + nextThread.projectId, + nextThread.id, + ); + return { + ...state, + threads, + sidebarThreadsById, + threadIdsByProjectId, + }; } case "thread.deleted": { const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId); - return threads.length === state.threads.length ? state : { ...state, threads }; + if (threads.length === state.threads.length) { + return state; + } + const deletedThread = state.threads.find((thread) => thread.id === event.payload.threadId); + const sidebarThreadsById = { ...state.sidebarThreadsById }; + delete sidebarThreadsById[event.payload.threadId]; + const threadIdsByProjectId = deletedThread + ? removeThreadIdByProjectId( + state.threadIdsByProjectId, + deletedThread.projectId, + deletedThread.id, + ) + : state.threadIdsByProjectId; + return { + ...state, + threads, + sidebarThreadsById, + threadIdsByProjectId, + }; } case "thread.archived": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, archivedAt: event.payload.archivedAt, updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.unarchived": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, archivedAt: null, updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.meta-updated": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), ...(event.payload.modelSelection !== undefined @@ -528,29 +739,26 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : {}), updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.runtime-mode-set": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, runtimeMode: event.payload.runtimeMode, updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.interaction-mode-set": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, interactionMode: event.payload.interactionMode, updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.turn-start-requested": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.modelSelection !== undefined ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } @@ -560,14 +768,13 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve pendingSourceProposedPlan: event.payload.sourceProposedPlan, updatedAt: event.occurredAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.turn-interrupt-requested": { if (event.payload.turnId === undefined) { return state; } - const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + return updateThreadState(state, event.payload.threadId, (thread) => { const latestTurn = thread.latestTurn; if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { return thread; @@ -586,11 +793,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads }; } case "thread.message-sent": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + return updateThreadState(state, event.payload.threadId, (thread) => { const message = mapMessage({ id: event.payload.messageId, role: event.payload.role, @@ -678,11 +884,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads }; } case "thread.session-set": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, session: mapSession(event.payload.session), error: event.payload.session.lastError ?? null, @@ -710,11 +915,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : thread.latestTurn, updatedAt: event.occurredAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.session-stop-requested": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => + return updateThreadState(state, event.payload.threadId, (thread) => thread.session === null ? thread : { @@ -729,11 +933,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }, ); - return threads === state.threads ? state : { ...state, threads }; } case "thread.proposed-plan-upserted": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + return updateThreadState(state, event.payload.threadId, (thread) => { const proposedPlan = mapProposedPlan(event.payload.proposedPlan); const proposedPlans = [ ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id), @@ -750,11 +953,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads }; } case "thread.turn-diff-completed": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + return updateThreadState(state, event.payload.threadId, (thread) => { const checkpoint = mapTurnDiffSummary({ turnId: event.payload.turnId, checkpointTurnCount: event.payload.checkpointTurnCount, @@ -800,11 +1002,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads }; } case "thread.reverted": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + return updateThreadState(state, event.payload.threadId, (thread) => { const turnDiffSummaries = thread.turnDiffSummaries .filter( (entry) => @@ -853,11 +1054,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads }; } case "thread.activity-appended": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + return updateThreadState(state, event.payload.threadId, (thread) => { const activities = [ ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), { ...event.payload.activity }, @@ -870,7 +1070,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads }; } case "thread.approval-response-requested": @@ -901,12 +1100,21 @@ export const selectThreadById = (state: AppState): Thread | undefined => threadId ? state.threads.find((thread) => thread.id === threadId) : undefined; +export const selectSidebarThreadSummaryById = + (threadId: ThreadId | null | undefined) => + (state: AppState): SidebarThreadSummary | undefined => + threadId ? state.sidebarThreadsById[threadId] : undefined; + +export const selectThreadIdsByProjectId = + (projectId: ProjectId | null | undefined) => + (state: AppState): ThreadId[] => + projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; + export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - const threads = updateThread(state.threads, threadId, (t) => { + return updateThreadState(state, threadId, (t) => { if (t.error === error) return t; return { ...t, error }; }); - return threads === state.threads ? state : { ...state, threads }; } export function setThreadBranch( @@ -915,7 +1123,7 @@ export function setThreadBranch( branch: string | null, worktreePath: string | null, ): AppState { - const threads = updateThread(state.threads, threadId, (t) => { + return updateThreadState(state, threadId, (t) => { if (t.branch === branch && t.worktreePath === worktreePath) return t; const cwdChanged = t.worktreePath !== worktreePath; return { @@ -925,7 +1133,6 @@ export function setThreadBranch( ...(cwdChanged ? { session: null } : {}), }; }); - return threads === state.threads ? state : { ...state, threads }; } // ── Zustand store ──────────────────────────────────────────────────── diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index 271fbb256b..d6e69acb3e 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,7 +1,13 @@ -import { type ThreadId } from "@t3tools/contracts"; +import { type ProjectId, type ThreadId } from "@t3tools/contracts"; import { useMemo } from "react"; -import { selectProjectById, selectThreadById, useStore } from "./store"; -import { type Project, type Thread } from "./types"; +import { + selectProjectById, + selectSidebarThreadSummaryById, + selectThreadById, + selectThreadIdsByProjectId, + useStore, +} from "./store"; +import { type Project, type SidebarThreadSummary, type Thread } from "./types"; export function useProjectById(projectId: Project["id"] | null | undefined): Project | undefined { const selector = useMemo(() => selectProjectById(projectId), [projectId]); @@ -12,3 +18,15 @@ export function useThreadById(threadId: ThreadId | null | undefined): Thread | u const selector = useMemo(() => selectThreadById(threadId), [threadId]); return useStore(selector); } + +export function useSidebarThreadSummaryById( + threadId: ThreadId | null | undefined, +): SidebarThreadSummary | undefined { + const selector = useMemo(() => selectSidebarThreadSummaryById(threadId), [threadId]); + return useStore(selector); +} + +export function useThreadIdsByProjectId(projectId: ProjectId | null | undefined): ThreadId[] { + const selector = useMemo(() => selectThreadIdsByProjectId(projectId), [projectId]); + return useStore(selector); +} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 0ebf150310..0599b9c989 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -111,6 +111,24 @@ export interface Thread { activities: OrchestrationThreadActivity[]; } +export interface SidebarThreadSummary { + id: ThreadId; + projectId: ProjectId; + title: string; + interactionMode: ProviderInteractionMode; + session: ThreadSession | null; + createdAt: string; + archivedAt: string | null; + updatedAt?: string | undefined; + latestTurn: OrchestrationLatestTurn | null; + branch: string | null; + worktreePath: string | null; + latestUserMessageAt: string | null; + hasPendingApprovals: boolean; + hasPendingUserInput: boolean; + hasActionableProposedPlan: boolean; +} + export interface ThreadSession { provider: ProviderKind; status: SessionPhase | "error" | "closed"; From 10635a66340fb9129a198bc35215a24274effb69 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Apr 2026 22:29:01 +0000 Subject: [PATCH 2/7] Fix coalescing text semantics for non-streaming events and remove unused useThreadIdsByProjectId hook - In coalesceOrchestrationUiEvents, when the later event has streaming=false and non-empty text, use the later event's text as a replacement instead of concatenating, matching the store handler's replacement semantics. - Remove unused useThreadIdsByProjectId hook and its selectThreadIdsByProjectId import from storeSelectors.ts. Applied via @cursor push command --- apps/web/src/routes/__root.tsx | 5 ++++- apps/web/src/storeSelectors.ts | 8 +------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 99c3ad4e6a..d186a7c9c1 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -175,7 +175,10 @@ function coalesceOrchestrationUiEvents( ...event.payload, attachments: event.payload.attachments ?? previous.payload.attachments, createdAt: previous.payload.createdAt, - text: previous.payload.text + event.payload.text, + text: + !event.payload.streaming && event.payload.text.length > 0 + ? event.payload.text + : previous.payload.text + event.payload.text, }, }; continue; diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index d6e69acb3e..65f8e6caaa 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,10 +1,9 @@ -import { type ProjectId, type ThreadId } from "@t3tools/contracts"; +import { type ThreadId } from "@t3tools/contracts"; import { useMemo } from "react"; import { selectProjectById, selectSidebarThreadSummaryById, selectThreadById, - selectThreadIdsByProjectId, useStore, } from "./store"; import { type Project, type SidebarThreadSummary, type Thread } from "./types"; @@ -25,8 +24,3 @@ export function useSidebarThreadSummaryById( const selector = useMemo(() => selectSidebarThreadSummaryById(threadId), [threadId]); return useStore(selector); } - -export function useThreadIdsByProjectId(projectId: ProjectId | null | undefined): ThreadId[] { - const selector = useMemo(() => selectThreadIdsByProjectId(projectId), [projectId]); - return useStore(selector); -} From 4738d193cb08e8456fbcf5ed0f164305d197a79e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 1 Apr 2026 15:37:00 -0700 Subject: [PATCH 3/7] rm keys --- apps/web/src/routes/_chat.$threadId.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index b95d1ef7b0..31920cf40f 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -222,7 +222,7 @@ function ChatThreadRouteView() { return ( <> - + - + {shouldRenderDiffContent ? : null} From e3feaa15846fa976cc01a34836facf16167a5423 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 1 Apr 2026 16:23:22 -0700 Subject: [PATCH 4/7] Improve checkpoint diffs and background git refresh - Derive checkpoint diffs from orchestration snapshots - Refresh checked-out branch upstream refs asynchronously - Update git, orchestration, and UI tests for the new flow --- .../OrchestrationEngineHarness.integration.ts | 9 +- .../Layers/CheckpointDiffQuery.test.ts | 105 ++- .../Layers/CheckpointDiffQuery.ts | 22 +- apps/server/src/git/Layers/GitCore.test.ts | 284 ++----- apps/server/src/git/Layers/GitCore.ts | 147 ++-- apps/server/src/git/Layers/GitHubCli.ts | 2 +- apps/server/src/git/Layers/GitManager.test.ts | 706 +--------------- apps/server/src/git/Layers/GitManager.ts | 767 +++++------------- apps/server/src/git/Services/GitManager.ts | 2 +- apps/server/src/git/remoteRefs.ts | 67 -- .../Layers/CheckpointReactor.test.ts | 2 - .../Layers/OrchestrationEngine.test.ts | 107 +-- .../Layers/OrchestrationEngine.ts | 10 +- .../Layers/ProjectionPipeline.test.ts | 2 - .../Layers/ProjectionPipeline.ts | 30 +- .../Layers/ProjectionSnapshotQuery.test.ts | 286 ------- .../Layers/ProjectionSnapshotQuery.ts | 219 +---- .../Layers/ProviderCommandReactor.test.ts | 2 - .../Layers/ProviderRuntimeIngestion.test.ts | 2 - .../Services/ProjectionSnapshotQuery.ts | 48 +- .../Layers/ProjectionThreadMessages.test.ts | 7 - .../Layers/ProjectionThreadMessages.ts | 63 +- apps/server/src/persistence/Migrations.ts | 2 - ...19_ProjectionSnapshotLookupIndexes.test.ts | 73 -- .../019_ProjectionSnapshotLookupIndexes.ts | 16 - .../Services/ProjectionThreadMessages.ts | 15 +- apps/server/src/server.test.ts | 11 - apps/server/src/server.ts | 24 +- apps/server/src/serverRuntimeStartup.test.ts | 41 +- apps/server/src/serverRuntimeStartup.ts | 54 +- .../GitActionsControl.logic.test.ts | 164 ++-- .../src/components/GitActionsControl.logic.ts | 132 ++- apps/web/src/components/GitActionsControl.tsx | 204 ++--- apps/web/src/components/Sidebar.logic.test.ts | 63 -- apps/web/src/components/Sidebar.logic.ts | 40 - apps/web/src/components/Sidebar.tsx | 32 +- apps/web/src/routes/__root.tsx | 5 +- apps/web/src/store.test.ts | 64 ++ apps/web/src/store.ts | 6 +- apps/web/src/storeSelectors.ts | 8 +- packages/contracts/src/git.test.ts | 53 +- packages/contracts/src/git.ts | 41 +- 42 files changed, 851 insertions(+), 3086 deletions(-) delete mode 100644 apps/server/src/git/remoteRefs.ts delete mode 100644 apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts delete mode 100644 apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 152ed1d608..408ea827c2 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -289,10 +289,9 @@ export const makeOrchestrationIntegrationHarness = ( ); const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); - const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( - projectionSnapshotQueryLayer, - orchestrationLayer.pipe(Layer.provide(projectionSnapshotQueryLayer)), + orchestrationLayer, + OrchestrationProjectionSnapshotQueryLive, ProjectionCheckpointRepositoryLive, ProjectionPendingApprovalRepositoryLive, checkpointStoreLayer, @@ -334,9 +333,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(providerCommandReactorLayer), Layer.provideMerge(checkpointReactorLayer), ); - const layer = Layer.empty.pipe( - Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(orchestrationReactorLayer), + const layer = orchestrationReactorLayer.pipe( Layer.provide(persistenceLayer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index c66c529b9a..9fb2500ce4 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -1,38 +1,83 @@ -import { CheckpointRef, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; -import { Effect, Layer, Option } from "effect"; +import { + CheckpointRef, + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ThreadId, + TurnId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; -import { - ProjectionSnapshotQuery, - type ProjectionThreadCheckpointContext, -} from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { checkpointRefForThreadTurn } from "../Utils.ts"; import { CheckpointDiffQueryLive } from "./CheckpointDiffQuery.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointDiffQuery } from "../Services/CheckpointDiffQuery.ts"; -function makeThreadCheckpointContext(input: { +function makeSnapshot(input: { readonly projectId: ProjectId; readonly threadId: ThreadId; readonly workspaceRoot: string; readonly worktreePath: string | null; readonly checkpointTurnCount: number; readonly checkpointRef: CheckpointRef; -}): ProjectionThreadCheckpointContext { +}): OrchestrationReadModel { return { - threadId: input.threadId, - projectId: input.projectId, - workspaceRoot: input.workspaceRoot, - worktreePath: input.worktreePath, - checkpoints: [ + snapshotSequence: 0, + updatedAt: "2026-01-01T00:00:00.000Z", + projects: [ + { + id: input.projectId, + title: "Project", + workspaceRoot: input.workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }, + ], + threads: [ { - turnId: TurnId.makeUnsafe("turn-1"), - checkpointTurnCount: input.checkpointTurnCount, - checkpointRef: input.checkpointRef, - status: "ready", - files: [], - assistantMessageId: null, - completedAt: "2026-01-01T00:00:00.000Z", + id: input.threadId, + projectId: input.projectId, + title: "Thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: input.worktreePath, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-01-01T00:00:00.000Z", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:00.000Z", + assistantMessageId: null, + }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + messages: [], + activities: [], + proposedPlans: [], + checkpoints: [ + { + turnId: TurnId.makeUnsafe("turn-1"), + checkpointTurnCount: input.checkpointTurnCount, + checkpointRef: input.checkpointRef, + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-01-01T00:00:00.000Z", + }, + ], + session: null, }, ], }; @@ -50,7 +95,7 @@ describe("CheckpointDiffQueryLive", () => { readonly cwd: string; }> = []; - const threadCheckpointContext = makeThreadCheckpointContext({ + const snapshot = makeSnapshot({ projectId, threadId, workspaceRoot: "/tmp/workspace", @@ -80,12 +125,7 @@ describe("CheckpointDiffQueryLive", () => { Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery, { - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getSnapshot: () => Effect.succeed(snapshot), }), ), ); @@ -135,11 +175,12 @@ describe("CheckpointDiffQueryLive", () => { Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery, { getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", + } satisfies OrchestrationReadModel), }), ), ); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts index 1c2edee469..bbb5a42931 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts @@ -4,11 +4,11 @@ import { type OrchestrationGetFullThreadDiffResult, type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, } from "@t3tools/contracts"; -import { Effect, Layer, Option, Schema } from "effect"; +import { Effect, Layer, Schema } from "effect"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts"; -import { checkpointRefForThreadTurn } from "../Utils.ts"; +import { checkpointRefForThreadTurn, resolveThreadWorkspaceCwd } from "../Utils.ts"; import { CheckpointStore } from "../Services/CheckpointStore.ts"; import { CheckpointDiffQuery, @@ -41,17 +41,16 @@ const make = Effect.gen(function* () { return emptyDiff; } - const threadContext = yield* projectionSnapshotQuery.getThreadCheckpointContext( - input.threadId, - ); - if (Option.isNone(threadContext)) { + const snapshot = yield* projectionSnapshotQuery.getSnapshot(); + const thread = snapshot.threads.find((entry) => entry.id === input.threadId); + if (!thread) { return yield* new CheckpointInvariantError({ operation, detail: `Thread '${input.threadId}' not found.`, }); } - const maxTurnCount = threadContext.value.checkpoints.reduce( + const maxTurnCount = thread.checkpoints.reduce( (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), 0, ); @@ -63,7 +62,10 @@ const make = Effect.gen(function* () { }); } - const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; + const workspaceCwd = resolveThreadWorkspaceCwd({ + thread, + projects: snapshot.projects, + }); if (!workspaceCwd) { return yield* new CheckpointInvariantError({ operation, @@ -74,7 +76,7 @@ const make = Effect.gen(function* () { const fromCheckpointRef = input.fromTurnCount === 0 ? checkpointRefForThreadTurn(input.threadId, 0) - : threadContext.value.checkpoints.find( + : thread.checkpoints.find( (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, )?.checkpointRef; if (!fromCheckpointRef) { @@ -85,7 +87,7 @@ const make = Effect.gen(function* () { }); } - const toCheckpointRef = threadContext.value.checkpoints.find( + const toCheckpointRef = thread.checkpoints.find( (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, )?.checkpointRef; if (!toCheckpointRef) { diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 2838edcad7..07892ec447 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -59,23 +59,6 @@ function git( }); } -function configureRemote( - cwd: string, - remoteName: string, - remotePath: string, - fetchNamespace: string, -): Effect.Effect { - return Effect.gen(function* () { - yield* git(cwd, ["config", `remote.${remoteName}.url`, remotePath]); - return yield* git(cwd, [ - "config", - "--replace-all", - `remote.${remoteName}.fetch`, - `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, - ]); - }); -} - function runShellCommand(input: { command: string; cwd: string; @@ -604,7 +587,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect("statusDetails remains successful when upstream refresh fails after checkout", () => + it.effect("keeps checkout successful when upstream refresh fails", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); @@ -629,7 +612,7 @@ it.layer(TestLayer)("git integration", (it) => { const realGitCore = yield* GitCore; let refreshFetchAttempts = 0; const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { + if (input.args[0] === "fetch") { refreshFetchAttempts += 1; return Effect.fail( new GitCommandError({ @@ -643,15 +626,16 @@ it.layer(TestLayer)("git integration", (it) => { return realGitCore.execute(input); }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - const status = yield* core.statusDetails(source); - expect(refreshFetchAttempts).toBe(1); - expect(status.branch).toBe(featureBranch); - expect(status.upstreamRef).toBe(`origin/${featureBranch}`); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(refreshFetchAttempts).toBe(1); + }), + ); expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); }), ); - it.effect("defers upstream refresh until statusDetails is requested", () => + it.effect("refresh fetch is scoped to the checked out branch upstream refspec", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); @@ -673,10 +657,10 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["checkout", defaultBranch]); const realGitCore = yield* GitCore; - let refreshFetchAttempts = 0; + let fetchArgs: readonly string[] | null = null; const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - refreshFetchAttempts += 1; + if (input.args[0] === "fetch") { + fetchArgs = [...input.args]; return Effect.succeed({ code: 0, stdout: "", @@ -688,131 +672,73 @@ it.layer(TestLayer)("git integration", (it) => { return realGitCore.execute(input); }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))); - expect(refreshFetchAttempts).toBe(0); - const status = yield* core.statusDetails(source); - expect(status.branch).toBe(featureBranch); - expect(refreshFetchAttempts).toBe(1); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(fetchArgs).not.toBeNull(); + }), + ); + + expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); + expect(fetchArgs).toEqual([ + "fetch", + "--quiet", + "--no-tags", + "origin", + `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`, + ]); }), ); - it.effect("shares upstream refreshes across worktrees that use the same git common dir", () => + it.effect("returns checkout result before background upstream refresh completes", () => Effect.gen(function* () { - const ok = (stdout = "") => - Effect.succeed({ - code: 0, - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - - let fetchCount = 0; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args[0] === "rev-parse" && - input.args[1] === "--abbrev-ref" && - input.args[2] === "--symbolic-full-name" && - input.args[3] === "@{upstream}" - ) { - return ok("origin/main\n"); - } - if (input.args[0] === "remote") { - return ok("origin\n"); - } - if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { - return ok("/repo/.git\n"); - } - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchCount += 1; - expect(input.cwd).toBe("/repo"); - return ok(); - } - if (input.operation === "GitCore.statusDetails.status") { - return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); - } - if ( - input.operation === "GitCore.statusDetails.unstagedNumstat" || - input.operation === "GitCore.statusDetails.stagedNumstat" - ) { - return ok(); - } - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "Unexpected git command in shared refresh cache test.", - }), - ); - }); + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); - yield* core.statusDetails("/repo/worktrees/main"); - yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); - }), - ); + yield* initRepoWithCommit(source); + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", defaultBranch]); - it.effect("briefly backs off failed upstream refreshes across sibling worktrees", () => - Effect.gen(function* () { - const ok = (stdout = "") => - Effect.succeed({ - code: 0, - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); + const featureBranch = "feature/background-refresh"; + yield* git(source, ["checkout", "-b", featureBranch]); + yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); + yield* git(source, ["add", "feature.txt"]); + yield* git(source, ["commit", "-m", "feature base"]); + yield* git(source, ["push", "-u", "origin", featureBranch]); + yield* git(source, ["checkout", defaultBranch]); - let fetchCount = 0; + const realGitCore = yield* GitCore; + let fetchStarted = false; + let releaseFetch!: () => void; + const waitForReleasePromise = new Promise((resolve) => { + releaseFetch = resolve; + }); const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args[0] === "rev-parse" && - input.args[1] === "--abbrev-ref" && - input.args[2] === "--symbolic-full-name" && - input.args[3] === "@{upstream}" - ) { - return ok("origin/main\n"); - } - if (input.args[0] === "remote") { - return ok("origin\n"); - } - if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { - return ok("/repo/.git\n"); - } - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchCount += 1; - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "simulated fetch timeout", - }), + if (input.args[0] === "fetch") { + fetchStarted = true; + return Effect.promise(() => + waitForReleasePromise.then(() => ({ + code: 0, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + })), ); } - if (input.operation === "GitCore.statusDetails.status") { - return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); - } - if ( - input.operation === "GitCore.statusDetails.unstagedNumstat" || - input.operation === "GitCore.statusDetails.stagedNumstat" - ) { - return ok(); - } - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "Unexpected git command in refresh failure cooldown test.", - }), - ); + return realGitCore.execute(input); }); - - yield* core.statusDetails("/repo/worktrees/main"); - yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); + yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(fetchStarted).toBe(true); + }), + ); + expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); + releaseFetch(); }), ); @@ -853,21 +779,16 @@ it.layer(TestLayer)("git integration", (it) => { it.effect("checks out a remote tracking branch when remote name contains slashes", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); - const prefixRemote = yield* makeTmpDir(); const source = yield* makeTmpDir(); - const prefixFetchNamespace = "prefix-my-org"; - const prefixRemoteName = "my-org"; const remoteName = "my-org/upstream"; const featureBranch = "feature"; yield* git(remote, ["init", "--bare"]); - yield* git(prefixRemote, ["init", "--bare"]); yield* initRepoWithCommit(source); const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; - yield* configureRemote(source, prefixRemoteName, prefixRemote, prefixFetchNamespace); - yield* configureRemote(source, remoteName, remote, remoteName); + yield* git(source, ["remote", "add", remoteName, remote]); yield* git(source, ["push", "-u", remoteName, defaultBranch]); yield* git(source, ["checkout", "-b", featureBranch]); @@ -884,34 +805,6 @@ it.layer(TestLayer)("git integration", (it) => { }); expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); - const realGitCore = yield* GitCore; - let fetchArgs: readonly string[] | null = null; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchArgs = [...input.args]; - return Effect.succeed({ - code: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - } - return realGitCore.execute(input); - }); - - const status = yield* core.statusDetails(source); - expect(status.branch).toBe("upstream/feature"); - expect(status.upstreamRef).toBe(`${remoteName}/${featureBranch}`); - expect(fetchArgs).toEqual([ - "--git-dir", - path.join(source, ".git"), - "fetch", - "--quiet", - "--no-tags", - remoteName, - `+refs/heads/${featureBranch}:refs/remotes/${remoteName}/${featureBranch}`, - ]); }), ); @@ -1798,47 +1691,6 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect("pushes to the tracked upstream when the remote name contains slashes", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - const prefixRemote = yield* makeTmpDir(); - const prefixFetchNamespace = "prefix-my-org"; - const prefixRemoteName = "my-org"; - const remoteName = "my-org/upstream"; - const featureBranch = "feature/slash-remote-push"; - yield* git(remote, ["init", "--bare"]); - yield* git(prefixRemote, ["init", "--bare"]); - - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* configureRemote(tmp, prefixRemoteName, prefixRemote, prefixFetchNamespace); - yield* configureRemote(tmp, remoteName, remote, remoteName); - yield* git(tmp, ["push", "-u", remoteName, initialBranch]); - - yield* git(tmp, ["checkout", "-b", featureBranch]); - yield* writeTextFile(path.join(tmp, "feature.txt"), "first revision\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature base"]); - yield* git(tmp, ["push", "-u", remoteName, featureBranch]); - - yield* writeTextFile(path.join(tmp, "feature.txt"), "second revision\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature update"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(false); - expect(pushed.upstreamBranch).toBe(`${remoteName}/${featureBranch}`); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - `${remoteName}/${featureBranch}`, - ); - expect(yield* git(tmp, ["ls-remote", "--heads", remoteName, featureBranch])).toContain( - featureBranch, - ); - }), - ); - it.effect("includes command context when worktree removal fails", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 0a11abab5b..cbb2c67449 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -27,11 +27,6 @@ import { type ExecuteGitInput, type ExecuteGitResult, } from "../Services/GitCore.ts"; -import { - parseRemoteNames, - parseRemoteNamesInGitOrder, - parseRemoteRefWithRemoteNames, -} from "../remoteRefs.ts"; import { ServerConfig } from "../../config.ts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; @@ -46,7 +41,6 @@ const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); -const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; @@ -56,7 +50,7 @@ type TraceTailState = { }; class StatusUpstreamRefreshCacheKey extends Data.Class<{ - gitCommonDir: string; + cwd: string; upstreamRef: string; remoteName: string; upstreamBranch: string; @@ -183,6 +177,14 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul }; } +function parseRemoteNames(stdout: string): ReadonlyArray { + return stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .toSorted((a, b) => b.length - a.length); +} + function sanitizeRemoteName(value: string): string { const sanitized = value .trim() @@ -215,41 +217,30 @@ function parseRemoteFetchUrls(stdout: string): Map { return remotes; } -function parseUpstreamRefWithRemoteNames( - upstreamRef: string, +function parseRemoteRefWithRemoteNames( + branchName: string, remoteNames: ReadonlyArray, -): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { - const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); - if (!parsed) { - return null; - } - - return { - upstreamRef, - remoteName: parsed.remoteName, - upstreamBranch: parsed.branchName, - }; -} +): { remoteRef: string; remoteName: string; localBranch: string } | null { + const trimmedBranchName = branchName.trim(); + if (trimmedBranchName.length === 0) return null; -function parseUpstreamRefByFirstSeparator( - upstreamRef: string, -): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { - const separatorIndex = upstreamRef.indexOf("/"); - if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) { - return null; - } - - const remoteName = upstreamRef.slice(0, separatorIndex).trim(); - const upstreamBranch = upstreamRef.slice(separatorIndex + 1).trim(); - if (remoteName.length === 0 || upstreamBranch.length === 0) { - return null; + for (const remoteName of remoteNames) { + const remotePrefix = `${remoteName}/`; + if (!trimmedBranchName.startsWith(remotePrefix)) { + continue; + } + const localBranch = trimmedBranchName.slice(remotePrefix.length).trim(); + if (localBranch.length === 0) { + return null; + } + return { + remoteRef: trimmedBranchName, + remoteName, + localBranch, + }; } - return { - upstreamRef, - remoteName, - upstreamBranch, - }; + return null; } function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { @@ -801,27 +792,45 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return null; } - const remoteNames = yield* runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( - Effect.map(parseRemoteNames), - Effect.catch(() => Effect.succeed>([])), - ); - return ( - parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? - parseUpstreamRefByFirstSeparator(upstreamRef) - ); + const separatorIndex = upstreamRef.indexOf("/"); + if (separatorIndex <= 0) { + return null; + } + const remoteName = upstreamRef.slice(0, separatorIndex); + const upstreamBranch = upstreamRef.slice(separatorIndex + 1); + if (remoteName.length === 0 || upstreamBranch.length === 0) { + return null; + } + + return { + upstreamRef, + remoteName, + upstreamBranch, + }; }); + const fetchUpstreamRef = ( + cwd: string, + upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + ): Effect.Effect => { + const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + return runGit( + "GitCore.fetchUpstreamRef", + cwd, + ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], + true, + ); + }; + const fetchUpstreamRefForStatus = ( - gitCommonDir: string, + cwd: string, upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, ): Effect.Effect => { const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; - const fetchCwd = - path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; return executeGit( "GitCore.fetchUpstreamRefForStatus", - fetchCwd, - ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], + cwd, + ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], { allowNonZeroExit: true, timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), @@ -829,18 +838,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ).pipe(Effect.asVoid); }; - const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { - const gitCommonDir = yield* runGitStdout("GitCore.resolveGitCommonDir", cwd, [ - "rev-parse", - "--git-common-dir", - ]).pipe(Effect.map((stdout) => stdout.trim())); - return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); - }); - const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* ( cacheKey: StatusUpstreamRefreshCacheKey, ) { - yield* fetchUpstreamRefForStatus(cacheKey.gitCommonDir, { + yield* fetchUpstreamRefForStatus(cacheKey.cwd, { upstreamRef: cacheKey.upstreamRef, remoteName: cacheKey.remoteName, upstreamBranch: cacheKey.upstreamBranch, @@ -851,11 +852,8 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const statusUpstreamRefreshCache = yield* Cache.makeWith({ capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, lookup: refreshStatusUpstreamCacheEntry, - // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. - timeToLive: (exit) => - Exit.isSuccess(exit) - ? STATUS_UPSTREAM_REFRESH_INTERVAL - : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN, + // Keep successful refreshes warm; drop failures immediately so next request can retry. + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero), }); const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( @@ -863,11 +861,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ) { const upstream = yield* resolveCurrentUpstream(cwd); if (!upstream) return; - const gitCommonDir = yield* resolveGitCommonDir(cwd); yield* Cache.get( statusUpstreamRefreshCache, new StatusUpstreamRefreshCacheKey({ - gitCommonDir, + cwd, upstreamRef: upstream.upstreamRef, remoteName: upstream.remoteName, upstreamBranch: upstream.upstreamBranch, @@ -875,6 +872,14 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); }); + const refreshCheckedOutBranchUpstream = Effect.fn("refreshCheckedOutBranchUpstream")(function* ( + cwd: string, + ) { + const upstream = yield* resolveCurrentUpstream(cwd); + if (!upstream) return; + yield* fetchUpstreamRef(cwd, upstream); + }); + const resolveDefaultBranchName = ( cwd: string, remoteName: string, @@ -914,7 +919,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( - Effect.map(parseRemoteNamesInGitOrder), + Effect.map((stdout) => parseRemoteNames(stdout).toReversed()), ); const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { @@ -1934,6 +1939,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { timeoutMs: 10_000, fallbackErrorMessage: "git checkout failed", }); + + // Refresh upstream refs in the background so checkout remains responsive. + yield* refreshCheckedOutBranchUpstream(input.cwd).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkDetach({ startImmediately: true }), + ); }, ); diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 280679e337..76d7d30a47 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -187,7 +187,7 @@ const makeGitHubCli = Effect.sync(() => { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName", ], }).pipe( Effect.map((result) => result.stdout.trim()), diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 6fd55030fd..e05fc30875 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -25,7 +25,6 @@ import { ServerSettingsService } from "../../serverSettings.ts"; interface FakeGhScenario { prListSequence?: string[]; prListByHeadSelector?: Record; - prListSequenceByHeadSelector?: Record; createdPrUrl?: string; defaultBranch?: string; pullRequest?: { @@ -78,72 +77,6 @@ interface FakeGitTextGeneration { type FakePullRequest = NonNullable; -function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null { - if (!raw || typeof raw !== "object") { - return null; - } - - const record = raw as Record; - const number = record.number; - const title = record.title; - const url = record.url; - const baseRefName = record.baseRefName; - const headRefName = record.headRefName; - const headRepository = - typeof record.headRepository === "object" && record.headRepository !== null - ? (record.headRepository as Record) - : null; - const headRepositoryOwner = - typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null - ? (record.headRepositoryOwner as Record) - : null; - - if ( - typeof number !== "number" || - typeof title !== "string" || - typeof url !== "string" || - typeof baseRefName !== "string" || - typeof headRefName !== "string" - ) { - return null; - } - - const state = - typeof record.state === "string" - ? record.state === "OPEN" || record.state === "open" - ? "open" - : record.state === "CLOSED" || record.state === "closed" - ? "closed" - : "merged" - : undefined; - const isCrossRepository = - typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined; - const headRepositoryNameWithOwner = - typeof record.headRepositoryNameWithOwner === "string" - ? record.headRepositoryNameWithOwner - : typeof headRepository?.nameWithOwner === "string" - ? headRepository.nameWithOwner - : undefined; - const headRepositoryOwnerLogin = - typeof record.headRepositoryOwnerLogin === "string" - ? record.headRepositoryOwnerLogin - : typeof headRepositoryOwner?.login === "string" - ? headRepositoryOwner.login - : undefined; - - return { - number, - title, - url, - baseRefName, - headRefName, - ...(state ? { state } : {}), - ...(isCrossRepository !== undefined ? { isCrossRepository } : {}), - ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), - ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), - }; -} - function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { const result = spawnSync("git", args, { cwd, @@ -226,23 +159,6 @@ function createBareRemote(): Effect.Effect< }); } -function configureRemote( - cwd: string, - remoteName: string, - remotePath: string, - fetchNamespace: string, -): Effect.Effect { - return Effect.gen(function* () { - yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]); - yield* runGit(cwd, [ - "config", - "--replace-all", - `remote.${remoteName}.fetch`, - `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, - ]); - }); -} - function createTextGeneration(overrides: Partial = {}): TextGenerationShape { const implementation: FakeGitTextGeneration = { generateCommitMessage: (input) => @@ -320,12 +236,6 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; - const prListQueueByHeadSelector = new Map( - Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [ - headSelector, - [...values], - ]), - ); const ghCalls: string[] = []; const execute: GitHubCliShape["execute"] = (input) => { @@ -342,15 +252,11 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 ? args[headSelectorIndex + 1] : undefined; - const mappedQueue = - typeof headSelector === "string" - ? prListQueueByHeadSelector.get(headSelector)?.shift() - : undefined; const mappedStdout = typeof headSelector === "string" ? scenario.prListByHeadSelector?.[headSelector] : undefined; - const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; + const stdout = (mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; return Effect.succeed({ stdout, stderr: "", @@ -504,14 +410,11 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName", ], }).pipe( - Effect.map((result) => JSON.parse(result.stdout) as unknown[]), - Effect.map((raw) => - raw - .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter((entry): entry is GitHubPullRequestSummary => entry !== null), + Effect.map( + (result) => JSON.parse(result.stdout) as ReadonlyArray, ), ), createPullRequest: (input) => @@ -570,7 +473,7 @@ function runStackedAction( manager: GitManagerShape, input: { cwd: string; - action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; + action: "commit" | "commit_push" | "commit_push_pr"; actionId?: string; commitMessage?: string; featureBranch?: boolean; @@ -672,78 +575,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("status briefly caches repeated lookups for the same cwd", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]); - - const existingPr = { - number: 113, - title: "Cached PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/113", - baseRefName: "main", - headRefName: "feature/status-cache", - }; - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])], - }, - }); - - const first = yield* manager.status({ cwd: repoDir }); - const second = yield* manager.status({ cwd: repoDir }); - - expect(first.pr?.number).toBe(113); - expect(second.pr?.number).toBe(113); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); - }), - ); - - it.effect( - "status ignores unrelated fork PRs when the current branch tracks the same repository", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 1661, - title: "Fork PR from main", - url: "https://github.com/pingdotgg/t3code/pull/1661", - baseRefName: "main", - headRefName: "main", - state: "OPEN", - updatedAt: "2026-04-01T15:00:00Z", - isCrossRepository: true, - headRepository: { - nameWithOwner: "lnieuwenhuis/t3code", - }, - headRepositoryOwner: { - login: "lnieuwenhuis", - }, - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("main"); - expect(status.pr).toBeNull(); - }), - ); - it.effect( "status detects cross-repo PRs from the upstream remote URL owner", () => @@ -779,13 +610,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { headRefName: "statemachine", state: "OPEN", updatedAt: "2026-03-10T07:00:00Z", - isCrossRepository: true, - headRepository: { - nameWithOwner: "jasonLaster/codething-mvp", - }, - headRepositoryOwner: { - login: "jasonLaster", - }, }, ]), ], @@ -803,116 +627,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { state: "open", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ); - }), - 12_000, - ); - - it.effect( - "status ignores synthetic local branch aliases when the upstream remote name contains slashes", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const upstreamDir = yield* createBareRemote(); - yield* configureRemote(repoDir, "origin", originDir, "origin"); - yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); - - yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); - yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); - yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); - yield* runGit(repoDir, [ - "config", - "remote.origin.url", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); - yield* runGit(repoDir, [ - "config", - "remote.my-org/upstream.url", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); - yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - "effect-atom": JSON.stringify([ - { - number: 1618, - title: "Correct PR", - url: "https://github.com/pingdotgg/t3code/pull/1618", - baseRefName: "main", - headRefName: "effect-atom", - state: "OPEN", - updatedAt: "2026-03-01T10:00:00Z", - }, - ]), - "upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), - "pingdotgg:effect-atom": JSON.stringify([]), - "my-org/upstream:effect-atom": JSON.stringify([]), - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), - }, - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("upstream/effect-atom"); - expect(status.pr).toEqual({ - number: 1618, - title: "Correct PR", - url: "https://github.com/pingdotgg/t3code/pull/1618", - baseBranch: "main", - headBranch: "effect-atom", - state: "open", - }); - expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( - false, + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", ); - expect( - ghCalls.some((call) => call.includes("pr list --head pingdotgg:upstream/effect-atom ")), - ).toBe(false); - expect( - ghCalls.some((call) => - call.includes("pr list --head my-org/upstream:upstream/effect-atom "), - ), - ).toBe(false); }), 12_000, ); @@ -1042,17 +758,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("skipped_not_requested"); - expect(result.toast).toMatchObject({ - description: "Implement stacked git actions", - cta: { - kind: "run_action", - label: "Push", - action: { - kind: "push", - }, - }, - }); - expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/); expect( yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( Effect.map((result) => result.stdout.trim()), @@ -1162,19 +867,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("pushed"); - expect(result.toast).toMatchObject({ - description: "Implement stacked git actions", - cta: { - kind: "run_action", - label: "Create PR", - action: { - kind: "create_pr", - }, - }, - }); - expect(result.toast.title).toMatch( - /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/, - ); expect( yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( Effect.map((result) => result.stdout.trim()), @@ -1371,80 +1063,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("pushes existing clean commits without rerunning commit logic", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n"); - yield* runGit(repoDir, ["add", "push-only.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "push", - }); - - expect(result.commit.status).toBe("skipped_not_requested"); - expect(result.push.status).toBe("pushed"); - expect(result.pr.status).toBe("skipped_not_requested"); - expect( - yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( - Effect.map((output) => output.stdout.trim()), - ), - ).toBe("origin/feature/push-only"); - }), - ); - - it.effect("create_pr pushes a clean branch before creating the PR when needed", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n"); - yield* runGit(repoDir, ["add", "create-pr-only.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - "[]", - JSON.stringify([ - { - number: 303, - title: "Create PR only branch", - url: "https://github.com/pingdotgg/codething-mvp/pull/303", - baseRefName: "main", - headRefName: "feature/create-pr-only", - }, - ]), - ], - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "create_pr", - }); - - expect(result.commit.status).toBe("skipped_not_requested"); - expect(result.push.status).toBe("pushed"); - expect(result.push.setUpstream).toBe(true); - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(303); - expect( - ghCalls.some((call) => - call.includes("pr create --base main --head feature/create-pr-only"), - ), - ).toBe(true); - }), - ); - it.effect("returns existing PR metadata for commit/push/pr action", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1477,15 +1095,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(42); - expect(result.toast).toEqual({ - title: "Opened PR #42", - description: "Existing PR", - cta: { - kind: "open_pr", - label: "View PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - }, - }); expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); }), ); @@ -1517,14 +1126,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, }, ]), ], @@ -1548,98 +1149,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { 12_000, ); - it.effect( - "returns the correct existing PR when a slash remote checks out to a synthetic local alias", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const upstreamDir = yield* createBareRemote(); - yield* configureRemote(repoDir, "origin", originDir, "origin"); - yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); - - yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); - yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); - yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); - yield* runGit(repoDir, [ - "config", - "remote.origin.url", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); - yield* runGit(repoDir, [ - "config", - "remote.my-org/upstream.url", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); - yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - "effect-atom": JSON.stringify([ - { - number: 1618, - title: "Correct PR", - url: "https://github.com/pingdotgg/t3code/pull/1618", - baseRefName: "main", - headRefName: "effect-atom", - }, - ]), - "upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - "pingdotgg:effect-atom": JSON.stringify([]), - "my-org/upstream:effect-atom": JSON.stringify([]), - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(1618); - expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( - false, - ); - }), - 12_000, - ); - it.effect( "prefers owner-qualified selectors before bare branch names for cross-repo PRs", () => @@ -1678,14 +1187,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, }, ]), "fork-seed:statemachine": JSON.stringify([]), @@ -1738,14 +1239,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, }, ]), "fork-seed:statemachine": JSON.stringify([]), @@ -1763,9 +1256,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); - expect(openLookupCalls).toHaveLength(1); - expect(openLookupCalls[0]).toContain( + const prListCalls = ghCalls.filter((call) => call.startsWith("pr list ")); + expect(prListCalls).toHaveLength(1); + expect(prListCalls[0]).toContain( "pr list --head octocat:statemachine --state open --limit 1", ); }), @@ -1809,7 +1302,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(88); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), ).toBe(true); @@ -1817,78 +1309,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect( - "creates a new PR instead of reusing an unrelated fork PR with the same head branch", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 1661, - title: "Fork PR with same branch name", - url: "https://github.com/pingdotgg/t3code/pull/1661", - baseRefName: "main", - headRefName: "feature/no-fork-match", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "lnieuwenhuis/t3code", - }, - headRepositoryOwner: { - login: "lnieuwenhuis", - }, - }, - ]), - JSON.stringify([ - { - number: 188, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - baseRefName: "main", - headRefName: "feature/no-fork-match", - state: "OPEN", - isCrossRepository: false, - }, - ]), - ], - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(188); - expect(result.toast).toEqual({ - title: "Created PR #188", - description: "Add stacked git actions", - cta: { - kind: "open_pr", - label: "View PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - }, - }); - expect( - ghCalls.some((call) => - call.includes("pr create --base main --head feature/no-fork-match"), - ), - ).toBe(true); - }), - ); - it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1910,30 +1330,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { - prListSequenceByHeadSelector: { - "octocat:statemachine": [ - JSON.stringify([]), - JSON.stringify([ - { - number: 188, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - ], - "fork-seed:statemachine": [JSON.stringify([])], - statemachine: [JSON.stringify([])], - }, + prListSequence: [ + JSON.stringify([]), + JSON.stringify([]), + JSON.stringify([]), + JSON.stringify([]), + JSON.stringify([]), + JSON.stringify([]), + JSON.stringify([ + { + number: 188, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + baseRefName: "main", + headRefName: "statemachine", + }, + ]), + ], }, }); @@ -2677,71 +2090,4 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); }), ); - - it.effect("create_pr emits only the PR phase when the branch is already pushed", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n"); - yield* runGit(repoDir, ["add", "pr-only.txt"]); - yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([]), - JSON.stringify([ - { - number: 201, - title: "PR only branch", - url: "https://github.com/pingdotgg/codething-mvp/pull/201", - baseRefName: "main", - headRefName: "feature/pr-only-follow-up", - state: "OPEN", - isCrossRepository: false, - }, - ]), - ], - }, - }); - const events: GitActionProgressEvent[] = []; - - const result = yield* runStackedAction( - manager, - { - cwd: repoDir, - action: "create_pr", - }, - { - actionId: "action-pr-only", - progressReporter: { - publish: (event) => - Effect.sync(() => { - events.push(event); - }), - }, - }, - ); - - expect(result.commit.status).toBe("skipped_not_requested"); - expect(result.push.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("created"); - expect( - events.filter( - (event): event is Extract => - event.kind === "phase_started", - ), - ).toEqual([ - expect.objectContaining({ - kind: "phase_started", - phase: "pr", - label: "Creating PR...", - }), - ]); - }), - ); }); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index ca9d562c03..f8445cf09a 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1,12 +1,11 @@ import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; -import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Ref } from "effect"; import { GitActionProgressEvent, GitActionProgressPhase, GitRunStackedActionResult, - GitStackedAction, ModelSelection, } from "@t3tools/contracts"; import { @@ -23,18 +22,13 @@ import { type GitRunStackedActionOptions, } from "../Services/GitManager.ts"; import { GitCore } from "../Services/GitCore.ts"; -import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; +import { GitHubCli } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; -import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; -const SHORT_SHA_LENGTH = 7; -const TOAST_DESCRIPTION_MAX = 72; -const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); -const STATUS_RESULT_CACHE_CAPACITY = 2_048; type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; @@ -46,7 +40,7 @@ interface OpenPrInfo { headRefName: string; } -interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { +interface PullRequestInfo extends OpenPrInfo { state: "open" | "closed" | "merged"; updatedAt: string | null; } @@ -141,94 +135,6 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; } -function normalizeOptionalString(value: string | null | undefined): string | null { - const trimmed = value?.trim() ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null { - const normalized = normalizeOptionalString(value); - return normalized ? normalized.toLowerCase() : null; -} - -function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null { - const normalized = normalizeOptionalString(value); - return normalized ? normalized.toLowerCase() : null; -} - -function resolvePullRequestHeadRepositoryNameWithOwner( - pr: PullRequestHeadRemoteInfo & { url: string }, -) { - const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); - if (explicitRepository) { - return explicitRepository; - } - - if (!pr.isCrossRepository) { - return null; - } - - const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); - const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); - if (!ownerLogin || !repositoryName) { - return null; - } - - return `${ownerLogin}/${repositoryName}`; -} - -function matchesBranchHeadContext( - pr: PullRequestInfo, - headContext: Pick< - BranchHeadContext, - "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" - >, -): boolean { - if (pr.headRefName !== headContext.headBranch) { - return false; - } - - const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner( - headContext.headRepositoryNameWithOwner, - ); - const expectedHeadOwner = - normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? - parseRepositoryOwnerLogin(expectedHeadRepository); - const prHeadRepository = normalizeOptionalRepositoryNameWithOwner( - resolvePullRequestHeadRepositoryNameWithOwner(pr), - ); - const prHeadOwner = - normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? - parseRepositoryOwnerLogin(prHeadRepository); - - if (headContext.isCrossRepository) { - if (pr.isCrossRepository === false) { - return false; - } - if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { - return false; - } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { - return false; - } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { - return false; - } - return true; - } - - if (pr.isCrossRepository === true) { - return false; - } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { - return false; - } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { - return false; - } - return true; -} - function parsePullRequestList(raw: unknown): PullRequestInfo[] { if (!Array.isArray(raw)) return []; @@ -244,27 +150,6 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { const state = record.state; const mergedAt = record.mergedAt; const updatedAt = record.updatedAt; - const isCrossRepository = record.isCrossRepository; - const headRepositoryRecord = - typeof record.headRepository === "object" && record.headRepository !== null - ? (record.headRepository as Record) - : null; - const headRepositoryOwnerRecord = - typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null - ? (record.headRepositoryOwner as Record) - : null; - const headRepositoryNameWithOwner = - typeof record.headRepositoryNameWithOwner === "string" - ? record.headRepositoryNameWithOwner - : typeof headRepositoryRecord?.nameWithOwner === "string" - ? headRepositoryRecord.nameWithOwner - : null; - const headRepositoryOwnerLogin = - typeof record.headRepositoryOwnerLogin === "string" - ? record.headRepositoryOwnerLogin - : typeof headRepositoryOwnerRecord?.login === "string" - ? headRepositoryOwnerRecord.login - : null; if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { continue; } @@ -278,15 +163,11 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { } let normalizedState: "open" | "closed" | "merged"; - if ( - (typeof mergedAt === "string" && mergedAt.trim().length > 0) || - state === "MERGED" || - state === "merged" - ) { + if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { normalizedState = "merged"; - } else if (state === "OPEN" || state === "open" || state === undefined || state === null) { + } else if (state === "OPEN" || state === undefined || state === null) { normalizedState = "open"; - } else if (state === "CLOSED" || state === "closed") { + } else if (state === "CLOSED") { normalizedState = "closed"; } else { continue; @@ -300,35 +181,11 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { headRefName, state: normalizedState, updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null, - ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), - ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), - ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), }); } return parsed; } -function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { - return { - number: summary.number, - title: summary.title, - url: summary.url, - baseRefName: summary.baseRefName, - headRefName: summary.headRefName, - state: summary.state ?? "open", - updatedAt: null, - ...(summary.isCrossRepository !== undefined - ? { isCrossRepository: summary.isCrossRepository } - : {}), - ...(summary.headRepositoryNameWithOwner !== undefined - ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } - : {}), - ...(summary.headRepositoryOwnerLogin !== undefined - ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } - : {}), - }; -} - function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { return new GitManagerError({ operation, @@ -342,57 +199,6 @@ function limitContext(value: string, maxChars: number): string { return `${value.slice(0, maxChars)}\n\n[truncated]`; } -function shortenSha(sha: string | undefined): string | null { - if (!sha) return null; - return sha.slice(0, SHORT_SHA_LENGTH); -} - -function truncateText( - value: string | undefined, - maxLength = TOAST_DESCRIPTION_MAX, -): string | undefined { - if (!value) return undefined; - if (value.length <= maxLength) return value; - if (maxLength <= 3) return "...".slice(0, maxLength); - return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; -} - -function withDescription(title: string, description: string | undefined) { - return description ? { title, description } : { title }; -} - -function summarizeGitActionResult( - result: Pick, -): { - title: string; - description?: string; -} { - if (result.pr.status === "created" || result.pr.status === "opened_existing") { - const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; - const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; - return withDescription(title, truncateText(result.pr.title)); - } - - if (result.push.status === "pushed") { - const shortSha = shortenSha(result.commit.commitSha); - const branch = result.push.upstreamBranch ?? result.push.branch; - const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; - const branchPart = branch ? ` to ${branch}` : ""; - return withDescription( - `Pushed${pushedCommitPart}${branchPart}`, - truncateText(result.commit.subject), - ); - } - - if (result.commit.status === "created") { - const shortSha = shortenSha(result.commit.commitSha); - const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; - return withDescription(title, truncateText(result.commit.subject)); - } - - return { title: "Done" }; -} - function sanitizeCommitMessage(generated: { subject: string; body: string; @@ -430,12 +236,6 @@ interface CommitAndBranchSuggestion { commitMessage: string; } -function isCommitAction( - action: GitStackedAction, -): action is "commit" | "commit_push" | "commit_push_pr" { - return action === "commit" || action === "commit_push" || action === "commit_push_pr"; -} - function formatCommitMessage(subject: string, body: string): string { const trimmedBody = body.trim(); if (trimmedBody.length === 0) { @@ -462,6 +262,25 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string }; } +function extractBranchFromRef(ref: string): string { + const normalized = ref.trim(); + + if (normalized.startsWith("refs/remotes/")) { + const withoutPrefix = normalized.slice("refs/remotes/".length); + const firstSlash = withoutPrefix.indexOf("/"); + if (firstSlash === -1) { + return withoutPrefix.trim(); + } + return withoutPrefix.slice(firstSlash + 1).trim(); + } + + const firstSlash = normalized.indexOf("/"); + if (firstSlash === -1) { + return normalized; + } + return normalized.slice(firstSlash + 1).trim(); +} + function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { @@ -549,7 +368,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const serverSettingsService = yield* ServerSettingsService; const createProgressEmitter = ( - input: { cwd: string; action: GitStackedAction }, + input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" }, options?: GitRunStackedActionOptions, ) => { const actionId = options?.actionId ?? randomUUID(); @@ -686,38 +505,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const path = yield* Path.Path; const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; - const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); - const readStatus = Effect.fn("readStatus")(function* (cwd: string) { - const details = yield* gitCore.statusDetails(cwd); - - const pr = - details.branch !== null - ? yield* findLatestPr(cwd, { - branch: details.branch, - upstreamRef: details.upstreamRef, - }).pipe( - Effect.map((latest) => (latest ? toStatusPr(latest) : null)), - Effect.catch(() => Effect.succeed(null)), - ) - : null; - - return { - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, - hasUpstream: details.hasUpstream, - aheadCount: details.aheadCount, - behindCount: details.behindCount, - pr, - }; - }); - const statusResultCache = yield* Cache.makeWith({ - capacity: STATUS_RESULT_CACHE_CAPACITY, - lookup: readStatus, - timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), - }); - const invalidateStatusResultCache = (cwd: string) => - Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd)); const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); @@ -747,11 +534,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ) { const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); const headBranchFromUpstream = details.upstreamRef - ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) + ? extractBranchFromRef(details.upstreamRef) : ""; const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; - const shouldProbeLocalBranchSelector = - headBranchFromUpstream.length === 0 || headBranch === details.branch; const [remoteRepository, originRepository] = yield* Effect.all( [ @@ -787,9 +572,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, ); } - if (shouldProbeLocalBranchSelector) { - appendUnique(headSelectors, details.branch); - } + appendUnique(headSelectors, details.branch); appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { appendUnique(headSelectors, ownerHeadSelector); @@ -814,26 +597,16 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const findOpenPr = Effect.fn("findOpenPr")(function* ( cwd: string, - headContext: Pick< - BranchHeadContext, - | "headBranch" - | "headSelectors" - | "headRepositoryNameWithOwner" - | "headRepositoryOwnerLogin" - | "isCrossRepository" - >, + headSelectors: ReadonlyArray, ) { - for (const headSelector of headContext.headSelectors) { + for (const headSelector of headSelectors) { const pullRequests = yield* gitHubCli.listOpenPullRequests({ cwd, headSelector, limit: 1, }); - const normalizedPullRequests = pullRequests.map(toPullRequestInfo); - const firstPullRequest = normalizedPullRequests.find((pullRequest) => - matchesBranchHeadContext(pullRequest, headContext), - ); + const [firstPullRequest] = pullRequests; if (firstPullRequest) { return { number: firstPullRequest.number, @@ -871,7 +644,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { "--limit", "20", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", ], }) .pipe(Effect.map((result) => result.stdout)); @@ -888,9 +661,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); for (const pr of parsePullRequestList(parsedJson)) { - if (!matchesBranchHeadContext(pr, headContext)) { - continue; - } parsedByNumber.set(pr.number, pr); } } @@ -908,119 +678,17 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return parsed[0] ?? null; }); - const isDefaultBranch = Effect.fn("isDefaultBranch")(function* (cwd: string, branch: string) { - const branches = yield* gitCore.listBranches({ cwd }); - const currentBranch = branches.branches.find((candidate) => candidate.name === branch); - return currentBranch?.isDefault ?? (branch === "main" || branch === "master"); - }); - - const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( - cwd: string, - result: Pick, - ) { - const summary = summarizeGitActionResult(result); - let latestOpenPr: PullRequestInfo | null = null; - let currentBranchIsDefault = false; - let finalBranchContext: { - branch: string; - upstreamRef: string | null; - hasUpstream: boolean; - } | null = null; - - if (result.action !== "commit") { - const finalStatus = yield* gitCore.statusDetails(cwd); - if (finalStatus.branch) { - finalBranchContext = { - branch: finalStatus.branch, - upstreamRef: finalStatus.upstreamRef, - hasUpstream: finalStatus.hasUpstream, - }; - currentBranchIsDefault = yield* isDefaultBranch(cwd, finalStatus.branch).pipe( - Effect.catch(() => - Effect.succeed(finalStatus.branch === "main" || finalStatus.branch === "master"), - ), - ); - } - } - - const explicitResultPr = - (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url - ? { - url: result.pr.url, - state: "open" as const, - } - : null; - const shouldLookupExistingOpenPr = - (result.action === "commit_push" || result.action === "push") && - result.push.status === "pushed" && - result.branch.status !== "created" && - !currentBranchIsDefault && - explicitResultPr === null && - finalBranchContext?.hasUpstream === true; - - if (shouldLookupExistingOpenPr && finalBranchContext) { - latestOpenPr = yield* resolveBranchHeadContext(cwd, { - branch: finalBranchContext.branch, - upstreamRef: finalBranchContext.upstreamRef, - }).pipe( - Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), - Effect.catch(() => Effect.succeed(null)), - ); - } - - const openPr = latestOpenPr ?? explicitResultPr; - - const cta = - result.action === "commit" && result.commit.status === "created" - ? { - kind: "run_action" as const, - label: "Push", - action: { kind: "push" as const }, - } - : (result.action === "push" || - result.action === "create_pr" || - result.action === "commit_push" || - result.action === "commit_push_pr") && - openPr?.url && - (!currentBranchIsDefault || - result.pr.status === "created" || - result.pr.status === "opened_existing") - ? { - kind: "open_pr" as const, - label: "View PR", - url: openPr.url, - } - : (result.action === "push" || result.action === "commit_push") && - result.push.status === "pushed" && - !currentBranchIsDefault - ? { - kind: "run_action" as const, - label: "Create PR", - action: { kind: "create_pr" as const }, - } - : { - kind: "none" as const, - }; - - return { - ...summary, - cta, - }; - }); - const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( cwd: string, branch: string, upstreamRef: string | null, - headContext: Pick, + headContext: Pick, ) { const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); if (configured) return configured; if (upstreamRef && !headContext.isCrossRepository) { - const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { - remoteName: headContext.remoteName, - }); + const upstreamBranch = extractBranchFromRef(upstreamRef); if (upstreamBranch.length > 0 && upstreamBranch !== branch) { return upstreamBranch; } @@ -1221,7 +889,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { upstreamRef: details.upstreamRef, }); - const existing = yield* findOpenPr(cwd, headContext); + const existing = yield* findOpenPr(cwd, headContext.headSelectors); if (existing) { return { status: "opened_existing" as const, @@ -1264,7 +932,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }) .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); - const created = yield* findOpenPr(cwd, headContext); + const created = yield* findOpenPr(cwd, headContext.headSelectors); if (!created) { return { status: "created" as const, @@ -1285,7 +953,28 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { - return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd)); + const details = yield* gitCore.statusDetails(input.cwd); + + const pr = + details.branch !== null + ? yield* findLatestPr(input.cwd, { + branch: details.branch, + upstreamRef: details.upstreamRef, + }).pipe( + Effect.map((latest) => (latest ? toStatusPr(latest) : null)), + Effect.catch(() => Effect.succeed(null)), + ) + : null; + + return { + branch: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + pr, + }; }); const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( @@ -1304,145 +993,143 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { - return yield* Effect.gen(function* () { - const normalizedReference = normalizePullRequestReference(input.reference); - const rootWorktreePath = canonicalizeExistingPath(input.cwd); - const pullRequestSummary = yield* gitHubCli.getPullRequest({ + const normalizedReference = normalizePullRequestReference(input.reference); + const rootWorktreePath = canonicalizeExistingPath(input.cwd); + const pullRequestSummary = yield* gitHubCli.getPullRequest({ + cwd: input.cwd, + reference: normalizedReference, + }); + const pullRequest = toResolvedPullRequest(pullRequestSummary); + + if (input.mode === "local") { + yield* gitHubCli.checkoutPullRequest({ cwd: input.cwd, reference: normalizedReference, + force: true, }); - const pullRequest = toResolvedPullRequest(pullRequestSummary); - - if (input.mode === "local") { - yield* gitHubCli.checkoutPullRequest({ - cwd: input.cwd, - reference: normalizedReference, - force: true, - }); - const details = yield* gitCore.statusDetails(input.cwd); - yield* configurePullRequestHeadUpstream( - input.cwd, - { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - }, - details.branch ?? pullRequest.headBranch, - ); - return { - pullRequest, - branch: details.branch ?? pullRequest.headBranch, - worktreePath: null, - }; - } - - const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( - worktreePath: string, - ) { - const details = yield* gitCore.statusDetails(worktreePath); - yield* configurePullRequestHeadUpstream( - worktreePath, - { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - }, - details.branch ?? pullRequest.headBranch, - ); - }); - - const pullRequestWithRemoteInfo = { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - } as const; - const localPullRequestBranch = - resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); - - const findLocalHeadBranch = (cwd: string) => - gitCore.listBranches({ cwd }).pipe( - Effect.map((result) => { - const localBranch = result.branches.find( - (branch) => !branch.isRemote && branch.name === localPullRequestBranch, - ); - if (localBranch) { - return localBranch; - } - if (localPullRequestBranch === pullRequest.headBranch) { - return null; - } - return ( - result.branches.find( - (branch) => - !branch.isRemote && - branch.name === pullRequest.headBranch && - branch.worktreePath !== null && - canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, - ) ?? null - ); - }), - ); - - const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); - const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) - : null; - if ( - existingBranchBeforeFetch?.worktreePath && - existingBranchBeforeFetchPath !== rootWorktreePath - ) { - yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); - return { - pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchBeforeFetch.worktreePath, - }; - } - if (existingBranchBeforeFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", - ); - } - - yield* materializePullRequestHeadBranch( + const details = yield* gitCore.statusDetails(input.cwd); + yield* configurePullRequestHeadUpstream( input.cwd, - pullRequestWithRemoteInfo, - localPullRequestBranch, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, ); + return { + pullRequest, + branch: details.branch ?? pullRequest.headBranch, + worktreePath: null, + }; + } - const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); - const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) - : null; - if ( - existingBranchAfterFetch?.worktreePath && - existingBranchAfterFetchPath !== rootWorktreePath - ) { - yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); - return { - pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchAfterFetch.worktreePath, - }; - } - if (existingBranchAfterFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", - ); - } + const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( + worktreePath: string, + ) { + const details = yield* gitCore.statusDetails(worktreePath); + yield* configurePullRequestHeadUpstream( + worktreePath, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + }); - const worktree = yield* gitCore.createWorktree({ - cwd: input.cwd, + const pullRequestWithRemoteInfo = { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + } as const; + const localPullRequestBranch = + resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); + + const findLocalHeadBranch = (cwd: string) => + gitCore.listBranches({ cwd }).pipe( + Effect.map((result) => { + const localBranch = result.branches.find( + (branch) => !branch.isRemote && branch.name === localPullRequestBranch, + ); + if (localBranch) { + return localBranch; + } + if (localPullRequestBranch === pullRequest.headBranch) { + return null; + } + return ( + result.branches.find( + (branch) => + !branch.isRemote && + branch.name === pullRequest.headBranch && + branch.worktreePath !== null && + canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, + ) ?? null + ); + }), + ); + + const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath + ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + : null; + if ( + existingBranchBeforeFetch?.worktreePath && + existingBranchBeforeFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); + return { + pullRequest, branch: localPullRequestBranch, - path: null, - }); - yield* ensureExistingWorktreeUpstream(worktree.worktree.path); + worktreePath: existingBranchBeforeFetch.worktreePath, + }; + } + if (existingBranchBeforeFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + ); + } + + yield* materializePullRequestHeadBranch( + input.cwd, + pullRequestWithRemoteInfo, + localPullRequestBranch, + ); + const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath + ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + : null; + if ( + existingBranchAfterFetch?.worktreePath && + existingBranchAfterFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); return { pullRequest, - branch: worktree.worktree.branch, - worktreePath: worktree.worktree.path, + branch: localPullRequestBranch, + worktreePath: existingBranchAfterFetch.worktreePath, }; - }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd))); + } + if (existingBranchAfterFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + ); + } + + const worktree = yield* gitCore.createWorktree({ + cwd: input.cwd, + branch: localPullRequestBranch, + path: null, + }); + yield* ensureExistingWorktreeUpstream(worktree.worktree.path); + + return { + pullRequest, + branch: worktree.worktree.branch, + worktreePath: worktree.worktree.path, + }; }); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( @@ -1484,53 +1171,27 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( function* (input, options) { const progress = createProgressEmitter(input, options); + const phases: GitActionProgressPhase[] = [ + ...(input.featureBranch ? (["branch"] as const) : []), + "commit", + ...(input.action !== "commit" ? (["push"] as const) : []), + ...(input.action === "commit_push_pr" ? (["pr"] as const) : []), + ]; const currentPhase = yield* Ref.make>(Option.none()); const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return< GitRunStackedActionResult, GitManagerServiceError > { - const initialStatus = yield* gitCore.statusDetails(input.cwd); - const wantsCommit = isCommitAction(input.action); - const wantsPush = - input.action === "push" || - input.action === "commit_push" || - input.action === "commit_push_pr" || - (input.action === "create_pr" && - (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); - const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; - - if (input.featureBranch && !wantsCommit) { - return yield* gitManagerError( - "runStackedAction", - "Feature-branch checkout is only supported for commit actions.", - ); - } - if (input.action === "push" && initialStatus.hasWorkingTreeChanges) { - return yield* gitManagerError( - "runStackedAction", - "Commit or stash local changes before pushing.", - ); - } - if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { - return yield* gitManagerError( - "runStackedAction", - "Commit local changes before creating a PR.", - ); - } - - const phases: GitActionProgressPhase[] = [ - ...(input.featureBranch ? (["branch"] as const) : []), - ...(wantsCommit ? (["commit"] as const) : []), - ...(wantsPush ? (["push"] as const) : []), - ...(wantsPr ? (["pr"] as const) : []), - ]; - yield* progress.emit({ kind: "action_started", phases, }); + const wantsPush = input.action !== "commit"; + const wantsPr = input.action === "commit_push_pr"; + + const initialStatus = yield* gitCore.statusDetails(input.cwd); if (!input.featureBranch && wantsPush && !initialStatus.branch) { return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); } @@ -1574,25 +1235,19 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } const currentBranch = branchStep.name ?? initialStatus.branch; - const commitAction = isCommitAction(input.action) ? input.action : null; - - const commit = commitAction - ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( - Effect.flatMap(() => - runCommitStep( - modelSelection, - input.cwd, - commitAction, - currentBranch, - commitMessageForStep, - preResolvedCommitSuggestion, - input.filePaths, - options?.progressReporter, - progress.actionId, - ), - ), - ) - : { status: "skipped_not_requested" as const }; + + yield* Ref.set(currentPhase, Option.some("commit")); + const commit = yield* runCommitStep( + modelSelection, + input.cwd, + input.action, + currentBranch, + commitMessageForStep, + preResolvedCommitSuggestion, + input.filePaths, + options?.progressReporter, + progress.actionId, + ); const push = wantsPush ? yield* progress @@ -1620,21 +1275,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ) : { status: "skipped_not_requested" as const }; - const toast = yield* buildCompletionToast(input.cwd, { - action: input.action, - branch: branchStep, - commit, - push, - pr, - }); - const result = { action: input.action, branch: branchStep, commit, push, pr, - toast, }; yield* progress.emit({ kind: "action_finished", @@ -1644,7 +1290,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); return yield* runAction().pipe( - Effect.ensuring(invalidateStatusResultCache(input.cwd)), Effect.tapError((error) => Effect.flatMap(Ref.get(currentPhase), (phase) => progress.emit({ diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index 86842257b4..a99e4d3bc4 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -56,7 +56,7 @@ export interface GitManagerShape { ) => Effect.Effect; /** - * Run a Git action (`commit`, `push`, `create_pr`, `commit_push`, `commit_push_pr`). + * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). * When `featureBranch` is set, creates and checks out a feature branch first. */ readonly runStackedAction: ( diff --git a/apps/server/src/git/remoteRefs.ts b/apps/server/src/git/remoteRefs.ts deleted file mode 100644 index b5dfc87bd0..0000000000 --- a/apps/server/src/git/remoteRefs.ts +++ /dev/null @@ -1,67 +0,0 @@ -export function parseRemoteNamesInGitOrder(stdout: string): ReadonlyArray { - return stdout - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); -} - -export function parseRemoteNames(stdout: string): ReadonlyArray { - return parseRemoteNamesInGitOrder(stdout).toSorted((a, b) => b.length - a.length); -} - -export function parseRemoteRefWithRemoteNames( - ref: string, - remoteNames: ReadonlyArray, -): { remoteRef: string; remoteName: string; branchName: string } | null { - const trimmedRef = ref.trim(); - if (trimmedRef.length === 0) { - return null; - } - - for (const remoteName of remoteNames) { - const remotePrefix = `${remoteName}/`; - if (!trimmedRef.startsWith(remotePrefix)) { - continue; - } - const branchName = trimmedRef.slice(remotePrefix.length).trim(); - if (branchName.length === 0) { - return null; - } - return { - remoteRef: trimmedRef, - remoteName, - branchName, - }; - } - - return null; -} - -export function extractBranchNameFromRemoteRef( - ref: string, - options?: { - remoteName?: string | null; - remoteNames?: ReadonlyArray; - }, -): string { - const normalized = ref.trim(); - if (normalized.length === 0) { - return ""; - } - - if (normalized.startsWith("refs/remotes/")) { - return extractBranchNameFromRemoteRef(normalized.slice("refs/remotes/".length), options); - } - - const remoteNames = options?.remoteName ? [options.remoteName] : (options?.remoteNames ?? []); - const parsedRemoteRef = parseRemoteRefWithRemoteNames(normalized, remoteNames); - if (parsedRemoteRef) { - return parsedRemoteRef.branchName; - } - - const firstSlash = normalized.indexOf("/"); - if (firstSlash === -1) { - return normalized; - } - return normalized.slice(firstSlash + 1).trim(); -} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index ab9f633e02..781a0025e2 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -23,7 +23,6 @@ import { GitCoreLive } from "../../git/Layers/GitCore.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; -import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -250,7 +249,6 @@ describe("CheckpointReactor", () => { options?.providerName ?? "codex", ); const orchestrationLayer = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 16bbd98d6b..6aa889991e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -8,7 +8,7 @@ import { TurnId, type OrchestrationEvent, } from "@t3tools/contracts"; -import { Effect, Layer, ManagedRuntime, Option, Queue, Stream } from "effect"; +import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; import { describe, expect, it } from "vitest"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; @@ -21,13 +21,11 @@ import { } from "../../persistence/Services/OrchestrationEventStore.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; -import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, } from "../Services/ProjectionPipeline.ts"; -import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ServerConfig } from "../../config.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -41,7 +39,6 @@ async function createOrchestrationSystem() { prefix: "t3-orchestration-engine-test-", }); const orchestrationLayer = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -63,105 +60,6 @@ function now() { } describe("OrchestrationEngine", () => { - it("bootstraps the in-memory read model from persisted projections", async () => { - const failOnHistoricalReplayStore: OrchestrationEventStoreShape = { - append: () => - Effect.fail( - new PersistenceSqlError({ - operation: "test.append", - detail: "append should not be called during bootstrap", - }), - ), - readFromSequence: () => Stream.empty, - readAll: () => - Stream.fail( - new PersistenceSqlError({ - operation: "test.readAll", - detail: "historical replay should not be used during bootstrap", - }), - ), - }; - - const projectionSnapshot = { - snapshotSequence: 7, - updatedAt: "2026-03-03T00:00:04.000Z", - projects: [ - { - id: asProjectId("project-bootstrap"), - title: "Bootstrap Project", - workspaceRoot: "/tmp/project-bootstrap", - defaultModelSelection: { - provider: "codex" as const, - model: "gpt-5-codex", - }, - scripts: [], - createdAt: "2026-03-03T00:00:00.000Z", - updatedAt: "2026-03-03T00:00:01.000Z", - deletedAt: null, - }, - ], - threads: [ - { - id: ThreadId.makeUnsafe("thread-bootstrap"), - projectId: asProjectId("project-bootstrap"), - title: "Bootstrap Thread", - modelSelection: { - provider: "codex" as const, - model: "gpt-5-codex", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access" as const, - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-03-03T00:00:02.000Z", - updatedAt: "2026-03-03T00:00:03.000Z", - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, - }, - ], - }; - - const layer = OrchestrationEngineLive.pipe( - Layer.provide( - Layer.succeed(ProjectionSnapshotQuery, { - getSnapshot: () => Effect.succeed(projectionSnapshot), - getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - }), - ), - Layer.provide( - Layer.succeed(OrchestrationProjectionPipeline, { - bootstrap: Effect.void, - projectEvent: () => Effect.void, - } satisfies OrchestrationProjectionPipelineShape), - ), - Layer.provide(Layer.succeed(OrchestrationEventStore, failOnHistoricalReplayStore)), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(SqlitePersistenceMemory), - ); - - const runtime = ManagedRuntime.make(layer); - - const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); - const readModel = await runtime.runPromise(engine.getReadModel()); - - expect(readModel.snapshotSequence).toBe(7); - expect(readModel.projects).toHaveLength(1); - expect(readModel.projects[0]?.title).toBe("Bootstrap Project"); - expect(readModel.threads).toHaveLength(1); - expect(readModel.threads[0]?.title).toBe("Bootstrap Thread"); - - await runtime.dispose(); - }); - it("returns deterministic read models for repeated reads", async () => { const createdAt = now(); const system = await createOrchestrationSystem(); @@ -519,7 +417,6 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make( OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -615,7 +512,6 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make( OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -757,7 +653,6 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make( OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index bd3581deb6..5c52379f47 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -19,7 +19,6 @@ import { import { decideOrchestrationCommand } from "../decider.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; -import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -55,7 +54,6 @@ const makeOrchestrationEngine = Effect.gen(function* () { const eventStore = yield* OrchestrationEventStore; const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; const projectionPipeline = yield* OrchestrationProjectionPipeline; - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; let readModel = createEmptyReadModel(new Date().toISOString()); @@ -197,7 +195,13 @@ const makeOrchestrationEngine = Effect.gen(function* () { }; yield* projectionPipeline.bootstrap; - readModel = yield* projectionSnapshotQuery.getSnapshot(); + + // bootstrap in-memory read model from event store + yield* Stream.runForEach(eventStore.readAll(), (event) => + Effect.gen(function* () { + readModel = yield* projectEvent(readModel, event); + }), + ); const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); yield* Effect.forkScoped(worker); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1850745469..77b5d4d619 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -25,7 +25,6 @@ import { ORCHESTRATION_PROJECTOR_NAMES, OrchestrationProjectionPipelineLive, } from "./ProjectionPipeline.ts"; -import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; @@ -1842,7 +1841,6 @@ it.effect("restores pending turn-start metadata across projection pipeline resta const engineLayer = it.layer( OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 0844cf8bb0..61de04ad0e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -623,28 +623,24 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti )(function* (event, attachmentSideEffects) { switch (event.type) { case "thread.message-sent": { - const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ - messageId: event.payload.messageId, - }); - const previousMessage = Option.getOrUndefined(existingMessage); - const nextText = Option.match(existingMessage, { - onNone: () => event.payload.text, - onSome: (message) => { - if (event.payload.streaming) { - return `${message.text}${event.payload.text}`; - } - if (event.payload.text.length === 0) { - return message.text; - } - return event.payload.text; - }, + const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: event.payload.threadId, }); + const existingMessage = existingRows.find( + (row) => row.messageId === event.payload.messageId, + ); + const nextText = + existingMessage && event.payload.streaming + ? `${existingMessage.text}${event.payload.text}` + : existingMessage && event.payload.text.length === 0 + ? existingMessage.text + : event.payload.text; const nextAttachments = event.payload.attachments !== undefined ? yield* materializeAttachmentsForProjection({ attachments: event.payload.attachments, }) - : previousMessage?.attachments; + : existingMessage?.attachments; yield* projectionThreadMessageRepository.upsert({ messageId: event.payload.messageId, threadId: event.payload.threadId, @@ -653,7 +649,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), isStreaming: event.payload.streaming, - createdAt: previousMessage?.createdAt ?? event.payload.createdAt, + createdAt: existingMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c038bc9d2c..32143d751f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -338,290 +338,4 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ]); }), ); - - it.effect( - "reads targeted project, thread, and count queries without hydrating the full snapshot", - () => - Effect.gen(function* () { - const snapshotQuery = yield* ProjectionSnapshotQuery; - const sql = yield* SqlClient.SqlClient; - - yield* sql`DELETE FROM projection_projects`; - yield* sql`DELETE FROM projection_threads`; - yield* sql`DELETE FROM projection_turns`; - - yield* sql` - INSERT INTO projection_projects ( - project_id, - title, - workspace_root, - default_model_selection_json, - scripts_json, - created_at, - updated_at, - deleted_at - ) - VALUES - ( - 'project-active', - 'Active Project', - '/tmp/workspace', - '{"provider":"codex","model":"gpt-5-codex"}', - '[]', - '2026-03-01T00:00:00.000Z', - '2026-03-01T00:00:01.000Z', - NULL - ), - ( - 'project-deleted', - 'Deleted Project', - '/tmp/deleted', - NULL, - '[]', - '2026-03-01T00:00:02.000Z', - '2026-03-01T00:00:03.000Z', - '2026-03-01T00:00:04.000Z' - ) - `; - - yield* sql` - INSERT INTO projection_threads ( - thread_id, - project_id, - title, - model_selection_json, - runtime_mode, - interaction_mode, - branch, - worktree_path, - latest_turn_id, - created_at, - updated_at, - archived_at, - deleted_at - ) - VALUES - ( - 'thread-first', - 'project-active', - 'First Thread', - '{"provider":"codex","model":"gpt-5-codex"}', - 'full-access', - 'default', - NULL, - NULL, - NULL, - '2026-03-01T00:00:05.000Z', - '2026-03-01T00:00:06.000Z', - NULL, - NULL - ), - ( - 'thread-second', - 'project-active', - 'Second Thread', - '{"provider":"codex","model":"gpt-5-codex"}', - 'full-access', - 'default', - NULL, - NULL, - NULL, - '2026-03-01T00:00:07.000Z', - '2026-03-01T00:00:08.000Z', - NULL, - NULL - ), - ( - 'thread-deleted', - 'project-active', - 'Deleted Thread', - '{"provider":"codex","model":"gpt-5-codex"}', - 'full-access', - 'default', - NULL, - NULL, - NULL, - '2026-03-01T00:00:09.000Z', - '2026-03-01T00:00:10.000Z', - NULL, - '2026-03-01T00:00:11.000Z' - ) - `; - - const counts = yield* snapshotQuery.getCounts(); - assert.deepEqual(counts, { - projectCount: 2, - threadCount: 3, - }); - - const project = yield* snapshotQuery.getActiveProjectByWorkspaceRoot("/tmp/workspace"); - assert.equal(project._tag, "Some"); - if (project._tag === "Some") { - assert.equal(project.value.id, asProjectId("project-active")); - } - - const missingProject = yield* snapshotQuery.getActiveProjectByWorkspaceRoot("/tmp/missing"); - assert.equal(missingProject._tag, "None"); - - const firstThreadId = yield* snapshotQuery.getFirstActiveThreadIdByProjectId( - asProjectId("project-active"), - ); - assert.equal(firstThreadId._tag, "Some"); - if (firstThreadId._tag === "Some") { - assert.equal(firstThreadId.value, ThreadId.makeUnsafe("thread-first")); - } - }), - ); - - it.effect("reads single-thread checkpoint context without hydrating unrelated threads", () => - Effect.gen(function* () { - const snapshotQuery = yield* ProjectionSnapshotQuery; - const sql = yield* SqlClient.SqlClient; - - yield* sql`DELETE FROM projection_projects`; - yield* sql`DELETE FROM projection_threads`; - yield* sql`DELETE FROM projection_turns`; - - yield* sql` - INSERT INTO projection_projects ( - project_id, - title, - workspace_root, - default_model_selection_json, - scripts_json, - created_at, - updated_at, - deleted_at - ) - VALUES ( - 'project-context', - 'Context Project', - '/tmp/context-workspace', - NULL, - '[]', - '2026-03-02T00:00:00.000Z', - '2026-03-02T00:00:01.000Z', - NULL - ) - `; - - yield* sql` - INSERT INTO projection_threads ( - thread_id, - project_id, - title, - model_selection_json, - runtime_mode, - interaction_mode, - branch, - worktree_path, - latest_turn_id, - created_at, - updated_at, - archived_at, - deleted_at - ) - VALUES ( - 'thread-context', - 'project-context', - 'Context Thread', - '{"provider":"codex","model":"gpt-5-codex"}', - 'full-access', - 'default', - 'feature/perf', - '/tmp/context-worktree', - NULL, - '2026-03-02T00:00:02.000Z', - '2026-03-02T00:00:03.000Z', - NULL, - NULL - ) - `; - - yield* sql` - INSERT INTO projection_turns ( - thread_id, - turn_id, - pending_message_id, - source_proposed_plan_thread_id, - source_proposed_plan_id, - assistant_message_id, - state, - requested_at, - started_at, - completed_at, - checkpoint_turn_count, - checkpoint_ref, - checkpoint_status, - checkpoint_files_json - ) - VALUES - ( - 'thread-context', - 'turn-1', - NULL, - NULL, - NULL, - NULL, - 'completed', - '2026-03-02T00:00:04.000Z', - '2026-03-02T00:00:04.000Z', - '2026-03-02T00:00:04.000Z', - 1, - 'checkpoint-a', - 'ready', - '[]' - ), - ( - 'thread-context', - 'turn-2', - NULL, - NULL, - NULL, - NULL, - 'completed', - '2026-03-02T00:00:05.000Z', - '2026-03-02T00:00:05.000Z', - '2026-03-02T00:00:05.000Z', - 2, - 'checkpoint-b', - 'ready', - '[]' - ) - `; - - const context = yield* snapshotQuery.getThreadCheckpointContext( - ThreadId.makeUnsafe("thread-context"), - ); - assert.equal(context._tag, "Some"); - if (context._tag === "Some") { - assert.deepEqual(context.value, { - threadId: ThreadId.makeUnsafe("thread-context"), - projectId: asProjectId("project-context"), - workspaceRoot: "/tmp/context-workspace", - worktreePath: "/tmp/context-worktree", - checkpoints: [ - { - turnId: asTurnId("turn-1"), - checkpointTurnCount: 1, - checkpointRef: asCheckpointRef("checkpoint-a"), - status: "ready", - files: [], - assistantMessageId: null, - completedAt: "2026-03-02T00:00:04.000Z", - }, - { - turnId: asTurnId("turn-2"), - checkpointTurnCount: 2, - checkpointRef: asCheckpointRef("checkpoint-b"), - status: "ready", - files: [], - assistantMessageId: null, - completedAt: "2026-03-02T00:00:05.000Z", - }, - ], - }); - } - }), - ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index da7c695674..f951c54b5b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -7,6 +7,7 @@ import { OrchestrationProposedPlanId, OrchestrationReadModel, ProjectScript, + ThreadId, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, @@ -17,10 +18,8 @@ import { type OrchestrationThread, type OrchestrationThreadActivity, ModelSelection, - ProjectId, - ThreadId, } from "@t3tools/contracts"; -import { Effect, Layer, Option, Schema, Struct } from "effect"; +import { Effect, Layer, Schema, Struct } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; @@ -41,8 +40,6 @@ import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.t import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, - type ProjectionSnapshotCounts, - type ProjectionThreadCheckpointContext, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; @@ -89,29 +86,6 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), }); const ProjectionStateDbRowSchema = ProjectionState; -const ProjectionCountsRowSchema = Schema.Struct({ - projectCount: Schema.Number, - threadCount: Schema.Number, -}); -const WorkspaceRootLookupInput = Schema.Struct({ - workspaceRoot: Schema.String, -}); -const ProjectIdLookupInput = Schema.Struct({ - projectId: ProjectId, -}); -const ThreadIdLookupInput = Schema.Struct({ - threadId: ThreadId, -}); -const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema; -const ProjectionThreadIdLookupRowSchema = Schema.Struct({ - threadId: ThreadId, -}); -const ProjectionThreadCheckpointContextThreadRowSchema = Schema.Struct({ - threadId: ThreadId, - projectId: ProjectId, - workspaceRoot: Schema.String, - worktreePath: Schema.NullOr(Schema.String), -}); const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.projects, @@ -345,94 +319,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); - const readProjectionCounts = SqlSchema.findOne({ - Request: Schema.Void, - Result: ProjectionCountsRowSchema, - execute: () => - sql` - SELECT - (SELECT COUNT(*) FROM projection_projects) AS "projectCount", - (SELECT COUNT(*) FROM projection_threads) AS "threadCount" - `, - }); - - const getActiveProjectRowByWorkspaceRoot = SqlSchema.findOneOption({ - Request: WorkspaceRootLookupInput, - Result: ProjectionProjectLookupRowSchema, - execute: ({ workspaceRoot }) => - sql` - SELECT - project_id AS "projectId", - title, - workspace_root AS "workspaceRoot", - default_model_selection_json AS "defaultModelSelection", - scripts_json AS "scripts", - created_at AS "createdAt", - updated_at AS "updatedAt", - deleted_at AS "deletedAt" - FROM projection_projects - WHERE workspace_root = ${workspaceRoot} - AND deleted_at IS NULL - ORDER BY created_at ASC, project_id ASC - LIMIT 1 - `, - }); - - const getFirstActiveThreadIdByProject = SqlSchema.findOneOption({ - Request: ProjectIdLookupInput, - Result: ProjectionThreadIdLookupRowSchema, - execute: ({ projectId }) => - sql` - SELECT - thread_id AS "threadId" - FROM projection_threads - WHERE project_id = ${projectId} - AND deleted_at IS NULL - ORDER BY created_at ASC, thread_id ASC - LIMIT 1 - `, - }); - - const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({ - Request: ThreadIdLookupInput, - Result: ProjectionThreadCheckpointContextThreadRowSchema, - execute: ({ threadId }) => - sql` - SELECT - threads.thread_id AS "threadId", - threads.project_id AS "projectId", - projects.workspace_root AS "workspaceRoot", - threads.worktree_path AS "worktreePath" - FROM projection_threads AS threads - INNER JOIN projection_projects AS projects - ON projects.project_id = threads.project_id - WHERE threads.thread_id = ${threadId} - AND threads.deleted_at IS NULL - LIMIT 1 - `, - }); - - const listCheckpointRowsByThread = SqlSchema.findAll({ - Request: ThreadIdLookupInput, - Result: ProjectionCheckpointDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - turn_id AS "turnId", - checkpoint_turn_count AS "checkpointTurnCount", - checkpoint_ref AS "checkpointRef", - checkpoint_status AS "status", - checkpoint_files_json AS "files", - assistant_message_id AS "assistantMessageId", - completed_at AS "completedAt" - FROM projection_turns - WHERE thread_id = ${threadId} - AND checkpoint_turn_count IS NOT NULL - ORDER BY checkpoint_turn_count ASC - `, - }); - const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => sql .withTransaction( @@ -707,109 +593,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }), ); - const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => - readProjectionCounts(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getCounts:query", - "ProjectionSnapshotQuery.getCounts:decodeRow", - ), - ), - Effect.map( - (row): ProjectionSnapshotCounts => ({ - projectCount: row.projectCount, - threadCount: row.threadCount, - }), - ), - ); - - const getActiveProjectByWorkspaceRoot: ProjectionSnapshotQueryShape["getActiveProjectByWorkspaceRoot"] = - (workspaceRoot) => - getActiveProjectRowByWorkspaceRoot({ workspaceRoot }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:query", - "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow", - ), - ), - Effect.map( - Option.map( - (row): OrchestrationProject => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection: row.defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - }), - ), - ), - ); - - const getFirstActiveThreadIdByProjectId: ProjectionSnapshotQueryShape["getFirstActiveThreadIdByProjectId"] = - (projectId) => - getFirstActiveThreadIdByProject({ projectId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getFirstActiveThreadIdByProjectId:query", - "ProjectionSnapshotQuery.getFirstActiveThreadIdByProjectId:decodeRow", - ), - ), - Effect.map(Option.map((row) => row.threadId)), - ); - - const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = ( - threadId, - ) => - Effect.gen(function* () { - const threadRow = yield* getThreadCheckpointContextThreadRow({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadCheckpointContext:getThread:query", - "ProjectionSnapshotQuery.getThreadCheckpointContext:getThread:decodeRow", - ), - ), - ); - if (Option.isNone(threadRow)) { - return Option.none(); - } - - const checkpointRows = yield* listCheckpointRowsByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadCheckpointContext:listCheckpoints:query", - "ProjectionSnapshotQuery.getThreadCheckpointContext:listCheckpoints:decodeRows", - ), - ), - ); - - return Option.some({ - threadId: threadRow.value.threadId, - projectId: threadRow.value.projectId, - workspaceRoot: threadRow.value.workspaceRoot, - worktreePath: threadRow.value.worktreePath, - checkpoints: checkpointRows.map( - (row): OrchestrationCheckpointSummary => ({ - turnId: row.turnId, - checkpointTurnCount: row.checkpointTurnCount, - checkpointRef: row.checkpointRef, - status: row.status, - files: row.files, - assistantMessageId: row.assistantMessageId, - completedAt: row.completedAt, - }), - ), - }); - }); - return { getSnapshot, - getCounts, - getActiveProjectByWorkspaceRoot, - getFirstActiveThreadIdByProjectId, - getThreadCheckpointContext, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ca3dc04517..506d6d2864 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -30,7 +30,6 @@ import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; -import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; @@ -212,7 +211,6 @@ describe("ProviderCommandReactor", () => { }; const orchestrationLayer = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 6c27e1010c..529eae2444 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -31,7 +31,6 @@ import { } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; -import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService, @@ -198,7 +197,6 @@ describe("ProviderRuntimeIngestion", () => { fs.mkdirSync(path.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); const orchestrationLayer = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index a7673dc32e..91e42f02ff 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -6,32 +6,12 @@ * * @module ProjectionSnapshotQuery */ -import type { - OrchestrationCheckpointSummary, - OrchestrationProject, - OrchestrationReadModel, - ProjectId, - ThreadId, -} from "@t3tools/contracts"; +import type { OrchestrationReadModel } from "@t3tools/contracts"; import { ServiceMap } from "effect"; -import type { Option } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; -export interface ProjectionSnapshotCounts { - readonly projectCount: number; - readonly threadCount: number; -} - -export interface ProjectionThreadCheckpointContext { - readonly threadId: ThreadId; - readonly projectId: ProjectId; - readonly workspaceRoot: string; - readonly worktreePath: string | null; - readonly checkpoints: ReadonlyArray; -} - /** * ProjectionSnapshotQueryShape - Service API for read-model snapshots. */ @@ -43,32 +23,6 @@ export interface ProjectionSnapshotQueryShape { * projector cursor state. */ readonly getSnapshot: () => Effect.Effect; - - /** - * Read aggregate projection counts without hydrating the full read model. - */ - readonly getCounts: () => Effect.Effect; - - /** - * Read the active project for an exact workspace root match. - */ - readonly getActiveProjectByWorkspaceRoot: ( - workspaceRoot: string, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Read the earliest active thread for a project. - */ - readonly getFirstActiveThreadIdByProjectId: ( - projectId: ProjectId, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Read the checkpoint context needed to resolve a single thread diff. - */ - readonly getThreadCheckpointContext: ( - threadId: ThreadId, - ) => Effect.Effect, ProjectionRepositoryError>; } /** diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 5993ad6c20..b761387d47 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -55,13 +55,6 @@ layer("ProjectionThreadMessageRepository", (it) => { assert.equal(rows.length, 1); assert.equal(rows[0]?.text, "updated"); assert.deepEqual(rows[0]?.attachments, persistedAttachments); - - const rowById = yield* repository.getByMessageId({ messageId }); - assert.equal(rowById._tag, "Some"); - if (rowById._tag === "Some") { - assert.equal(rowById.value.text, "updated"); - assert.deepEqual(rowById.value.attachments, persistedAttachments); - } }), ); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 13b7086cec..6f0b25ddff 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -1,11 +1,10 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema, Struct } from "effect"; +import { Effect, Layer, Schema, Struct } from "effect"; import { ChatAttachment } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { - GetProjectionThreadMessageInput, ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, DeleteProjectionThreadMessagesInput, @@ -20,22 +19,6 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( }), ); -function toProjectionThreadMessage( - row: Schema.Schema.Type, -): ProjectionThreadMessage { - return { - messageId: row.messageId, - threadId: row.threadId, - turnId: row.turnId, - role: row.role, - text: row.text, - isStreaming: row.isStreaming === 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - ...(row.attachments !== null ? { attachments: row.attachments } : {}), - }; -} - const makeProjectionThreadMessageRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -91,27 +74,6 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { }, }); - const getProjectionThreadMessageRow = SqlSchema.findOneOption({ - Request: GetProjectionThreadMessageInput, - Result: ProjectionThreadMessageDbRowSchema, - execute: ({ messageId }) => - sql` - SELECT - message_id AS "messageId", - thread_id AS "threadId", - turn_id AS "turnId", - role, - text, - attachments_json AS "attachments", - is_streaming AS "isStreaming", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM projection_thread_messages - WHERE message_id = ${messageId} - LIMIT 1 - `, - }); - const listProjectionThreadMessageRows = SqlSchema.findAll({ Request: ListProjectionThreadMessagesInput, Result: ProjectionThreadMessageDbRowSchema, @@ -147,20 +109,24 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), ); - const getByMessageId: ProjectionThreadMessageRepositoryShape["getByMessageId"] = (input) => - getProjectionThreadMessageRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadMessageRepository.getByMessageId:query"), - ), - Effect.map(Option.map(toProjectionThreadMessage)), - ); - const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => listProjectionThreadMessageRows(input).pipe( Effect.mapError( toPersistenceSqlError("ProjectionThreadMessageRepository.listByThreadId:query"), ), - Effect.map((rows) => rows.map(toProjectionThreadMessage)), + Effect.map((rows) => + rows.map((row) => ({ + messageId: row.messageId, + threadId: row.threadId, + turnId: row.turnId, + role: row.role, + text: row.text, + isStreaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + })), + ), ); const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => @@ -172,7 +138,6 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { return { upsert, - getByMessageId, listByThreadId, deleteByThreadId, } satisfies ProjectionThreadMessageRepositoryShape; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a03c3c2d18..47102471ac 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -31,7 +31,6 @@ import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; -import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; /** * Migration loader with all migrations defined inline. @@ -62,7 +61,6 @@ export const migrationEntries = [ [16, "CanonicalizeModelSelections", Migration0016], [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], - [19, "ProjectionSnapshotLookupIndexes", Migration0019], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts deleted file mode 100644 index 6207a9bcb6..0000000000 --- a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); - -layer("019_ProjectionSnapshotLookupIndexes", (it) => { - it.effect("creates indexes for targeted projection lookup filters", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* runMigrations({ toMigrationInclusive: 18 }); - yield* runMigrations({ toMigrationInclusive: 19 }); - - const projectIndexes = yield* sql<{ - readonly seq: number; - readonly name: string; - readonly unique: number; - readonly origin: string; - readonly partial: number; - }>` - PRAGMA index_list(projection_projects) - `; - assert.ok( - projectIndexes.some( - (index) => index.name === "idx_projection_projects_workspace_root_deleted_at", - ), - ); - - const projectIndexColumns = yield* sql<{ - readonly seqno: number; - readonly cid: number; - readonly name: string; - }>` - PRAGMA index_info('idx_projection_projects_workspace_root_deleted_at') - `; - assert.deepStrictEqual( - projectIndexColumns.map((column) => column.name), - ["workspace_root", "deleted_at"], - ); - - const threadIndexes = yield* sql<{ - readonly seq: number; - readonly name: string; - readonly unique: number; - readonly origin: string; - readonly partial: number; - }>` - PRAGMA index_list(projection_threads) - `; - assert.ok( - threadIndexes.some( - (index) => index.name === "idx_projection_threads_project_deleted_created", - ), - ); - - const threadIndexColumns = yield* sql<{ - readonly seqno: number; - readonly cid: number; - readonly name: string; - }>` - PRAGMA index_info('idx_projection_threads_project_deleted_created') - `; - assert.deepStrictEqual( - threadIndexColumns.map((column) => column.name), - ["project_id", "deleted_at", "created_at"], - ); - }), - ); -}); diff --git a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.ts b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.ts deleted file mode 100644 index bf74a5147d..0000000000 --- a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 INDEX IF NOT EXISTS idx_projection_projects_workspace_root_deleted_at - ON projection_projects(workspace_root, deleted_at) - `; - - yield* sql` - CREATE INDEX IF NOT EXISTS idx_projection_threads_project_deleted_created - ON projection_threads(project_id, deleted_at, created_at) - `; -}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index b1a769cd91..00b1d399c6 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -8,14 +8,13 @@ */ import { ChatAttachment, - MessageId, OrchestrationMessageRole, + MessageId, ThreadId, TurnId, IsoDateTime, } from "@t3tools/contracts"; import { Schema, ServiceMap } from "effect"; -import type { Option } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -38,11 +37,6 @@ export const ListProjectionThreadMessagesInput = Schema.Struct({ }); export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type; -export const GetProjectionThreadMessageInput = Schema.Struct({ - messageId: MessageId, -}); -export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type; - export const DeleteProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -61,13 +55,6 @@ export interface ProjectionThreadMessageRepositoryShape { message: ProjectionThreadMessage, ) => Effect.Effect; - /** - * Read a projected thread message by id. - */ - readonly getByMessageId: ( - input: GetProjectionThreadMessageInput, - ) => Effect.Effect, ProjectionRepositoryError>; - /** * List projected thread messages for a thread. * diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 53829f37b8..0e53eae43e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -842,17 +842,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, push: { status: "skipped_not_requested" as const }, pr: { status: "skipped_not_requested" as const }, - toast: { - title: "Committed abc123", - description: "feat: demo", - cta: { - kind: "run_action" as const, - label: "Push", - action: { - kind: "push" as const, - }, - }, - }, }; yield* ( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4e296e817a..40a8eb09bc 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -99,24 +99,12 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(RuntimeReceiptBusLive), ); -const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( - OrchestrationEventStoreLive, - OrchestrationCommandReceiptRepositoryLive, -); - -const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe( - Layer.provide(OrchestrationEventStoreLive), -); - -const OrchestrationInfrastructureLayerLive = Layer.mergeAll( - OrchestrationProjectionSnapshotQueryLive, - OrchestrationEventInfrastructureLayerLive, - OrchestrationProjectionPipelineLayerLive, -); - -const OrchestrationLayerLive = Layer.mergeAll( - OrchestrationInfrastructureLayerLive, - OrchestrationEngineLive.pipe(Layer.provide(OrchestrationInfrastructureLayerLive)), +const OrchestrationLayerLive = Layer.empty.pipe( + Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), + Layer.provideMerge(OrchestrationEngineLive), + Layer.provideMerge(OrchestrationProjectionPipelineLive), + Layer.provideMerge(OrchestrationEventStoreLive), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), ); const CheckpointingLayerLive = Layer.empty.pipe( diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index fc06d77566..55700e3482 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,13 +1,8 @@ import { assert, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Option, Ref } from "effect"; +import { Deferred, Effect, Fiber, Ref } from "effect"; +import { TestClock } from "effect/testing"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { - launchStartupHeartbeat, - makeCommandGate, - ServerRuntimeStartupError, -} from "./serverRuntimeStartup.ts"; +import { makeCommandGate, ServerRuntimeStartupError } from "./serverRuntimeStartup.ts"; it.effect("enqueueCommand waits for readiness and then drains queued work", () => Effect.scoped( @@ -19,7 +14,7 @@ it.effect("enqueueCommand waits for readiness and then drains queued work", () = .enqueueCommand(Ref.updateAndGet(executionCount, (count) => count + 1)) .pipe(Effect.forkScoped); - yield* Effect.yieldNow; + yield* TestClock.adjust("50 millis"); assert.equal(yield* Ref.get(executionCount), 0); yield* commandGate.signalCommandReady; @@ -52,31 +47,3 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => }), ), ); - -it.effect("launchStartupHeartbeat does not block the caller while counts are loading", () => - Effect.scoped( - Effect.gen(function* () { - const releaseCounts = yield* Deferred.make(); - - yield* launchStartupHeartbeat.pipe( - Effect.provideService(ProjectionSnapshotQuery, { - getSnapshot: () => Effect.die("unused"), - getCounts: () => - Deferred.await(releaseCounts).pipe( - Effect.as({ - projectCount: 2, - threadCount: 3, - }), - ), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - }), - Effect.provideService(AnalyticsService, { - record: () => Effect.void, - flush: Effect.void, - }), - ); - }), - ), -); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 026145eca5..2457f6ffe8 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -5,19 +5,7 @@ import { ProjectId, ThreadId, } from "@t3tools/contracts"; -import { - Data, - Deferred, - Effect, - Exit, - Layer, - Option, - Path, - Queue, - Ref, - Scope, - ServiceMap, -} from "effect"; +import { Data, Deferred, Effect, Exit, Layer, Path, Queue, Ref, Scope, ServiceMap } from "effect"; import { ServerConfig } from "./config"; import { Keybindings } from "./keybindings"; @@ -117,15 +105,17 @@ export const makeCommandGate = Effect.gen(function* () { } satisfies CommandGate; }); -export const recordStartupHeartbeat = Effect.gen(function* () { +const recordStartupHeartbeat = Effect.gen(function* () { const analytics = yield* AnalyticsService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const { threadCount, projectCount } = yield* projectionSnapshotQuery.getCounts().pipe( + const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe( + Effect.map((snapshot) => ({ + threadCount: snapshot.threads.length, + projectCount: snapshot.projects.length, + })), Effect.catch((cause) => - Effect.logWarning("failed to gather startup projection counts for telemetry", { - cause, - }).pipe( + Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe( Effect.as({ threadCount: 0, projectCount: 0, @@ -140,12 +130,6 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); }); -export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( - Effect.ignoreCause({ log: true }), - Effect.forkScoped, - Effect.asVoid, -); - const autoBootstrapWelcome = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const projectionReadModelQuery = yield* ProjectionSnapshotQuery; @@ -157,13 +141,14 @@ const autoBootstrapWelcome = Effect.gen(function* () { if (serverConfig.autoBootstrapProjectFromCwd) { yield* Effect.gen(function* () { - const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot( - serverConfig.cwd, + const snapshot = yield* projectionReadModelQuery.getSnapshot(); + const existingProject = snapshot.projects.find( + (project) => project.workspaceRoot === serverConfig.cwd && project.deletedAt === null, ); let nextProjectId: ProjectId; let nextProjectDefaultModelSelection: ModelSelection; - if (Option.isNone(existingProject)) { + if (!existingProject) { const createdAt = new Date().toISOString(); nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; @@ -181,16 +166,17 @@ const autoBootstrapWelcome = Effect.gen(function* () { createdAt, }); } else { - nextProjectId = existingProject.value.id; - nextProjectDefaultModelSelection = existingProject.value.defaultModelSelection ?? { + nextProjectId = existingProject.id; + nextProjectDefaultModelSelection = existingProject.defaultModelSelection ?? { provider: "codex", model: "gpt-5-codex", }; } - const existingThreadId = - yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); - if (Option.isNone(existingThreadId)) { + const existingThread = snapshot.threads.find( + (thread) => thread.projectId === nextProjectId && thread.deletedAt === null, + ); + if (!existingThread) { const createdAt = new Date().toISOString(); const createdThreadId = ThreadId.makeUnsafe(crypto.randomUUID()); yield* orchestrationEngine.dispatch({ @@ -210,7 +196,7 @@ const autoBootstrapWelcome = Effect.gen(function* () { bootstrapThreadId = createdThreadId; } else { bootstrapProjectId = nextProjectId; - bootstrapThreadId = existingThreadId.value; + bootstrapThreadId = existingThread.id; } }); } @@ -328,7 +314,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { }); yield* Effect.logDebug("startup phase: recording startup heartbeat"); - yield* launchStartupHeartbeat; + yield* recordStartupHeartbeat; yield* Effect.logDebug("startup phase: browser open check"); yield* maybeOpenBrowser; yield* Effect.logDebug("startup phase: complete"); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f21c924c79..44ad29efac 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -6,9 +6,8 @@ import { requiresDefaultBranchConfirmation, resolveAutoFeatureBranchName, resolveDefaultBranchActionDialogCopy, - resolveLiveThreadBranchUpdate, resolveQuickAction, - resolveThreadBranchUpdate, + summarizeGitResult, } from "./GitActionsControl.logic"; function status(overrides: Partial = {}): GitStatusResult { @@ -163,7 +162,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { }), false, ); - assert.deepInclude(quick, { kind: "run_action", action: "push", label: "Push" }); + assert.deepInclude(quick, { kind: "run_action", action: "commit_push", label: "Push" }); }); it("buildMenuItems enables push and keeps open PR available", () => { @@ -214,7 +213,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => { const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); assert.deepInclude(quick, { kind: "run_action", - action: "create_pr", + action: "commit_push_pr", label: "Push & create PR", }); }); @@ -586,7 +585,7 @@ describe("when: branch has no upstream configured", () => { ); assert.deepInclude(quick, { kind: "run_action", - action: "push", + action: "commit_push", label: "Push", disabled: false, }); @@ -633,7 +632,7 @@ describe("when: branch has no upstream configured", () => { ); assert.deepInclude(quick, { kind: "run_action", - action: "create_pr", + action: "commit_push_pr", label: "Push & create PR", disabled: false, }); @@ -802,12 +801,9 @@ describe("when: branch has no upstream configured", () => { describe("requiresDefaultBranchConfirmation", () => { it("requires confirmation for push actions on default branch", () => { assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); - assert.isTrue(requiresDefaultBranchConfirmation("push", true)); - assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true)); assert.isTrue(requiresDefaultBranchConfirmation("commit_push", true)); assert.isTrue(requiresDefaultBranchConfirmation("commit_push_pr", true)); assert.isFalse(requiresDefaultBranchConfirmation("commit_push", false)); - assert.isFalse(requiresDefaultBranchConfirmation("push", false)); }); }); @@ -859,37 +855,28 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); describe("buildGitActionProgressStages", () => { - it("shows only push progress for explicit push actions", () => { + it("shows only push progress when push-only is forced", () => { const stages = buildGitActionProgressStages({ - action: "push", + action: "commit_push", hasCustomCommitMessage: false, - hasWorkingTreeChanges: false, + hasWorkingTreeChanges: true, + forcePushOnly: true, pushTarget: "origin/feature/test", }); assert.deepEqual(stages, ["Pushing to origin/feature/test..."]); }); - it("shows push and PR progress for create-pr actions that still need a push", () => { + it("skips commit stages for create-pr flow when push-only is forced", () => { const stages = buildGitActionProgressStages({ - action: "create_pr", + action: "commit_push_pr", hasCustomCommitMessage: false, - hasWorkingTreeChanges: false, + hasWorkingTreeChanges: true, + forcePushOnly: true, pushTarget: "origin/feature/test", - shouldPushBeforePr: true, }); assert.deepEqual(stages, ["Pushing to origin/feature/test...", "Creating PR..."]); }); - it("shows only PR progress when create-pr can skip the push", () => { - const stages = buildGitActionProgressStages({ - action: "create_pr", - hasCustomCommitMessage: false, - hasWorkingTreeChanges: false, - shouldPushBeforePr: false, - }); - assert.deepEqual(stages, ["Creating PR..."]); - }); - it("includes commit stages for commit+push when working tree is dirty", () => { const stages = buildGitActionProgressStages({ action: "commit_push", @@ -905,92 +892,97 @@ describe("buildGitActionProgressStages", () => { }); }); -describe("resolveThreadBranchUpdate", () => { - it("returns a branch update when the action created a new branch", () => { - const update = resolveThreadBranchUpdate({ - action: "commit_push_pr", - branch: { - status: "created", - name: "feature/fix-toast-copy", - }, +describe("summarizeGitResult", () => { + it("returns commit-focused toast for commit action", () => { + const result = summarizeGitResult({ + action: "commit", + branch: { status: "skipped_not_requested" }, commit: { status: "created", - commitSha: "89abcdef01234567", - subject: "feat: add branch sync", + commitSha: "0123456789abcdef", + subject: "feat: add optimistic UI for git action button", }, - push: { status: "pushed", branch: "feature/fix-toast-copy" }, + push: { status: "skipped_not_requested" }, pr: { status: "skipped_not_requested" }, - toast: { - title: "Pushed 89abcde to origin/feature/fix-toast-copy", - cta: { kind: "none" }, - }, }); - assert.deepEqual(update, { - branch: "feature/fix-toast-copy", + assert.deepEqual(result, { + title: "Committed 0123456", + description: "feat: add optimistic UI for git action button", }); }); - it("returns null when the action stayed on the existing branch", () => { - const update = resolveThreadBranchUpdate({ + it("returns push-focused toast for push action", () => { + const result = summarizeGitResult({ action: "commit_push", - branch: { - status: "skipped_not_requested", - }, + branch: { status: "skipped_not_requested" }, commit: { status: "created", - commitSha: "89abcdef01234567", - subject: "feat: add branch sync", + commitSha: "abcdef0123456789", + subject: "fix: tighten quick action tooltip hover handling", }, - push: { status: "pushed", branch: "feature/fix-toast-copy" }, - pr: { status: "skipped_not_requested" }, - toast: { - title: "Pushed 89abcde to origin/feature/fix-toast-copy", - cta: { kind: "none" }, + push: { + status: "pushed", + branch: "foo", + upstreamBranch: "origin/foo", }, + pr: { status: "skipped_not_requested" }, }); - assert.equal(update, null); - }); -}); - -describe("resolveLiveThreadBranchUpdate", () => { - it("returns a branch update when live git status differs from stored thread metadata", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-branch", - gitStatus: status({ branch: "effect-atom" }), - }); - - assert.deepEqual(update, { - branch: "effect-atom", + assert.deepEqual(result, { + title: "Pushed abcdef0 to origin/foo", + description: "fix: tighten quick action tooltip hover handling", }); }); - it("returns null when live git status is unavailable", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-branch", - gitStatus: null, + it("returns PR-focused toast for created PR action", () => { + const result = summarizeGitResult({ + action: "commit_push_pr", + branch: { status: "skipped_not_requested" }, + commit: { + status: "created", + commitSha: "89abcdef01234567", + subject: "feat: ship github shortcuts", + }, + push: { + status: "pushed", + branch: "foo", + }, + pr: { + status: "created", + number: 42, + title: "feat: ship github shortcuts and improve PR CTA in success toast", + }, }); - assert.equal(update, null); - }); - - it("returns null when the stored thread branch already matches git status", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "effect-atom", - gitStatus: status({ branch: "effect-atom" }), + assert.deepEqual(result, { + title: "Created PR #42", + description: "feat: ship github shortcuts and improve PR CTA in success toast", }); - - assert.equal(update, null); }); - it("returns null when git status is detached HEAD but the thread already has a branch", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "effect-atom", - gitStatus: status({ branch: null }), + it("truncates long description text", () => { + const result = summarizeGitResult({ + action: "commit_push_pr", + branch: { status: "skipped_not_requested" }, + commit: { + status: "created", + commitSha: "89abcdef01234567", + subject: "short subject", + }, + push: { status: "pushed", branch: "foo" }, + pr: { + status: "created", + number: 99, + title: + "feat: this title is intentionally extremely long so we can validate that toast descriptions are truncated with an ellipsis suffix", + }, }); - assert.equal(update, null); + assert.deepEqual(result, { + title: "Created PR #99", + description: "feat: this title is intentionally extremely long so we can validate t...", + }); }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 80906a982b..8f7f023ef7 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -31,36 +31,43 @@ export interface DefaultBranchActionDialogCopy { continueLabel: string; } -export type DefaultBranchConfirmableAction = - | "push" - | "create_pr" - | "commit_push" - | "commit_push_pr"; +export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr"; + +const SHORT_SHA_LENGTH = 7; +const TOAST_DESCRIPTION_MAX = 72; + +function shortenSha(sha: string | undefined): string | null { + if (!sha) return null; + return sha.slice(0, SHORT_SHA_LENGTH); +} + +function truncateText( + value: string | undefined, + maxLength = TOAST_DESCRIPTION_MAX, +): string | undefined { + if (!value) return undefined; + if (value.length <= maxLength) return value; + if (maxLength <= 3) return "...".slice(0, maxLength); + return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} export function buildGitActionProgressStages(input: { action: GitStackedAction; hasCustomCommitMessage: boolean; hasWorkingTreeChanges: boolean; + forcePushOnly?: boolean; pushTarget?: string; featureBranch?: boolean; - shouldPushBeforePr?: boolean; }): string[] { const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; - const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; - - if (input.action === "push") { - return [pushStage]; - } - if (input.action === "create_pr") { - return input.shouldPushBeforePr ? [pushStage, "Creating PR..."] : ["Creating PR..."]; - } - - const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; + const shouldIncludeCommitStages = + !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges); const commitStages = !shouldIncludeCommitStages ? [] : input.hasCustomCommitMessage ? ["Committing..."] : ["Generating commit message...", "Committing..."]; + const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; if (input.action === "commit") { return [...branchStages, ...commitStages]; } @@ -70,6 +77,39 @@ export function buildGitActionProgressStages(input: { return [...branchStages, ...commitStages, pushStage, "Creating PR..."]; } +const withDescription = (title: string, description: string | undefined) => + description ? { title, description } : { title }; + +export function summarizeGitResult(result: GitRunStackedActionResult): { + title: string; + description?: string; +} { + if (result.pr.status === "created" || result.pr.status === "opened_existing") { + const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; + const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; + return withDescription(title, truncateText(result.pr.title)); + } + + if (result.push.status === "pushed") { + const shortSha = shortenSha(result.commit.commitSha); + const branch = result.push.upstreamBranch ?? result.push.branch; + const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; + const branchPart = branch ? ` to ${branch}` : ""; + return withDescription( + `Pushed${pushedCommitPart}${branchPart}`, + truncateText(result.commit.subject), + ); + } + + if (result.commit.status === "created") { + const shortSha = shortenSha(result.commit.commitSha); + const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; + return withDescription(title, truncateText(result.commit.subject)); + } + + return { title: "Done" }; +} + export function buildMenuItems( gitStatus: GitStatusResult | null, isBusy: boolean, @@ -210,18 +250,13 @@ export function resolveQuickAction( }; } if (hasOpenPr || isDefaultBranch) { - return { - label: "Push", - disabled: false, - kind: "run_action", - action: isDefaultBranch ? "commit_push" : "push", - }; + return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; } return { label: "Push & create PR", disabled: false, kind: "run_action", - action: "create_pr", + action: "commit_push_pr", }; } @@ -244,18 +279,13 @@ export function resolveQuickAction( if (isAhead) { if (hasOpenPr || isDefaultBranch) { - return { - label: "Push", - disabled: false, - kind: "run_action", - action: isDefaultBranch ? "commit_push" : "push", - }; + return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; } return { label: "Push & create PR", disabled: false, kind: "run_action", - action: "create_pr", + action: "commit_push_pr", }; } @@ -276,12 +306,7 @@ export function requiresDefaultBranchConfirmation( isDefaultBranch: boolean, ): boolean { if (!isDefaultBranch) return false; - return ( - action === "push" || - action === "create_pr" || - action === "commit_push" || - action === "commit_push_pr" - ); + return action === "commit_push" || action === "commit_push_pr"; } export function resolveDefaultBranchActionDialogCopy(input: { @@ -292,7 +317,7 @@ export function resolveDefaultBranchActionDialogCopy(input: { const branchLabel = input.branchName; const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; - if (input.action === "push" || input.action === "commit_push") { + if (input.action === "commit_push") { if (input.includesCommit) { return { title: "Commit & push to default branch?", @@ -321,38 +346,5 @@ export function resolveDefaultBranchActionDialogCopy(input: { }; } -export function resolveThreadBranchUpdate( - result: GitRunStackedActionResult, -): { branch: string } | null { - if (result.branch.status !== "created" || !result.branch.name) { - return null; - } - - return { - branch: result.branch.name, - }; -} - -export function resolveLiveThreadBranchUpdate(input: { - threadBranch: string | null; - gitStatus: GitStatusResult | null; -}): { branch: string | null } | null { - if (!input.gitStatus) { - return null; - } - - if (input.gitStatus.branch === null && input.threadBranch !== null) { - return null; - } - - if (input.threadBranch === input.gitStatus.branch) { - return null; - } - - return { - branch: input.gitStatus.branch, - }; -} - // Re-export from shared for backwards compatibility in this module's exports export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d8d86dd4f0..6384709620 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,6 +1,5 @@ import type { GitActionProgressEvent, - GitRunStackedActionResult, GitStackedAction, GitStatusResult, ThreadId, @@ -18,9 +17,8 @@ import { type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, - resolveLiveThreadBranchUpdate, resolveQuickAction, - resolveThreadBranchUpdate, + summarizeGitResult, } from "./GitActionsControl.logic"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; @@ -49,10 +47,9 @@ import { gitStatusQueryOptions, invalidateGitQueries, } from "~/lib/gitReactQuery"; -import { newCommandId, randomUUID } from "~/lib/utils"; +import { randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; -import { useStore } from "~/store"; interface GitActionsControlProps { gitCwd: string | null; @@ -64,6 +61,7 @@ interface PendingDefaultBranchAction { branchName: string; includesCommit: boolean; commitMessage?: string; + forcePushOnlyProgress: boolean; onConfirmed?: () => void; filePaths?: string[]; } @@ -84,10 +82,12 @@ interface ActiveGitActionProgress { interface RunGitActionWithToastInput { action: GitStackedAction; commitMessage?: string; + forcePushOnlyProgress?: boolean; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; statusOverride?: GitStatusResult | null; featureBranch?: boolean; + isDefaultBranchOverride?: boolean; progressToastId?: GitActionToastId; filePaths?: string[]; } @@ -196,9 +196,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; - if (quickAction.action === "push" || quickAction.action === "commit_push") { - return ; - } + if (quickAction.action === "commit_push") return ; return ; } if (quickAction.label === "Commit") return ; @@ -210,10 +208,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], ); - const activeServerThread = useStore((store) => - activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, - ); - const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); @@ -237,43 +231,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); }, [threadToastData]); - const persistThreadBranchSync = useCallback( - (branch: string | null) => { - if (!activeThreadId || !activeServerThread || activeServerThread.branch === branch) { - return; - } - - const worktreePath = activeServerThread.worktreePath; - const api = readNativeApi(); - if (api) { - void api.orchestration - .dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadId, - branch, - worktreePath, - }) - .catch(() => undefined); - } - - setThreadBranch(activeThreadId, branch, worktreePath); - }, - [activeServerThread, activeThreadId, setThreadBranch], - ); - - const syncThreadBranchAfterGitAction = useCallback( - (result: GitRunStackedActionResult) => { - const branchUpdate = resolveThreadBranchUpdate(result); - if (!branchUpdate) { - return; - } - - persistThreadBranchSync(branchUpdate.branch); - }, - [persistThreadBranchSync], - ); - const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); @@ -310,28 +267,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; - - useEffect(() => { - if (isGitActionRunning) { - return; - } - - const branchUpdate = resolveLiveThreadBranchUpdate({ - threadBranch: activeServerThread?.branch ?? null, - gitStatus: gitStatusForActions, - }); - if (!branchUpdate) { - return; - } - - persistThreadBranchSync(branchUpdate.branch); - }, [ - activeServerThread?.branch, - gitStatusForActions, - isGitActionRunning, - persistThreadBranchSync, - ]); - const isDefaultBranch = useMemo(() => { const branchName = gitStatusForActions?.branch; if (!branchName) return false; @@ -405,32 +340,27 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions async ({ action, commitMessage, + forcePushOnlyProgress = false, onConfirmed, skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, + isDefaultBranchOverride, progressToastId, filePaths, }: RunGitActionWithToastInput) => { const actionStatus = statusOverride ?? gitStatusForActions; const actionBranch = actionStatus?.branch ?? null; - const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; - const actionCanCommit = - action === "commit" || action === "commit_push" || action === "commit_push_pr"; + const actionIsDefaultBranch = + isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch); const includesCommit = - actionCanCommit && - (action === "commit" || !!actionStatus?.hasWorkingTreeChanges || featureBranch); + !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges); if ( !skipDefaultBranchPrompt && requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && actionBranch ) { - if ( - action !== "push" && - action !== "create_pr" && - action !== "commit_push" && - action !== "commit_push_pr" - ) { + if (action !== "commit_push" && action !== "commit_push_pr") { return; } setPendingDefaultBranchAction({ @@ -438,6 +368,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions branchName: actionBranch, includesCommit, ...(commitMessage ? { commitMessage } : {}), + forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), }); @@ -449,10 +380,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action, hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, + forcePushOnly: forcePushOnlyProgress, featureBranch, - shouldPushBeforePr: - action === "create_pr" && - (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), }); const actionId = randomUUID(); const resolvedProgressToastId = @@ -554,48 +483,79 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions try { const result = await promise; activeGitActionProgressRef.current = null; - syncThreadBranchAfterGitAction(result); + const resultToast = summarizeGitResult(result); + + const existingOpenPrUrl = + actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined; + const prUrl = result.pr.url ?? existingOpenPrUrl; + const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; + const shouldOfferOpenPrCta = + (action === "commit_push" || action === "commit_push_pr") && + !!prUrl && + (!actionIsDefaultBranch || + result.pr.status === "created" || + result.pr.status === "opened_existing"); + const shouldOfferCreatePrCta = + action === "commit_push" && + !prUrl && + result.push.status === "pushed" && + !actionIsDefaultBranch; const closeResultToast = () => { toastManager.close(resolvedProgressToastId); }; - const toastCta = result.toast.cta; - let toastActionProps: { - children: string; - onClick: () => void; - } | null = null; - if (toastCta.kind === "run_action") { - toastActionProps = { - children: toastCta.label, - onClick: () => { - closeResultToast(); - void runGitActionWithToast({ - action: toastCta.action.kind, - }); - }, - }; - } else if (toastCta.kind === "open_pr") { - toastActionProps = { - children: toastCta.label, - onClick: () => { - const api = readNativeApi(); - if (!api) return; - closeResultToast(); - void api.shell.openExternal(toastCta.url); - }, - }; - } - toastManager.update(resolvedProgressToastId, { type: "success", - title: result.toast.title, - description: result.toast.description, + title: resultToast.title, + description: resultToast.description, timeout: 0, data: { ...threadToastData, dismissAfterVisibleMs: 10_000, }, - ...(toastActionProps ? { actionProps: toastActionProps } : {}), + ...(shouldOfferPushCta + ? { + actionProps: { + children: "Push", + onClick: () => { + void runGitActionWithToast({ + action: "commit_push", + forcePushOnlyProgress: true, + onConfirmed: closeResultToast, + statusOverride: actionStatus, + isDefaultBranchOverride: actionIsDefaultBranch, + }); + }, + }, + } + : shouldOfferOpenPrCta + ? { + actionProps: { + children: "View PR", + onClick: () => { + const api = readNativeApi(); + if (!api) return; + closeResultToast(); + void api.shell.openExternal(prUrl); + }, + }, + } + : shouldOfferCreatePrCta + ? { + actionProps: { + children: "Create PR", + onClick: () => { + closeResultToast(); + void runGitActionWithToast({ + action: "commit_push_pr", + forcePushOnlyProgress: true, + statusOverride: actionStatus, + isDefaultBranchOverride: actionIsDefaultBranch, + }); + }, + }, + } + : {}), }); } catch (err) { activeGitActionProgressRef.current = null; @@ -611,11 +571,13 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const continuePendingDefaultBranchAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; + const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = + pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), + forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), skipDefaultBranchPrompt: true, @@ -624,11 +586,13 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; + const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = + pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), + forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), featureBranch: true, @@ -702,11 +666,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions return; } if (item.dialogAction === "push") { - void runGitActionWithToast({ action: "push" }); + void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true }); return; } if (item.dialogAction === "create_pr") { - void runGitActionWithToast({ action: "create_pr" }); + void runGitActionWithToast({ action: "commit_push_pr" }); return; } setExcludedFiles(new Set()); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4f4468279..844b01bb37 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -11,7 +11,6 @@ import { isContextMenuPointerDown, orderItemsByPreferredIds, resolveProjectStatusIndicator, - resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -167,68 +166,6 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); -describe("resolveSidebarNewThreadSeedContext", () => { - it("inherits the active server thread context when creating a new thread in the same project", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-1", - defaultEnvMode: "local", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: null, - }), - ).toEqual({ - branch: "effect-atom", - worktreePath: null, - envMode: "local", - }); - }); - - it("prefers the active draft thread context when it matches the target project", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-1", - defaultEnvMode: "local", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: { - projectId: "project-1", - branch: "feature/new-draft", - worktreePath: "/repo/worktree", - envMode: "worktree", - }, - }), - ).toEqual({ - branch: "feature/new-draft", - worktreePath: "/repo/worktree", - envMode: "worktree", - }); - }); - - it("falls back to the default env mode when there is no matching active thread context", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-2", - defaultEnvMode: "worktree", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: null, - }), - ).toEqual({ - envMode: "worktree", - }); - }); -}); - describe("orderItemsByPreferredIds", () => { it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { const ordered = orderItemsByPreferredIds({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ed151c6da8..5f9b244a50 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -162,46 +162,6 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } -export function resolveSidebarNewThreadSeedContext(input: { - projectId: string; - defaultEnvMode: SidebarNewThreadEnvMode; - activeThread?: { - projectId: string; - branch: string | null; - worktreePath: string | null; - } | null; - activeDraftThread?: { - projectId: string; - branch: string | null; - worktreePath: string | null; - envMode: SidebarNewThreadEnvMode; - } | null; -}): { - branch?: string | null; - worktreePath?: string | null; - envMode: SidebarNewThreadEnvMode; -} { - if (input.activeDraftThread?.projectId === input.projectId) { - return { - branch: input.activeDraftThread.branch, - worktreePath: input.activeDraftThread.worktreePath, - envMode: input.activeDraftThread.envMode, - }; - } - - if (input.activeThread?.projectId === input.projectId) { - return { - branch: input.activeThread.branch, - worktreePath: input.activeThread.worktreePath, - envMode: input.activeThread.worktreePath ? "worktree" : "local", - }; - } - - return { - envMode: input.defaultEnvMode, - }; -} - export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fbe4e0528a..27ce7378e2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -115,7 +115,6 @@ import { resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, - resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -692,7 +691,7 @@ export default function Sidebar() { const isOnSettings = pathname.startsWith("/settings"); const appSettings = useSettings(); const { updateSettings } = useUpdateSettings(); - const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); + const { handleNewThread } = useHandleNewThread(); const { archiveThread, deleteThread } = useThreadActions(); const routeThreadId = useParams({ strict: false, @@ -1657,35 +1656,10 @@ export default function Sidebar() { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - const seedContext = resolveSidebarNewThreadSeedContext({ - projectId: project.id, - defaultEnvMode: resolveSidebarNewThreadEnvMode({ + void handleNewThread(project.id, { + envMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, }), - activeThread: - activeThread && activeThread.projectId === project.id - ? { - projectId: activeThread.projectId, - branch: activeThread.branch, - worktreePath: activeThread.worktreePath, - } - : null, - activeDraftThread: - activeDraftThread && activeDraftThread.projectId === project.id - ? { - projectId: activeDraftThread.projectId, - branch: activeDraftThread.branch, - worktreePath: activeDraftThread.worktreePath, - envMode: activeDraftThread.envMode, - } - : null, - }); - void handleNewThread(project.id, { - ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), - ...(seedContext.worktreePath !== undefined - ? { worktreePath: seedContext.worktreePath } - : {}), - envMode: seedContext.envMode, }); }} > diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index d186a7c9c1..99c3ad4e6a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -175,10 +175,7 @@ function coalesceOrchestrationUiEvents( ...event.payload, attachments: event.payload.attachments ?? previous.payload.attachments, createdAt: previous.payload.createdAt, - text: - !event.payload.streaming && event.payload.text.length > 0 - ? event.payload.text - : previous.payload.text + event.payload.text, + text: previous.payload.text + event.payload.text, }, }; continue; diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index f0d42d8b1b..da1498d494 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -395,6 +395,70 @@ describe("incremental orchestration updates", () => { expect(next.projects[0]?.name).toBe("Project Recreated"); }); + it("removes stale project index entries when thread.created recreates a thread under a new project", () => { + const originalProjectId = ProjectId.makeUnsafe("project-1"); + const recreatedProjectId = ProjectId.makeUnsafe("project-2"); + const threadId = ThreadId.makeUnsafe("thread-1"); + const thread = makeThread({ + id: threadId, + projectId: originalProjectId, + }); + const state: AppState = { + projects: [ + { + id: originalProjectId, + name: "Project 1", + cwd: "/tmp/project-1", + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + scripts: [], + }, + { + id: recreatedProjectId, + name: "Project 2", + cwd: "/tmp/project-2", + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + scripts: [], + }, + ], + threads: [thread], + sidebarThreadsById: {}, + threadIdsByProjectId: { + [originalProjectId]: [threadId], + }, + bootstrapComplete: true, + }; + + const next = applyOrchestrationEvent( + state, + makeEvent("thread.created", { + threadId, + projectId: recreatedProjectId, + title: "Recovered thread", + modelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: "2026-02-27T00:00:01.000Z", + updatedAt: "2026-02-27T00:00:01.000Z", + }), + ); + + expect(next.threads).toHaveLength(1); + expect(next.threads[0]?.projectId).toBe(recreatedProjectId); + expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined(); + expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]); + }); + it("updates only the affected thread for message events", () => { const thread1 = makeThread({ id: ThreadId.makeUnsafe("thread-1"), diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index a98e56383f..12c709b796 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -674,8 +674,12 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve ...state.sidebarThreadsById, [nextThread.id]: nextSummary, }; + const nextThreadIdsByProjectId = + existing !== undefined && existing.projectId !== nextThread.projectId + ? removeThreadIdByProjectId(state.threadIdsByProjectId, existing.projectId, existing.id) + : state.threadIdsByProjectId; const threadIdsByProjectId = appendThreadIdByProjectId( - state.threadIdsByProjectId, + nextThreadIdsByProjectId, nextThread.projectId, nextThread.id, ); diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index 65f8e6caaa..d6e69acb3e 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,9 +1,10 @@ -import { type ThreadId } from "@t3tools/contracts"; +import { type ProjectId, type ThreadId } from "@t3tools/contracts"; import { useMemo } from "react"; import { selectProjectById, selectSidebarThreadSummaryById, selectThreadById, + selectThreadIdsByProjectId, useStore, } from "./store"; import { type Project, type SidebarThreadSummary, type Thread } from "./types"; @@ -24,3 +25,8 @@ export function useSidebarThreadSummaryById( const selector = useMemo(() => selectSidebarThreadSummaryById(threadId), [threadId]); return useStore(selector); } + +export function useThreadIdsByProjectId(projectId: ProjectId | null | undefined): ThreadId[] { + const selector = useMemo(() => selectThreadIdsByProjectId(projectId), [projectId]); + return useStore(selector); +} diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index d5b2d7dfd8..d2bfac6028 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -4,7 +4,6 @@ import { Schema } from "effect"; import { GitCreateWorktreeInput, GitPreparePullRequestThreadInput, - GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, } from "./git"; @@ -14,7 +13,6 @@ const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( GitPreparePullRequestThreadInput, ); const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput); -const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); describe("GitCreateWorktreeInput", () => { @@ -62,55 +60,18 @@ describe("GitResolvePullRequestResult", () => { }); describe("GitRunStackedActionInput", () => { - it("accepts explicit stacked actions and requires a client-provided actionId", () => { + it("requires a client-provided actionId for progress correlation", () => { const parsed = decodeRunStackedActionInput({ actionId: "action-1", cwd: "/repo", - action: "create_pr", - }); - - expect(parsed.actionId).toBe("action-1"); - expect(parsed.action).toBe("create_pr"); - }); -}); - -describe("GitRunStackedActionResult", () => { - it("decodes a server-authored completion toast", () => { - const parsed = decodeRunStackedActionResult({ - action: "commit_push", - branch: { - status: "created", - name: "feature/server-owned-toast", - }, - commit: { - status: "created", - commitSha: "89abcdef01234567", - subject: "feat: move toast state into git manager", - }, - push: { - status: "pushed", - branch: "feature/server-owned-toast", - upstreamBranch: "origin/feature/server-owned-toast", - }, - pr: { - status: "skipped_not_requested", - }, - toast: { - title: "Pushed 89abcde to origin/feature/server-owned-toast", - description: "feat: move toast state into git manager", - cta: { - kind: "run_action", - label: "Create PR", - action: { - kind: "create_pr", - }, - }, + action: "commit", + modelSelection: { + provider: "codex", + model: "gpt-5.4-mini", }, }); - expect(parsed.toast.cta.kind).toBe("run_action"); - if (parsed.toast.cta.kind === "run_action") { - expect(parsed.toast.cta.action.kind).toBe("create_pr"); - } + expect(parsed.actionId).toBe("action-1"); + expect(parsed.action).toBe("commit"); }); }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 03fc050d4a..65504fabc1 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -5,13 +5,7 @@ const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; // Domain Types -export const GitStackedAction = Schema.Literals([ - "commit", - "push", - "create_pr", - "commit_push", - "commit_push_pr", -]); +export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); export type GitStackedAction = typeof GitStackedAction.Type; export const GitActionProgressPhase = Schema.Literals(["branch", "commit", "push", "pr"]); export type GitActionProgressPhase = typeof GitActionProgressPhase.Type; @@ -27,11 +21,7 @@ export const GitActionProgressKind = Schema.Literals([ export type GitActionProgressKind = typeof GitActionProgressKind.Type; export const GitActionProgressStream = Schema.Literals(["stdout", "stderr"]); export type GitActionProgressStream = typeof GitActionProgressStream.Type; -const GitCommitStepStatus = Schema.Literals([ - "created", - "skipped_no_changes", - "skipped_not_requested", -]); +const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); const GitPushStepStatus = Schema.Literals([ "pushed", "skipped_not_requested", @@ -43,32 +33,6 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); const GitPullRequestReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); -export const GitRunStackedActionToastRunAction = Schema.Struct({ - kind: GitStackedAction, -}); -export type GitRunStackedActionToastRunAction = typeof GitRunStackedActionToastRunAction.Type; -const GitRunStackedActionToastCta = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("none"), - }), - Schema.Struct({ - kind: Schema.Literal("open_pr"), - label: TrimmedNonEmptyStringSchema, - url: Schema.String, - }), - Schema.Struct({ - kind: Schema.Literal("run_action"), - label: TrimmedNonEmptyStringSchema, - action: GitRunStackedActionToastRunAction, - }), -]); -export type GitRunStackedActionToastCta = typeof GitRunStackedActionToastCta.Type; -const GitRunStackedActionToast = Schema.Struct({ - title: TrimmedNonEmptyStringSchema, - description: Schema.optional(TrimmedNonEmptyStringSchema), - cta: GitRunStackedActionToastCta, -}); -export type GitRunStackedActionToast = typeof GitRunStackedActionToast.Type; export const GitBranch = Schema.Struct({ name: TrimmedNonEmptyStringSchema, @@ -249,7 +213,6 @@ export const GitRunStackedActionResult = Schema.Struct({ headBranch: Schema.optional(TrimmedNonEmptyStringSchema), title: Schema.optional(TrimmedNonEmptyStringSchema), }), - toast: GitRunStackedActionToast, }); export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; From f72141eebc0834b9f4c887dedfa1a1b995f79a58 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 1 Apr 2026 16:26:50 -0700 Subject: [PATCH 5/7] Remove accidental checkpoint diff changes from typing lag branch --- .../OrchestrationEngineHarness.integration.ts | 9 +- .../Layers/CheckpointDiffQuery.test.ts | 105 +-- .../Layers/CheckpointDiffQuery.ts | 22 +- apps/server/src/git/Layers/GitCore.test.ts | 284 +++++-- apps/server/src/git/Layers/GitCore.ts | 147 ++-- apps/server/src/git/Layers/GitHubCli.ts | 2 +- apps/server/src/git/Layers/GitManager.test.ts | 706 +++++++++++++++- apps/server/src/git/Layers/GitManager.ts | 767 +++++++++++++----- apps/server/src/git/Services/GitManager.ts | 2 +- apps/server/src/git/remoteRefs.ts | 67 ++ .../Layers/CheckpointReactor.test.ts | 2 + .../Layers/OrchestrationEngine.test.ts | 107 ++- .../Layers/OrchestrationEngine.ts | 10 +- .../Layers/ProjectionPipeline.test.ts | 2 + .../Layers/ProjectionPipeline.ts | 30 +- .../Layers/ProjectionSnapshotQuery.test.ts | 286 +++++++ .../Layers/ProjectionSnapshotQuery.ts | 219 ++++- .../Layers/ProviderCommandReactor.test.ts | 2 + .../Layers/ProviderRuntimeIngestion.test.ts | 2 + .../Services/ProjectionSnapshotQuery.ts | 48 +- .../Layers/ProjectionThreadMessages.test.ts | 7 + .../Layers/ProjectionThreadMessages.ts | 63 +- apps/server/src/persistence/Migrations.ts | 2 + ...19_ProjectionSnapshotLookupIndexes.test.ts | 73 ++ .../019_ProjectionSnapshotLookupIndexes.ts | 16 + .../Services/ProjectionThreadMessages.ts | 15 +- apps/server/src/server.test.ts | 11 + apps/server/src/server.ts | 24 +- apps/server/src/serverRuntimeStartup.test.ts | 41 +- apps/server/src/serverRuntimeStartup.ts | 54 +- .../GitActionsControl.logic.test.ts | 164 ++-- .../src/components/GitActionsControl.logic.ts | 132 +-- apps/web/src/components/GitActionsControl.tsx | 204 +++-- apps/web/src/components/Sidebar.logic.test.ts | 63 ++ apps/web/src/components/Sidebar.logic.ts | 40 + apps/web/src/components/Sidebar.tsx | 32 +- apps/web/src/routes/__root.tsx | 5 +- apps/web/src/storeSelectors.ts | 8 +- packages/contracts/src/git.test.ts | 53 +- packages/contracts/src/git.ts | 41 +- 40 files changed, 3085 insertions(+), 782 deletions(-) create mode 100644 apps/server/src/git/remoteRefs.ts create mode 100644 apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts create mode 100644 apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 408ea827c2..152ed1d608 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -289,9 +289,10 @@ export const makeOrchestrationIntegrationHarness = ( ); const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); + const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( - orchestrationLayer, - OrchestrationProjectionSnapshotQueryLive, + projectionSnapshotQueryLayer, + orchestrationLayer.pipe(Layer.provide(projectionSnapshotQueryLayer)), ProjectionCheckpointRepositoryLive, ProjectionPendingApprovalRepositoryLive, checkpointStoreLayer, @@ -333,7 +334,9 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(providerCommandReactorLayer), Layer.provideMerge(checkpointReactorLayer), ); - const layer = orchestrationReactorLayer.pipe( + const layer = Layer.empty.pipe( + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(orchestrationReactorLayer), Layer.provide(persistenceLayer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 9fb2500ce4..c66c529b9a 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -1,83 +1,38 @@ -import { - CheckpointRef, - DEFAULT_PROVIDER_INTERACTION_MODE, - ProjectId, - ThreadId, - TurnId, - type OrchestrationReadModel, -} from "@t3tools/contracts"; -import { Effect, Layer } from "effect"; +import { CheckpointRef, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { Effect, Layer, Option } from "effect"; import { describe, expect, it } from "vitest"; -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionThreadCheckpointContext, +} from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { checkpointRefForThreadTurn } from "../Utils.ts"; import { CheckpointDiffQueryLive } from "./CheckpointDiffQuery.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointDiffQuery } from "../Services/CheckpointDiffQuery.ts"; -function makeSnapshot(input: { +function makeThreadCheckpointContext(input: { readonly projectId: ProjectId; readonly threadId: ThreadId; readonly workspaceRoot: string; readonly worktreePath: string | null; readonly checkpointTurnCount: number; readonly checkpointRef: CheckpointRef; -}): OrchestrationReadModel { +}): ProjectionThreadCheckpointContext { return { - snapshotSequence: 0, - updatedAt: "2026-01-01T00:00:00.000Z", - projects: [ - { - id: input.projectId, - title: "Project", - workspaceRoot: input.workspaceRoot, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - deletedAt: null, - }, - ], - threads: [ + threadId: input.threadId, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + worktreePath: input.worktreePath, + checkpoints: [ { - id: input.threadId, - projectId: input.projectId, - title: "Thread", - modelSelection: { - provider: "codex", - model: "gpt-5-codex", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", - branch: null, - worktreePath: input.worktreePath, - latestTurn: { - turnId: TurnId.makeUnsafe("turn-1"), - state: "completed", - requestedAt: "2026-01-01T00:00:00.000Z", - startedAt: "2026-01-01T00:00:00.000Z", - completedAt: "2026-01-01T00:00:00.000Z", - assistantMessageId: null, - }, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - archivedAt: null, - deletedAt: null, - messages: [], - activities: [], - proposedPlans: [], - checkpoints: [ - { - turnId: TurnId.makeUnsafe("turn-1"), - checkpointTurnCount: input.checkpointTurnCount, - checkpointRef: input.checkpointRef, - status: "ready", - files: [], - assistantMessageId: null, - completedAt: "2026-01-01T00:00:00.000Z", - }, - ], - session: null, + turnId: TurnId.makeUnsafe("turn-1"), + checkpointTurnCount: input.checkpointTurnCount, + checkpointRef: input.checkpointRef, + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-01-01T00:00:00.000Z", }, ], }; @@ -95,7 +50,7 @@ describe("CheckpointDiffQueryLive", () => { readonly cwd: string; }> = []; - const snapshot = makeSnapshot({ + const threadCheckpointContext = makeThreadCheckpointContext({ projectId, threadId, workspaceRoot: "/tmp/workspace", @@ -125,7 +80,12 @@ describe("CheckpointDiffQueryLive", () => { Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery, { - getSnapshot: () => Effect.succeed(snapshot), + getSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), }), ), ); @@ -175,12 +135,11 @@ describe("CheckpointDiffQueryLive", () => { Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery, { getSnapshot: () => - Effect.succeed({ - snapshotSequence: 0, - projects: [], - threads: [], - updatedAt: "2026-01-01T00:00:00.000Z", - } satisfies OrchestrationReadModel), + Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), }), ), ); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts index bbb5a42931..1c2edee469 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts @@ -4,11 +4,11 @@ import { type OrchestrationGetFullThreadDiffResult, type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, } from "@t3tools/contracts"; -import { Effect, Layer, Schema } from "effect"; +import { Effect, Layer, Option, Schema } from "effect"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts"; -import { checkpointRefForThreadTurn, resolveThreadWorkspaceCwd } from "../Utils.ts"; +import { checkpointRefForThreadTurn } from "../Utils.ts"; import { CheckpointStore } from "../Services/CheckpointStore.ts"; import { CheckpointDiffQuery, @@ -41,16 +41,17 @@ const make = Effect.gen(function* () { return emptyDiff; } - const snapshot = yield* projectionSnapshotQuery.getSnapshot(); - const thread = snapshot.threads.find((entry) => entry.id === input.threadId); - if (!thread) { + const threadContext = yield* projectionSnapshotQuery.getThreadCheckpointContext( + input.threadId, + ); + if (Option.isNone(threadContext)) { return yield* new CheckpointInvariantError({ operation, detail: `Thread '${input.threadId}' not found.`, }); } - const maxTurnCount = thread.checkpoints.reduce( + const maxTurnCount = threadContext.value.checkpoints.reduce( (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), 0, ); @@ -62,10 +63,7 @@ const make = Effect.gen(function* () { }); } - const workspaceCwd = resolveThreadWorkspaceCwd({ - thread, - projects: snapshot.projects, - }); + const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; if (!workspaceCwd) { return yield* new CheckpointInvariantError({ operation, @@ -76,7 +74,7 @@ const make = Effect.gen(function* () { const fromCheckpointRef = input.fromTurnCount === 0 ? checkpointRefForThreadTurn(input.threadId, 0) - : thread.checkpoints.find( + : threadContext.value.checkpoints.find( (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, )?.checkpointRef; if (!fromCheckpointRef) { @@ -87,7 +85,7 @@ const make = Effect.gen(function* () { }); } - const toCheckpointRef = thread.checkpoints.find( + const toCheckpointRef = threadContext.value.checkpoints.find( (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, )?.checkpointRef; if (!toCheckpointRef) { diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 07892ec447..2838edcad7 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -59,6 +59,23 @@ function git( }); } +function configureRemote( + cwd: string, + remoteName: string, + remotePath: string, + fetchNamespace: string, +): Effect.Effect { + return Effect.gen(function* () { + yield* git(cwd, ["config", `remote.${remoteName}.url`, remotePath]); + return yield* git(cwd, [ + "config", + "--replace-all", + `remote.${remoteName}.fetch`, + `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, + ]); + }); +} + function runShellCommand(input: { command: string; cwd: string; @@ -587,7 +604,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect("keeps checkout successful when upstream refresh fails", () => + it.effect("statusDetails remains successful when upstream refresh fails after checkout", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); @@ -612,7 +629,7 @@ it.layer(TestLayer)("git integration", (it) => { const realGitCore = yield* GitCore; let refreshFetchAttempts = 0; const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "fetch") { + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { refreshFetchAttempts += 1; return Effect.fail( new GitCommandError({ @@ -626,16 +643,15 @@ it.layer(TestLayer)("git integration", (it) => { return realGitCore.execute(input); }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - yield* Effect.promise(() => - vi.waitFor(() => { - expect(refreshFetchAttempts).toBe(1); - }), - ); + const status = yield* core.statusDetails(source); + expect(refreshFetchAttempts).toBe(1); + expect(status.branch).toBe(featureBranch); + expect(status.upstreamRef).toBe(`origin/${featureBranch}`); expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); }), ); - it.effect("refresh fetch is scoped to the checked out branch upstream refspec", () => + it.effect("defers upstream refresh until statusDetails is requested", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); @@ -657,10 +673,10 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["checkout", defaultBranch]); const realGitCore = yield* GitCore; - let fetchArgs: readonly string[] | null = null; + let refreshFetchAttempts = 0; const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "fetch") { - fetchArgs = [...input.args]; + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { + refreshFetchAttempts += 1; return Effect.succeed({ code: 0, stdout: "", @@ -672,73 +688,131 @@ it.layer(TestLayer)("git integration", (it) => { return realGitCore.execute(input); }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - yield* Effect.promise(() => - vi.waitFor(() => { - expect(fetchArgs).not.toBeNull(); - }), - ); - - expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); - expect(fetchArgs).toEqual([ - "fetch", - "--quiet", - "--no-tags", - "origin", - `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`, - ]); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))); + expect(refreshFetchAttempts).toBe(0); + const status = yield* core.statusDetails(source); + expect(status.branch).toBe(featureBranch); + expect(refreshFetchAttempts).toBe(1); }), ); - it.effect("returns checkout result before background upstream refresh completes", () => + it.effect("shares upstream refreshes across worktrees that use the same git common dir", () => Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); + const ok = (stdout = "") => + Effect.succeed({ + code: 0, + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); + let fetchCount = 0; + const core = yield* makeIsolatedGitCore((input) => { + if ( + input.args[0] === "rev-parse" && + input.args[1] === "--abbrev-ref" && + input.args[2] === "--symbolic-full-name" && + input.args[3] === "@{upstream}" + ) { + return ok("origin/main\n"); + } + if (input.args[0] === "remote") { + return ok("origin\n"); + } + if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { + return ok("/repo/.git\n"); + } + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { + fetchCount += 1; + expect(input.cwd).toBe("/repo"); + return ok(); + } + if (input.operation === "GitCore.statusDetails.status") { + return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); + } + if ( + input.operation === "GitCore.statusDetails.unstagedNumstat" || + input.operation === "GitCore.statusDetails.stagedNumstat" + ) { + return ok(); + } + return Effect.fail( + new GitCommandError({ + operation: input.operation, + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "Unexpected git command in shared refresh cache test.", + }), + ); + }); - const featureBranch = "feature/background-refresh"; - yield* git(source, ["checkout", "-b", featureBranch]); - yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature base"]); - yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* git(source, ["checkout", defaultBranch]); + yield* core.statusDetails("/repo/worktrees/main"); + yield* core.statusDetails("/repo/worktrees/pr-123"); + expect(fetchCount).toBe(1); + }), + ); - const realGitCore = yield* GitCore; - let fetchStarted = false; - let releaseFetch!: () => void; - const waitForReleasePromise = new Promise((resolve) => { - releaseFetch = resolve; - }); + it.effect("briefly backs off failed upstream refreshes across sibling worktrees", () => + Effect.gen(function* () { + const ok = (stdout = "") => + Effect.succeed({ + code: 0, + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + + let fetchCount = 0; const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "fetch") { - fetchStarted = true; - return Effect.promise(() => - waitForReleasePromise.then(() => ({ - code: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - })), + if ( + input.args[0] === "rev-parse" && + input.args[1] === "--abbrev-ref" && + input.args[2] === "--symbolic-full-name" && + input.args[3] === "@{upstream}" + ) { + return ok("origin/main\n"); + } + if (input.args[0] === "remote") { + return ok("origin\n"); + } + if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { + return ok("/repo/.git\n"); + } + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { + fetchCount += 1; + return Effect.fail( + new GitCommandError({ + operation: input.operation, + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "simulated fetch timeout", + }), ); } - return realGitCore.execute(input); + if (input.operation === "GitCore.statusDetails.status") { + return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); + } + if ( + input.operation === "GitCore.statusDetails.unstagedNumstat" || + input.operation === "GitCore.statusDetails.stagedNumstat" + ) { + return ok(); + } + return Effect.fail( + new GitCommandError({ + operation: input.operation, + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "Unexpected git command in refresh failure cooldown test.", + }), + ); }); - yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - yield* Effect.promise(() => - vi.waitFor(() => { - expect(fetchStarted).toBe(true); - }), - ); - expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); - releaseFetch(); + + yield* core.statusDetails("/repo/worktrees/main"); + yield* core.statusDetails("/repo/worktrees/pr-123"); + expect(fetchCount).toBe(1); }), ); @@ -779,16 +853,21 @@ it.layer(TestLayer)("git integration", (it) => { it.effect("checks out a remote tracking branch when remote name contains slashes", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); + const prefixRemote = yield* makeTmpDir(); const source = yield* makeTmpDir(); + const prefixFetchNamespace = "prefix-my-org"; + const prefixRemoteName = "my-org"; const remoteName = "my-org/upstream"; const featureBranch = "feature"; yield* git(remote, ["init", "--bare"]); + yield* git(prefixRemote, ["init", "--bare"]); yield* initRepoWithCommit(source); const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; - yield* git(source, ["remote", "add", remoteName, remote]); + yield* configureRemote(source, prefixRemoteName, prefixRemote, prefixFetchNamespace); + yield* configureRemote(source, remoteName, remote, remoteName); yield* git(source, ["push", "-u", remoteName, defaultBranch]); yield* git(source, ["checkout", "-b", featureBranch]); @@ -805,6 +884,34 @@ it.layer(TestLayer)("git integration", (it) => { }); expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); + const realGitCore = yield* GitCore; + let fetchArgs: readonly string[] | null = null; + const core = yield* makeIsolatedGitCore((input) => { + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { + fetchArgs = [...input.args]; + return Effect.succeed({ + code: 0, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + } + return realGitCore.execute(input); + }); + + const status = yield* core.statusDetails(source); + expect(status.branch).toBe("upstream/feature"); + expect(status.upstreamRef).toBe(`${remoteName}/${featureBranch}`); + expect(fetchArgs).toEqual([ + "--git-dir", + path.join(source, ".git"), + "fetch", + "--quiet", + "--no-tags", + remoteName, + `+refs/heads/${featureBranch}:refs/remotes/${remoteName}/${featureBranch}`, + ]); }), ); @@ -1691,6 +1798,47 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("pushes to the tracked upstream when the remote name contains slashes", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const remote = yield* makeTmpDir(); + const prefixRemote = yield* makeTmpDir(); + const prefixFetchNamespace = "prefix-my-org"; + const prefixRemoteName = "my-org"; + const remoteName = "my-org/upstream"; + const featureBranch = "feature/slash-remote-push"; + yield* git(remote, ["init", "--bare"]); + yield* git(prefixRemote, ["init", "--bare"]); + + const { initialBranch } = yield* initRepoWithCommit(tmp); + yield* configureRemote(tmp, prefixRemoteName, prefixRemote, prefixFetchNamespace); + yield* configureRemote(tmp, remoteName, remote, remoteName); + yield* git(tmp, ["push", "-u", remoteName, initialBranch]); + + yield* git(tmp, ["checkout", "-b", featureBranch]); + yield* writeTextFile(path.join(tmp, "feature.txt"), "first revision\n"); + yield* git(tmp, ["add", "feature.txt"]); + yield* git(tmp, ["commit", "-m", "feature base"]); + yield* git(tmp, ["push", "-u", remoteName, featureBranch]); + + yield* writeTextFile(path.join(tmp, "feature.txt"), "second revision\n"); + yield* git(tmp, ["add", "feature.txt"]); + yield* git(tmp, ["commit", "-m", "feature update"]); + + const core = yield* GitCore; + const pushed = yield* core.pushCurrentBranch(tmp, null); + expect(pushed.status).toBe("pushed"); + expect(pushed.setUpstream).toBe(false); + expect(pushed.upstreamBranch).toBe(`${remoteName}/${featureBranch}`); + expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( + `${remoteName}/${featureBranch}`, + ); + expect(yield* git(tmp, ["ls-remote", "--heads", remoteName, featureBranch])).toContain( + featureBranch, + ); + }), + ); + it.effect("includes command context when worktree removal fails", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index cbb2c67449..0a11abab5b 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -27,6 +27,11 @@ import { type ExecuteGitInput, type ExecuteGitResult, } from "../Services/GitCore.ts"; +import { + parseRemoteNames, + parseRemoteNamesInGitOrder, + parseRemoteRefWithRemoteNames, +} from "../remoteRefs.ts"; import { ServerConfig } from "../../config.ts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; @@ -41,6 +46,7 @@ const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); +const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; @@ -50,7 +56,7 @@ type TraceTailState = { }; class StatusUpstreamRefreshCacheKey extends Data.Class<{ - cwd: string; + gitCommonDir: string; upstreamRef: string; remoteName: string; upstreamBranch: string; @@ -177,14 +183,6 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul }; } -function parseRemoteNames(stdout: string): ReadonlyArray { - return stdout - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .toSorted((a, b) => b.length - a.length); -} - function sanitizeRemoteName(value: string): string { const sanitized = value .trim() @@ -217,30 +215,41 @@ function parseRemoteFetchUrls(stdout: string): Map { return remotes; } -function parseRemoteRefWithRemoteNames( - branchName: string, +function parseUpstreamRefWithRemoteNames( + upstreamRef: string, remoteNames: ReadonlyArray, -): { remoteRef: string; remoteName: string; localBranch: string } | null { - const trimmedBranchName = branchName.trim(); - if (trimmedBranchName.length === 0) return null; +): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { + const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); + if (!parsed) { + return null; + } - for (const remoteName of remoteNames) { - const remotePrefix = `${remoteName}/`; - if (!trimmedBranchName.startsWith(remotePrefix)) { - continue; - } - const localBranch = trimmedBranchName.slice(remotePrefix.length).trim(); - if (localBranch.length === 0) { - return null; - } - return { - remoteRef: trimmedBranchName, - remoteName, - localBranch, - }; + return { + upstreamRef, + remoteName: parsed.remoteName, + upstreamBranch: parsed.branchName, + }; +} + +function parseUpstreamRefByFirstSeparator( + upstreamRef: string, +): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { + const separatorIndex = upstreamRef.indexOf("/"); + if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) { + return null; } - return null; + const remoteName = upstreamRef.slice(0, separatorIndex).trim(); + const upstreamBranch = upstreamRef.slice(separatorIndex + 1).trim(); + if (remoteName.length === 0 || upstreamBranch.length === 0) { + return null; + } + + return { + upstreamRef, + remoteName, + upstreamBranch, + }; } function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { @@ -792,45 +801,27 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return null; } - const separatorIndex = upstreamRef.indexOf("/"); - if (separatorIndex <= 0) { - return null; - } - const remoteName = upstreamRef.slice(0, separatorIndex); - const upstreamBranch = upstreamRef.slice(separatorIndex + 1); - if (remoteName.length === 0 || upstreamBranch.length === 0) { - return null; - } - - return { - upstreamRef, - remoteName, - upstreamBranch, - }; - }); - - const fetchUpstreamRef = ( - cwd: string, - upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, - ): Effect.Effect => { - const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; - return runGit( - "GitCore.fetchUpstreamRef", - cwd, - ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], - true, + const remoteNames = yield* runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( + Effect.map(parseRemoteNames), + Effect.catch(() => Effect.succeed>([])), ); - }; + return ( + parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? + parseUpstreamRefByFirstSeparator(upstreamRef) + ); + }); const fetchUpstreamRefForStatus = ( - cwd: string, + gitCommonDir: string, upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, ): Effect.Effect => { const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; return executeGit( "GitCore.fetchUpstreamRefForStatus", - cwd, - ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], + fetchCwd, + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], { allowNonZeroExit: true, timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), @@ -838,10 +829,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ).pipe(Effect.asVoid); }; + const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { + const gitCommonDir = yield* runGitStdout("GitCore.resolveGitCommonDir", cwd, [ + "rev-parse", + "--git-common-dir", + ]).pipe(Effect.map((stdout) => stdout.trim())); + return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); + }); + const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* ( cacheKey: StatusUpstreamRefreshCacheKey, ) { - yield* fetchUpstreamRefForStatus(cacheKey.cwd, { + yield* fetchUpstreamRefForStatus(cacheKey.gitCommonDir, { upstreamRef: cacheKey.upstreamRef, remoteName: cacheKey.remoteName, upstreamBranch: cacheKey.upstreamBranch, @@ -852,8 +851,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const statusUpstreamRefreshCache = yield* Cache.makeWith({ capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, lookup: refreshStatusUpstreamCacheEntry, - // Keep successful refreshes warm; drop failures immediately so next request can retry. - timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero), + // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. + timeToLive: (exit) => + Exit.isSuccess(exit) + ? STATUS_UPSTREAM_REFRESH_INTERVAL + : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN, }); const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( @@ -861,10 +863,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ) { const upstream = yield* resolveCurrentUpstream(cwd); if (!upstream) return; + const gitCommonDir = yield* resolveGitCommonDir(cwd); yield* Cache.get( statusUpstreamRefreshCache, new StatusUpstreamRefreshCacheKey({ - cwd, + gitCommonDir, upstreamRef: upstream.upstreamRef, remoteName: upstream.remoteName, upstreamBranch: upstream.upstreamBranch, @@ -872,14 +875,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); }); - const refreshCheckedOutBranchUpstream = Effect.fn("refreshCheckedOutBranchUpstream")(function* ( - cwd: string, - ) { - const upstream = yield* resolveCurrentUpstream(cwd); - if (!upstream) return; - yield* fetchUpstreamRef(cwd, upstream); - }); - const resolveDefaultBranchName = ( cwd: string, remoteName: string, @@ -919,7 +914,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( - Effect.map((stdout) => parseRemoteNames(stdout).toReversed()), + Effect.map(parseRemoteNamesInGitOrder), ); const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { @@ -1939,12 +1934,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { timeoutMs: 10_000, fallbackErrorMessage: "git checkout failed", }); - - // Refresh upstream refs in the background so checkout remains responsive. - yield* refreshCheckedOutBranchUpstream(input.cwd).pipe( - Effect.ignoreCause({ log: true }), - Effect.forkDetach({ startImmediately: true }), - ); }, ); diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 76d7d30a47..280679e337 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -187,7 +187,7 @@ const makeGitHubCli = Effect.sync(() => { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index e05fc30875..6fd55030fd 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -25,6 +25,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; interface FakeGhScenario { prListSequence?: string[]; prListByHeadSelector?: Record; + prListSequenceByHeadSelector?: Record; createdPrUrl?: string; defaultBranch?: string; pullRequest?: { @@ -77,6 +78,72 @@ interface FakeGitTextGeneration { type FakePullRequest = NonNullable; +function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null { + if (!raw || typeof raw !== "object") { + return null; + } + + const record = raw as Record; + const number = record.number; + const title = record.title; + const url = record.url; + const baseRefName = record.baseRefName; + const headRefName = record.headRefName; + const headRepository = + typeof record.headRepository === "object" && record.headRepository !== null + ? (record.headRepository as Record) + : null; + const headRepositoryOwner = + typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null + ? (record.headRepositoryOwner as Record) + : null; + + if ( + typeof number !== "number" || + typeof title !== "string" || + typeof url !== "string" || + typeof baseRefName !== "string" || + typeof headRefName !== "string" + ) { + return null; + } + + const state = + typeof record.state === "string" + ? record.state === "OPEN" || record.state === "open" + ? "open" + : record.state === "CLOSED" || record.state === "closed" + ? "closed" + : "merged" + : undefined; + const isCrossRepository = + typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined; + const headRepositoryNameWithOwner = + typeof record.headRepositoryNameWithOwner === "string" + ? record.headRepositoryNameWithOwner + : typeof headRepository?.nameWithOwner === "string" + ? headRepository.nameWithOwner + : undefined; + const headRepositoryOwnerLogin = + typeof record.headRepositoryOwnerLogin === "string" + ? record.headRepositoryOwnerLogin + : typeof headRepositoryOwner?.login === "string" + ? headRepositoryOwner.login + : undefined; + + return { + number, + title, + url, + baseRefName, + headRefName, + ...(state ? { state } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { const result = spawnSync("git", args, { cwd, @@ -159,6 +226,23 @@ function createBareRemote(): Effect.Effect< }); } +function configureRemote( + cwd: string, + remoteName: string, + remotePath: string, + fetchNamespace: string, +): Effect.Effect { + return Effect.gen(function* () { + yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]); + yield* runGit(cwd, [ + "config", + "--replace-all", + `remote.${remoteName}.fetch`, + `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, + ]); + }); +} + function createTextGeneration(overrides: Partial = {}): TextGenerationShape { const implementation: FakeGitTextGeneration = { generateCommitMessage: (input) => @@ -236,6 +320,12 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; + const prListQueueByHeadSelector = new Map( + Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [ + headSelector, + [...values], + ]), + ); const ghCalls: string[] = []; const execute: GitHubCliShape["execute"] = (input) => { @@ -252,11 +342,15 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 ? args[headSelectorIndex + 1] : undefined; + const mappedQueue = + typeof headSelector === "string" + ? prListQueueByHeadSelector.get(headSelector)?.shift() + : undefined; const mappedStdout = typeof headSelector === "string" ? scenario.prListByHeadSelector?.[headSelector] : undefined; - const stdout = (mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; + const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; return Effect.succeed({ stdout, stderr: "", @@ -410,11 +504,14 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( - Effect.map( - (result) => JSON.parse(result.stdout) as ReadonlyArray, + Effect.map((result) => JSON.parse(result.stdout) as unknown[]), + Effect.map((raw) => + raw + .map((entry) => normalizeFakePullRequestSummary(entry)) + .filter((entry): entry is GitHubPullRequestSummary => entry !== null), ), ), createPullRequest: (input) => @@ -473,7 +570,7 @@ function runStackedAction( manager: GitManagerShape, input: { cwd: string; - action: "commit" | "commit_push" | "commit_push_pr"; + action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; actionId?: string; commitMessage?: string; featureBranch?: boolean; @@ -575,6 +672,78 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status briefly caches repeated lookups for the same cwd", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]); + + const existingPr = { + number: 113, + title: "Cached PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/113", + baseRefName: "main", + headRefName: "feature/status-cache", + }; + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])], + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + const second = yield* manager.status({ cwd: repoDir }); + + expect(first.pr?.number).toBe(113); + expect(second.pr?.number).toBe(113); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + }), + ); + + it.effect( + "status ignores unrelated fork PRs when the current branch tracks the same repository", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 1661, + title: "Fork PR from main", + url: "https://github.com/pingdotgg/t3code/pull/1661", + baseRefName: "main", + headRefName: "main", + state: "OPEN", + updatedAt: "2026-04-01T15:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "lnieuwenhuis/t3code", + }, + headRepositoryOwner: { + login: "lnieuwenhuis", + }, + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("main"); + expect(status.pr).toBeNull(); + }), + ); + it.effect( "status detects cross-repo PRs from the upstream remote URL owner", () => @@ -610,6 +779,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { headRefName: "statemachine", state: "OPEN", updatedAt: "2026-03-10T07:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "jasonLaster/codething-mvp", + }, + headRepositoryOwner: { + login: "jasonLaster", + }, }, ]), ], @@ -627,8 +803,116 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { state: "open", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ); + }), + 12_000, + ); + + it.effect( + "status ignores synthetic local branch aliases when the upstream remote name contains slashes", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const upstreamDir = yield* createBareRemote(); + yield* configureRemote(repoDir, "origin", originDir, "origin"); + yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); + + yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); + yield* runGit(repoDir, [ + "config", + "remote.origin.url", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); + yield* runGit(repoDir, [ + "config", + "remote.my-org/upstream.url", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); + yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "effect-atom": JSON.stringify([ + { + number: 1618, + title: "Correct PR", + url: "https://github.com/pingdotgg/t3code/pull/1618", + baseRefName: "main", + headRefName: "effect-atom", + state: "OPEN", + updatedAt: "2026-03-01T10:00:00Z", + }, + ]), + "upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + state: "OPEN", + updatedAt: "2026-04-01T10:00:00Z", + }, + ]), + "pingdotgg:effect-atom": JSON.stringify([]), + "my-org/upstream:effect-atom": JSON.stringify([]), + "pingdotgg:upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + state: "OPEN", + updatedAt: "2026-04-01T10:00:00Z", + }, + ]), + "my-org/upstream:upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + state: "OPEN", + updatedAt: "2026-04-01T10:00:00Z", + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("upstream/effect-atom"); + expect(status.pr).toEqual({ + number: 1618, + title: "Correct PR", + url: "https://github.com/pingdotgg/t3code/pull/1618", + baseBranch: "main", + headBranch: "effect-atom", + state: "open", + }); + expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( + false, ); + expect( + ghCalls.some((call) => call.includes("pr list --head pingdotgg:upstream/effect-atom ")), + ).toBe(false); + expect( + ghCalls.some((call) => + call.includes("pr list --head my-org/upstream:upstream/effect-atom "), + ), + ).toBe(false); }), 12_000, ); @@ -758,6 +1042,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("skipped_not_requested"); + expect(result.toast).toMatchObject({ + description: "Implement stacked git actions", + cta: { + kind: "run_action", + label: "Push", + action: { + kind: "push", + }, + }, + }); + expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/); expect( yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( Effect.map((result) => result.stdout.trim()), @@ -867,6 +1162,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("pushed"); + expect(result.toast).toMatchObject({ + description: "Implement stacked git actions", + cta: { + kind: "run_action", + label: "Create PR", + action: { + kind: "create_pr", + }, + }, + }); + expect(result.toast.title).toMatch( + /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/, + ); expect( yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( Effect.map((result) => result.stdout.trim()), @@ -1063,6 +1371,80 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("pushes existing clean commits without rerunning commit logic", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n"); + yield* runGit(repoDir, ["add", "push-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "push", + }); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("pushed"); + expect(result.pr.status).toBe("skipped_not_requested"); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( + Effect.map((output) => output.stdout.trim()), + ), + ).toBe("origin/feature/push-only"); + }), + ); + + it.effect("create_pr pushes a clean branch before creating the PR when needed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n"); + yield* runGit(repoDir, ["add", "create-pr-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 303, + title: "Create PR only branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/303", + baseRefName: "main", + headRefName: "feature/create-pr-only", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("pushed"); + expect(result.push.setUpstream).toBe(true); + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(303); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature/create-pr-only"), + ), + ).toBe(true); + }), + ); + it.effect("returns existing PR metadata for commit/push/pr action", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1095,6 +1477,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(42); + expect(result.toast).toEqual({ + title: "Opened PR #42", + description: "Existing PR", + cta: { + kind: "open_pr", + label: "View PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + }, + }); expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); }), ); @@ -1126,6 +1517,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, }, ]), ], @@ -1149,6 +1548,98 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { 12_000, ); + it.effect( + "returns the correct existing PR when a slash remote checks out to a synthetic local alias", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const upstreamDir = yield* createBareRemote(); + yield* configureRemote(repoDir, "origin", originDir, "origin"); + yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); + + yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); + yield* runGit(repoDir, [ + "config", + "remote.origin.url", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); + yield* runGit(repoDir, [ + "config", + "remote.my-org/upstream.url", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); + yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "effect-atom": JSON.stringify([ + { + number: 1618, + title: "Correct PR", + url: "https://github.com/pingdotgg/t3code/pull/1618", + baseRefName: "main", + headRefName: "effect-atom", + }, + ]), + "upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + }, + ]), + "pingdotgg:effect-atom": JSON.stringify([]), + "my-org/upstream:effect-atom": JSON.stringify([]), + "pingdotgg:upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + }, + ]), + "my-org/upstream:upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + }, + ]), + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(1618); + expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( + false, + ); + }), + 12_000, + ); + it.effect( "prefers owner-qualified selectors before bare branch names for cross-repo PRs", () => @@ -1187,6 +1678,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, }, ]), "fork-seed:statemachine": JSON.stringify([]), @@ -1239,6 +1738,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, }, ]), "fork-seed:statemachine": JSON.stringify([]), @@ -1256,9 +1763,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - const prListCalls = ghCalls.filter((call) => call.startsWith("pr list ")); - expect(prListCalls).toHaveLength(1); - expect(prListCalls[0]).toContain( + const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); + expect(openLookupCalls).toHaveLength(1); + expect(openLookupCalls[0]).toContain( "pr list --head octocat:statemachine --state open --limit 1", ); }), @@ -1302,6 +1809,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(88); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), ).toBe(true); @@ -1309,6 +1817,78 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect( + "creates a new PR instead of reusing an unrelated fork PR with the same head branch", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 1661, + title: "Fork PR with same branch name", + url: "https://github.com/pingdotgg/t3code/pull/1661", + baseRefName: "main", + headRefName: "feature/no-fork-match", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "lnieuwenhuis/t3code", + }, + headRepositoryOwner: { + login: "lnieuwenhuis", + }, + }, + ]), + JSON.stringify([ + { + number: 188, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + baseRefName: "main", + headRefName: "feature/no-fork-match", + state: "OPEN", + isCrossRepository: false, + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(188); + expect(result.toast).toEqual({ + title: "Created PR #188", + description: "Add stacked git actions", + cta: { + kind: "open_pr", + label: "View PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + }, + }); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature/no-fork-match"), + ), + ).toBe(true); + }), + ); + it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1330,23 +1910,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { - prListSequence: [ - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([ - { - number: 188, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - baseRefName: "main", - headRefName: "statemachine", - }, - ]), - ], + prListSequenceByHeadSelector: { + "octocat:statemachine": [ + JSON.stringify([]), + JSON.stringify([ + { + number: 188, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }, + ]), + ], + "fork-seed:statemachine": [JSON.stringify([])], + statemachine: [JSON.stringify([])], + }, }, }); @@ -2090,4 +2677,71 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); }), ); + + it.effect("create_pr emits only the PR phase when the branch is already pushed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n"); + yield* runGit(repoDir, ["add", "pr-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([]), + JSON.stringify([ + { + number: 201, + title: "PR only branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/201", + baseRefName: "main", + headRefName: "feature/pr-only-follow-up", + state: "OPEN", + isCrossRepository: false, + }, + ]), + ], + }, + }); + const events: GitActionProgressEvent[] = []; + + const result = yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "create_pr", + }, + { + actionId: "action-pr-only", + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("created"); + expect( + events.filter( + (event): event is Extract => + event.kind === "phase_started", + ), + ).toEqual([ + expect.objectContaining({ + kind: "phase_started", + phase: "pr", + label: "Creating PR...", + }), + ]); + }), + ); }); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index f8445cf09a..ca9d562c03 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1,11 +1,12 @@ import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; -import { Effect, FileSystem, Layer, Option, Path, Ref } from "effect"; +import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect"; import { GitActionProgressEvent, GitActionProgressPhase, GitRunStackedActionResult, + GitStackedAction, ModelSelection, } from "@t3tools/contracts"; import { @@ -22,13 +23,18 @@ import { type GitRunStackedActionOptions, } from "../Services/GitManager.ts"; import { GitCore } from "../Services/GitCore.ts"; -import { GitHubCli } from "../Services/GitHubCli.ts"; +import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; +const SHORT_SHA_LENGTH = 7; +const TOAST_DESCRIPTION_MAX = 72; +const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); +const STATUS_RESULT_CACHE_CAPACITY = 2_048; type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; @@ -40,7 +46,7 @@ interface OpenPrInfo { headRefName: string; } -interface PullRequestInfo extends OpenPrInfo { +interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; updatedAt: string | null; } @@ -135,6 +141,94 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; } +function normalizeOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null { + const normalized = normalizeOptionalString(value); + return normalized ? normalized.toLowerCase() : null; +} + +function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null { + const normalized = normalizeOptionalString(value); + return normalized ? normalized.toLowerCase() : null; +} + +function resolvePullRequestHeadRepositoryNameWithOwner( + pr: PullRequestHeadRemoteInfo & { url: string }, +) { + const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); + if (explicitRepository) { + return explicitRepository; + } + + if (!pr.isCrossRepository) { + return null; + } + + const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); + const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); + if (!ownerLogin || !repositoryName) { + return null; + } + + return `${ownerLogin}/${repositoryName}`; +} + +function matchesBranchHeadContext( + pr: PullRequestInfo, + headContext: Pick< + BranchHeadContext, + "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" + >, +): boolean { + if (pr.headRefName !== headContext.headBranch) { + return false; + } + + const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner( + headContext.headRepositoryNameWithOwner, + ); + const expectedHeadOwner = + normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(expectedHeadRepository); + const prHeadRepository = normalizeOptionalRepositoryNameWithOwner( + resolvePullRequestHeadRepositoryNameWithOwner(pr), + ); + const prHeadOwner = + normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(prHeadRepository); + + if (headContext.isCrossRepository) { + if (pr.isCrossRepository === false) { + return false; + } + if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { + return false; + } + if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { + return false; + } + if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { + return false; + } + return true; + } + + if (pr.isCrossRepository === true) { + return false; + } + if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { + return false; + } + if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { + return false; + } + return true; +} + function parsePullRequestList(raw: unknown): PullRequestInfo[] { if (!Array.isArray(raw)) return []; @@ -150,6 +244,27 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { const state = record.state; const mergedAt = record.mergedAt; const updatedAt = record.updatedAt; + const isCrossRepository = record.isCrossRepository; + const headRepositoryRecord = + typeof record.headRepository === "object" && record.headRepository !== null + ? (record.headRepository as Record) + : null; + const headRepositoryOwnerRecord = + typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null + ? (record.headRepositoryOwner as Record) + : null; + const headRepositoryNameWithOwner = + typeof record.headRepositoryNameWithOwner === "string" + ? record.headRepositoryNameWithOwner + : typeof headRepositoryRecord?.nameWithOwner === "string" + ? headRepositoryRecord.nameWithOwner + : null; + const headRepositoryOwnerLogin = + typeof record.headRepositoryOwnerLogin === "string" + ? record.headRepositoryOwnerLogin + : typeof headRepositoryOwnerRecord?.login === "string" + ? headRepositoryOwnerRecord.login + : null; if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { continue; } @@ -163,11 +278,15 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { } let normalizedState: "open" | "closed" | "merged"; - if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { + if ( + (typeof mergedAt === "string" && mergedAt.trim().length > 0) || + state === "MERGED" || + state === "merged" + ) { normalizedState = "merged"; - } else if (state === "OPEN" || state === undefined || state === null) { + } else if (state === "OPEN" || state === "open" || state === undefined || state === null) { normalizedState = "open"; - } else if (state === "CLOSED") { + } else if (state === "CLOSED" || state === "closed") { normalizedState = "closed"; } else { continue; @@ -181,11 +300,35 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { headRefName, state: normalizedState, updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null, + ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), }); } return parsed; } +function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { + return { + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state ?? "open", + updatedAt: null, + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { return new GitManagerError({ operation, @@ -199,6 +342,57 @@ function limitContext(value: string, maxChars: number): string { return `${value.slice(0, maxChars)}\n\n[truncated]`; } +function shortenSha(sha: string | undefined): string | null { + if (!sha) return null; + return sha.slice(0, SHORT_SHA_LENGTH); +} + +function truncateText( + value: string | undefined, + maxLength = TOAST_DESCRIPTION_MAX, +): string | undefined { + if (!value) return undefined; + if (value.length <= maxLength) return value; + if (maxLength <= 3) return "...".slice(0, maxLength); + return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + +function withDescription(title: string, description: string | undefined) { + return description ? { title, description } : { title }; +} + +function summarizeGitActionResult( + result: Pick, +): { + title: string; + description?: string; +} { + if (result.pr.status === "created" || result.pr.status === "opened_existing") { + const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; + const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; + return withDescription(title, truncateText(result.pr.title)); + } + + if (result.push.status === "pushed") { + const shortSha = shortenSha(result.commit.commitSha); + const branch = result.push.upstreamBranch ?? result.push.branch; + const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; + const branchPart = branch ? ` to ${branch}` : ""; + return withDescription( + `Pushed${pushedCommitPart}${branchPart}`, + truncateText(result.commit.subject), + ); + } + + if (result.commit.status === "created") { + const shortSha = shortenSha(result.commit.commitSha); + const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; + return withDescription(title, truncateText(result.commit.subject)); + } + + return { title: "Done" }; +} + function sanitizeCommitMessage(generated: { subject: string; body: string; @@ -236,6 +430,12 @@ interface CommitAndBranchSuggestion { commitMessage: string; } +function isCommitAction( + action: GitStackedAction, +): action is "commit" | "commit_push" | "commit_push_pr" { + return action === "commit" || action === "commit_push" || action === "commit_push_pr"; +} + function formatCommitMessage(subject: string, body: string): string { const trimmedBody = body.trim(); if (trimmedBody.length === 0) { @@ -262,25 +462,6 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string }; } -function extractBranchFromRef(ref: string): string { - const normalized = ref.trim(); - - if (normalized.startsWith("refs/remotes/")) { - const withoutPrefix = normalized.slice("refs/remotes/".length); - const firstSlash = withoutPrefix.indexOf("/"); - if (firstSlash === -1) { - return withoutPrefix.trim(); - } - return withoutPrefix.slice(firstSlash + 1).trim(); - } - - const firstSlash = normalized.indexOf("/"); - if (firstSlash === -1) { - return normalized; - } - return normalized.slice(firstSlash + 1).trim(); -} - function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { @@ -368,7 +549,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const serverSettingsService = yield* ServerSettingsService; const createProgressEmitter = ( - input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" }, + input: { cwd: string; action: GitStackedAction }, options?: GitRunStackedActionOptions, ) => { const actionId = options?.actionId ?? randomUUID(); @@ -505,6 +686,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const path = yield* Path.Path; const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; + const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); + const readStatus = Effect.fn("readStatus")(function* (cwd: string) { + const details = yield* gitCore.statusDetails(cwd); + + const pr = + details.branch !== null + ? yield* findLatestPr(cwd, { + branch: details.branch, + upstreamRef: details.upstreamRef, + }).pipe( + Effect.map((latest) => (latest ? toStatusPr(latest) : null)), + Effect.catch(() => Effect.succeed(null)), + ) + : null; + + return { + branch: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + pr, + }; + }); + const statusResultCache = yield* Cache.makeWith({ + capacity: STATUS_RESULT_CACHE_CAPACITY, + lookup: readStatus, + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), + }); + const invalidateStatusResultCache = (cwd: string) => + Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd)); const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); @@ -534,9 +747,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ) { const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); const headBranchFromUpstream = details.upstreamRef - ? extractBranchFromRef(details.upstreamRef) + ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) : ""; const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; + const shouldProbeLocalBranchSelector = + headBranchFromUpstream.length === 0 || headBranch === details.branch; const [remoteRepository, originRepository] = yield* Effect.all( [ @@ -572,7 +787,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, ); } - appendUnique(headSelectors, details.branch); + if (shouldProbeLocalBranchSelector) { + appendUnique(headSelectors, details.branch); + } appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { appendUnique(headSelectors, ownerHeadSelector); @@ -597,16 +814,26 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const findOpenPr = Effect.fn("findOpenPr")(function* ( cwd: string, - headSelectors: ReadonlyArray, + headContext: Pick< + BranchHeadContext, + | "headBranch" + | "headSelectors" + | "headRepositoryNameWithOwner" + | "headRepositoryOwnerLogin" + | "isCrossRepository" + >, ) { - for (const headSelector of headSelectors) { + for (const headSelector of headContext.headSelectors) { const pullRequests = yield* gitHubCli.listOpenPullRequests({ cwd, headSelector, limit: 1, }); + const normalizedPullRequests = pullRequests.map(toPullRequestInfo); - const [firstPullRequest] = pullRequests; + const firstPullRequest = normalizedPullRequests.find((pullRequest) => + matchesBranchHeadContext(pullRequest, headContext), + ); if (firstPullRequest) { return { number: firstPullRequest.number, @@ -644,7 +871,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { "--limit", "20", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe(Effect.map((result) => result.stdout)); @@ -661,6 +888,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); for (const pr of parsePullRequestList(parsedJson)) { + if (!matchesBranchHeadContext(pr, headContext)) { + continue; + } parsedByNumber.set(pr.number, pr); } } @@ -678,17 +908,119 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return parsed[0] ?? null; }); + const isDefaultBranch = Effect.fn("isDefaultBranch")(function* (cwd: string, branch: string) { + const branches = yield* gitCore.listBranches({ cwd }); + const currentBranch = branches.branches.find((candidate) => candidate.name === branch); + return currentBranch?.isDefault ?? (branch === "main" || branch === "master"); + }); + + const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( + cwd: string, + result: Pick, + ) { + const summary = summarizeGitActionResult(result); + let latestOpenPr: PullRequestInfo | null = null; + let currentBranchIsDefault = false; + let finalBranchContext: { + branch: string; + upstreamRef: string | null; + hasUpstream: boolean; + } | null = null; + + if (result.action !== "commit") { + const finalStatus = yield* gitCore.statusDetails(cwd); + if (finalStatus.branch) { + finalBranchContext = { + branch: finalStatus.branch, + upstreamRef: finalStatus.upstreamRef, + hasUpstream: finalStatus.hasUpstream, + }; + currentBranchIsDefault = yield* isDefaultBranch(cwd, finalStatus.branch).pipe( + Effect.catch(() => + Effect.succeed(finalStatus.branch === "main" || finalStatus.branch === "master"), + ), + ); + } + } + + const explicitResultPr = + (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url + ? { + url: result.pr.url, + state: "open" as const, + } + : null; + const shouldLookupExistingOpenPr = + (result.action === "commit_push" || result.action === "push") && + result.push.status === "pushed" && + result.branch.status !== "created" && + !currentBranchIsDefault && + explicitResultPr === null && + finalBranchContext?.hasUpstream === true; + + if (shouldLookupExistingOpenPr && finalBranchContext) { + latestOpenPr = yield* resolveBranchHeadContext(cwd, { + branch: finalBranchContext.branch, + upstreamRef: finalBranchContext.upstreamRef, + }).pipe( + Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), + Effect.catch(() => Effect.succeed(null)), + ); + } + + const openPr = latestOpenPr ?? explicitResultPr; + + const cta = + result.action === "commit" && result.commit.status === "created" + ? { + kind: "run_action" as const, + label: "Push", + action: { kind: "push" as const }, + } + : (result.action === "push" || + result.action === "create_pr" || + result.action === "commit_push" || + result.action === "commit_push_pr") && + openPr?.url && + (!currentBranchIsDefault || + result.pr.status === "created" || + result.pr.status === "opened_existing") + ? { + kind: "open_pr" as const, + label: "View PR", + url: openPr.url, + } + : (result.action === "push" || result.action === "commit_push") && + result.push.status === "pushed" && + !currentBranchIsDefault + ? { + kind: "run_action" as const, + label: "Create PR", + action: { kind: "create_pr" as const }, + } + : { + kind: "none" as const, + }; + + return { + ...summary, + cta, + }; + }); + const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( cwd: string, branch: string, upstreamRef: string | null, - headContext: Pick, + headContext: Pick, ) { const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); if (configured) return configured; if (upstreamRef && !headContext.isCrossRepository) { - const upstreamBranch = extractBranchFromRef(upstreamRef); + const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { + remoteName: headContext.remoteName, + }); if (upstreamBranch.length > 0 && upstreamBranch !== branch) { return upstreamBranch; } @@ -889,7 +1221,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { upstreamRef: details.upstreamRef, }); - const existing = yield* findOpenPr(cwd, headContext.headSelectors); + const existing = yield* findOpenPr(cwd, headContext); if (existing) { return { status: "opened_existing" as const, @@ -932,7 +1264,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }) .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); - const created = yield* findOpenPr(cwd, headContext.headSelectors); + const created = yield* findOpenPr(cwd, headContext); if (!created) { return { status: "created" as const, @@ -953,28 +1285,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { - const details = yield* gitCore.statusDetails(input.cwd); - - const pr = - details.branch !== null - ? yield* findLatestPr(input.cwd, { - branch: details.branch, - upstreamRef: details.upstreamRef, - }).pipe( - Effect.map((latest) => (latest ? toStatusPr(latest) : null)), - Effect.catch(() => Effect.succeed(null)), - ) - : null; - - return { - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, - hasUpstream: details.hasUpstream, - aheadCount: details.aheadCount, - behindCount: details.behindCount, - pr, - }; + return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd)); }); const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( @@ -993,143 +1304,145 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { - const normalizedReference = normalizePullRequestReference(input.reference); - const rootWorktreePath = canonicalizeExistingPath(input.cwd); - const pullRequestSummary = yield* gitHubCli.getPullRequest({ - cwd: input.cwd, - reference: normalizedReference, - }); - const pullRequest = toResolvedPullRequest(pullRequestSummary); - - if (input.mode === "local") { - yield* gitHubCli.checkoutPullRequest({ + return yield* Effect.gen(function* () { + const normalizedReference = normalizePullRequestReference(input.reference); + const rootWorktreePath = canonicalizeExistingPath(input.cwd); + const pullRequestSummary = yield* gitHubCli.getPullRequest({ cwd: input.cwd, reference: normalizedReference, - force: true, }); - const details = yield* gitCore.statusDetails(input.cwd); - yield* configurePullRequestHeadUpstream( - input.cwd, - { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - }, - details.branch ?? pullRequest.headBranch, - ); - return { - pullRequest, - branch: details.branch ?? pullRequest.headBranch, - worktreePath: null, - }; - } + const pullRequest = toResolvedPullRequest(pullRequestSummary); - const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( - worktreePath: string, - ) { - const details = yield* gitCore.statusDetails(worktreePath); - yield* configurePullRequestHeadUpstream( - worktreePath, - { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - }, - details.branch ?? pullRequest.headBranch, - ); - }); + if (input.mode === "local") { + yield* gitHubCli.checkoutPullRequest({ + cwd: input.cwd, + reference: normalizedReference, + force: true, + }); + const details = yield* gitCore.statusDetails(input.cwd); + yield* configurePullRequestHeadUpstream( + input.cwd, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + return { + pullRequest, + branch: details.branch ?? pullRequest.headBranch, + worktreePath: null, + }; + } - const pullRequestWithRemoteInfo = { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - } as const; - const localPullRequestBranch = - resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); - - const findLocalHeadBranch = (cwd: string) => - gitCore.listBranches({ cwd }).pipe( - Effect.map((result) => { - const localBranch = result.branches.find( - (branch) => !branch.isRemote && branch.name === localPullRequestBranch, - ); - if (localBranch) { - return localBranch; - } - if (localPullRequestBranch === pullRequest.headBranch) { - return null; - } - return ( - result.branches.find( - (branch) => - !branch.isRemote && - branch.name === pullRequest.headBranch && - branch.worktreePath !== null && - canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, - ) ?? null - ); - }), - ); + const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( + worktreePath: string, + ) { + const details = yield* gitCore.statusDetails(worktreePath); + yield* configurePullRequestHeadUpstream( + worktreePath, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + }); - const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); - const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) - : null; - if ( - existingBranchBeforeFetch?.worktreePath && - existingBranchBeforeFetchPath !== rootWorktreePath - ) { - yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); - return { - pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchBeforeFetch.worktreePath, - }; - } - if (existingBranchBeforeFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + const pullRequestWithRemoteInfo = { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + } as const; + const localPullRequestBranch = + resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); + + const findLocalHeadBranch = (cwd: string) => + gitCore.listBranches({ cwd }).pipe( + Effect.map((result) => { + const localBranch = result.branches.find( + (branch) => !branch.isRemote && branch.name === localPullRequestBranch, + ); + if (localBranch) { + return localBranch; + } + if (localPullRequestBranch === pullRequest.headBranch) { + return null; + } + return ( + result.branches.find( + (branch) => + !branch.isRemote && + branch.name === pullRequest.headBranch && + branch.worktreePath !== null && + canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, + ) ?? null + ); + }), + ); + + const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath + ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + : null; + if ( + existingBranchBeforeFetch?.worktreePath && + existingBranchBeforeFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath: existingBranchBeforeFetch.worktreePath, + }; + } + if (existingBranchBeforeFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + ); + } + + yield* materializePullRequestHeadBranch( + input.cwd, + pullRequestWithRemoteInfo, + localPullRequestBranch, ); - } - yield* materializePullRequestHeadBranch( - input.cwd, - pullRequestWithRemoteInfo, - localPullRequestBranch, - ); + const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath + ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + : null; + if ( + existingBranchAfterFetch?.worktreePath && + existingBranchAfterFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath: existingBranchAfterFetch.worktreePath, + }; + } + if (existingBranchAfterFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + ); + } + + const worktree = yield* gitCore.createWorktree({ + cwd: input.cwd, + branch: localPullRequestBranch, + path: null, + }); + yield* ensureExistingWorktreeUpstream(worktree.worktree.path); - const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); - const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) - : null; - if ( - existingBranchAfterFetch?.worktreePath && - existingBranchAfterFetchPath !== rootWorktreePath - ) { - yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); return { pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchAfterFetch.worktreePath, + branch: worktree.worktree.branch, + worktreePath: worktree.worktree.path, }; - } - if (existingBranchAfterFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", - ); - } - - const worktree = yield* gitCore.createWorktree({ - cwd: input.cwd, - branch: localPullRequestBranch, - path: null, - }); - yield* ensureExistingWorktreeUpstream(worktree.worktree.path); - - return { - pullRequest, - branch: worktree.worktree.branch, - worktreePath: worktree.worktree.path, - }; + }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd))); }); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( @@ -1171,27 +1484,53 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( function* (input, options) { const progress = createProgressEmitter(input, options); - const phases: GitActionProgressPhase[] = [ - ...(input.featureBranch ? (["branch"] as const) : []), - "commit", - ...(input.action !== "commit" ? (["push"] as const) : []), - ...(input.action === "commit_push_pr" ? (["pr"] as const) : []), - ]; const currentPhase = yield* Ref.make>(Option.none()); const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return< GitRunStackedActionResult, GitManagerServiceError > { + const initialStatus = yield* gitCore.statusDetails(input.cwd); + const wantsCommit = isCommitAction(input.action); + const wantsPush = + input.action === "push" || + input.action === "commit_push" || + input.action === "commit_push_pr" || + (input.action === "create_pr" && + (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); + const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; + + if (input.featureBranch && !wantsCommit) { + return yield* gitManagerError( + "runStackedAction", + "Feature-branch checkout is only supported for commit actions.", + ); + } + if (input.action === "push" && initialStatus.hasWorkingTreeChanges) { + return yield* gitManagerError( + "runStackedAction", + "Commit or stash local changes before pushing.", + ); + } + if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { + return yield* gitManagerError( + "runStackedAction", + "Commit local changes before creating a PR.", + ); + } + + const phases: GitActionProgressPhase[] = [ + ...(input.featureBranch ? (["branch"] as const) : []), + ...(wantsCommit ? (["commit"] as const) : []), + ...(wantsPush ? (["push"] as const) : []), + ...(wantsPr ? (["pr"] as const) : []), + ]; + yield* progress.emit({ kind: "action_started", phases, }); - const wantsPush = input.action !== "commit"; - const wantsPr = input.action === "commit_push_pr"; - - const initialStatus = yield* gitCore.statusDetails(input.cwd); if (!input.featureBranch && wantsPush && !initialStatus.branch) { return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); } @@ -1235,19 +1574,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } const currentBranch = branchStep.name ?? initialStatus.branch; - - yield* Ref.set(currentPhase, Option.some("commit")); - const commit = yield* runCommitStep( - modelSelection, - input.cwd, - input.action, - currentBranch, - commitMessageForStep, - preResolvedCommitSuggestion, - input.filePaths, - options?.progressReporter, - progress.actionId, - ); + const commitAction = isCommitAction(input.action) ? input.action : null; + + const commit = commitAction + ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( + Effect.flatMap(() => + runCommitStep( + modelSelection, + input.cwd, + commitAction, + currentBranch, + commitMessageForStep, + preResolvedCommitSuggestion, + input.filePaths, + options?.progressReporter, + progress.actionId, + ), + ), + ) + : { status: "skipped_not_requested" as const }; const push = wantsPush ? yield* progress @@ -1275,12 +1620,21 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ) : { status: "skipped_not_requested" as const }; + const toast = yield* buildCompletionToast(input.cwd, { + action: input.action, + branch: branchStep, + commit, + push, + pr, + }); + const result = { action: input.action, branch: branchStep, commit, push, pr, + toast, }; yield* progress.emit({ kind: "action_finished", @@ -1290,6 +1644,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); return yield* runAction().pipe( + Effect.ensuring(invalidateStatusResultCache(input.cwd)), Effect.tapError((error) => Effect.flatMap(Ref.get(currentPhase), (phase) => progress.emit({ diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index a99e4d3bc4..86842257b4 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -56,7 +56,7 @@ export interface GitManagerShape { ) => Effect.Effect; /** - * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). + * Run a Git action (`commit`, `push`, `create_pr`, `commit_push`, `commit_push_pr`). * When `featureBranch` is set, creates and checks out a feature branch first. */ readonly runStackedAction: ( diff --git a/apps/server/src/git/remoteRefs.ts b/apps/server/src/git/remoteRefs.ts new file mode 100644 index 0000000000..b5dfc87bd0 --- /dev/null +++ b/apps/server/src/git/remoteRefs.ts @@ -0,0 +1,67 @@ +export function parseRemoteNamesInGitOrder(stdout: string): ReadonlyArray { + return stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +export function parseRemoteNames(stdout: string): ReadonlyArray { + return parseRemoteNamesInGitOrder(stdout).toSorted((a, b) => b.length - a.length); +} + +export function parseRemoteRefWithRemoteNames( + ref: string, + remoteNames: ReadonlyArray, +): { remoteRef: string; remoteName: string; branchName: string } | null { + const trimmedRef = ref.trim(); + if (trimmedRef.length === 0) { + return null; + } + + for (const remoteName of remoteNames) { + const remotePrefix = `${remoteName}/`; + if (!trimmedRef.startsWith(remotePrefix)) { + continue; + } + const branchName = trimmedRef.slice(remotePrefix.length).trim(); + if (branchName.length === 0) { + return null; + } + return { + remoteRef: trimmedRef, + remoteName, + branchName, + }; + } + + return null; +} + +export function extractBranchNameFromRemoteRef( + ref: string, + options?: { + remoteName?: string | null; + remoteNames?: ReadonlyArray; + }, +): string { + const normalized = ref.trim(); + if (normalized.length === 0) { + return ""; + } + + if (normalized.startsWith("refs/remotes/")) { + return extractBranchNameFromRemoteRef(normalized.slice("refs/remotes/".length), options); + } + + const remoteNames = options?.remoteName ? [options.remoteName] : (options?.remoteNames ?? []); + const parsedRemoteRef = parseRemoteRefWithRemoteNames(normalized, remoteNames); + if (parsedRemoteRef) { + return parsedRemoteRef.branchName; + } + + const firstSlash = normalized.indexOf("/"); + if (firstSlash === -1) { + return normalized; + } + return normalized.slice(firstSlash + 1).trim(); +} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 781a0025e2..ab9f633e02 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -23,6 +23,7 @@ import { GitCoreLive } from "../../git/Layers/GitCore.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -249,6 +250,7 @@ describe("CheckpointReactor", () => { options?.providerName ?? "codex", ); const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 6aa889991e..16bbd98d6b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -8,7 +8,7 @@ import { TurnId, type OrchestrationEvent, } from "@t3tools/contracts"; -import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; +import { Effect, Layer, ManagedRuntime, Option, Queue, Stream } from "effect"; import { describe, expect, it } from "vitest"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; @@ -21,11 +21,13 @@ import { } from "../../persistence/Services/OrchestrationEventStore.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, } from "../Services/ProjectionPipeline.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ServerConfig } from "../../config.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -39,6 +41,7 @@ async function createOrchestrationSystem() { prefix: "t3-orchestration-engine-test-", }); const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -60,6 +63,105 @@ function now() { } describe("OrchestrationEngine", () => { + it("bootstraps the in-memory read model from persisted projections", async () => { + const failOnHistoricalReplayStore: OrchestrationEventStoreShape = { + append: () => + Effect.fail( + new PersistenceSqlError({ + operation: "test.append", + detail: "append should not be called during bootstrap", + }), + ), + readFromSequence: () => Stream.empty, + readAll: () => + Stream.fail( + new PersistenceSqlError({ + operation: "test.readAll", + detail: "historical replay should not be used during bootstrap", + }), + ), + }; + + const projectionSnapshot = { + snapshotSequence: 7, + updatedAt: "2026-03-03T00:00:04.000Z", + projects: [ + { + id: asProjectId("project-bootstrap"), + title: "Bootstrap Project", + workspaceRoot: "/tmp/project-bootstrap", + defaultModelSelection: { + provider: "codex" as const, + model: "gpt-5-codex", + }, + scripts: [], + createdAt: "2026-03-03T00:00:00.000Z", + updatedAt: "2026-03-03T00:00:01.000Z", + deletedAt: null, + }, + ], + threads: [ + { + id: ThreadId.makeUnsafe("thread-bootstrap"), + projectId: asProjectId("project-bootstrap"), + title: "Bootstrap Thread", + modelSelection: { + provider: "codex" as const, + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access" as const, + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-03-03T00:00:02.000Z", + updatedAt: "2026-03-03T00:00:03.000Z", + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + }; + + const layer = OrchestrationEngineLive.pipe( + Layer.provide( + Layer.succeed(ProjectionSnapshotQuery, { + getSnapshot: () => Effect.succeed(projectionSnapshot), + getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide( + Layer.succeed(OrchestrationProjectionPipeline, { + bootstrap: Effect.void, + projectEvent: () => Effect.void, + } satisfies OrchestrationProjectionPipelineShape), + ), + Layer.provide(Layer.succeed(OrchestrationEventStore, failOnHistoricalReplayStore)), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(SqlitePersistenceMemory), + ); + + const runtime = ManagedRuntime.make(layer); + + const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + const readModel = await runtime.runPromise(engine.getReadModel()); + + expect(readModel.snapshotSequence).toBe(7); + expect(readModel.projects).toHaveLength(1); + expect(readModel.projects[0]?.title).toBe("Bootstrap Project"); + expect(readModel.threads).toHaveLength(1); + expect(readModel.threads[0]?.title).toBe("Bootstrap Thread"); + + await runtime.dispose(); + }); + it("returns deterministic read models for repeated reads", async () => { const createdAt = now(); const system = await createOrchestrationSystem(); @@ -417,6 +519,7 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make( OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -512,6 +615,7 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make( OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -653,6 +757,7 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make( OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 5c52379f47..bd3581deb6 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -19,6 +19,7 @@ import { import { decideOrchestrationCommand } from "../decider.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -54,6 +55,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const eventStore = yield* OrchestrationEventStore; const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; const projectionPipeline = yield* OrchestrationProjectionPipeline; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; let readModel = createEmptyReadModel(new Date().toISOString()); @@ -195,13 +197,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { }; yield* projectionPipeline.bootstrap; - - // bootstrap in-memory read model from event store - yield* Stream.runForEach(eventStore.readAll(), (event) => - Effect.gen(function* () { - readModel = yield* projectEvent(readModel, event); - }), - ); + readModel = yield* projectionSnapshotQuery.getSnapshot(); const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); yield* Effect.forkScoped(worker); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 77b5d4d619..1850745469 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -25,6 +25,7 @@ import { ORCHESTRATION_PROJECTOR_NAMES, OrchestrationProjectionPipelineLive, } from "./ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; @@ -1841,6 +1842,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta const engineLayer = it.layer( OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 61de04ad0e..0844cf8bb0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -623,24 +623,28 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti )(function* (event, attachmentSideEffects) { switch (event.type) { case "thread.message-sent": { - const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ - threadId: event.payload.threadId, + const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ + messageId: event.payload.messageId, + }); + const previousMessage = Option.getOrUndefined(existingMessage); + const nextText = Option.match(existingMessage, { + onNone: () => event.payload.text, + onSome: (message) => { + if (event.payload.streaming) { + return `${message.text}${event.payload.text}`; + } + if (event.payload.text.length === 0) { + return message.text; + } + return event.payload.text; + }, }); - const existingMessage = existingRows.find( - (row) => row.messageId === event.payload.messageId, - ); - const nextText = - existingMessage && event.payload.streaming - ? `${existingMessage.text}${event.payload.text}` - : existingMessage && event.payload.text.length === 0 - ? existingMessage.text - : event.payload.text; const nextAttachments = event.payload.attachments !== undefined ? yield* materializeAttachmentsForProjection({ attachments: event.payload.attachments, }) - : existingMessage?.attachments; + : previousMessage?.attachments; yield* projectionThreadMessageRepository.upsert({ messageId: event.payload.messageId, threadId: event.payload.threadId, @@ -649,7 +653,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), isStreaming: event.payload.streaming, - createdAt: existingMessage?.createdAt ?? event.payload.createdAt, + createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 32143d751f..c038bc9d2c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -338,4 +338,290 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ]); }), ); + + it.effect( + "reads targeted project, thread, and count queries without hydrating the full snapshot", + () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES + ( + 'project-active', + 'Active Project', + '/tmp/workspace', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-03-01T00:00:00.000Z', + '2026-03-01T00:00:01.000Z', + NULL + ), + ( + 'project-deleted', + 'Deleted Project', + '/tmp/deleted', + NULL, + '[]', + '2026-03-01T00:00:02.000Z', + '2026-03-01T00:00:03.000Z', + '2026-03-01T00:00:04.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES + ( + 'thread-first', + 'project-active', + 'First Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + '2026-03-01T00:00:05.000Z', + '2026-03-01T00:00:06.000Z', + NULL, + NULL + ), + ( + 'thread-second', + 'project-active', + 'Second Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + '2026-03-01T00:00:07.000Z', + '2026-03-01T00:00:08.000Z', + NULL, + NULL + ), + ( + 'thread-deleted', + 'project-active', + 'Deleted Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + '2026-03-01T00:00:09.000Z', + '2026-03-01T00:00:10.000Z', + NULL, + '2026-03-01T00:00:11.000Z' + ) + `; + + const counts = yield* snapshotQuery.getCounts(); + assert.deepEqual(counts, { + projectCount: 2, + threadCount: 3, + }); + + const project = yield* snapshotQuery.getActiveProjectByWorkspaceRoot("/tmp/workspace"); + assert.equal(project._tag, "Some"); + if (project._tag === "Some") { + assert.equal(project.value.id, asProjectId("project-active")); + } + + const missingProject = yield* snapshotQuery.getActiveProjectByWorkspaceRoot("/tmp/missing"); + assert.equal(missingProject._tag, "None"); + + const firstThreadId = yield* snapshotQuery.getFirstActiveThreadIdByProjectId( + asProjectId("project-active"), + ); + assert.equal(firstThreadId._tag, "Some"); + if (firstThreadId._tag === "Some") { + assert.equal(firstThreadId.value, ThreadId.makeUnsafe("thread-first")); + } + }), + ); + + it.effect("reads single-thread checkpoint context without hydrating unrelated threads", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-context', + 'Context Project', + '/tmp/context-workspace', + NULL, + '[]', + '2026-03-02T00:00:00.000Z', + '2026-03-02T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES ( + 'thread-context', + 'project-context', + 'Context Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + 'feature/perf', + '/tmp/context-worktree', + NULL, + '2026-03-02T00:00:02.000Z', + '2026-03-02T00:00:03.000Z', + NULL, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES + ( + 'thread-context', + 'turn-1', + NULL, + NULL, + NULL, + NULL, + 'completed', + '2026-03-02T00:00:04.000Z', + '2026-03-02T00:00:04.000Z', + '2026-03-02T00:00:04.000Z', + 1, + 'checkpoint-a', + 'ready', + '[]' + ), + ( + 'thread-context', + 'turn-2', + NULL, + NULL, + NULL, + NULL, + 'completed', + '2026-03-02T00:00:05.000Z', + '2026-03-02T00:00:05.000Z', + '2026-03-02T00:00:05.000Z', + 2, + 'checkpoint-b', + 'ready', + '[]' + ) + `; + + const context = yield* snapshotQuery.getThreadCheckpointContext( + ThreadId.makeUnsafe("thread-context"), + ); + assert.equal(context._tag, "Some"); + if (context._tag === "Some") { + assert.deepEqual(context.value, { + threadId: ThreadId.makeUnsafe("thread-context"), + projectId: asProjectId("project-context"), + workspaceRoot: "/tmp/context-workspace", + worktreePath: "/tmp/context-worktree", + checkpoints: [ + { + turnId: asTurnId("turn-1"), + checkpointTurnCount: 1, + checkpointRef: asCheckpointRef("checkpoint-a"), + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-03-02T00:00:04.000Z", + }, + { + turnId: asTurnId("turn-2"), + checkpointTurnCount: 2, + checkpointRef: asCheckpointRef("checkpoint-b"), + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-03-02T00:00:05.000Z", + }, + ], + }); + } + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index f951c54b5b..da7c695674 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -7,7 +7,6 @@ import { OrchestrationProposedPlanId, OrchestrationReadModel, ProjectScript, - ThreadId, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, @@ -18,8 +17,10 @@ import { type OrchestrationThread, type OrchestrationThreadActivity, ModelSelection, + ProjectId, + ThreadId, } from "@t3tools/contracts"; -import { Effect, Layer, Schema, Struct } from "effect"; +import { Effect, Layer, Option, Schema, Struct } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; @@ -40,6 +41,8 @@ import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.t import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, + type ProjectionSnapshotCounts, + type ProjectionThreadCheckpointContext, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; @@ -86,6 +89,29 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), }); const ProjectionStateDbRowSchema = ProjectionState; +const ProjectionCountsRowSchema = Schema.Struct({ + projectCount: Schema.Number, + threadCount: Schema.Number, +}); +const WorkspaceRootLookupInput = Schema.Struct({ + workspaceRoot: Schema.String, +}); +const ProjectIdLookupInput = Schema.Struct({ + projectId: ProjectId, +}); +const ThreadIdLookupInput = Schema.Struct({ + threadId: ThreadId, +}); +const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema; +const ProjectionThreadIdLookupRowSchema = Schema.Struct({ + threadId: ThreadId, +}); +const ProjectionThreadCheckpointContextThreadRowSchema = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + workspaceRoot: Schema.String, + worktreePath: Schema.NullOr(Schema.String), +}); const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.projects, @@ -319,6 +345,94 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const readProjectionCounts = SqlSchema.findOne({ + Request: Schema.Void, + Result: ProjectionCountsRowSchema, + execute: () => + sql` + SELECT + (SELECT COUNT(*) FROM projection_projects) AS "projectCount", + (SELECT COUNT(*) FROM projection_threads) AS "threadCount" + `, + }); + + const getActiveProjectRowByWorkspaceRoot = SqlSchema.findOneOption({ + Request: WorkspaceRootLookupInput, + Result: ProjectionProjectLookupRowSchema, + execute: ({ workspaceRoot }) => + sql` + SELECT + project_id AS "projectId", + title, + workspace_root AS "workspaceRoot", + default_model_selection_json AS "defaultModelSelection", + scripts_json AS "scripts", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_projects + WHERE workspace_root = ${workspaceRoot} + AND deleted_at IS NULL + ORDER BY created_at ASC, project_id ASC + LIMIT 1 + `, + }); + + const getFirstActiveThreadIdByProject = SqlSchema.findOneOption({ + Request: ProjectIdLookupInput, + Result: ProjectionThreadIdLookupRowSchema, + execute: ({ projectId }) => + sql` + SELECT + thread_id AS "threadId" + FROM projection_threads + WHERE project_id = ${projectId} + AND deleted_at IS NULL + ORDER BY created_at ASC, thread_id ASC + LIMIT 1 + `, + }); + + const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadCheckpointContextThreadRowSchema, + execute: ({ threadId }) => + sql` + SELECT + threads.thread_id AS "threadId", + threads.project_id AS "projectId", + projects.workspace_root AS "workspaceRoot", + threads.worktree_path AS "worktreePath" + FROM projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + WHERE threads.thread_id = ${threadId} + AND threads.deleted_at IS NULL + LIMIT 1 + `, + }); + + const listCheckpointRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionCheckpointDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + checkpoint_status AS "status", + checkpoint_files_json AS "files", + assistant_message_id AS "assistantMessageId", + completed_at AS "completedAt" + FROM projection_turns + WHERE thread_id = ${threadId} + AND checkpoint_turn_count IS NOT NULL + ORDER BY checkpoint_turn_count ASC + `, + }); + const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => sql .withTransaction( @@ -593,8 +707,109 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }), ); + const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => + readProjectionCounts(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCounts:query", + "ProjectionSnapshotQuery.getCounts:decodeRow", + ), + ), + Effect.map( + (row): ProjectionSnapshotCounts => ({ + projectCount: row.projectCount, + threadCount: row.threadCount, + }), + ), + ); + + const getActiveProjectByWorkspaceRoot: ProjectionSnapshotQueryShape["getActiveProjectByWorkspaceRoot"] = + (workspaceRoot) => + getActiveProjectRowByWorkspaceRoot({ workspaceRoot }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:query", + "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow", + ), + ), + Effect.map( + Option.map( + (row): OrchestrationProject => ({ + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + }), + ), + ), + ); + + const getFirstActiveThreadIdByProjectId: ProjectionSnapshotQueryShape["getFirstActiveThreadIdByProjectId"] = + (projectId) => + getFirstActiveThreadIdByProject({ projectId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getFirstActiveThreadIdByProjectId:query", + "ProjectionSnapshotQuery.getFirstActiveThreadIdByProjectId:decodeRow", + ), + ), + Effect.map(Option.map((row) => row.threadId)), + ); + + const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = ( + threadId, + ) => + Effect.gen(function* () { + const threadRow = yield* getThreadCheckpointContextThreadRow({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadCheckpointContext:getThread:query", + "ProjectionSnapshotQuery.getThreadCheckpointContext:getThread:decodeRow", + ), + ), + ); + if (Option.isNone(threadRow)) { + return Option.none(); + } + + const checkpointRows = yield* listCheckpointRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadCheckpointContext:listCheckpoints:query", + "ProjectionSnapshotQuery.getThreadCheckpointContext:listCheckpoints:decodeRows", + ), + ), + ); + + return Option.some({ + threadId: threadRow.value.threadId, + projectId: threadRow.value.projectId, + workspaceRoot: threadRow.value.workspaceRoot, + worktreePath: threadRow.value.worktreePath, + checkpoints: checkpointRows.map( + (row): OrchestrationCheckpointSummary => ({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + }), + ), + }); + }); + return { getSnapshot, + getCounts, + getActiveProjectByWorkspaceRoot, + getFirstActiveThreadIdByProjectId, + getThreadCheckpointContext, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 506d6d2864..ca3dc04517 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -30,6 +30,7 @@ import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; @@ -211,6 +212,7 @@ describe("ProviderCommandReactor", () => { }; const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 529eae2444..6c27e1010c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -31,6 +31,7 @@ import { } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService, @@ -197,6 +198,7 @@ describe("ProviderRuntimeIngestion", () => { fs.mkdirSync(path.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 91e42f02ff..a7673dc32e 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -6,12 +6,32 @@ * * @module ProjectionSnapshotQuery */ -import type { OrchestrationReadModel } from "@t3tools/contracts"; +import type { + OrchestrationCheckpointSummary, + OrchestrationProject, + OrchestrationReadModel, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; import { ServiceMap } from "effect"; +import type { Option } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; +export interface ProjectionSnapshotCounts { + readonly projectCount: number; + readonly threadCount: number; +} + +export interface ProjectionThreadCheckpointContext { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly worktreePath: string | null; + readonly checkpoints: ReadonlyArray; +} + /** * ProjectionSnapshotQueryShape - Service API for read-model snapshots. */ @@ -23,6 +43,32 @@ export interface ProjectionSnapshotQueryShape { * projector cursor state. */ readonly getSnapshot: () => Effect.Effect; + + /** + * Read aggregate projection counts without hydrating the full read model. + */ + readonly getCounts: () => Effect.Effect; + + /** + * Read the active project for an exact workspace root match. + */ + readonly getActiveProjectByWorkspaceRoot: ( + workspaceRoot: string, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read the earliest active thread for a project. + */ + readonly getFirstActiveThreadIdByProjectId: ( + projectId: ProjectId, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read the checkpoint context needed to resolve a single thread diff. + */ + readonly getThreadCheckpointContext: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; } /** diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index b761387d47..5993ad6c20 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -55,6 +55,13 @@ layer("ProjectionThreadMessageRepository", (it) => { assert.equal(rows.length, 1); assert.equal(rows[0]?.text, "updated"); assert.deepEqual(rows[0]?.attachments, persistedAttachments); + + const rowById = yield* repository.getByMessageId({ messageId }); + assert.equal(rowById._tag, "Some"); + if (rowById._tag === "Some") { + assert.equal(rowById.value.text, "updated"); + assert.deepEqual(rowById.value.attachments, persistedAttachments); + } }), ); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 6f0b25ddff..13b7086cec 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -1,10 +1,11 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema, Struct } from "effect"; +import { Effect, Layer, Option, Schema, Struct } from "effect"; import { ChatAttachment } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { + GetProjectionThreadMessageInput, ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, DeleteProjectionThreadMessagesInput, @@ -19,6 +20,22 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( }), ); +function toProjectionThreadMessage( + row: Schema.Schema.Type, +): ProjectionThreadMessage { + return { + messageId: row.messageId, + threadId: row.threadId, + turnId: row.turnId, + role: row.role, + text: row.text, + isStreaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + }; +} + const makeProjectionThreadMessageRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -74,6 +91,27 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { }, }); + const getProjectionThreadMessageRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadMessageInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ messageId }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE message_id = ${messageId} + LIMIT 1 + `, + }); + const listProjectionThreadMessageRows = SqlSchema.findAll({ Request: ListProjectionThreadMessagesInput, Result: ProjectionThreadMessageDbRowSchema, @@ -109,24 +147,20 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), ); + const getByMessageId: ProjectionThreadMessageRepositoryShape["getByMessageId"] = (input) => + getProjectionThreadMessageRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.getByMessageId:query"), + ), + Effect.map(Option.map(toProjectionThreadMessage)), + ); + const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => listProjectionThreadMessageRows(input).pipe( Effect.mapError( toPersistenceSqlError("ProjectionThreadMessageRepository.listByThreadId:query"), ), - Effect.map((rows) => - rows.map((row) => ({ - messageId: row.messageId, - threadId: row.threadId, - turnId: row.turnId, - role: row.role, - text: row.text, - isStreaming: row.isStreaming === 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - ...(row.attachments !== null ? { attachments: row.attachments } : {}), - })), - ), + Effect.map((rows) => rows.map(toProjectionThreadMessage)), ); const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => @@ -138,6 +172,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { return { upsert, + getByMessageId, listByThreadId, deleteByThreadId, } satisfies ProjectionThreadMessageRepositoryShape; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 47102471ac..a03c3c2d18 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -31,6 +31,7 @@ import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; +import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; /** * Migration loader with all migrations defined inline. @@ -61,6 +62,7 @@ export const migrationEntries = [ [16, "CanonicalizeModelSelections", Migration0016], [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], + [19, "ProjectionSnapshotLookupIndexes", Migration0019], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts new file mode 100644 index 0000000000..6207a9bcb6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts @@ -0,0 +1,73 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("019_ProjectionSnapshotLookupIndexes", (it) => { + it.effect("creates indexes for targeted projection lookup filters", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 18 }); + yield* runMigrations({ toMigrationInclusive: 19 }); + + const projectIndexes = yield* sql<{ + readonly seq: number; + readonly name: string; + readonly unique: number; + readonly origin: string; + readonly partial: number; + }>` + PRAGMA index_list(projection_projects) + `; + assert.ok( + projectIndexes.some( + (index) => index.name === "idx_projection_projects_workspace_root_deleted_at", + ), + ); + + const projectIndexColumns = yield* sql<{ + readonly seqno: number; + readonly cid: number; + readonly name: string; + }>` + PRAGMA index_info('idx_projection_projects_workspace_root_deleted_at') + `; + assert.deepStrictEqual( + projectIndexColumns.map((column) => column.name), + ["workspace_root", "deleted_at"], + ); + + const threadIndexes = yield* sql<{ + readonly seq: number; + readonly name: string; + readonly unique: number; + readonly origin: string; + readonly partial: number; + }>` + PRAGMA index_list(projection_threads) + `; + assert.ok( + threadIndexes.some( + (index) => index.name === "idx_projection_threads_project_deleted_created", + ), + ); + + const threadIndexColumns = yield* sql<{ + readonly seqno: number; + readonly cid: number; + readonly name: string; + }>` + PRAGMA index_info('idx_projection_threads_project_deleted_created') + `; + assert.deepStrictEqual( + threadIndexColumns.map((column) => column.name), + ["project_id", "deleted_at", "created_at"], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.ts b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.ts new file mode 100644 index 0000000000..bf74a5147d --- /dev/null +++ b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.ts @@ -0,0 +1,16 @@ +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 INDEX IF NOT EXISTS idx_projection_projects_workspace_root_deleted_at + ON projection_projects(workspace_root, deleted_at) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_project_deleted_created + ON projection_threads(project_id, deleted_at, created_at) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index 00b1d399c6..b1a769cd91 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -8,13 +8,14 @@ */ import { ChatAttachment, - OrchestrationMessageRole, MessageId, + OrchestrationMessageRole, ThreadId, TurnId, IsoDateTime, } from "@t3tools/contracts"; import { Schema, ServiceMap } from "effect"; +import type { Option } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -37,6 +38,11 @@ export const ListProjectionThreadMessagesInput = Schema.Struct({ }); export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type; +export const GetProjectionThreadMessageInput = Schema.Struct({ + messageId: MessageId, +}); +export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type; + export const DeleteProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -55,6 +61,13 @@ export interface ProjectionThreadMessageRepositoryShape { message: ProjectionThreadMessage, ) => Effect.Effect; + /** + * Read a projected thread message by id. + */ + readonly getByMessageId: ( + input: GetProjectionThreadMessageInput, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * List projected thread messages for a thread. * diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0e53eae43e..53829f37b8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -842,6 +842,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, push: { status: "skipped_not_requested" as const }, pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Committed abc123", + description: "feat: demo", + cta: { + kind: "run_action" as const, + label: "Push", + action: { + kind: "push" as const, + }, + }, + }, }; yield* ( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 40a8eb09bc..4e296e817a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -99,12 +99,24 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(RuntimeReceiptBusLive), ); -const OrchestrationLayerLive = Layer.empty.pipe( - Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), - Layer.provideMerge(OrchestrationEngineLive), - Layer.provideMerge(OrchestrationProjectionPipelineLive), - Layer.provideMerge(OrchestrationEventStoreLive), - Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), +const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( + OrchestrationEventStoreLive, + OrchestrationCommandReceiptRepositoryLive, +); + +const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe( + Layer.provide(OrchestrationEventStoreLive), +); + +const OrchestrationInfrastructureLayerLive = Layer.mergeAll( + OrchestrationProjectionSnapshotQueryLive, + OrchestrationEventInfrastructureLayerLive, + OrchestrationProjectionPipelineLayerLive, +); + +const OrchestrationLayerLive = Layer.mergeAll( + OrchestrationInfrastructureLayerLive, + OrchestrationEngineLive.pipe(Layer.provide(OrchestrationInfrastructureLayerLive)), ); const CheckpointingLayerLive = Layer.empty.pipe( diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 55700e3482..fc06d77566 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,8 +1,13 @@ import { assert, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Ref } from "effect"; -import { TestClock } from "effect/testing"; +import { Deferred, Effect, Fiber, Option, Ref } from "effect"; -import { makeCommandGate, ServerRuntimeStartupError } from "./serverRuntimeStartup.ts"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + launchStartupHeartbeat, + makeCommandGate, + ServerRuntimeStartupError, +} from "./serverRuntimeStartup.ts"; it.effect("enqueueCommand waits for readiness and then drains queued work", () => Effect.scoped( @@ -14,7 +19,7 @@ it.effect("enqueueCommand waits for readiness and then drains queued work", () = .enqueueCommand(Ref.updateAndGet(executionCount, (count) => count + 1)) .pipe(Effect.forkScoped); - yield* TestClock.adjust("50 millis"); + yield* Effect.yieldNow; assert.equal(yield* Ref.get(executionCount), 0); yield* commandGate.signalCommandReady; @@ -47,3 +52,31 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => }), ), ); + +it.effect("launchStartupHeartbeat does not block the caller while counts are loading", () => + Effect.scoped( + Effect.gen(function* () { + const releaseCounts = yield* Deferred.make(); + + yield* launchStartupHeartbeat.pipe( + Effect.provideService(ProjectionSnapshotQuery, { + getSnapshot: () => Effect.die("unused"), + getCounts: () => + Deferred.await(releaseCounts).pipe( + Effect.as({ + projectCount: 2, + threadCount: 3, + }), + ), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + }), + Effect.provideService(AnalyticsService, { + record: () => Effect.void, + flush: Effect.void, + }), + ); + }), + ), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 2457f6ffe8..026145eca5 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -5,7 +5,19 @@ import { ProjectId, ThreadId, } from "@t3tools/contracts"; -import { Data, Deferred, Effect, Exit, Layer, Path, Queue, Ref, Scope, ServiceMap } from "effect"; +import { + Data, + Deferred, + Effect, + Exit, + Layer, + Option, + Path, + Queue, + Ref, + Scope, + ServiceMap, +} from "effect"; import { ServerConfig } from "./config"; import { Keybindings } from "./keybindings"; @@ -105,17 +117,15 @@ export const makeCommandGate = Effect.gen(function* () { } satisfies CommandGate; }); -const recordStartupHeartbeat = Effect.gen(function* () { +export const recordStartupHeartbeat = Effect.gen(function* () { const analytics = yield* AnalyticsService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe( - Effect.map((snapshot) => ({ - threadCount: snapshot.threads.length, - projectCount: snapshot.projects.length, - })), + const { threadCount, projectCount } = yield* projectionSnapshotQuery.getCounts().pipe( Effect.catch((cause) => - Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe( + Effect.logWarning("failed to gather startup projection counts for telemetry", { + cause, + }).pipe( Effect.as({ threadCount: 0, projectCount: 0, @@ -130,6 +140,12 @@ const recordStartupHeartbeat = Effect.gen(function* () { }); }); +export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( + Effect.ignoreCause({ log: true }), + Effect.forkScoped, + Effect.asVoid, +); + const autoBootstrapWelcome = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const projectionReadModelQuery = yield* ProjectionSnapshotQuery; @@ -141,14 +157,13 @@ const autoBootstrapWelcome = Effect.gen(function* () { if (serverConfig.autoBootstrapProjectFromCwd) { yield* Effect.gen(function* () { - const snapshot = yield* projectionReadModelQuery.getSnapshot(); - const existingProject = snapshot.projects.find( - (project) => project.workspaceRoot === serverConfig.cwd && project.deletedAt === null, + const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot( + serverConfig.cwd, ); let nextProjectId: ProjectId; let nextProjectDefaultModelSelection: ModelSelection; - if (!existingProject) { + if (Option.isNone(existingProject)) { const createdAt = new Date().toISOString(); nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; @@ -166,17 +181,16 @@ const autoBootstrapWelcome = Effect.gen(function* () { createdAt, }); } else { - nextProjectId = existingProject.id; - nextProjectDefaultModelSelection = existingProject.defaultModelSelection ?? { + nextProjectId = existingProject.value.id; + nextProjectDefaultModelSelection = existingProject.value.defaultModelSelection ?? { provider: "codex", model: "gpt-5-codex", }; } - const existingThread = snapshot.threads.find( - (thread) => thread.projectId === nextProjectId && thread.deletedAt === null, - ); - if (!existingThread) { + const existingThreadId = + yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); + if (Option.isNone(existingThreadId)) { const createdAt = new Date().toISOString(); const createdThreadId = ThreadId.makeUnsafe(crypto.randomUUID()); yield* orchestrationEngine.dispatch({ @@ -196,7 +210,7 @@ const autoBootstrapWelcome = Effect.gen(function* () { bootstrapThreadId = createdThreadId; } else { bootstrapProjectId = nextProjectId; - bootstrapThreadId = existingThread.id; + bootstrapThreadId = existingThreadId.value; } }); } @@ -314,7 +328,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { }); yield* Effect.logDebug("startup phase: recording startup heartbeat"); - yield* recordStartupHeartbeat; + yield* launchStartupHeartbeat; yield* Effect.logDebug("startup phase: browser open check"); yield* maybeOpenBrowser; yield* Effect.logDebug("startup phase: complete"); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 44ad29efac..f21c924c79 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -6,8 +6,9 @@ import { requiresDefaultBranchConfirmation, resolveAutoFeatureBranchName, resolveDefaultBranchActionDialogCopy, + resolveLiveThreadBranchUpdate, resolveQuickAction, - summarizeGitResult, + resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; function status(overrides: Partial = {}): GitStatusResult { @@ -162,7 +163,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { }), false, ); - assert.deepInclude(quick, { kind: "run_action", action: "commit_push", label: "Push" }); + assert.deepInclude(quick, { kind: "run_action", action: "push", label: "Push" }); }); it("buildMenuItems enables push and keeps open PR available", () => { @@ -213,7 +214,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => { const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); assert.deepInclude(quick, { kind: "run_action", - action: "commit_push_pr", + action: "create_pr", label: "Push & create PR", }); }); @@ -585,7 +586,7 @@ describe("when: branch has no upstream configured", () => { ); assert.deepInclude(quick, { kind: "run_action", - action: "commit_push", + action: "push", label: "Push", disabled: false, }); @@ -632,7 +633,7 @@ describe("when: branch has no upstream configured", () => { ); assert.deepInclude(quick, { kind: "run_action", - action: "commit_push_pr", + action: "create_pr", label: "Push & create PR", disabled: false, }); @@ -801,9 +802,12 @@ describe("when: branch has no upstream configured", () => { describe("requiresDefaultBranchConfirmation", () => { it("requires confirmation for push actions on default branch", () => { assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); + assert.isTrue(requiresDefaultBranchConfirmation("push", true)); + assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true)); assert.isTrue(requiresDefaultBranchConfirmation("commit_push", true)); assert.isTrue(requiresDefaultBranchConfirmation("commit_push_pr", true)); assert.isFalse(requiresDefaultBranchConfirmation("commit_push", false)); + assert.isFalse(requiresDefaultBranchConfirmation("push", false)); }); }); @@ -855,28 +859,37 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); describe("buildGitActionProgressStages", () => { - it("shows only push progress when push-only is forced", () => { + it("shows only push progress for explicit push actions", () => { const stages = buildGitActionProgressStages({ - action: "commit_push", + action: "push", hasCustomCommitMessage: false, - hasWorkingTreeChanges: true, - forcePushOnly: true, + hasWorkingTreeChanges: false, pushTarget: "origin/feature/test", }); assert.deepEqual(stages, ["Pushing to origin/feature/test..."]); }); - it("skips commit stages for create-pr flow when push-only is forced", () => { + it("shows push and PR progress for create-pr actions that still need a push", () => { const stages = buildGitActionProgressStages({ - action: "commit_push_pr", + action: "create_pr", hasCustomCommitMessage: false, - hasWorkingTreeChanges: true, - forcePushOnly: true, + hasWorkingTreeChanges: false, pushTarget: "origin/feature/test", + shouldPushBeforePr: true, }); assert.deepEqual(stages, ["Pushing to origin/feature/test...", "Creating PR..."]); }); + it("shows only PR progress when create-pr can skip the push", () => { + const stages = buildGitActionProgressStages({ + action: "create_pr", + hasCustomCommitMessage: false, + hasWorkingTreeChanges: false, + shouldPushBeforePr: false, + }); + assert.deepEqual(stages, ["Creating PR..."]); + }); + it("includes commit stages for commit+push when working tree is dirty", () => { const stages = buildGitActionProgressStages({ action: "commit_push", @@ -892,97 +905,92 @@ describe("buildGitActionProgressStages", () => { }); }); -describe("summarizeGitResult", () => { - it("returns commit-focused toast for commit action", () => { - const result = summarizeGitResult({ - action: "commit", - branch: { status: "skipped_not_requested" }, +describe("resolveThreadBranchUpdate", () => { + it("returns a branch update when the action created a new branch", () => { + const update = resolveThreadBranchUpdate({ + action: "commit_push_pr", + branch: { + status: "created", + name: "feature/fix-toast-copy", + }, commit: { status: "created", - commitSha: "0123456789abcdef", - subject: "feat: add optimistic UI for git action button", + commitSha: "89abcdef01234567", + subject: "feat: add branch sync", }, - push: { status: "skipped_not_requested" }, + push: { status: "pushed", branch: "feature/fix-toast-copy" }, pr: { status: "skipped_not_requested" }, + toast: { + title: "Pushed 89abcde to origin/feature/fix-toast-copy", + cta: { kind: "none" }, + }, }); - assert.deepEqual(result, { - title: "Committed 0123456", - description: "feat: add optimistic UI for git action button", + assert.deepEqual(update, { + branch: "feature/fix-toast-copy", }); }); - it("returns push-focused toast for push action", () => { - const result = summarizeGitResult({ + it("returns null when the action stayed on the existing branch", () => { + const update = resolveThreadBranchUpdate({ action: "commit_push", - branch: { status: "skipped_not_requested" }, + branch: { + status: "skipped_not_requested", + }, commit: { status: "created", - commitSha: "abcdef0123456789", - subject: "fix: tighten quick action tooltip hover handling", - }, - push: { - status: "pushed", - branch: "foo", - upstreamBranch: "origin/foo", + commitSha: "89abcdef01234567", + subject: "feat: add branch sync", }, + push: { status: "pushed", branch: "feature/fix-toast-copy" }, pr: { status: "skipped_not_requested" }, + toast: { + title: "Pushed 89abcde to origin/feature/fix-toast-copy", + cta: { kind: "none" }, + }, }); - assert.deepEqual(result, { - title: "Pushed abcdef0 to origin/foo", - description: "fix: tighten quick action tooltip hover handling", - }); + assert.equal(update, null); }); +}); - it("returns PR-focused toast for created PR action", () => { - const result = summarizeGitResult({ - action: "commit_push_pr", - branch: { status: "skipped_not_requested" }, - commit: { - status: "created", - commitSha: "89abcdef01234567", - subject: "feat: ship github shortcuts", - }, - push: { - status: "pushed", - branch: "foo", - }, - pr: { - status: "created", - number: 42, - title: "feat: ship github shortcuts and improve PR CTA in success toast", - }, +describe("resolveLiveThreadBranchUpdate", () => { + it("returns a branch update when live git status differs from stored thread metadata", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "feature/old-branch", + gitStatus: status({ branch: "effect-atom" }), }); - assert.deepEqual(result, { - title: "Created PR #42", - description: "feat: ship github shortcuts and improve PR CTA in success toast", + assert.deepEqual(update, { + branch: "effect-atom", }); }); - it("truncates long description text", () => { - const result = summarizeGitResult({ - action: "commit_push_pr", - branch: { status: "skipped_not_requested" }, - commit: { - status: "created", - commitSha: "89abcdef01234567", - subject: "short subject", - }, - push: { status: "pushed", branch: "foo" }, - pr: { - status: "created", - number: 99, - title: - "feat: this title is intentionally extremely long so we can validate that toast descriptions are truncated with an ellipsis suffix", - }, + it("returns null when live git status is unavailable", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "feature/old-branch", + gitStatus: null, }); - assert.deepEqual(result, { - title: "Created PR #99", - description: "feat: this title is intentionally extremely long so we can validate t...", + assert.equal(update, null); + }); + + it("returns null when the stored thread branch already matches git status", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "effect-atom", + gitStatus: status({ branch: "effect-atom" }), }); + + assert.equal(update, null); + }); + + it("returns null when git status is detached HEAD but the thread already has a branch", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "effect-atom", + gitStatus: status({ branch: null }), + }); + + assert.equal(update, null); }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 8f7f023ef7..80906a982b 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -31,43 +31,36 @@ export interface DefaultBranchActionDialogCopy { continueLabel: string; } -export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr"; - -const SHORT_SHA_LENGTH = 7; -const TOAST_DESCRIPTION_MAX = 72; - -function shortenSha(sha: string | undefined): string | null { - if (!sha) return null; - return sha.slice(0, SHORT_SHA_LENGTH); -} - -function truncateText( - value: string | undefined, - maxLength = TOAST_DESCRIPTION_MAX, -): string | undefined { - if (!value) return undefined; - if (value.length <= maxLength) return value; - if (maxLength <= 3) return "...".slice(0, maxLength); - return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; -} +export type DefaultBranchConfirmableAction = + | "push" + | "create_pr" + | "commit_push" + | "commit_push_pr"; export function buildGitActionProgressStages(input: { action: GitStackedAction; hasCustomCommitMessage: boolean; hasWorkingTreeChanges: boolean; - forcePushOnly?: boolean; pushTarget?: string; featureBranch?: boolean; + shouldPushBeforePr?: boolean; }): string[] { const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; - const shouldIncludeCommitStages = - !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges); + const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; + + if (input.action === "push") { + return [pushStage]; + } + if (input.action === "create_pr") { + return input.shouldPushBeforePr ? [pushStage, "Creating PR..."] : ["Creating PR..."]; + } + + const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; const commitStages = !shouldIncludeCommitStages ? [] : input.hasCustomCommitMessage ? ["Committing..."] : ["Generating commit message...", "Committing..."]; - const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; if (input.action === "commit") { return [...branchStages, ...commitStages]; } @@ -77,39 +70,6 @@ export function buildGitActionProgressStages(input: { return [...branchStages, ...commitStages, pushStage, "Creating PR..."]; } -const withDescription = (title: string, description: string | undefined) => - description ? { title, description } : { title }; - -export function summarizeGitResult(result: GitRunStackedActionResult): { - title: string; - description?: string; -} { - if (result.pr.status === "created" || result.pr.status === "opened_existing") { - const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; - const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; - return withDescription(title, truncateText(result.pr.title)); - } - - if (result.push.status === "pushed") { - const shortSha = shortenSha(result.commit.commitSha); - const branch = result.push.upstreamBranch ?? result.push.branch; - const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; - const branchPart = branch ? ` to ${branch}` : ""; - return withDescription( - `Pushed${pushedCommitPart}${branchPart}`, - truncateText(result.commit.subject), - ); - } - - if (result.commit.status === "created") { - const shortSha = shortenSha(result.commit.commitSha); - const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; - return withDescription(title, truncateText(result.commit.subject)); - } - - return { title: "Done" }; -} - export function buildMenuItems( gitStatus: GitStatusResult | null, isBusy: boolean, @@ -250,13 +210,18 @@ export function resolveQuickAction( }; } if (hasOpenPr || isDefaultBranch) { - return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; + return { + label: "Push", + disabled: false, + kind: "run_action", + action: isDefaultBranch ? "commit_push" : "push", + }; } return { label: "Push & create PR", disabled: false, kind: "run_action", - action: "commit_push_pr", + action: "create_pr", }; } @@ -279,13 +244,18 @@ export function resolveQuickAction( if (isAhead) { if (hasOpenPr || isDefaultBranch) { - return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; + return { + label: "Push", + disabled: false, + kind: "run_action", + action: isDefaultBranch ? "commit_push" : "push", + }; } return { label: "Push & create PR", disabled: false, kind: "run_action", - action: "commit_push_pr", + action: "create_pr", }; } @@ -306,7 +276,12 @@ export function requiresDefaultBranchConfirmation( isDefaultBranch: boolean, ): boolean { if (!isDefaultBranch) return false; - return action === "commit_push" || action === "commit_push_pr"; + return ( + action === "push" || + action === "create_pr" || + action === "commit_push" || + action === "commit_push_pr" + ); } export function resolveDefaultBranchActionDialogCopy(input: { @@ -317,7 +292,7 @@ export function resolveDefaultBranchActionDialogCopy(input: { const branchLabel = input.branchName; const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; - if (input.action === "commit_push") { + if (input.action === "push" || input.action === "commit_push") { if (input.includesCommit) { return { title: "Commit & push to default branch?", @@ -346,5 +321,38 @@ export function resolveDefaultBranchActionDialogCopy(input: { }; } +export function resolveThreadBranchUpdate( + result: GitRunStackedActionResult, +): { branch: string } | null { + if (result.branch.status !== "created" || !result.branch.name) { + return null; + } + + return { + branch: result.branch.name, + }; +} + +export function resolveLiveThreadBranchUpdate(input: { + threadBranch: string | null; + gitStatus: GitStatusResult | null; +}): { branch: string | null } | null { + if (!input.gitStatus) { + return null; + } + + if (input.gitStatus.branch === null && input.threadBranch !== null) { + return null; + } + + if (input.threadBranch === input.gitStatus.branch) { + return null; + } + + return { + branch: input.gitStatus.branch, + }; +} + // Re-export from shared for backwards compatibility in this module's exports export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6384709620..d8d86dd4f0 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,5 +1,6 @@ import type { GitActionProgressEvent, + GitRunStackedActionResult, GitStackedAction, GitStatusResult, ThreadId, @@ -17,8 +18,9 @@ import { type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, + resolveLiveThreadBranchUpdate, resolveQuickAction, - summarizeGitResult, + resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; @@ -47,9 +49,10 @@ import { gitStatusQueryOptions, invalidateGitQueries, } from "~/lib/gitReactQuery"; -import { randomUUID } from "~/lib/utils"; +import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; +import { useStore } from "~/store"; interface GitActionsControlProps { gitCwd: string | null; @@ -61,7 +64,6 @@ interface PendingDefaultBranchAction { branchName: string; includesCommit: boolean; commitMessage?: string; - forcePushOnlyProgress: boolean; onConfirmed?: () => void; filePaths?: string[]; } @@ -82,12 +84,10 @@ interface ActiveGitActionProgress { interface RunGitActionWithToastInput { action: GitStackedAction; commitMessage?: string; - forcePushOnlyProgress?: boolean; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; statusOverride?: GitStatusResult | null; featureBranch?: boolean; - isDefaultBranchOverride?: boolean; progressToastId?: GitActionToastId; filePaths?: string[]; } @@ -196,7 +196,9 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; - if (quickAction.action === "commit_push") return ; + if (quickAction.action === "push" || quickAction.action === "commit_push") { + return ; + } return ; } if (quickAction.label === "Commit") return ; @@ -208,6 +210,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], ); + const activeServerThread = useStore((store) => + activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, + ); + const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); @@ -231,6 +237,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); }, [threadToastData]); + const persistThreadBranchSync = useCallback( + (branch: string | null) => { + if (!activeThreadId || !activeServerThread || activeServerThread.branch === branch) { + return; + } + + const worktreePath = activeServerThread.worktreePath; + const api = readNativeApi(); + if (api) { + void api.orchestration + .dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: activeThreadId, + branch, + worktreePath, + }) + .catch(() => undefined); + } + + setThreadBranch(activeThreadId, branch, worktreePath); + }, + [activeServerThread, activeThreadId, setThreadBranch], + ); + + const syncThreadBranchAfterGitAction = useCallback( + (result: GitRunStackedActionResult) => { + const branchUpdate = resolveThreadBranchUpdate(result); + if (!branchUpdate) { + return; + } + + persistThreadBranchSync(branchUpdate.branch); + }, + [persistThreadBranchSync], + ); + const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); @@ -267,6 +310,28 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; + + useEffect(() => { + if (isGitActionRunning) { + return; + } + + const branchUpdate = resolveLiveThreadBranchUpdate({ + threadBranch: activeServerThread?.branch ?? null, + gitStatus: gitStatusForActions, + }); + if (!branchUpdate) { + return; + } + + persistThreadBranchSync(branchUpdate.branch); + }, [ + activeServerThread?.branch, + gitStatusForActions, + isGitActionRunning, + persistThreadBranchSync, + ]); + const isDefaultBranch = useMemo(() => { const branchName = gitStatusForActions?.branch; if (!branchName) return false; @@ -340,27 +405,32 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions async ({ action, commitMessage, - forcePushOnlyProgress = false, onConfirmed, skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, - isDefaultBranchOverride, progressToastId, filePaths, }: RunGitActionWithToastInput) => { const actionStatus = statusOverride ?? gitStatusForActions; const actionBranch = actionStatus?.branch ?? null; - const actionIsDefaultBranch = - isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch); + const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; + const actionCanCommit = + action === "commit" || action === "commit_push" || action === "commit_push_pr"; const includesCommit = - !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges); + actionCanCommit && + (action === "commit" || !!actionStatus?.hasWorkingTreeChanges || featureBranch); if ( !skipDefaultBranchPrompt && requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && actionBranch ) { - if (action !== "commit_push" && action !== "commit_push_pr") { + if ( + action !== "push" && + action !== "create_pr" && + action !== "commit_push" && + action !== "commit_push_pr" + ) { return; } setPendingDefaultBranchAction({ @@ -368,7 +438,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions branchName: actionBranch, includesCommit, ...(commitMessage ? { commitMessage } : {}), - forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), }); @@ -380,8 +449,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action, hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, - forcePushOnly: forcePushOnlyProgress, featureBranch, + shouldPushBeforePr: + action === "create_pr" && + (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), }); const actionId = randomUUID(); const resolvedProgressToastId = @@ -483,79 +554,48 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions try { const result = await promise; activeGitActionProgressRef.current = null; - const resultToast = summarizeGitResult(result); - - const existingOpenPrUrl = - actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined; - const prUrl = result.pr.url ?? existingOpenPrUrl; - const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; - const shouldOfferOpenPrCta = - (action === "commit_push" || action === "commit_push_pr") && - !!prUrl && - (!actionIsDefaultBranch || - result.pr.status === "created" || - result.pr.status === "opened_existing"); - const shouldOfferCreatePrCta = - action === "commit_push" && - !prUrl && - result.push.status === "pushed" && - !actionIsDefaultBranch; + syncThreadBranchAfterGitAction(result); const closeResultToast = () => { toastManager.close(resolvedProgressToastId); }; + const toastCta = result.toast.cta; + let toastActionProps: { + children: string; + onClick: () => void; + } | null = null; + if (toastCta.kind === "run_action") { + toastActionProps = { + children: toastCta.label, + onClick: () => { + closeResultToast(); + void runGitActionWithToast({ + action: toastCta.action.kind, + }); + }, + }; + } else if (toastCta.kind === "open_pr") { + toastActionProps = { + children: toastCta.label, + onClick: () => { + const api = readNativeApi(); + if (!api) return; + closeResultToast(); + void api.shell.openExternal(toastCta.url); + }, + }; + } + toastManager.update(resolvedProgressToastId, { type: "success", - title: resultToast.title, - description: resultToast.description, + title: result.toast.title, + description: result.toast.description, timeout: 0, data: { ...threadToastData, dismissAfterVisibleMs: 10_000, }, - ...(shouldOfferPushCta - ? { - actionProps: { - children: "Push", - onClick: () => { - void runGitActionWithToast({ - action: "commit_push", - forcePushOnlyProgress: true, - onConfirmed: closeResultToast, - statusOverride: actionStatus, - isDefaultBranchOverride: actionIsDefaultBranch, - }); - }, - }, - } - : shouldOfferOpenPrCta - ? { - actionProps: { - children: "View PR", - onClick: () => { - const api = readNativeApi(); - if (!api) return; - closeResultToast(); - void api.shell.openExternal(prUrl); - }, - }, - } - : shouldOfferCreatePrCta - ? { - actionProps: { - children: "Create PR", - onClick: () => { - closeResultToast(); - void runGitActionWithToast({ - action: "commit_push_pr", - forcePushOnlyProgress: true, - statusOverride: actionStatus, - isDefaultBranchOverride: actionIsDefaultBranch, - }); - }, - }, - } - : {}), + ...(toastActionProps ? { actionProps: toastActionProps } : {}), }); } catch (err) { activeGitActionProgressRef.current = null; @@ -571,13 +611,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const continuePendingDefaultBranchAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = - pendingDefaultBranchAction; + const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), - forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), skipDefaultBranchPrompt: true, @@ -586,13 +624,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = - pendingDefaultBranchAction; + const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), - forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), featureBranch: true, @@ -666,11 +702,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions return; } if (item.dialogAction === "push") { - void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true }); + void runGitActionWithToast({ action: "push" }); return; } if (item.dialogAction === "create_pr") { - void runGitActionWithToast({ action: "commit_push_pr" }); + void runGitActionWithToast({ action: "create_pr" }); return; } setExcludedFiles(new Set()); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 844b01bb37..a4f4468279 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -11,6 +11,7 @@ import { isContextMenuPointerDown, orderItemsByPreferredIds, resolveProjectStatusIndicator, + resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -166,6 +167,68 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +describe("resolveSidebarNewThreadSeedContext", () => { + it("inherits the active server thread context when creating a new thread in the same project", () => { + expect( + resolveSidebarNewThreadSeedContext({ + projectId: "project-1", + defaultEnvMode: "local", + activeThread: { + projectId: "project-1", + branch: "effect-atom", + worktreePath: null, + }, + activeDraftThread: null, + }), + ).toEqual({ + branch: "effect-atom", + worktreePath: null, + envMode: "local", + }); + }); + + it("prefers the active draft thread context when it matches the target project", () => { + expect( + resolveSidebarNewThreadSeedContext({ + projectId: "project-1", + defaultEnvMode: "local", + activeThread: { + projectId: "project-1", + branch: "effect-atom", + worktreePath: null, + }, + activeDraftThread: { + projectId: "project-1", + branch: "feature/new-draft", + worktreePath: "/repo/worktree", + envMode: "worktree", + }, + }), + ).toEqual({ + branch: "feature/new-draft", + worktreePath: "/repo/worktree", + envMode: "worktree", + }); + }); + + it("falls back to the default env mode when there is no matching active thread context", () => { + expect( + resolveSidebarNewThreadSeedContext({ + projectId: "project-2", + defaultEnvMode: "worktree", + activeThread: { + projectId: "project-1", + branch: "effect-atom", + worktreePath: null, + }, + activeDraftThread: null, + }), + ).toEqual({ + envMode: "worktree", + }); + }); +}); + describe("orderItemsByPreferredIds", () => { it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { const ordered = orderItemsByPreferredIds({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5f9b244a50..ed151c6da8 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -162,6 +162,46 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +export function resolveSidebarNewThreadSeedContext(input: { + projectId: string; + defaultEnvMode: SidebarNewThreadEnvMode; + activeThread?: { + projectId: string; + branch: string | null; + worktreePath: string | null; + } | null; + activeDraftThread?: { + projectId: string; + branch: string | null; + worktreePath: string | null; + envMode: SidebarNewThreadEnvMode; + } | null; +}): { + branch?: string | null; + worktreePath?: string | null; + envMode: SidebarNewThreadEnvMode; +} { + if (input.activeDraftThread?.projectId === input.projectId) { + return { + branch: input.activeDraftThread.branch, + worktreePath: input.activeDraftThread.worktreePath, + envMode: input.activeDraftThread.envMode, + }; + } + + if (input.activeThread?.projectId === input.projectId) { + return { + branch: input.activeThread.branch, + worktreePath: input.activeThread.worktreePath, + envMode: input.activeThread.worktreePath ? "worktree" : "local", + }; + } + + return { + envMode: input.defaultEnvMode, + }; +} + export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 27ce7378e2..fbe4e0528a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -115,6 +115,7 @@ import { resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, + resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -691,7 +692,7 @@ export default function Sidebar() { const isOnSettings = pathname.startsWith("/settings"); const appSettings = useSettings(); const { updateSettings } = useUpdateSettings(); - const { handleNewThread } = useHandleNewThread(); + const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); const { archiveThread, deleteThread } = useThreadActions(); const routeThreadId = useParams({ strict: false, @@ -1656,10 +1657,35 @@ export default function Sidebar() { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - void handleNewThread(project.id, { - envMode: resolveSidebarNewThreadEnvMode({ + const seedContext = resolveSidebarNewThreadSeedContext({ + projectId: project.id, + defaultEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, }), + activeThread: + activeThread && activeThread.projectId === project.id + ? { + projectId: activeThread.projectId, + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + } + : null, + activeDraftThread: + activeDraftThread && activeDraftThread.projectId === project.id + ? { + projectId: activeDraftThread.projectId, + branch: activeDraftThread.branch, + worktreePath: activeDraftThread.worktreePath, + envMode: activeDraftThread.envMode, + } + : null, + }); + void handleNewThread(project.id, { + ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), + ...(seedContext.worktreePath !== undefined + ? { worktreePath: seedContext.worktreePath } + : {}), + envMode: seedContext.envMode, }); }} > diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 99c3ad4e6a..d186a7c9c1 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -175,7 +175,10 @@ function coalesceOrchestrationUiEvents( ...event.payload, attachments: event.payload.attachments ?? previous.payload.attachments, createdAt: previous.payload.createdAt, - text: previous.payload.text + event.payload.text, + text: + !event.payload.streaming && event.payload.text.length > 0 + ? event.payload.text + : previous.payload.text + event.payload.text, }, }; continue; diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index d6e69acb3e..65f8e6caaa 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,10 +1,9 @@ -import { type ProjectId, type ThreadId } from "@t3tools/contracts"; +import { type ThreadId } from "@t3tools/contracts"; import { useMemo } from "react"; import { selectProjectById, selectSidebarThreadSummaryById, selectThreadById, - selectThreadIdsByProjectId, useStore, } from "./store"; import { type Project, type SidebarThreadSummary, type Thread } from "./types"; @@ -25,8 +24,3 @@ export function useSidebarThreadSummaryById( const selector = useMemo(() => selectSidebarThreadSummaryById(threadId), [threadId]); return useStore(selector); } - -export function useThreadIdsByProjectId(projectId: ProjectId | null | undefined): ThreadId[] { - const selector = useMemo(() => selectThreadIdsByProjectId(projectId), [projectId]); - return useStore(selector); -} diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index d2bfac6028..d5b2d7dfd8 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -4,6 +4,7 @@ import { Schema } from "effect"; import { GitCreateWorktreeInput, GitPreparePullRequestThreadInput, + GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, } from "./git"; @@ -13,6 +14,7 @@ const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( GitPreparePullRequestThreadInput, ); const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput); +const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); describe("GitCreateWorktreeInput", () => { @@ -60,18 +62,55 @@ describe("GitResolvePullRequestResult", () => { }); describe("GitRunStackedActionInput", () => { - it("requires a client-provided actionId for progress correlation", () => { + it("accepts explicit stacked actions and requires a client-provided actionId", () => { const parsed = decodeRunStackedActionInput({ actionId: "action-1", cwd: "/repo", - action: "commit", - modelSelection: { - provider: "codex", - model: "gpt-5.4-mini", - }, + action: "create_pr", }); expect(parsed.actionId).toBe("action-1"); - expect(parsed.action).toBe("commit"); + expect(parsed.action).toBe("create_pr"); + }); +}); + +describe("GitRunStackedActionResult", () => { + it("decodes a server-authored completion toast", () => { + const parsed = decodeRunStackedActionResult({ + action: "commit_push", + branch: { + status: "created", + name: "feature/server-owned-toast", + }, + commit: { + status: "created", + commitSha: "89abcdef01234567", + subject: "feat: move toast state into git manager", + }, + push: { + status: "pushed", + branch: "feature/server-owned-toast", + upstreamBranch: "origin/feature/server-owned-toast", + }, + pr: { + status: "skipped_not_requested", + }, + toast: { + title: "Pushed 89abcde to origin/feature/server-owned-toast", + description: "feat: move toast state into git manager", + cta: { + kind: "run_action", + label: "Create PR", + action: { + kind: "create_pr", + }, + }, + }, + }); + + expect(parsed.toast.cta.kind).toBe("run_action"); + if (parsed.toast.cta.kind === "run_action") { + expect(parsed.toast.cta.action.kind).toBe("create_pr"); + } }); }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 65504fabc1..03fc050d4a 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -5,7 +5,13 @@ const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; // Domain Types -export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); +export const GitStackedAction = Schema.Literals([ + "commit", + "push", + "create_pr", + "commit_push", + "commit_push_pr", +]); export type GitStackedAction = typeof GitStackedAction.Type; export const GitActionProgressPhase = Schema.Literals(["branch", "commit", "push", "pr"]); export type GitActionProgressPhase = typeof GitActionProgressPhase.Type; @@ -21,7 +27,11 @@ export const GitActionProgressKind = Schema.Literals([ export type GitActionProgressKind = typeof GitActionProgressKind.Type; export const GitActionProgressStream = Schema.Literals(["stdout", "stderr"]); export type GitActionProgressStream = typeof GitActionProgressStream.Type; -const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); +const GitCommitStepStatus = Schema.Literals([ + "created", + "skipped_no_changes", + "skipped_not_requested", +]); const GitPushStepStatus = Schema.Literals([ "pushed", "skipped_not_requested", @@ -33,6 +43,32 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); const GitPullRequestReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); +export const GitRunStackedActionToastRunAction = Schema.Struct({ + kind: GitStackedAction, +}); +export type GitRunStackedActionToastRunAction = typeof GitRunStackedActionToastRunAction.Type; +const GitRunStackedActionToastCta = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("none"), + }), + Schema.Struct({ + kind: Schema.Literal("open_pr"), + label: TrimmedNonEmptyStringSchema, + url: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal("run_action"), + label: TrimmedNonEmptyStringSchema, + action: GitRunStackedActionToastRunAction, + }), +]); +export type GitRunStackedActionToastCta = typeof GitRunStackedActionToastCta.Type; +const GitRunStackedActionToast = Schema.Struct({ + title: TrimmedNonEmptyStringSchema, + description: Schema.optional(TrimmedNonEmptyStringSchema), + cta: GitRunStackedActionToastCta, +}); +export type GitRunStackedActionToast = typeof GitRunStackedActionToast.Type; export const GitBranch = Schema.Struct({ name: TrimmedNonEmptyStringSchema, @@ -213,6 +249,7 @@ export const GitRunStackedActionResult = Schema.Struct({ headBranch: Schema.optional(TrimmedNonEmptyStringSchema), title: Schema.optional(TrimmedNonEmptyStringSchema), }), + toast: GitRunStackedActionToast, }); export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; From a56c655f5d0fc058e5f71957c084410b26ed84a5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 1 Apr 2026 16:53:07 -0700 Subject: [PATCH 6/7] Fix AGENTS test command typo - Remove the accidental double period in the test command note --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index cea5090cce..adcc90544d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Task Completion Requirements - All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed. -- NEVER run `bun test`. Always use `bun run test` (runs Vitest). +- NEVER run `bun test`. Always use `bun run test` (runs Vitest).. ## Project Snapshot From 2badf765a06b6bd7ce491ab4980b76de705f6c39 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 1 Apr 2026 16:53:31 -0700 Subject: [PATCH 7/7] Fix AGENTS.md punctuation - Remove stray period from the test command note --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index adcc90544d..cea5090cce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Task Completion Requirements - All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed. -- NEVER run `bun test`. Always use `bun run test` (runs Vitest).. +- NEVER run `bun test`. Always use `bun run test` (runs Vitest). ## Project Snapshot