diff --git a/.gitignore b/.gitignore index ef6067824f2..81a88e3d73b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ node_modules/ *.log .env* !.env.example +.superpowers/ diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 1583fdbb2d7..8fca4fddeae 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -118,6 +118,16 @@ function AppNavigatorContent() { headerShown: false, }} /> + + ); diff --git a/apps/mobile/src/app/needs-you.tsx b/apps/mobile/src/app/needs-you.tsx new file mode 100644 index 00000000000..54b0fcd7fe4 --- /dev/null +++ b/apps/mobile/src/app/needs-you.tsx @@ -0,0 +1,5 @@ +import { NeedsYouInboxScreen } from "../features/board/NeedsYouInboxScreen"; + +export default function NeedsYouRoute() { + return ; +} diff --git a/apps/mobile/src/app/tickets/[environmentId]/[boardId]/[ticketId]/index.tsx b/apps/mobile/src/app/tickets/[environmentId]/[boardId]/[ticketId]/index.tsx new file mode 100644 index 00000000000..805f48d71de --- /dev/null +++ b/apps/mobile/src/app/tickets/[environmentId]/[boardId]/[ticketId]/index.tsx @@ -0,0 +1,5 @@ +import { TicketActionSheetScreen } from "../../../../../features/board/TicketActionSheetScreen"; + +export default function TicketRoute() { + return ; +} diff --git a/apps/mobile/src/features/agent-awareness/notificationPayload.test.ts b/apps/mobile/src/features/agent-awareness/notificationPayload.test.ts new file mode 100644 index 00000000000..7894a0a6f5f --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/notificationPayload.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + encodeTicketDeepLink, + extractAgentNotificationDeepLink, + normalizeTicketDeepLink, + routeAgentNotificationResponseOnce, +} from "./notificationPayload"; + +function responseWithData(data: Record, identifier = "notification-1") { + return { + notification: { + request: { + identifier, + content: { + data, + }, + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// encodeTicketDeepLink +// --------------------------------------------------------------------------- +describe("encodeTicketDeepLink", () => { + it("returns null when environmentId is empty", () => { + expect(encodeTicketDeepLink({ environmentId: "", boardId: "b1", ticketId: "t1" })).toBeNull(); + }); + + it("returns null when boardId is empty", () => { + expect(encodeTicketDeepLink({ environmentId: "env", boardId: "", ticketId: "t1" })).toBeNull(); + }); + + it("returns null when ticketId is empty", () => { + expect(encodeTicketDeepLink({ environmentId: "env", boardId: "b1", ticketId: "" })).toBeNull(); + }); + + it("encodes a basic ticket deep link", () => { + expect( + encodeTicketDeepLink({ environmentId: "env-1", boardId: "board-1", ticketId: "ticket-1" }), + ).toBe("/tickets/env-1/board-1/ticket-1"); + }); + + it("percent-encodes components with special characters", () => { + expect( + encodeTicketDeepLink({ + environmentId: "env 1", + boardId: "board/2", + ticketId: "ticket 3", + }), + ).toBe("/tickets/env%201/board%2F2/ticket%203"); + }); +}); + +// --------------------------------------------------------------------------- +// normalizeTicketDeepLink +// --------------------------------------------------------------------------- +describe("normalizeTicketDeepLink", () => { + it("accepts and round-trips a well-formed ticket path", () => { + expect(normalizeTicketDeepLink("/tickets/env-1/b1/t1")).toBe("/tickets/env-1/b1/t1"); + }); + + it("accepts a path with percent-encoded components", () => { + expect(normalizeTicketDeepLink("/tickets/env%201/board%2F2/ticket%203")).toBe( + "/tickets/env%201/board%2F2/ticket%203", + ); + }); + + it("rejects a path with too few segments (missing ticketId)", () => { + expect(normalizeTicketDeepLink("/tickets/env-1/b1")).toBeNull(); + }); + + it("rejects a path with too many segments", () => { + expect(normalizeTicketDeepLink("/tickets/a/b/c/d")).toBeNull(); + }); + + it("rejects a thread path", () => { + expect(normalizeTicketDeepLink("/threads/env-1/t1")).toBeNull(); + }); + + it("rejects a path with a query string", () => { + expect(normalizeTicketDeepLink("/tickets/env/b/t?x=1")).toBeNull(); + }); + + it("rejects a path with a hash fragment", () => { + expect(normalizeTicketDeepLink("/tickets/env/b/t#section")).toBeNull(); + }); + + it("rejects a path with leading double-slash", () => { + expect(normalizeTicketDeepLink("//tickets/env/b/t")).toBeNull(); + }); + + it("rejects a value with surrounding whitespace", () => { + expect(normalizeTicketDeepLink(" /tickets/env/b/t")).toBeNull(); + expect(normalizeTicketDeepLink("/tickets/env/b/t ")).toBeNull(); + }); + + it("rejects an empty middle segment (passes 5-segment check, fails encode)", () => { + expect(normalizeTicketDeepLink("/tickets/env//t")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// extractAgentNotificationDeepLink — ticket paths +// --------------------------------------------------------------------------- +describe("extractAgentNotificationDeepLink — ticket deep links", () => { + it("uses explicit ticket deep link from APNs payload data", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/tickets/env/b/t", + }), + ), + ).toBe("/tickets/env/b/t"); + }); + + it("normalizes explicit ticket deep links with encoded components", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/tickets/env%201/board%2F2/ticket%203", + }), + ), + ).toBe("/tickets/env%201/board%2F2/ticket%203"); + }); + + it("falls back to identity fields when no deepLink", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + environmentId: "env 1", + boardId: "board/2", + ticketId: "ticket 3", + }), + ), + ).toBe("/tickets/env%201/board%2F2/ticket%203"); + }); + + it("uses ticket identity fallback when deepLink is not a recognized route", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/", + environmentId: "env", + boardId: "b", + ticketId: "t", + }), + ), + ).toBe("/tickets/env/b/t"); + }); + + it("ignores malformed ticket deep link and falls back to ids", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/tickets/env/b", + environmentId: "env", + boardId: "b", + ticketId: "t", + }), + ), + ).toBe("/tickets/env/b/t"); + }); +}); + +// --------------------------------------------------------------------------- +// REGRESSION: thread paths still work +// --------------------------------------------------------------------------- +describe("extractAgentNotificationDeepLink — thread deep links (regression)", () => { + it("uses explicit thread deep link from APNs payload data", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/threads/env/thread", + environmentId: "ignored", + threadId: "ignored", + }), + ), + ).toBe("/threads/env/thread"); + }); + + it("prefers the thread identity fallback over ticket when both id sets are present", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + environmentId: "env", + threadId: "thread", + boardId: "b", + ticketId: "t", + }), + ), + ).toBe("/threads/env/thread"); + }); + + it("normalizes explicit thread deep links from APNs payload data", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/threads/env%201/thread%2F2", + }), + ), + ).toBe("/threads/env%201/thread%2F2"); + }); + + it("falls back to the thread route from environment and thread ids", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + environmentId: "env 1", + threadId: "thread/2", + }), + ), + ).toBe("/threads/env%201/thread%2F2"); + }); + + it("falls back to thread ids when explicit deep link is not a recognized route", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/", + environmentId: "env", + threadId: "thread", + }), + ), + ).toBe("/threads/env/thread"); + }); + + it("ignores malformed or external links with no usable fallback", () => { + expect( + extractAgentNotificationDeepLink(responseWithData({ deepLink: "https://example.com" })), + ).toBeNull(); + expect( + extractAgentNotificationDeepLink(responseWithData({ deepLink: "/settings" })), + ).toBeNull(); + expect( + extractAgentNotificationDeepLink(responseWithData({ deepLink: "//example.com" })), + ).toBeNull(); + expect( + extractAgentNotificationDeepLink(responseWithData({ deepLink: "/threads/env/thread?x=1" })), + ).toBeNull(); + expect(extractAgentNotificationDeepLink({})).toBeNull(); + }); + + it("falls back to ticket identity when threadId is an empty string", () => { + // An empty threadId must NOT short-circuit into the thread branch and return + // null; the ticket-identity fallback must run instead. + expect( + extractAgentNotificationDeepLink( + responseWithData({ + environmentId: "env", + threadId: "", + boardId: "board-1", + ticketId: "ticket-1", + }), + ), + ).toBe("/tickets/env/board-1/ticket-1"); + }); +}); + +// --------------------------------------------------------------------------- +// routeAgentNotificationResponseOnce (regression) +// --------------------------------------------------------------------------- +describe("routeAgentNotificationResponseOnce", () => { + it("does not navigate twice when the initial and listener responses refer to one notification", () => { + const handledResponseIds = new Set(); + const navigations: Array = []; + const response = responseWithData({ + environmentId: "env", + threadId: "thread", + }); + + routeAgentNotificationResponseOnce({ + handledResponseIds, + response, + navigate: (deepLink) => navigations.push(deepLink), + }); + routeAgentNotificationResponseOnce({ + handledResponseIds, + response, + navigate: (deepLink) => navigations.push(deepLink), + }); + + expect(navigations).toEqual(["/threads/env/thread"]); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/notificationPayload.ts b/apps/mobile/src/features/agent-awareness/notificationPayload.ts index dc72e3d1bd2..0ba5ff58c39 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPayload.ts @@ -69,21 +69,86 @@ function normalizeThreadDeepLink(value: string): string | null { } } +export function encodeTicketDeepLink(input: { + readonly environmentId: string; + readonly boardId: string; + readonly ticketId: string; +}): string | null { + if ( + input.environmentId.length === 0 || + input.boardId.length === 0 || + input.ticketId.length === 0 + ) { + return null; + } + return `/tickets/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.boardId)}/${encodeURIComponent(input.ticketId)}`; +} + +// Canonical ticket push deep-link contract: `/tickets/{env}/{board}/{ticket}`. +// The server dispatcher +// (apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.ts) emits +// exactly this shape. Query-string/fragment forms are rejected; the structured +// boardId/ticketId/environmentId fields in `extractAgentNotificationDeepLink` +// remain a defensive fallback if `deepLink` is ever absent. +export function normalizeTicketDeepLink(value: string): string | null { + if ( + value.trim() !== value || + value.startsWith("//") || + value.includes("?") || + value.includes("#") + ) { + return null; + } + + const parts = value.split("/"); + if (parts.length !== 5 || parts[0] !== "" || parts[1] !== "tickets") { + return null; + } + + try { + return encodeTicketDeepLink({ + environmentId: decodeURIComponent(parts[2] ?? ""), + boardId: decodeURIComponent(parts[3] ?? ""), + ticketId: decodeURIComponent(parts[4] ?? ""), + }); + } catch { + return null; + } +} + export function extractAgentNotificationDeepLink(response: unknown): string | null { const data = dataFromNotificationResponse(response); const deepLink = data?.deepLink; if (typeof deepLink === "string") { - const normalizedDeepLink = normalizeThreadDeepLink(deepLink); - if (normalizedDeepLink) { - return normalizedDeepLink; + const normalizedThreadDeepLink = normalizeThreadDeepLink(deepLink); + if (normalizedThreadDeepLink) { + return normalizedThreadDeepLink; + } + const normalizedTicketDeepLink = normalizeTicketDeepLink(deepLink); + if (normalizedTicketDeepLink) { + return normalizedTicketDeepLink; } } const environmentId = data?.environmentId; const threadId = data?.threadId; - if (typeof environmentId === "string" && typeof threadId === "string") { - return encodeThreadDeepLink({ environmentId, threadId }); + if (typeof environmentId === "string" && typeof threadId === "string" && threadId.length > 0) { + const threadDeepLink = encodeThreadDeepLink({ environmentId, threadId }); + if (threadDeepLink) { + return threadDeepLink; + } } + + const boardId = data?.boardId; + const ticketId = data?.ticketId; + if ( + typeof environmentId === "string" && + typeof boardId === "string" && + typeof ticketId === "string" + ) { + return encodeTicketDeepLink({ environmentId, boardId, ticketId }); + } + return null; } diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index 44ef38df0ef..1587294e44d 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -28,6 +28,7 @@ export function makeRelayDeviceRegistrationRequest(input: { notifyOnInput: true, notifyOnCompletion: true, notifyOnFailure: true, + notifyOnBlocked: true, }, }; } diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 346680df8c0..a9a0d5f9e70 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -201,6 +201,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { notifyOnInput: true, notifyOnCompletion: true, notifyOnFailure: true, + notifyOnBlocked: true, }, }); }); @@ -232,6 +233,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { notifyOnInput: true, notifyOnCompletion: true, notifyOnFailure: true, + notifyOnBlocked: true, }, }); }); diff --git a/apps/mobile/src/features/board/InboxSkeleton.tsx b/apps/mobile/src/features/board/InboxSkeleton.tsx new file mode 100644 index 00000000000..a095b21dd25 --- /dev/null +++ b/apps/mobile/src/features/board/InboxSkeleton.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef } from "react"; +import { Animated, View } from "react-native"; + +function SkeletonCard() { + const opacity = useRef(new Animated.Value(1)).current; + + useEffect(() => { + const pulse = Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { + toValue: 0.45, + duration: 750, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 1, + duration: 750, + useNativeDriver: true, + }), + ]), + ); + pulse.start(); + return () => pulse.stop(); + }, [opacity]); + + return ( + + {/* Title row */} + + + + + {/* Board name */} + + {/* Badge */} + + + ); +} + +export function InboxSkeleton() { + return ( + <> + + + + + + ); +} diff --git a/apps/mobile/src/features/board/NeedsYouInboxScreen.tsx b/apps/mobile/src/features/board/NeedsYouInboxScreen.tsx new file mode 100644 index 00000000000..cfba620141b --- /dev/null +++ b/apps/mobile/src/features/board/NeedsYouInboxScreen.tsx @@ -0,0 +1,249 @@ +import { Stack, useFocusEffect, useRouter } from "expo-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { EnvironmentId, type WorkflowNeedsAttentionTicketView } from "@t3tools/contracts"; + +import { AppText as Text } from "../../components/AppText"; +import { EmptyState } from "../../components/EmptyState"; +import { ErrorBanner } from "../../components/ErrorBanner"; +import { buildTicketRoutePath } from "../../lib/routes"; +import { getEnvironmentClient } from "../../state/environment-session-registry"; +import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; +import { InboxSkeleton } from "./InboxSkeleton"; +import { deriveInboxViewState } from "./inboxViewState"; + +interface NeedsYouRow { + readonly environmentId: EnvironmentId; + readonly ticket: WorkflowNeedsAttentionTicketView; +} + +function attentionLabel(ticket: WorkflowNeedsAttentionTicketView): string { + switch (ticket.attentionKind) { + case "waiting_for_approval": + return "Needs approval"; + case "waiting_for_input": + return "Needs input"; + case "blocked": + return "Blocked"; + default: + return ticket.status; + } +} + +function formatRelative(updatedAt: string): string { + const then = Date.parse(updatedAt); + if (Number.isNaN(then)) { + return ""; + } + const deltaMs = Date.now() - then; + const minutes = Math.floor(deltaMs / 60_000); + if (minutes < 1) { + return "just now"; + } + if (minutes < 60) { + return `${minutes}m ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h ago`; + } + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export function NeedsYouInboxScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { environmentStateById } = useRemoteEnvironmentState(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [partialError, setPartialError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const mountedRef = useRef(true); + // Monotonic load generation: every load() captures its id at start; only the + // LATEST-started load may commit rows/error/partialError/loading. Without this, + // a slow focus-triggered load could resolve after a newer retry and overwrite + // its result — e.g. stomping a real failure with a stale "all caught up". + const loadIdRef = useRef(0); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const environmentIds = useMemo( + () => Object.keys(environmentStateById).map((id) => EnvironmentId.make(id)), + [environmentStateById], + ); + + const load = useCallback(async () => { + // Claim a generation synchronously, before the first await. The most recent + // load() to start owns loadIdRef; any older in-flight load fails isLatest() + // after its awaits and commits nothing. + const myLoadId = (loadIdRef.current += 1); + const isLatest = () => loadIdRef.current === myLoadId && mountedRef.current; + + if (isLatest()) { + setError(null); + setPartialError(null); + } + const aggregated: NeedsYouRow[] = []; + const failures: string[] = []; + + await Promise.all( + environmentIds.map(async (environmentId) => { + const client = getEnvironmentClient(environmentId); + if (!client) { + return; + } + try { + const tickets = await client.workflow.listNeedsAttentionTickets({}); + for (const ticket of tickets) { + aggregated.push({ environmentId, ticket }); + } + } catch (cause) { + failures.push(cause instanceof Error ? cause.message : "Failed to load tickets."); + } + }), + ); + + if (!isLatest()) { + return; + } + + aggregated.sort((a, b) => { + // Date.parse yields NaN for malformed timestamps; treat those as oldest so + // the comparator stays a deterministic total order (NaN subtraction would + // corrupt the sort across engines). + const dateA = Date.parse(a.ticket.updatedAt); + const dateB = Date.parse(b.ticket.updatedAt); + if (Number.isNaN(dateA) && Number.isNaN(dateB)) return 0; + if (Number.isNaN(dateA)) return 1; + if (Number.isNaN(dateB)) return -1; + return dateB - dateA; + }); + setRows(aggregated); + + if (aggregated.length === 0 && failures.length > 0) { + // Full failure: every environment errored (or there were no rows at all). + setError(failures[0] ?? "Failed to load tickets."); + setPartialError(null); + } else if (aggregated.length > 0 && failures.length > 0) { + // Partial failure: we got some rows but at least one environment failed. + setError(null); + setPartialError("Some boards couldn't be loaded — pull to refresh to retry."); + } else { + setError(null); + setPartialError(null); + } + setLoading(false); + }, [environmentIds]); + + useFocusEffect( + useCallback(() => { + setLoading(true); + void load(); + }, [load]), + ); + + const onRefresh = useCallback(() => { + setRefreshing(true); + void load().finally(() => { + if (mountedRef.current) { + setRefreshing(false); + } + }); + }, [load]); + + const triggerLoad = useCallback(() => { + setRows([]); + setLoading(true); + void load(); + }, [load]); + + const viewState = deriveInboxViewState({ loading, refreshing, rows, error, partialError }); + + return ( + + + } + > + Needs you + + {/* Skeleton during initial load */} + {viewState.kind === "skeleton" ? : null} + + {/* Full failure: zero rows + error */} + {viewState.kind === "error" ? ( + + + + ) : null} + + {/* True empty: fetch finished cleanly */} + {viewState.kind === "empty" ? ( + + + + ) : null} + + {/* List with optional partial-failure banner */} + {viewState.kind === "list" ? ( + <> + {viewState.partialErrorMessage !== null ? ( + + ) : null} + + {rows.map((row) => ( + + router.push( + buildTicketRoutePath({ + environmentId: row.environmentId, + boardId: row.ticket.boardId, + ticketId: row.ticket.ticketId, + }), + ) + } + > + + + {row.ticket.title} + + + {formatRelative(row.ticket.updatedAt)} + + + + {row.ticket.boardName} + + + + {attentionLabel(row.ticket)} + + + + ))} + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/board/TicketActionSheetScreen.tsx b/apps/mobile/src/features/board/TicketActionSheetScreen.tsx new file mode 100644 index 00000000000..9904067281b --- /dev/null +++ b/apps/mobile/src/features/board/TicketActionSheetScreen.tsx @@ -0,0 +1,449 @@ +import { Stack, useFocusEffect, useLocalSearchParams } from "expo-router"; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { AppState, Linking, Pressable, ScrollView, View } from "react-native"; +import { + BoardId, + EnvironmentId, + LaneKey, + TicketId, + type StepRunId, + type WorkflowTicketDetailView, + type WorkflowTicketMessageView, +} from "@t3tools/contracts"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { EmptyState } from "../../components/EmptyState"; +import { LoadingScreen } from "../../components/LoadingScreen"; +import { + getEnvironmentClient, + subscribeEnvironmentConnections, +} from "../../state/environment-session-registry"; +import { useEnvironmentRuntime } from "../../state/use-environment-runtime"; +import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; +import { isTicketSourceOwned, selectTicketAffordance } from "./ticketAffordance"; + +function firstRouteParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} + +export function TicketActionSheetScreen() { + const params = useLocalSearchParams<{ + environmentId?: string | string[]; + boardId?: string | string[]; + ticketId?: string | string[]; + }>(); + + const environmentIdRaw = firstRouteParam(params.environmentId); + const boardIdRaw = firstRouteParam(params.boardId); + const ticketIdRaw = firstRouteParam(params.ticketId); + + const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; + const ticketId = ticketIdRaw ? TicketId.make(ticketIdRaw) : null; + // boardId is part of the deep-link contract; surfaced for parity with routing. + const boardId = boardIdRaw ? BoardId.make(boardIdRaw) : null; + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [mutationError, setMutationError] = useState(null); + const [busy, setBusy] = useState(false); + const [answerText, setAnswerText] = useState(""); + const [commentText, setCommentText] = useState(""); + + const { isLoadingSavedConnection, pendingConnectionError } = useRemoteEnvironmentState(); + const routeEnvironmentRuntime = useEnvironmentRuntime(environmentId); + const routeConnectionState = routeEnvironmentRuntime.connectionState; + const routeConnectionError = pendingConnectionError ?? routeEnvironmentRuntime.connectionError; + + // Re-read the environment client whenever a connection connects/disconnects so + // a cold-start notification tap (session not yet connected at first render) + // picks up the session as soon as bootstrap finishes. + const subscribeConnections = useCallback( + (onStoreChange: () => void) => subscribeEnvironmentConnections(onStoreChange), + [], + ); + const getSessionSnapshot = useCallback( + () => (environmentId ? getEnvironmentClient(environmentId) : null), + [environmentId], + ); + const session = useSyncExternalStore( + subscribeConnections, + getSessionSnapshot, + getSessionSnapshot, + ); + + // Still hydrating: saved connections are loading, or the route's environment is + // mid-(re)connect. Drives "Connecting…" instead of the terminal disconnected state. + const stillHydrating = + isLoadingSavedConnection || + routeConnectionState === "connecting" || + routeConnectionState === "reconnecting"; + + const refetch = useCallback(async () => { + if (!session || !ticketId) { + return; + } + const next = await session.workflow.getTicketDetail({ ticketId }); + setDetail(next); + }, [session, ticketId]); + + useEffect(() => { + if (!session || !ticketId) { + return; + } + + let cancelled = false; + setLoading(true); + setLoadError(null); + + void (async () => { + try { + const next = await session.workflow.getTicketDetail({ ticketId }); + if (!cancelled) { + setDetail(next); + } + } catch (error) { + if (!cancelled) { + setLoadError(error instanceof Error ? error.message : "Failed to load ticket."); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [session, ticketId]); + + // Keep a stable handle to the latest refetch so the focus/foreground listeners + // below don't re-subscribe (and don't re-fire) whenever refetch's identity + // changes. Affordances/messages can go stale while the sheet is backgrounded + // (agent timeout, another client resolves the step, the ticket moves lanes), so + // refresh silently on re-focus and on app-foreground — mirroring the inbox's + // useFocusEffect refresh. Failures are swallowed: the existing detail stays + // shown and we retry on the next focus/foreground. + const refetchRef = useRef(refetch); + useEffect(() => { + refetchRef.current = refetch; + }, [refetch]); + + // Skip the very first focus pass: the initial-load effect already fetched. + const focusedOnceRef = useRef(false); + useFocusEffect( + useCallback(() => { + if (!focusedOnceRef.current) { + focusedOnceRef.current = true; + return; + } + void refetchRef.current().catch(() => undefined); + }, []), + ); + + useEffect(() => { + const subscription = AppState.addEventListener("change", (nextState) => { + if (nextState === "active") { + void refetchRef.current().catch(() => undefined); + } + }); + return () => subscription.remove(); + }, []); + + const runMutation = useCallback( + async (mutate: () => Promise) => { + setBusy(true); + setMutationError(null); + try { + await mutate(); + } catch (error) { + setMutationError(error instanceof Error ? error.message : "Action failed."); + return; + } finally { + setBusy(false); + } + // The mutation succeeded server-side; refetch separately so a transient + // detail-load failure (WebSocket drop, ticket momentarily unavailable after + // its own transition) is NOT surfaced as a mutation error and doesn't prompt + // a destructive retry of an already-applied action. The focus/foreground + // refresh below reconciles the view if this refresh is missed. + try { + await refetch(); + } catch { + // Intentionally ignored — the action was applied; stale detail self-heals + // on the next focus/foreground refetch. + } + }, + [refetch], + ); + + const onSubmitAnswer = useCallback( + (stepRunId: StepRunId) => { + const text = answerText.trim(); + if (!session || text.length === 0) { + return; + } + void runMutation(async () => { + await session.workflow.answerTicketStep({ stepRunId, text }); + setAnswerText(""); + }); + }, + [answerText, runMutation, session], + ); + + const onResolveApproval = useCallback( + (stepRunId: StepRunId, approved: boolean) => { + if (!session) { + return; + } + void runMutation(() => session.workflow.resolveApproval({ stepRunId, approved })); + }, + [runMutation, session], + ); + + const onMoveTicket = useCallback( + (toLane: LaneKey) => { + if (!session || !ticketId) { + return; + } + void runMutation(() => session.workflow.moveTicket({ ticketId, toLane })); + }, + [runMutation, session, ticketId], + ); + + const onPostComment = useCallback(() => { + const text = commentText.trim(); + if (!session || !ticketId || text.length === 0) { + return; + } + void runMutation(async () => { + await session.workflow.postTicketMessage({ ticketId, text }); + setCommentText(""); + }); + }, [commentText, runMutation, session, ticketId]); + + if (!environmentId || !boardId || !ticketId) { + return ; + } + + if (!session) { + // Cold-start notification tap: the saved session may still be (re)connecting. + // Show "Connecting…" while hydration is in flight; only fall through to the + // terminal "not connected" EmptyState once hydration has settled. + if (stillHydrating) { + return ; + } + + return ( + + + + ); + } + + if (loading) { + return ; + } + + if (loadError || !detail) { + return ( + + + + ); + } + + const affordance = selectTicketAffordance(detail); + const ticket = detail.ticket; + const sourceOwned = isTicketSourceOwned(detail); + + return ( + + + + + {ticket.title} + + {ticket.currentLane?.name ?? ticket.currentLaneKey} · {ticket.status} + + {sourceOwned && detail.syncedSource ? ( + void Linking.openURL(detail.syncedSource!.url)} + className="self-start" + > + + Synced from {detail.syncedSource.provider} ↗ + + + ) : null} + + + {mutationError ? ( + + {mutationError} + + ) : null} + + {affordance.kind === "answer" ? ( + + + {affordance.question ?? "The agent needs your input."} + + + onSubmitAnswer(affordance.stepRunId)} + /> + + ) : null} + + {affordance.kind === "approve" ? ( + + + {affordance.question ?? "The agent is waiting for your approval."} + + + onResolveApproval(affordance.stepRunId, true)} + /> + onResolveApproval(affordance.stepRunId, false)} + /> + + + ) : null} + + {affordance.kind === "blocked" ? ( + + Blocked + + {affordance.blockReason ?? "This ticket is blocked."} + + + ) : null} + + {affordance.laneActions.length > 0 ? ( + + Move ticket + + {affordance.laneActions.map((action) => ( + onMoveTicket(action.to)} + /> + ))} + + + ) : null} + + + Add a comment + + + + + {detail.messages.length > 0 ? ( + + Conversation + {detail.messages.map((message) => ( + + ))} + + ) : null} + + + ); +} + +function ScreenShell(props: { readonly children: React.ReactNode }) { + return ( + + + {props.children} + + ); +} + +function ActionButton(props: { + readonly label: string; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly tone?: "primary" | "secondary" | "danger"; +}) { + const tone = props.tone ?? "primary"; + const bg = tone === "danger" ? "bg-danger" : tone === "secondary" ? "bg-card-alt" : "bg-primary"; + const fg = tone === "secondary" ? "text-foreground" : "text-primary-foreground"; + + return ( + + {props.label} + + ); +} + +function MessageRow(props: { readonly message: WorkflowTicketMessageView }) { + const { message } = props; + return ( + + {message.author} + {message.body} + + ); +} diff --git a/apps/mobile/src/features/board/inboxViewState.test.ts b/apps/mobile/src/features/board/inboxViewState.test.ts new file mode 100644 index 00000000000..71e46e171ae --- /dev/null +++ b/apps/mobile/src/features/board/inboxViewState.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { deriveInboxViewState } from "./inboxViewState"; + +const NO_ROWS = [] as const; +const SOME_ROWS = [{}] as const; + +describe("deriveInboxViewState", () => { + // ── skeleton ────────────────────────────────────────────────────────────── + + it("returns skeleton during the initial load (loading, not refreshing, no rows)", () => { + const result = deriveInboxViewState({ + loading: true, + refreshing: false, + rows: NO_ROWS, + error: null, + partialError: null, + }); + expect(result.kind).toBe("skeleton"); + }); + + it("does NOT return skeleton during pull-to-refresh (refreshing flag set)", () => { + const result = deriveInboxViewState({ + loading: true, + refreshing: true, + rows: NO_ROWS, + error: null, + partialError: null, + }); + expect(result.kind).not.toBe("skeleton"); + }); + + it("does NOT return skeleton when rows are already present during a reload", () => { + const result = deriveInboxViewState({ + loading: true, + refreshing: false, + rows: SOME_ROWS, + error: null, + partialError: null, + }); + expect(result.kind).not.toBe("skeleton"); + }); + + // ── error (full failure) ────────────────────────────────────────────────── + + it("returns error when load finished with zero rows and an error", () => { + const result = deriveInboxViewState({ + loading: false, + refreshing: false, + rows: NO_ROWS, + error: "Network timeout", + partialError: null, + }); + expect(result.kind).toBe("error"); + if (result.kind !== "error") throw new Error("expected error"); + expect(result.message).toBe("Network timeout"); + }); + + it("does NOT show the success empty state on full failure", () => { + const result = deriveInboxViewState({ + loading: false, + refreshing: false, + rows: NO_ROWS, + error: "Server error", + partialError: null, + }); + expect(result.kind).not.toBe("empty"); + }); + + // ── empty (all caught up) ───────────────────────────────────────────────── + + it("returns empty when load finished cleanly with zero rows", () => { + const result = deriveInboxViewState({ + loading: false, + refreshing: false, + rows: NO_ROWS, + error: null, + partialError: null, + }); + expect(result.kind).toBe("empty"); + }); + + // ── list ────────────────────────────────────────────────────────────────── + + it("returns list with no partial error banner when all environments succeeded", () => { + const result = deriveInboxViewState({ + loading: false, + refreshing: false, + rows: SOME_ROWS, + error: null, + partialError: null, + }); + expect(result.kind).toBe("list"); + if (result.kind !== "list") throw new Error("expected list"); + expect(result.partialErrorMessage).toBeNull(); + }); + + it("returns list with a partial error banner when some environments failed", () => { + const result = deriveInboxViewState({ + loading: false, + refreshing: false, + rows: SOME_ROWS, + error: null, + partialError: "Some boards couldn't be loaded — pull to refresh to retry.", + }); + expect(result.kind).toBe("list"); + if (result.kind !== "list") throw new Error("expected list"); + expect(result.partialErrorMessage).toBe( + "Some boards couldn't be loaded — pull to refresh to retry.", + ); + }); + + it("returns list even while a reload is in flight (rows already present)", () => { + const result = deriveInboxViewState({ + loading: true, + refreshing: false, + rows: SOME_ROWS, + error: null, + partialError: null, + }); + expect(result.kind).toBe("list"); + }); +}); diff --git a/apps/mobile/src/features/board/inboxViewState.ts b/apps/mobile/src/features/board/inboxViewState.ts new file mode 100644 index 00000000000..bbd9acc395a --- /dev/null +++ b/apps/mobile/src/features/board/inboxViewState.ts @@ -0,0 +1,49 @@ +/** + * Pure state-derivation helper for the NeedsYouInboxScreen. + * + * Given the raw async state, returns a discriminated union describing which + * view to render, keeping that logic out of the component and fully testable. + */ + +export type InboxViewState = + | { readonly kind: "skeleton" } + | { readonly kind: "error"; readonly message: string } + | { readonly kind: "empty" } + | { readonly kind: "list"; readonly partialErrorMessage: string | null }; + +/** + * Derive the correct view state from raw async state. + * + * Rules: + * - `loading` + no rows yet (initial load, not pull-to-refresh) → skeleton + * - `!loading` + zero rows + error → error (full failure) + * - `!loading` + zero rows + no error → empty (all caught up) + * - rows present → list (may include a partial-error banner) + */ +export function deriveInboxViewState(opts: { + readonly loading: boolean; + readonly refreshing: boolean; + readonly rows: readonly unknown[]; + readonly error: string | null; + readonly partialError: string | null; +}): InboxViewState { + const { loading, refreshing, rows, error, partialError } = opts; + + // Show skeleton only during the initial load (not during pull-to-refresh). + if (loading && !refreshing && rows.length === 0) { + return { kind: "skeleton" }; + } + + // Full failure: fetch finished, no rows at all, at least one error. + if (!loading && rows.length === 0 && error !== null) { + return { kind: "error", message: error }; + } + + // True empty: fetch finished cleanly with no rows. + if (!loading && rows.length === 0 && error === null) { + return { kind: "empty" }; + } + + // We have rows (possibly with a partial failure banner). + return { kind: "list", partialErrorMessage: partialError }; +} diff --git a/apps/mobile/src/features/board/ticketAffordance.test.ts b/apps/mobile/src/features/board/ticketAffordance.test.ts new file mode 100644 index 00000000000..a72b2d3a3e2 --- /dev/null +++ b/apps/mobile/src/features/board/ticketAffordance.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + BoardId, + LaneKey, + StepRunId, + StepKey, + TicketId, + type BoardTicketView, + type WorkflowCurrentLaneView, + type WorkflowLaneActionView, + type WorkflowStepRunView, + type WorkflowTicketDetailView, + type WorkflowTicketAttentionKind, +} from "@t3tools/contracts"; + +import { isTicketSourceOwned, selectTicketAffordance } from "./ticketAffordance"; + +const TICKET_ID = TicketId.make("ticket-1"); +const BOARD_ID = BoardId.make("board-1"); + +const LANE_ACTIONS: readonly WorkflowLaneActionView[] = [ + { label: "Send back", to: LaneKey.make("triage") }, + { label: "Ship", to: LaneKey.make("done") }, +]; + +const CURRENT_LANE: WorkflowCurrentLaneView = { + key: LaneKey.make("review"), + name: "Review", + actions: LANE_ACTIONS, +}; + +function makeAwaitingStep(overrides: Partial = {}): WorkflowStepRunView { + return { + stepRunId: StepRunId.make("step-run-1"), + stepKey: StepKey.make("review-step"), + stepType: "agent", + status: "awaiting_user", + waitingReason: null, + blockedReason: null, + scriptThreadId: null, + terminalId: null, + scriptStatus: null, + exitCode: null, + signal: null, + ...overrides, + }; +} + +function makeTicket(overrides: Partial = {}): BoardTicketView { + return { + ticketId: TICKET_ID, + boardId: BOARD_ID, + title: "Investigate flake", + currentLaneKey: LaneKey.make("review"), + status: "running", + currentLane: CURRENT_LANE, + ...overrides, + }; +} + +function makeDetail(args: { + readonly ticket?: Partial; + readonly steps?: readonly WorkflowStepRunView[]; +}): WorkflowTicketDetailView { + return { + ticket: makeTicket(args.ticket), + steps: args.steps ?? [], + messages: [], + }; +} + +describe("selectTicketAffordance", () => { + it("maps waiting_for_input to answer with the awaiting step's stepRunId and question", () => { + const detail = makeDetail({ + ticket: { attentionKind: "waiting_for_input", attentionReason: "fallback reason" }, + steps: [ + makeAwaitingStep({ + stepRunId: StepRunId.make("step-input"), + waitingReason: "Which database should I target?", + }), + ], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("answer"); + if (result.kind !== "answer") throw new Error("expected answer"); + expect(result.stepRunId).toBe(StepRunId.make("step-input")); + expect(result.question).toBe("Which database should I target?"); + expect(result.laneActions).toEqual(LANE_ACTIONS); + }); + + it("falls back to attentionReason when the awaiting input step has no waitingReason", () => { + const detail = makeDetail({ + ticket: { attentionKind: "waiting_for_input", attentionReason: "Need credentials" }, + steps: [makeAwaitingStep({ waitingReason: null })], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("answer"); + if (result.kind !== "answer") throw new Error("expected answer"); + expect(result.question).toBe("Need credentials"); + }); + + it("derives answer from providerResponseKind when attentionKind is absent", () => { + const detail = makeDetail({ + ticket: {}, + steps: [makeAwaitingStep({ providerResponseKind: "user-input", waitingReason: "?" })], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("answer"); + }); + + it("maps waiting_for_approval to approve with the awaiting step's stepRunId", () => { + const detail = makeDetail({ + ticket: { attentionKind: "waiting_for_approval" }, + steps: [ + makeAwaitingStep({ + stepRunId: StepRunId.make("step-approve"), + waitingReason: "Approve deploy to prod?", + }), + ], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("approve"); + if (result.kind !== "approve") throw new Error("expected approve"); + expect(result.stepRunId).toBe(StepRunId.make("step-approve")); + expect(result.question).toBe("Approve deploy to prod?"); + }); + + it("derives approve from providerResponseKind=request when attentionKind is absent", () => { + const detail = makeDetail({ + ticket: {}, + steps: [makeAwaitingStep({ providerResponseKind: "request" })], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("approve"); + }); + + it("derives approve from an explicit approval step awaiting user with null providerResponseKind", () => { + // Mirrors web's isAwaitingApprovalRequestStep fallback: an approval step + // awaiting the user with no providerResponseKind is still an approval, not + // a plain comment. + const detail = makeDetail({ + ticket: {}, + steps: [ + makeAwaitingStep({ + stepRunId: StepRunId.make("step-approve"), + stepType: "approval", + providerResponseKind: null, + waitingReason: "Approve this?", + }), + ], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("approve"); + if (result.kind !== "approve") throw new Error("expected approve"); + expect(result.stepRunId).toBe(StepRunId.make("step-approve")); + expect(result.question).toBe("Approve this?"); + }); + + it("maps blocked attention to blocked with blockReason and laneActions", () => { + const detail = makeDetail({ + ticket: { attentionKind: "blocked", attentionReason: "ticket-level block" }, + steps: [makeAwaitingStep({ blockedReason: "Missing API key" })], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("blocked"); + if (result.kind !== "blocked") throw new Error("expected blocked"); + expect(result.blockReason).toBe("Missing API key"); + expect(result.laneActions).toEqual(LANE_ACTIONS); + }); + + it("treats ticket.status === blocked as blocked even without attentionKind", () => { + const detail = makeDetail({ + ticket: { status: "blocked", attentionReason: "blocked reason" }, + steps: [], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("blocked"); + if (result.kind !== "blocked") throw new Error("expected blocked"); + expect(result.blockReason).toBe("blocked reason"); + }); + + it("prefers answer over blocked when attentionKind is absent but status is blocked and the awaiting step wants input", () => { + // Precedence lock: wants-input must win over the blocked branch so the user + // can actually respond instead of hitting a dead-end. Do not flip silently. + const detail = makeDetail({ + ticket: { attentionKind: undefined, status: "blocked" }, + steps: [ + makeAwaitingStep({ + stepRunId: StepRunId.make("step-input"), + providerResponseKind: "user-input", + waitingReason: "Pick a target", + }), + ], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("answer"); + if (result.kind !== "answer") throw new Error("expected answer"); + expect(result.stepRunId).toBe(StepRunId.make("step-input")); + }); + + it("maps a ticket with no attention to comment", () => { + const detail = makeDetail({ ticket: {}, steps: [] }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("comment"); + expect(result.laneActions).toEqual(LANE_ACTIONS); + }); + + it("degrades waiting_for_input to comment when no awaiting step is present", () => { + const detail = makeDetail({ + ticket: { attentionKind: "waiting_for_input" }, + steps: [], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("comment"); + }); + + it("degrades waiting_for_approval to comment when no awaiting step is present", () => { + const detail = makeDetail({ + ticket: { attentionKind: "waiting_for_approval" }, + steps: [], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("comment"); + }); + + it("defaults laneActions to an empty array when currentLane is absent", () => { + const detail = makeDetail({ ticket: { currentLane: undefined }, steps: [] }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("comment"); + expect(result.laneActions).toEqual([]); + }); + + // Type guard usage to keep WorkflowTicketAttentionKind imported and meaningful. + it("only recognizes the three attention kinds", () => { + const kinds: readonly WorkflowTicketAttentionKind[] = [ + "waiting_for_approval", + "waiting_for_input", + "blocked", + ]; + expect(kinds).toHaveLength(3); + }); +}); + +describe("isTicketSourceOwned", () => { + it("returns false when syncedSource is absent", () => { + expect(isTicketSourceOwned({ syncedSource: undefined })).toBe(false); + }); + + it("returns true when syncedSource is present", () => { + expect( + isTicketSourceOwned({ + syncedSource: { provider: "github", url: "https://github.com/o/r/issues/1" }, + }), + ).toBe(true); + }); + + it("returns true for an Asana syncedSource", () => { + expect( + isTicketSourceOwned({ + syncedSource: { provider: "asana", url: "https://app.asana.com/0/123/456" }, + }), + ).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/board/ticketAffordance.ts b/apps/mobile/src/features/board/ticketAffordance.ts new file mode 100644 index 00000000000..ce2b2286fdc --- /dev/null +++ b/apps/mobile/src/features/board/ticketAffordance.ts @@ -0,0 +1,119 @@ +import type { + StepRunId, + WorkflowLaneActionView, + WorkflowStepRunView, + WorkflowTicketDetailView, +} from "@t3tools/contracts"; + +/** + * Returns true when the ticket is owned by an external sync source + * (i.e. its title/description are managed by the source provider and + * should be treated as read-only in the UI). + */ +export function isTicketSourceOwned( + detail: Pick, +): boolean { + return Boolean(detail.syncedSource); +} + +/** + * Discriminated union describing what the human can do with a ticket that + * surfaced in the "Needs you" inbox / notification deep-link. The `kind` is + * driven primarily off the server-projected `ticket.attentionKind`; the awaiting + * step's `providerResponseKind` is only consulted as a fallback. Every variant + * carries `laneActions` so the action sheet can always offer manual lane moves. + */ +export type TicketAffordance = + | { + readonly kind: "answer"; + readonly stepRunId: StepRunId; + readonly question: string | null; + readonly laneActions: readonly WorkflowLaneActionView[]; + } + | { + readonly kind: "approve"; + readonly stepRunId: StepRunId; + readonly question: string | null; + readonly laneActions: readonly WorkflowLaneActionView[]; + } + | { + readonly kind: "blocked"; + readonly blockReason: string | null; + readonly laneActions: readonly WorkflowLaneActionView[]; + } + | { + readonly kind: "comment"; + readonly laneActions: readonly WorkflowLaneActionView[]; + }; + +function findAwaitingStep(detail: WorkflowTicketDetailView): WorkflowStepRunView | undefined { + return detail.steps.find((step) => step.status === "awaiting_user"); +} + +/** + * Maps a ticket detail view onto the single best human affordance. + * + * Mapping rules (see TicketActionSheetScreen for the UI): + * - `waiting_for_input` (or awaiting step `providerResponseKind === "user-input"`) + * → `answer`, requires the awaiting step's `stepRunId`; degrades to `comment` + * when no awaiting step is present. + * - `waiting_for_approval` (or `providerResponseKind === "request"`) → `approve`, + * same `stepRunId` requirement / degrade. + * - `blocked` attention OR `ticket.status === "blocked"` → `blocked`. + * - otherwise → `comment`. + */ +export function selectTicketAffordance(detail: WorkflowTicketDetailView): TicketAffordance { + const ticket = detail.ticket; + const awaitingStep = findAwaitingStep(detail); + const laneActions = ticket.currentLane?.actions ?? []; + + const attentionKind = ticket.attentionKind; + const providerResponseKind = awaitingStep?.providerResponseKind ?? null; + + const wantsInput = + attentionKind === "waiting_for_input" || + (attentionKind === undefined && providerResponseKind === "user-input"); + const wantsApproval = + attentionKind === "waiting_for_approval" || + (attentionKind === undefined && + (providerResponseKind === "request" || + // Mirror web's isAwaitingApprovalRequestStep fallback: an explicit + // approval step awaiting the user with no providerResponseKind is + // still an approval request. + (awaitingStep?.stepType === "approval" && providerResponseKind === null))); + const isBlocked = attentionKind === "blocked" || ticket.status === "blocked"; + + if (wantsInput) { + if (awaitingStep) { + return { + kind: "answer", + stepRunId: awaitingStep.stepRunId, + question: awaitingStep.waitingReason ?? ticket.attentionReason ?? null, + laneActions, + }; + } + return { kind: "comment", laneActions }; + } + + if (wantsApproval) { + if (awaitingStep) { + return { + kind: "approve", + stepRunId: awaitingStep.stepRunId, + question: awaitingStep.waitingReason ?? ticket.attentionReason ?? null, + laneActions, + }; + } + return { kind: "comment", laneActions }; + } + + if (isBlocked) { + return { + kind: "blocked", + blockReason: awaitingStep?.blockedReason ?? ticket.attentionReason ?? null, + laneActions, + }; + } + + return { kind: "comment", laneActions }; +} diff --git a/apps/mobile/src/lib/routes.ts b/apps/mobile/src/lib/routes.ts index bf49a20ac41..6811ba6d5ac 100644 --- a/apps/mobile/src/lib/routes.ts +++ b/apps/mobile/src/lib/routes.ts @@ -1,6 +1,6 @@ import type { Href, useRouter } from "expo-router"; import type { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { BoardId, EnvironmentId, ThreadId, TicketId } from "@t3tools/contracts"; import type { SelectedThreadRef } from "../state/remote-runtime-types"; @@ -71,6 +71,16 @@ export function buildThreadTerminalNavigation( }; } +export function buildTicketRoutePath(input: { + readonly environmentId: EnvironmentId; + readonly boardId: BoardId; + readonly ticketId: TicketId; +}): string { + return `/tickets/${encodeURIComponent(input.environmentId)}/${encodeURIComponent( + input.boardId, + )}/${encodeURIComponent(input.ticketId)}`; +} + export function dismissRoute(router: Router) { if (router.canGoBack()) { router.back(); diff --git a/apps/server/package.json b/apps/server/package.json index 01003d7c176..2e4f155e911 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -31,6 +31,7 @@ "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", + "json-logic-js": "^2.0.5", "node-pty": "^1.1.0" }, "devDependencies": { diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 871ec1eab60..14f9fe99dcb 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AuthAdministrativeScopes } from "@t3tools/contracts"; +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -92,13 +92,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ); expect(verified.sessionId.length).toBeGreaterThan(0); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(verified.scopes).toEqual([...AuthStandardClientScopes]); expect(verified.subject).toBe("one-time-token"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); @@ -173,16 +167,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { makeCookieRequest(exchanged.sessionToken), ); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(verified.scopes).toEqual([...AuthAdministrativeScopes]); expect(verified.subject).toBe("administrative-bootstrap"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 44c28dea416..020b0b9e7d1 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthAdministrativeScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -74,29 +75,11 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = const listedAfterRevoke = yield* environmentAuth.listSessions(); expect(issued.method).toBe("bearer-access-token"); - expect(issued.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(issued.scopes).toEqual([...AuthAdministrativeScopes]); expect(issued.client.deviceType).toBe("bot"); expect(issued.client.label).toBe("deploy-bot"); expect(verified.sessionId).toBe(issued.sessionId); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(verified.scopes).toEqual([...AuthAdministrativeScopes]); expect(verified.method).toBe("bearer-access-token"); expect(listedBeforeRevoke).toHaveLength(1); expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 3861b4fc78f..a2685bfeb18 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -52,13 +53,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); expect(first.method).toBe("one-time-token"); - expect(first.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(first.scopes).toEqual([...AuthStandardClientScopes]); expect(first.subject).toBe("one-time-token"); expect(first.label).toBe("Julius iPhone"); expect(issued.label).toBe("Julius iPhone"); @@ -122,16 +117,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); expect(first.method).toBe("desktop-bootstrap"); - expect(first.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(first.scopes).toEqual([...AuthAdministrativeScopes]); expect(first.subject).toBe("desktop-bootstrap"); expect(second._tag).toBe("BootstrapCredentialInvalidError"); }).pipe( diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 00abd6b9945..33b53d161b8 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -123,13 +124,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(verified.method).toBe("bearer-access-token"); expect(verified.subject).toBe("test-clock"); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(verified.scopes).toEqual([...AuthStandardClientScopes]); }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index ed640863d21..ae0ff3fac76 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -4,6 +4,8 @@ import { AuthStandardClientScopes, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthWorkflowOperateScope, + AuthWorkflowReadScope, AuthRelayReadScope, AuthRelayWriteScope, AuthReviewWriteScope, @@ -249,6 +251,8 @@ export const authHttpApiLayer = HttpApiBuilder.group( allowedScopes: new Set([ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, + AuthWorkflowReadScope, + AuthWorkflowOperateScope, AuthTerminalOperateScope, AuthReviewWriteScope, AuthAccessReadScope, diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 27a1d55e90d..06115b728cc 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; +import { AuthAdministrativeScopes, EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -77,6 +77,7 @@ const makeCliTestServerConfig = (baseDir: string) => ...derivedPaths, staticDir: undefined, devUrl: undefined, + webBaseUrl: undefined, noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, @@ -351,28 +352,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(typeof issued.sessionId, "string"); assert.equal(typeof issued.token, "string"); - assert.deepEqual(issued.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(issued.scopes, [...AuthAdministrativeScopes]); assert.equal(listed.length, 1); assert.equal(listed[0]?.sessionId, issued.sessionId); - assert.deepEqual(listed[0]?.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(listed[0]?.scopes, [...AuthAdministrativeScopes]); assert.equal("token" in (listed[0] ?? {}), false); }), ); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 9f31532855a..c6f7cc9cbae 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -108,6 +108,7 @@ describe("CheckpointDiffQueryLive", () => { }), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), ), ); @@ -200,6 +201,7 @@ describe("CheckpointDiffQueryLive", () => { getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), ), ); @@ -282,6 +284,7 @@ describe("CheckpointDiffQueryLive", () => { getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), ), ); @@ -349,6 +352,7 @@ describe("CheckpointDiffQueryLive", () => { getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), ), ); @@ -401,6 +405,7 @@ describe("CheckpointDiffQueryLive", () => { getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), ), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 7182854e18c..65cb836431b 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -112,6 +112,10 @@ const EnvServerConfig = Config.all({ host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), + webBaseUrl: Config.url("T3CODE_WEB_BASE_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -267,6 +271,7 @@ export const resolveServerConfig = ( resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)), () => undefined, ); + const webBaseUrl = env.webBaseUrl; const baseDir = yield* resolveBaseDir( Option.getOrUndefined( resolveOptionPrecedence( @@ -367,6 +372,7 @@ export const resolveServerConfig = ( host, staticDir, devUrl, + webBaseUrl, noBrowser, startupPresentation, desktopBootstrapToken, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b0a23cb273c..45ada164ef4 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -66,6 +66,11 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly baseDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; + /** Optional base URL for building absolute ticket links in outbound deliveries + * (e.g. the Slack "View ticket" button, which requires an absolute URL). + * Undefined when unset → outbound links are omitted. Sourced from + * T3CODE_WEB_BASE_URL. */ + readonly webBaseUrl: URL | undefined; readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; readonly desktopBootstrapToken: string | undefined; @@ -173,6 +178,7 @@ export class ServerConfig extends Context.Service = {}): T }), ), ), + generateBoardProposal: () => + Effect.fail( + new TextGenerationError({ + operation: "generateBoardProposal", + detail: "fake text generation does not support board proposals", + }), + ), }; } @@ -610,6 +617,41 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), + mergePullRequest: (input) => + Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected merge: #${input.number}`, + }), + ), + getPullRequestDetail: (input) => + Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected detail: #${input.number}`, + }), + ), + listPullRequestChecks: (input) => + Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected checks: #${input.number}`, + }), + ), + listPullRequestReviews: (input) => + Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected reviews: #${input.number}`, + }), + ), + listPullRequestReviewComments: (input) => + Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected review comments: #${input.number}`, + }), + ), }, ghCalls, }; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 56876ec148e..ebb8b4c8a7b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => { getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..3ae0df43884 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -612,6 +612,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pendingUserInputCount: 0, hasActionableProposedPlan: 0, deletedAt: null, + hidden: event.payload.hidden === true ? 1 : 0, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 7db2a23e5ec..be71f531ce8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -563,6 +563,123 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("excludes hidden threads from the archived shell 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_state`; + + 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-hidden-archived-test', + 'Hidden Archived Test', + '/tmp/hidden-archived-test', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-07T00:00:00.000Z', + '2026-04-07T00: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, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at, + hidden + ) + VALUES + ( + 'thread-archived-visible', + 'project-hidden-archived-test', + 'Archived Visible Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-07T00:00:02.000Z', + '2026-04-07T00:00:03.000Z', + '2026-04-07T00:00:04.000Z', + NULL, + 0 + ), + ( + 'thread-archived-hidden', + 'project-hidden-archived-test', + 'Archived Hidden Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-07T00:00:05.000Z', + '2026-04-07T00:00:06.000Z', + '2026-04-07T00:00:07.000Z', + NULL, + 1 + ) + `; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES + (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 5, '2026-04-07T00:00:08.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 5, '2026-04-07T00:00:08.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 5, '2026-04-07T00:00:08.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 5, '2026-04-07T00:00:08.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 5, '2026-04-07T00:00:08.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 5, '2026-04-07T00:00:08.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 5, '2026-04-07T00:00:08.000Z') + `; + + const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot(); + assert.deepEqual( + archivedShellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-archived-visible")], + "hidden archived thread must not appear in archived shell snapshot", + ); + }), + ); + it.effect( "reads targeted project, thread, and count queries without hydrating the full snapshot", () => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e629d1604b3..78338bae516 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -369,6 +369,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NULL + AND hidden = 0 ORDER BY project_id ASC, created_at ASC, thread_id ASC `, }); @@ -399,6 +400,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NOT NULL + AND hidden = 0 ORDER BY project_id ASC, archived_at DESC, thread_id DESC `, }); @@ -508,6 +510,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON threads.thread_id = sessions.thread_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL + AND threads.hidden = 0 ORDER BY sessions.thread_id ASC `, }); @@ -533,6 +536,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON threads.thread_id = sessions.thread_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NOT NULL + AND threads.hidden = 0 ORDER BY sessions.thread_id ASC `, }); @@ -602,6 +606,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { AND turns.turn_id = threads.latest_turn_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL + AND threads.hidden = 0 AND threads.latest_turn_id IS NOT NULL ORDER BY turns.thread_id ASC `, @@ -628,6 +633,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { AND turns.turn_id = threads.latest_turn_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NOT NULL + AND threads.hidden = 0 AND threads.latest_turn_id IS NOT NULL ORDER BY turns.thread_id ASC `, @@ -711,6 +717,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE project_id = ${projectId} AND deleted_at IS NULL AND archived_at IS NULL + AND hidden = 0 ORDER BY created_at ASC, thread_id ASC LIMIT 1 `, @@ -1012,16 +1019,36 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { Effect.flatMap( ([ projectRows, - threadRows, - messageRows, - proposedPlanRows, - activityRows, - sessionRows, - checkpointRows, - latestTurnRows, + allThreadRows, + allMessageRows, + allProposedPlanRows, + allActivityRows, + allSessionRows, + allCheckpointRows, + allLatestTurnRows, stateRows, ]) => Effect.gen(function* () { + // The public snapshot must never expose hidden (workflow + // internal) threads or any of their child rows; the decider's + // command read model keeps them via getCommandReadModel. + const hiddenThreadIds = new Set( + (yield* listHiddenThreadIds.pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionSnapshotQuery.getSnapshot:listHidden:query"), + ), + )).map((row) => row.threadId), + ); + const visible = ( + rows: ReadonlyArray, + ) => rows.filter((row) => !hiddenThreadIds.has(row.threadId)); + const threadRows = visible(allThreadRows); + const messageRows = visible(allMessageRows); + const proposedPlanRows = visible(allProposedPlanRows); + const activityRows = visible(allActivityRows); + const sessionRows = visible(allSessionRows); + const checkpointRows = visible(allCheckpointRows); + const latestTurnRows = visible(allLatestTurnRows); const messagesByThread = new Map>(); const proposedPlansByThread = new Map>(); const activitiesByThread = new Map>(); @@ -1894,6 +1921,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } satisfies OrchestrationThreadShell); }); + const listHiddenThreadIds = sql<{ readonly threadId: string }>` + SELECT thread_id AS "threadId" + FROM projection_threads + WHERE hidden = 1 + `; + + const isThreadHidden: ProjectionSnapshotQueryShape["isThreadHidden"] = (threadId) => + sql<{ readonly hidden: number }>` + SELECT hidden + FROM projection_threads + WHERE thread_id = ${threadId} + `.pipe( + Effect.map((rows) => (rows[0]?.hidden ?? 0) !== 0), + Effect.mapError(toPersistenceSqlError("ProjectionSnapshotQuery.isThreadHidden:query")), + ); + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => Effect.gen(function* () { const [ @@ -2047,6 +2090,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getFullThreadDiffContext, getThreadShellById, getThreadDetailById, + isThreadHidden, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 7d85f0240f7..ff6e88ead47 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -157,6 +157,14 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadDetailById: ( threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Whether a thread is internal (workflow step/intake dispatch) and must be + * kept out of user-facing thread lists and live shell streams. + */ + readonly isThreadHidden: ( + threadId: ThreadId, + ) => Effect.Effect; } /** diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0d4af771ca8..de567b48237 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -241,6 +241,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" worktreePath: command.worktreePath, createdAt: command.createdAt, updatedAt: command.createdAt, + ...(command.hidden === undefined ? {} : { hidden: command.hidden }), }, }; } diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c15..3571ee2f9bf 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -47,7 +47,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, - deleted_at + deleted_at, + hidden ) VALUES ( ${row.threadId}, @@ -66,7 +67,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, ${row.hasActionableProposedPlan}, - ${row.deletedAt} + ${row.deletedAt}, + ${row.hidden ?? 0} ) ON CONFLICT (thread_id) DO UPDATE SET @@ -85,7 +87,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, - deleted_at = excluded.deleted_at + deleted_at = excluded.deleted_at, + hidden = excluded.hidden `, }); @@ -111,7 +114,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + hidden FROM projection_threads WHERE thread_id = ${threadId} `, @@ -139,7 +143,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + hidden FROM projection_threads WHERE project_id = ${projectId} ORDER BY created_at ASC, thread_id ASC diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee259..dd31d18847b 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_WorkflowSchema.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "WorkflowSchema", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_WorkflowSchema.test.ts b/apps/server/src/persistence/Migrations/033_WorkflowSchema.test.ts new file mode 100644 index 00000000000..d269b3a6e5d --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_WorkflowSchema.test.ts @@ -0,0 +1,691 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../Layers/Sqlite.ts"; +import { migrationEntries, runMigrations } from "../Migrations.ts"; + +/** + * Equivalence gate for the collapsed workflow schema. + * + * `GOLDEN` below was captured from the real, original 23-step migration chain + * (033 -> 055) — it is the authoritative reference. The consolidated migration + * 033_WorkflowSchema must reproduce it EXACTLY. The dump filters to + * `tbl_name LIKE 'workflow_%' OR tbl_name = 'projection_threads'` (the objects + * the workflow feature owns or extends) and normalizes whitespace. + * + * If this test fails, the collapsed schema diverged from the chain — fix the + * migration, do not weaken the assertion. + */ + +const layer = it.layer(Layer.mergeAll(SqlitePersistenceMemory)); + +/** Collapse all runs of whitespace to a single space and trim. */ +const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim(); + +interface MasterRow { + readonly type: string; + readonly name: string; + readonly tbl_name: string; + readonly sql: string; +} + +const GOLDEN: ReadonlyArray = [ + { + type: "table", + name: "projection_threads", + tbl_name: "projection_threads", + sql: "CREATE TABLE projection_threads ( thread_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, branch TEXT, worktree_path TEXT, latest_turn_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT , runtime_mode TEXT NOT NULL DEFAULT 'full-access', interaction_mode TEXT NOT NULL DEFAULT 'default', model_selection_json TEXT, archived_at TEXT, latest_user_message_at TEXT, pending_approval_count INTEGER NOT NULL DEFAULT 0, pending_user_input_count INTEGER NOT NULL DEFAULT 0, has_actionable_proposed_plan INTEGER NOT NULL DEFAULT 0, hidden INTEGER NOT NULL DEFAULT 0)", + }, + { + type: "index", + name: "idx_projection_threads_project_archived_at", + tbl_name: "projection_threads", + sql: "CREATE INDEX idx_projection_threads_project_archived_at ON projection_threads(project_id, archived_at)", + }, + { + type: "index", + name: "idx_projection_threads_project_deleted_created", + tbl_name: "projection_threads", + sql: "CREATE INDEX idx_projection_threads_project_deleted_created ON projection_threads(project_id, deleted_at, created_at)", + }, + { + type: "index", + name: "idx_projection_threads_project_id", + tbl_name: "projection_threads", + sql: "CREATE INDEX idx_projection_threads_project_id ON projection_threads(project_id)", + }, + { + type: "index", + name: "idx_projection_threads_shell_active", + tbl_name: "projection_threads", + sql: "CREATE INDEX idx_projection_threads_shell_active ON projection_threads(deleted_at, archived_at, project_id, created_at, thread_id)", + }, + { + type: "index", + name: "idx_projection_threads_shell_archived", + tbl_name: "projection_threads", + sql: "CREATE INDEX idx_projection_threads_shell_archived ON projection_threads(deleted_at, archived_at, project_id, thread_id)", + }, + { + type: "table", + name: "workflow_board_proposal", + tbl_name: "workflow_board_proposal", + sql: "CREATE TABLE workflow_board_proposal ( proposal_id TEXT PRIMARY KEY, board_id TEXT NOT NULL, base_version_hash TEXT NOT NULL, base_def_json TEXT NOT NULL, agent_json TEXT NOT NULL, proposed_def_json TEXT NOT NULL, rationale TEXT NOT NULL, validation_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', applied_version_hash TEXT NULL, created_at TEXT NOT NULL, resolved_at TEXT NULL )", + }, + { + type: "index", + name: "idx_workflow_board_proposal_board", + tbl_name: "workflow_board_proposal", + sql: "CREATE INDEX idx_workflow_board_proposal_board ON workflow_board_proposal (board_id, status, created_at)", + }, + { + type: "table", + name: "workflow_board_version", + tbl_name: "workflow_board_version", + sql: "CREATE TABLE workflow_board_version ( version_id INTEGER PRIMARY KEY AUTOINCREMENT, board_id TEXT NOT NULL, version_hash TEXT NOT NULL, content_json TEXT NOT NULL, source TEXT NOT NULL, created_at TEXT NOT NULL )", + }, + { + type: "index", + name: "idx_workflow_board_version_board", + tbl_name: "workflow_board_version", + sql: "CREATE INDEX idx_workflow_board_version_board ON workflow_board_version(board_id, version_id)", + }, + { + type: "index", + name: "idx_workflow_board_version_hash", + tbl_name: "workflow_board_version", + sql: "CREATE INDEX idx_workflow_board_version_hash ON workflow_board_version(board_id, version_hash)", + }, + { + type: "table", + name: "workflow_board_webhook", + tbl_name: "workflow_board_webhook", + sql: "CREATE TABLE workflow_board_webhook ( board_id TEXT PRIMARY KEY, token_hash TEXT NOT NULL, token_prefix TEXT NOT NULL, created_at TEXT NOT NULL )", + }, + { + type: "table", + name: "workflow_dispatch_outbox", + tbl_name: "workflow_dispatch_outbox", + sql: "CREATE TABLE workflow_dispatch_outbox ( dispatch_id TEXT PRIMARY KEY, ticket_id TEXT NOT NULL, step_run_id TEXT NOT NULL, thread_id TEXT NOT NULL, turn_id TEXT, provider_instance TEXT NOT NULL, model TEXT NOT NULL, instruction TEXT NOT NULL, worktree_path TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, started_at TEXT, confirmed_at TEXT , options_json TEXT, project_id TEXT, thread_title TEXT, runtime_mode TEXT)", + }, + { + type: "index", + name: "idx_dispatch_outbox_pending", + tbl_name: "workflow_dispatch_outbox", + sql: "CREATE INDEX idx_dispatch_outbox_pending ON workflow_dispatch_outbox(status)", + }, + { + type: "index", + name: "idx_dispatch_outbox_step_run", + tbl_name: "workflow_dispatch_outbox", + sql: "CREATE INDEX idx_dispatch_outbox_step_run ON workflow_dispatch_outbox(step_run_id)", + }, + { + type: "table", + name: "workflow_events", + tbl_name: "workflow_events", + sql: "CREATE TABLE workflow_events ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, ticket_id TEXT NOT NULL, stream_version INTEGER NOT NULL, event_type TEXT NOT NULL, occurred_at TEXT NOT NULL, payload_json TEXT NOT NULL )", + }, + { + type: "index", + name: "idx_workflow_events_stream_version", + tbl_name: "workflow_events", + sql: "CREATE UNIQUE INDEX idx_workflow_events_stream_version ON workflow_events(ticket_id, stream_version)", + }, + { + type: "index", + name: "idx_workflow_events_ticket_type_time", + tbl_name: "workflow_events", + sql: "CREATE INDEX idx_workflow_events_ticket_type_time ON workflow_events (ticket_id, event_type, occurred_at)", + }, + { + type: "table", + name: "workflow_outbound_connection", + tbl_name: "workflow_outbound_connection", + sql: "CREATE TABLE workflow_outbound_connection ( connection_ref TEXT PRIMARY KEY, kind TEXT NOT NULL, display_name TEXT NOT NULL, secret_name TEXT NOT NULL, created_at TEXT NOT NULL )", + }, + { + type: "table", + name: "workflow_outbound_delivery", + tbl_name: "workflow_outbound_delivery", + sql: "CREATE TABLE workflow_outbound_delivery ( delivery_id TEXT PRIMARY KEY, board_id TEXT NOT NULL, ticket_id TEXT NOT NULL, rule_id TEXT NOT NULL, event_sequence INTEGER NOT NULL, connection_ref TEXT NOT NULL, formatter TEXT NOT NULL, context_json TEXT NOT NULL, delivery_state TEXT NOT NULL DEFAULT 'pending', attempt_count INTEGER NOT NULL DEFAULT 0, next_attempt_at TEXT NULL, created_at TEXT NOT NULL, last_error TEXT NULL, UNIQUE (event_sequence, rule_id) )", + }, + { + type: "index", + name: "idx_workflow_outbound_delivery_due", + tbl_name: "workflow_outbound_delivery", + sql: "CREATE INDEX idx_workflow_outbound_delivery_due ON workflow_outbound_delivery (delivery_state, next_attempt_at)", + }, + { + type: "table", + name: "workflow_pr_observation", + tbl_name: "workflow_pr_observation", + sql: "CREATE TABLE workflow_pr_observation ( observation_id TEXT PRIMARY KEY, ticket_id TEXT NOT NULL, dedup_key TEXT NOT NULL UNIQUE, event_name TEXT NOT NULL, payload_json TEXT NOT NULL, message_body TEXT NULL, status TEXT NOT NULL DEFAULT 'pending', attempt_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL )", + }, + { + type: "index", + name: "idx_workflow_pr_observation_pending", + tbl_name: "workflow_pr_observation", + sql: "CREATE INDEX idx_workflow_pr_observation_pending ON workflow_pr_observation (status, ticket_id)", + }, + { + type: "table", + name: "workflow_pr_state", + tbl_name: "workflow_pr_state", + sql: "CREATE TABLE workflow_pr_state ( ticket_id TEXT PRIMARY KEY, pr_number INTEGER NOT NULL, pr_url TEXT NOT NULL, branch TEXT NOT NULL, remote_name TEXT NOT NULL, repo TEXT NOT NULL, pr_state TEXT NOT NULL DEFAULT 'open', last_head_sha TEXT NULL, last_ci_state TEXT NULL, last_review_decision TEXT NULL, last_comment_cursor TEXT NULL, updated_at TEXT NOT NULL )", + }, + { + type: "index", + name: "idx_workflow_pr_state_open", + tbl_name: "workflow_pr_state", + sql: "CREATE INDEX idx_workflow_pr_state_open ON workflow_pr_state (pr_state) WHERE pr_state = 'open'", + }, + { + type: "table", + name: "workflow_project_trust", + tbl_name: "workflow_project_trust", + sql: "CREATE TABLE workflow_project_trust ( project_id TEXT PRIMARY KEY, trusted_at TEXT NOT NULL )", + }, + { + type: "table", + name: "workflow_script_run", + tbl_name: "workflow_script_run", + sql: "CREATE TABLE workflow_script_run ( script_run_id TEXT PRIMARY KEY, step_run_id TEXT NOT NULL UNIQUE, ticket_id TEXT NOT NULL, script_thread_id TEXT NOT NULL, terminal_id TEXT NOT NULL, status TEXT NOT NULL, exit_code INTEGER, signal INTEGER, started_at TEXT NOT NULL, finished_at TEXT )", + }, + { + type: "index", + name: "idx_workflow_script_run_status", + tbl_name: "workflow_script_run", + sql: "CREATE INDEX idx_workflow_script_run_status ON workflow_script_run(status)", + }, + { + type: "index", + name: "idx_workflow_script_run_ticket", + tbl_name: "workflow_script_run", + sql: "CREATE INDEX idx_workflow_script_run_ticket ON workflow_script_run(ticket_id)", + }, + { + type: "table", + name: "workflow_setup_run", + tbl_name: "workflow_setup_run", + sql: "CREATE TABLE workflow_setup_run ( setup_run_id TEXT PRIMARY KEY, ticket_id TEXT NOT NULL UNIQUE, worktree_ref TEXT NOT NULL, status TEXT NOT NULL, exit_code INTEGER, started_at TEXT NOT NULL, finished_at TEXT )", + }, + { + type: "table", + name: "workflow_webhook_delivery", + tbl_name: "workflow_webhook_delivery", + sql: "CREATE TABLE workflow_webhook_delivery ( board_id TEXT NOT NULL, delivery_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (board_id, delivery_id) )", + }, +]; + +const GOLDEN_PROJECTION_THREADS_COLUMNS = + "thread_id,project_id,title,branch,worktree_path,latest_turn_id,created_at,updated_at,deleted_at,runtime_mode,interaction_mode,model_selection_json,archived_at,latest_user_message_at,pending_approval_count,pending_user_input_count,has_actionable_proposed_plan,hidden"; + +layer("033_WorkflowSchema", (it) => { + it.effect("migration entry exists at id 33", () => + Effect.gen(function* () { + assert.isTrue(migrationEntries.some(([id, name]) => id === 33 && name === "WorkflowSchema")); + }), + ); + + it.effect("collapsed schema equals the golden 033->055 chain schema", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 33 }); + + const rows = yield* sql` + SELECT type, name, tbl_name, sql + FROM sqlite_master + WHERE (tbl_name LIKE 'workflow_%' OR tbl_name = 'projection_threads') + AND tbl_name != 'workflow_notification_outbox' + AND tbl_name != 'workflow_agent_session' + AND sql IS NOT NULL + ORDER BY tbl_name ASC, type DESC, name ASC + `; + + const actual = rows.map((row) => ({ + type: row.type, + name: row.name, + tbl_name: row.tbl_name, + sql: normalize(row.sql), + })); + + assert.deepEqual(actual, GOLDEN as Array); + }), + ); + + it.effect("projection_threads columns match the golden chain (incl. hidden)", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations(); + + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_threads)`; + assert.strictEqual(cols.map((c) => c.name).join(","), GOLDEN_PROJECTION_THREADS_COLUMNS); + }), + ); + + // --- Readable targeted assertions for documentation value --- + + it.effect("projection_threads.hidden present", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_threads)`; + assert.isTrue(cols.some((c) => c.name === "hidden")); + }), + ); + + it.effect("workflow_pr_observation.attempt_count present and dedup_key is UNIQUE", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + const cols = yield* sql<{ readonly name: string }>` + PRAGMA table_info(workflow_pr_observation) + `; + assert.isTrue(cols.some((c) => c.name === "attempt_count")); + + yield* sql` + INSERT INTO workflow_pr_observation + (observation_id, ticket_id, dedup_key, event_name, payload_json, status, created_at) + VALUES + ('obs-1', 'ticket-a', 'dedup-xyz', 'ci_check', '{}', 'pending', '2026-01-01T00:00:00Z') + `; + const duplicate = yield* Effect.exit(sql` + INSERT INTO workflow_pr_observation + (observation_id, ticket_id, dedup_key, event_name, payload_json, status, created_at) + VALUES + ('obs-2', 'ticket-b', 'dedup-xyz', 'ci_check', '{}', 'pending', '2026-01-01T00:00:00Z') + `); + assert.strictEqual(duplicate._tag, "Failure"); + }), + ); + + it.effect("partial open index on workflow_pr_state present", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const indexes = yield* sql<{ readonly name: string }>`PRAGMA index_list(workflow_pr_state)`; + assert.isTrue(indexes.some((idx) => idx.name === "idx_workflow_pr_state_open")); + }), + ); + + // --- Folded-in coverage from the former 034 (BoardNotifications) --- + + it.effect("workflow_notification_outbox table exists with expected columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + const cols = yield* sql<{ + readonly name: string; + readonly type: string; + readonly notnull: number; + readonly pk: number; + }>`PRAGMA table_info(workflow_notification_outbox)`; + + assert.deepEqual( + cols.map((c) => c.name), + [ + "outbox_id", + "ticket_id", + "board_id", + "sequence", + "status", + "attention_kind", + "attention_reason", + "delivery_state", + "attempt_count", + "created_at", + ], + ); + + assert.strictEqual(cols.find((c) => c.name === "outbox_id")!.pk, 1); + assert.strictEqual(cols.find((c) => c.name === "ticket_id")!.notnull, 1); + assert.strictEqual(cols.find((c) => c.name === "sequence")!.type, "INTEGER"); + assert.strictEqual(cols.find((c) => c.name === "attention_kind")!.notnull, 0); + assert.strictEqual(cols.find((c) => c.name === "delivery_state")!.notnull, 1); + }), + ); + + it.effect("workflow_notification_outbox.sequence is UNIQUE", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + yield* sql` + INSERT INTO workflow_notification_outbox + (outbox_id, ticket_id, board_id, sequence, status, delivery_state, created_at) + VALUES + ('outbox-1', 'ticket-a', 'board-x', 42, 'pending', 'pending', '2026-01-01T00:00:00Z') + `; + const duplicate = yield* Effect.exit(sql` + INSERT INTO workflow_notification_outbox + (outbox_id, ticket_id, board_id, sequence, status, delivery_state, created_at) + VALUES + ('outbox-2', 'ticket-b', 'board-y', 42, 'pending', 'pending', '2026-01-01T00:00:00Z') + `); + assert.strictEqual(duplicate._tag, "Failure"); + }), + ); + + it.effect("idx_workflow_notification_outbox_pending index exists", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(workflow_notification_outbox) + `; + assert.isTrue(indexes.some((idx) => idx.name === "idx_workflow_notification_outbox_pending")); + }), + ); + + it.effect("projection_ticket has attention_kind and attention_reason columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_ticket)`; + const colNames = new Set(cols.map((c) => c.name)); + assert.isTrue(colNames.has("attention_kind"), "attention_kind column missing"); + assert.isTrue(colNames.has("attention_reason"), "attention_reason column missing"); + }), + ); + + it.effect("projection_ticket has current_lane_entered_at column", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_ticket)`; + assert.isTrue( + cols.some((c) => c.name === "current_lane_entered_at"), + "current_lane_entered_at column missing", + ); + }), + ); + + it.effect("idx_workflow_events_ticket_type_time index exists on workflow_events", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(workflow_events) + `; + assert.isTrue( + indexes.some((idx) => idx.name === "idx_workflow_events_ticket_type_time"), + "idx_workflow_events_ticket_type_time index missing", + ); + }), + ); + + // --- Folded-in coverage from the former 035 (WorkSources) --- + + it.effect("work_source_connection table exists with expected columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const cols = yield* sql<{ readonly name: string; readonly pk: number }>` + PRAGMA table_info(work_source_connection) + `; + assert.deepEqual( + cols.map((c) => c.name), + [ + "connection_ref", + "provider", + "display_name", + "auth_mode", + "token_secret_name", + "base_url", + "auth_email", + "created_at", + ], + ); + assert.strictEqual(cols.find((c) => c.name === "connection_ref")!.pk, 1); + }), + ); + + it.effect("work_source_mapping table exists with expected columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const cols = yield* sql<{ readonly name: string; readonly pk: number }>` + PRAGMA table_info(work_source_mapping) + `; + assert.deepEqual( + cols.map((c) => c.name), + [ + "mapping_id", + "board_id", + "source_id", + "provider", + "external_id", + "ticket_id", + "provider_version", + "content_hash", + "lifecycle", + "sync_status", + "source_metadata_json", + "created_at", + "last_synced_at", + ], + ); + assert.strictEqual(cols.find((c) => c.name === "mapping_id")!.pk, 1); + }), + ); + + it.effect("work_source_state table exists with composite primary key", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const cols = yield* sql<{ + readonly name: string; + readonly pk: number; + readonly type: string; + }>` + PRAGMA table_info(work_source_state) + `; + assert.deepEqual( + cols.map((c) => c.name), + [ + "board_id", + "source_id", + "cursor_or_etag", + "last_full_run_at", + "backoff_until", + "consecutive_failures", + "last_error", + ], + ); + assert.isAbove(cols.find((c) => c.name === "board_id")!.pk, 0); + assert.isAbove(cols.find((c) => c.name === "source_id")!.pk, 0); + }), + ); + + it.effect("unique indexes on work_source_mapping exist and enforce uniqueness", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + const objects = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'index' + AND name IN ('idx_work_source_mapping_external', 'idx_work_source_mapping_ticket') + ORDER BY name + `; + const indexNames = new Set(objects.map((o) => o.name)); + assert.isTrue(indexNames.has("idx_work_source_mapping_external")); + assert.isTrue(indexNames.has("idx_work_source_mapping_ticket")); + + yield* sql` + INSERT INTO work_source_mapping + (mapping_id, board_id, source_id, provider, external_id, ticket_id, content_hash, lifecycle, created_at, last_synced_at) + VALUES + ('map-1', 'board-a', 'src-1', 'github', 'ext-1', 'ticket-x', 'hash-1', 'open', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z') + `; + const duplicate = yield* Effect.exit(sql` + INSERT INTO work_source_mapping + (mapping_id, board_id, source_id, provider, external_id, ticket_id, content_hash, lifecycle, created_at, last_synced_at) + VALUES + ('map-2', 'board-b', 'src-2', 'github', 'ext-2', 'ticket-x', 'hash-2', 'open', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z') + `); + assert.strictEqual(duplicate._tag, "Failure"); + }), + ); + + it.effect("33 is the highest migration entry", () => + Effect.gen(function* () { + const highest = migrationEntries.reduce((max, [id]) => (id > max ? id : max), 0); + assert.strictEqual(highest, 33); + const top = migrationEntries.find(([id]) => id === highest); + assert.strictEqual(top?.[1], "WorkflowSchema"); + }), + ); + + it.effect("creates the outbound tables", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + const rows = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'table' + AND name IN ('workflow_outbound_connection', 'workflow_outbound_delivery') + ORDER BY name + `; + const names = new Set(rows.map((r) => r.name)); + assert.isTrue( + names.has("workflow_outbound_connection"), + "workflow_outbound_connection table missing", + ); + assert.isTrue( + names.has("workflow_outbound_delivery"), + "workflow_outbound_delivery table missing", + ); + }), + ); + + // --- Folded-in coverage from the former 034 (WorkflowAgentSession) --- + + it.effect("workflow_agent_session table exists with composite primary key", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + const cols = yield* sql<{ readonly name: string; readonly pk: number }>` + PRAGMA table_info(workflow_agent_session) + `; + assert.deepStrictEqual( + cols.map((c) => c.name), + ["ticket_id", "lane_key", "agent_key", "thread_id", "created_at", "last_used_at"], + ); + assert.deepStrictEqual( + cols + .filter((c) => c.pk > 0) + .sort((a, b) => a.pk - b.pk) + .map((c) => c.name), + ["ticket_id", "lane_key", "agent_key"], + ); + }), + ); + + it.effect("workflow_agent_session has ticket and thread indexes", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(workflow_agent_session) + `; + const names = new Set(indexes.map((i) => i.name)); + assert.isTrue( + names.has("idx_workflow_agent_session_ticket"), + "idx_workflow_agent_session_ticket missing", + ); + assert.isTrue( + names.has("idx_workflow_agent_session_thread"), + "idx_workflow_agent_session_thread missing", + ); + }), + ); + + // --- Folded-in coverage from the former 035 (TicketMessageEditedAt) --- + + it.effect("projection_ticket_message has edited_at column", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + const cols = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_ticket_message) + `; + assert.isTrue( + cols.some((c) => c.name === "edited_at"), + "edited_at column missing on projection_ticket_message", + ); + }), + ); + + // --- workflow_board_proposal (E2) --- + + it.effect("workflow_board_proposal table has expected columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + const cols = yield* sql<{ + readonly name: string; + readonly notnull: number; + readonly pk: number; + }>` + PRAGMA table_info(workflow_board_proposal) + `; + assert.deepEqual( + cols.map((c) => c.name), + [ + "proposal_id", + "board_id", + "base_version_hash", + "base_def_json", + "agent_json", + "proposed_def_json", + "rationale", + "validation_json", + "status", + "applied_version_hash", + "created_at", + "resolved_at", + ], + ); + assert.strictEqual(cols.find((c) => c.name === "proposal_id")!.pk, 1); + assert.strictEqual(cols.find((c) => c.name === "board_id")!.notnull, 1); + assert.strictEqual( + cols.find((c) => c.name === "applied_version_hash")!.notnull, + 0, + "applied_version_hash should be nullable", + ); + assert.strictEqual( + cols.find((c) => c.name === "resolved_at")!.notnull, + 0, + "resolved_at should be nullable", + ); + }), + ); + + it.effect("idx_workflow_board_proposal_board index exists", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(workflow_board_proposal) + `; + assert.isTrue( + indexes.some((idx) => idx.name === "idx_workflow_board_proposal_board"), + "idx_workflow_board_proposal_board index missing", + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/033_WorkflowSchema.ts b/apps/server/src/persistence/Migrations/033_WorkflowSchema.ts new file mode 100644 index 00000000000..38145c14da4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_WorkflowSchema.ts @@ -0,0 +1,523 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Consolidated workflow schema. + * + * Collapses the former migrations 033-055 (all pure DDL — CREATE TABLE / + * ALTER TABLE ADD COLUMN / CREATE INDEX, no data backfills) into a single + * migration. ALTER-added columns are folded inline in ascending original + * migration order, so the resulting schema is byte-for-byte equivalent to the + * one produced by running the original 23-step chain. + * + * This branch (ft/hyperion) has only ever run on a single instance that will + * be wiped, so renumbering is safe — there is no deployed DB to preserve. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // --- Event store (was 033) --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + ticket_id TEXT NOT NULL, + stream_version INTEGER NOT NULL, + event_type TEXT NOT NULL, + occurred_at TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + `; + + // --- Read-model projections (was 033, with later ALTERs folded in) --- + yield* sql` + CREATE TABLE IF NOT EXISTS projection_board ( + board_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + workflow_file_path TEXT NOT NULL, + workflow_version_hash TEXT NOT NULL, + max_concurrent_tickets INTEGER NOT NULL + ) + `; + + // projection_ticket base (033) + current_lane_entry_token (034) + queued_at + // (042) + terminal_at (046) + token_budget (053). description (044) and + // terminal_at (046) were guarded re-adds in the chain; description already + // exists in the 033 CREATE, so only the genuinely new columns are appended. + // attention_kind / attention_reason were added via ALTER in the former 034 + // (BoardNotifications) — folded inline here (TEXT, nullable, matching the + // ALTER-produced columns). + yield* sql` + CREATE TABLE IF NOT EXISTS projection_ticket ( + ticket_id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + current_lane_key TEXT NOT NULL, + status TEXT NOT NULL, + worktree_ref TEXT, + baseline_ref TEXT, + external_ref TEXT, + priority INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + current_lane_entry_token TEXT, + current_lane_entered_at TEXT, + queued_at TEXT, + terminal_at TEXT, + token_budget INTEGER, + attention_kind TEXT, + attention_reason TEXT + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_pipeline_run ( + pipeline_run_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + lane_key TEXT NOT NULL, + lane_entry_token TEXT NOT NULL, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT + ) + `; + + // projection_step_run base (033) + pre/post_checkpoint_ref (038) + + // output_json (041) + provider_response_kind (045) + attempt (048) + + // usage columns (049). + yield* sql` + CREATE TABLE IF NOT EXISTS projection_step_run ( + step_run_id TEXT PRIMARY KEY, + pipeline_run_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + step_key TEXT NOT NULL, + step_type TEXT NOT NULL, + status TEXT NOT NULL, + waiting_reason TEXT, + error TEXT, + started_at TEXT NOT NULL, + finished_at TEXT, + pre_checkpoint_ref TEXT, + post_checkpoint_ref TEXT, + output_json TEXT, + provider_response_kind TEXT, + attempt INTEGER, + input_tokens INTEGER, + cached_input_tokens INTEGER, + output_tokens INTEGER, + total_tokens INTEGER, + retryable INTEGER + ) + `; + + // projection_ticket_message (044). edited_at was added via ALTER in the + // former 035 (TicketMessageEditedAt) — folded inline here (TEXT, nullable, + // matching the ALTER-produced column). + yield* sql` + CREATE TABLE IF NOT EXISTS projection_ticket_message ( + message_id TEXT PRIMARY KEY NOT NULL, + ticket_id TEXT NOT NULL, + step_run_id TEXT, + author TEXT NOT NULL, + body TEXT NOT NULL, + attachments_json TEXT NOT NULL, + created_at TEXT NOT NULL, + edited_at TEXT + ) + `; + + // projection_ticket_dependency (052) + yield* sql` + CREATE TABLE IF NOT EXISTS projection_ticket_dependency ( + ticket_id TEXT NOT NULL, + depends_on_ticket_id TEXT NOT NULL, + PRIMARY KEY (ticket_id, depends_on_ticket_id) + ) + `; + + // --- Worktree lease (035) --- + yield* sql` + CREATE TABLE IF NOT EXISTS worktree_lease ( + worktree_ref TEXT PRIMARY KEY, + owner_kind TEXT NOT NULL, + owner_id TEXT NOT NULL, + fence_token INTEGER NOT NULL, + acquired_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ) + `; + + // --- Dispatch outbox --- + // Created (036) then extended via ALTER ADD COLUMN in 047 (options_json) and + // 051 (project_id, thread_title, runtime_mode). SQLite stores the canonical + // CREATE SQL with ALTER-appended columns spliced in before the closing paren, + // which leaves a characteristic ` ,` / ` )` whitespace shape. We reproduce + // the original CREATE + ALTER sequence verbatim so the stored sqlite_master + // SQL is byte-for-byte identical to the original 23-step chain. + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_dispatch_outbox ( + dispatch_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + step_run_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + turn_id TEXT, + provider_instance TEXT NOT NULL, + model TEXT NOT NULL, + instruction TEXT NOT NULL, + worktree_path TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + started_at TEXT, + confirmed_at TEXT + ) + `; + yield* sql`ALTER TABLE workflow_dispatch_outbox ADD COLUMN options_json TEXT`; + yield* sql`ALTER TABLE workflow_dispatch_outbox ADD COLUMN project_id TEXT`; + yield* sql`ALTER TABLE workflow_dispatch_outbox ADD COLUMN thread_title TEXT`; + yield* sql`ALTER TABLE workflow_dispatch_outbox ADD COLUMN runtime_mode TEXT`; + + // --- Setup run (037) --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_setup_run ( + setup_run_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL UNIQUE, + worktree_ref TEXT NOT NULL, + status TEXT NOT NULL, + exit_code INTEGER, + started_at TEXT NOT NULL, + finished_at TEXT + ) + `; + + // --- Project trust (039) --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_project_trust ( + project_id TEXT PRIMARY KEY, + trusted_at TEXT NOT NULL + ) + `; + + // --- Script run (040) --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_script_run ( + script_run_id TEXT PRIMARY KEY, + step_run_id TEXT NOT NULL UNIQUE, + ticket_id TEXT NOT NULL, + script_thread_id TEXT NOT NULL, + terminal_id TEXT NOT NULL, + status TEXT NOT NULL, + exit_code INTEGER, + signal INTEGER, + started_at TEXT NOT NULL, + finished_at TEXT + ) + `; + + // --- Board version (043) --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_board_version ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + board_id TEXT NOT NULL, + version_hash TEXT NOT NULL, + content_json TEXT NOT NULL, + source TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `; + + // --- Board webhook + delivery dedup (054) --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_board_webhook ( + board_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `; + // Concurrency-safe best-effort dedupe: the mere PRESENCE of a (board_id, + // delivery_id) row means "already seen". recordDelivery INSERTs ON CONFLICT + // DO NOTHING and proceeds only when it actually inserted; releaseDelivery + // DELETEs the row after a failed ingest so the sender's retry re-ingests. + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_webhook_delivery ( + board_id TEXT NOT NULL, + delivery_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (board_id, delivery_id) + ) + `; + + // --- Pull request state + observations (055) --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_pr_state ( + ticket_id TEXT PRIMARY KEY, + pr_number INTEGER NOT NULL, + pr_url TEXT NOT NULL, + branch TEXT NOT NULL, + remote_name TEXT NOT NULL, + repo TEXT NOT NULL, + pr_state TEXT NOT NULL DEFAULT 'open', + last_head_sha TEXT NULL, + last_ci_state TEXT NULL, + last_review_decision TEXT NULL, + last_comment_cursor TEXT NULL, + updated_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_pr_observation ( + observation_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + dedup_key TEXT NOT NULL UNIQUE, + event_name TEXT NOT NULL, + payload_json TEXT NOT NULL, + message_body TEXT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + `; + + // --- Board notification outbox (was 034) --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_notification_outbox ( + outbox_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + board_id TEXT NOT NULL, + sequence INTEGER NOT NULL UNIQUE, + status TEXT NOT NULL, + attention_kind TEXT NULL, + attention_reason TEXT NULL, + delivery_state TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + `; + + // --- Work sources (was 035) --- + yield* sql` + CREATE TABLE IF NOT EXISTS work_source_connection ( + connection_ref TEXT PRIMARY KEY, + provider TEXT NOT NULL, + display_name TEXT NOT NULL, + auth_mode TEXT NOT NULL, + token_secret_name TEXT NOT NULL, + base_url TEXT NULL, + auth_email TEXT NULL, + created_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS work_source_mapping ( + mapping_id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + source_id TEXT NOT NULL, + provider TEXT NOT NULL, + external_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + provider_version TEXT NULL, + content_hash TEXT NOT NULL, + lifecycle TEXT NOT NULL, + sync_status TEXT NOT NULL DEFAULT 'active', + source_metadata_json TEXT NULL, + created_at TEXT NOT NULL, + last_synced_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_work_source_mapping_external + ON work_source_mapping (board_id, source_id, provider, external_id) + `; + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_work_source_mapping_ticket + ON work_source_mapping (ticket_id) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS work_source_state ( + board_id TEXT NOT NULL, + source_id TEXT NOT NULL, + cursor_or_etag TEXT NULL, + last_full_run_at TEXT NULL, + backoff_until TEXT NULL, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + last_error TEXT NULL, + PRIMARY KEY (board_id, source_id) + ) + `; + + // --- Outbound webhooks --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_outbound_connection ( + connection_ref TEXT PRIMARY KEY, + kind TEXT NOT NULL, + display_name TEXT NOT NULL, + secret_name TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_outbound_delivery ( + delivery_id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + rule_id TEXT NOT NULL, + event_sequence INTEGER NOT NULL, + connection_ref TEXT NOT NULL, + formatter TEXT NOT NULL, + context_json TEXT NOT NULL, + delivery_state TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT NULL, + created_at TEXT NOT NULL, + last_error TEXT NULL, + UNIQUE (event_sequence, rule_id) + ) + `; + + // --- Indexes --- + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_events_stream_version + ON workflow_events(ticket_id, stream_version) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_events_ticket_type_time + ON workflow_events (ticket_id, event_type, occurred_at) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_board + ON projection_ticket(board_id) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_step_run_ticket + ON projection_step_run(ticket_id) + `; + // WorkflowRecovery scans projection_step_run by status (and step_type) on + // every server start: recoverConfirmedRunningSteps (WHERE status='running'), + // recoverRunningMergeSteps / recoverRunningPullRequestSteps + // (WHERE step_type=? AND status IN (...)). Leading `status` serves the bare + // status lookup; `step_type` narrows the merge/PR recovery scans. + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_step_run_status_type + ON projection_step_run(status, step_type) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_lane_admission + ON projection_ticket(board_id, current_lane_key, current_lane_entry_token) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_lane_queue + ON projection_ticket(board_id, current_lane_key, queued_at) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_message_ticket + ON projection_ticket_message(ticket_id, created_at) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_terminal_retention + ON projection_ticket(board_id, current_lane_key, terminal_at) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_dependency_depends_on + ON projection_ticket_dependency(depends_on_ticket_id) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_dispatch_outbox_pending + ON workflow_dispatch_outbox(status) + `; + // WorkflowRecovery correlates the outbox by step_run_id on every server + // start: the EXISTS subqueries in recoverConfirmedRunningSteps, isPanelStep's + // COUNT(*), and settleInterruptedPanel's UPDATE all filter + // WHERE step_run_id = ?. + yield* sql` + CREATE INDEX IF NOT EXISTS idx_dispatch_outbox_step_run + ON workflow_dispatch_outbox(step_run_id) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_script_run_ticket + ON workflow_script_run(ticket_id) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_script_run_status + ON workflow_script_run(status) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_board_version_board + ON workflow_board_version(board_id, version_id) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_board_version_hash + ON workflow_board_version(board_id, version_hash) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_pr_state_open + ON workflow_pr_state (pr_state) + WHERE pr_state = 'open' + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_pr_observation_pending + ON workflow_pr_observation (status, ticket_id) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_notification_outbox_pending + ON workflow_notification_outbox (delivery_state, created_at) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_outbound_delivery_due + ON workflow_outbound_delivery (delivery_state, next_attempt_at) + `; + + // --- Board self-improvement proposals (E2) --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_board_proposal ( + proposal_id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + base_version_hash TEXT NOT NULL, + base_def_json TEXT NOT NULL, + agent_json TEXT NOT NULL, + proposed_def_json TEXT NOT NULL, + rationale TEXT NOT NULL, + validation_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + applied_version_hash TEXT NULL, + created_at TEXT NOT NULL, + resolved_at TEXT NULL + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_board_proposal_board + ON workflow_board_proposal (board_id, status, created_at) + `; + + // --- projection_threads.hidden (050). The table is created by a <=032 + // migration, so this only appends the column. --- + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0 + `; + + // --- Per-agent session memory (was 034) --- + // Stores the stable workflow `thread_id` minted for each + // (ticket_id, lane_key, agent_key) so a continueSession agent step can resume + // its own provider session across steps/loops. + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_agent_session ( + ticket_id TEXT NOT NULL, + lane_key TEXT NOT NULL, + agent_key TEXT NOT NULL, + thread_id TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT NOT NULL, + PRIMARY KEY (ticket_id, lane_key, agent_key) + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_agent_session_ticket + ON workflow_agent_session (ticket_id) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_agent_session_thread + ON workflow_agent_session (thread_id) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a..a79a9028b51 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -41,6 +41,10 @@ export const ProjectionThread = Schema.Struct({ pendingUserInputCount: NonNegativeInt, hasActionableProposedPlan: NonNegativeInt, deletedAt: Schema.NullOr(IsoDateTime), + // Internal threads (workflow step/intake dispatches) carry projections but + // stay out of user-facing thread lists. Optional so ordinary chat-thread + // writers stay untouched; absent means visible. + hidden: Schema.optional(NonNegativeInt), }); export type ProjectionThread = typeof ProjectionThread.Type; diff --git a/apps/server/src/persistence/WorkflowIndexUsage.test.ts b/apps/server/src/persistence/WorkflowIndexUsage.test.ts new file mode 100644 index 00000000000..12e81009834 --- /dev/null +++ b/apps/server/src/persistence/WorkflowIndexUsage.test.ts @@ -0,0 +1,279 @@ +/** + * Query-plan (index-use) verification tests. + * + * Runs EXPLAIN QUERY PLAN against the real migrated in-memory SQLite schema and + * asserts that every hot-path workflow query uses an index rather than doing a + * full table scan. + * + * SQLite EXPLAIN QUERY PLAN returns rows with columns: id, parent, notused, detail + * "USING INDEX" or "USING COVERING INDEX" in `detail` → good (indexed lookup) + * "SCAN " without any "USING" clause → bad (full table scan) + * + * KNOWN LIMITATION (drift): the EXPLAINed statements below are hand-mirrored from + * the production hot-path queries (each block cites its source). They are NOT + * shared with the real query builders, so a production query that later changes + * its WHERE/ORDER shape into a scanning form would NOT be caught here — this suite + * would keep passing against the stale copy. Eliminating that fully would require + * centralizing the production queries behind shared constructors (out of scope). + * Treat this as "the intended indexes exist and serve the intended shapes," and + * keep these statements in sync when you touch the cited source queries. + */ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "./Migrations.ts"; +import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface EqpRow { + readonly id: number; + readonly parent: number; + readonly notused: number; + readonly detail: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Assert that the plan uses the SPECIFIC expected index by name (not merely + * "some" index — SQLite could otherwise pick a different, less-suitable index + * and still pass), and that no row is a bare full-table scan. + * + * SQLite renders an indexed lookup as e.g. + * "SEARCH workflow_outbound_delivery USING INDEX idx_..._due (delivery_state=?)" + * so asserting the plan detail contains the expected index name both proves an + * index is used AND pins which one. + */ +function assertIndexUsed( + planRows: ReadonlyArray, + queryLabel: string, + expectedIndex: string, +): void { + const details = Array.from(planRows).map((r) => r.detail ?? ""); + + // The expected index must appear by name in some plan row (this implies + // USING INDEX, since the name only renders inside a "USING ... INDEX" clause). + const usesExpectedIndex = details.some((d) => d.includes(expectedIndex)); + + // No row may be a bare SCAN
that lacks any USING clause. (A join's + // PK-side lookup renders as "SEARCH ... USING INTEGER PRIMARY KEY", not a bare + // SCAN, so this only catches genuine full-table scans.) + const bareScans = details.filter((d) => /^SCAN\s+\w+\s*$/i.test(d.trim())); + + assert.isTrue( + usesExpectedIndex, + `[${queryLabel}] Expected plan to use index "${expectedIndex}" but it did not.\nPlan rows:\n${details.join("\n")}`, + ); + assert.deepStrictEqual( + bareScans, + [], + `[${queryLabel}] Bare full-table scan detected — no index used.\nOffending rows:\n${bareScans.join("\n")}`, + ); +} + +// --------------------------------------------------------------------------- +// Test layer +// --------------------------------------------------------------------------- + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +layer("WorkflowIndexUsage — hot-path queries must use indexes", (it) => { + // ------------------------------------------------------------------------- + // 1. Outbound delivery dispatcher + // SELECT … FROM workflow_outbound_delivery + // WHERE delivery_state = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) + // ORDER BY created_at ASC LIMIT 50 + // → idx_workflow_outbound_delivery_due (delivery_state, next_attempt_at) + // ------------------------------------------------------------------------- + it.effect("workflow_outbound_delivery sweep uses idx_workflow_outbound_delivery_due", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const plan = yield* sql` + EXPLAIN QUERY PLAN + SELECT delivery_id, board_id, ticket_id, connection_ref, formatter, + context_json, attempt_count + FROM workflow_outbound_delivery + WHERE delivery_state = 'pending' + AND (next_attempt_at IS NULL OR next_attempt_at <= '2026-06-15T00:00:00.000Z') + ORDER BY created_at ASC + LIMIT 50 + `; + assertIndexUsed(plan, "outbound_delivery_sweep", "idx_workflow_outbound_delivery_due"); + }), + ); + + // ------------------------------------------------------------------------- + // 2. Notification outbox dispatcher + // SELECT … FROM workflow_notification_outbox + // WHERE delivery_state = 'pending' + // ORDER BY created_at ASC LIMIT 50 + // → idx_workflow_notification_outbox_pending (delivery_state, created_at) + // ------------------------------------------------------------------------- + it.effect( + "workflow_notification_outbox sweep uses idx_workflow_notification_outbox_pending", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const plan = yield* sql` + EXPLAIN QUERY PLAN + SELECT outbox_id, ticket_id, board_id, sequence, status, + attention_kind, attention_reason, attempt_count + FROM workflow_notification_outbox + WHERE delivery_state = 'pending' + ORDER BY created_at ASC + LIMIT 50 + `; + assertIndexUsed( + plan, + "notification_outbox_sweep", + "idx_workflow_notification_outbox_pending", + ); + }), + ); + + // ------------------------------------------------------------------------- + // 3. projection_ticket by board_id + // SELECT … FROM projection_ticket WHERE board_id = ? + // → idx_projection_ticket_board (board_id) + // ------------------------------------------------------------------------- + it.effect("projection_ticket board lookup uses idx_projection_ticket_board", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const plan = yield* sql` + EXPLAIN QUERY PLAN + SELECT ticket_id, board_id, title, current_lane_key, status + FROM projection_ticket + WHERE board_id = 'board-1' + `; + assertIndexUsed(plan, "projection_ticket_by_board", "idx_projection_ticket_board"); + }), + ); + + // ------------------------------------------------------------------------- + // 4. workflow_events replay by ticket_id + // SELECT … FROM workflow_events WHERE ticket_id = ? ORDER BY stream_version ASC + // → idx_workflow_events_stream_version (ticket_id, stream_version) [UNIQUE] + // ------------------------------------------------------------------------- + it.effect("workflow_events replay uses idx_workflow_events_stream_version", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const plan = yield* sql` + EXPLAIN QUERY PLAN + SELECT sequence, event_id, ticket_id, stream_version, event_type, + occurred_at, payload_json + FROM workflow_events + WHERE ticket_id = 'ticket-1' + ORDER BY stream_version ASC + `; + assertIndexUsed(plan, "workflow_events_by_ticket", "idx_workflow_events_stream_version"); + }), + ); + + // ------------------------------------------------------------------------- + // 5. workflow_pr_state — open PRs poller + // SELECT … FROM workflow_pr_state AS pr INNER JOIN projection_ticket … + // WHERE pr.pr_state = 'open' AND ticket.terminal_at IS NULL + // → idx_workflow_pr_state_open (partial index WHERE pr_state = 'open') + // ------------------------------------------------------------------------- + it.effect("workflow_pr_state open-prs query uses idx_workflow_pr_state_open", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const plan = yield* sql` + EXPLAIN QUERY PLAN + SELECT pr.ticket_id, pr.pr_number, pr.repo, + pr.last_head_sha, pr.last_ci_state, pr.last_review_decision, + pr.last_comment_cursor, ticket.board_id + FROM workflow_pr_state AS pr + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = pr.ticket_id + WHERE pr.pr_state = 'open' + AND ticket.terminal_at IS NULL + ORDER BY pr.ticket_id ASC + `; + assertIndexUsed(plan, "pr_state_open_tickets", "idx_workflow_pr_state_open"); + }), + ); + + // ------------------------------------------------------------------------- + // 6. workflow_pr_observation — pending observations drain + // SELECT … FROM workflow_pr_observation WHERE status = 'pending' + // → idx_workflow_pr_observation_pending (status, ticket_id) + // ------------------------------------------------------------------------- + it.effect("workflow_pr_observation pending drain uses idx_workflow_pr_observation_pending", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const plan = yield* sql` + EXPLAIN QUERY PLAN + SELECT obs.observation_id, obs.ticket_id, obs.event_name, + obs.payload_json, obs.message_body, obs.attempt_count + FROM workflow_pr_observation AS obs + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = obs.ticket_id + WHERE obs.status = 'pending' + ORDER BY obs.created_at ASC, obs.observation_id ASC + `; + assertIndexUsed(plan, "pr_observation_pending", "idx_workflow_pr_observation_pending"); + }), + ); + + // ------------------------------------------------------------------------- + // 7. projection_ticket terminal retention sweep + // SELECT … FROM projection_ticket + // WHERE board_id = ? AND current_lane_key = ? AND terminal_at IS NOT NULL + // AND terminal_at < ? + // ORDER BY terminal_at ASC + // → idx_projection_ticket_terminal_retention (board_id, current_lane_key, terminal_at) + // ------------------------------------------------------------------------- + it.effect( + "projection_ticket terminal retention sweep uses idx_projection_ticket_terminal_retention", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const plan = yield* sql` + EXPLAIN QUERY PLAN + SELECT ticket_id, terminal_at + FROM projection_ticket + WHERE board_id = 'board-1' + AND current_lane_key = 'done' + AND terminal_at IS NOT NULL + AND terminal_at < '2026-06-01T00:00:00.000Z' + ORDER BY terminal_at ASC, ticket_id ASC + `; + assertIndexUsed( + plan, + "projection_ticket_terminal_retention", + "idx_projection_ticket_terminal_retention", + ); + }), + ); + + // ------------------------------------------------------------------------- + // 8. workflow_dispatch_outbox — pending dispatch poll + // SELECT … FROM workflow_dispatch_outbox WHERE status = ? + // → idx_dispatch_outbox_pending (status) + // ------------------------------------------------------------------------- + it.effect("workflow_dispatch_outbox pending poll uses idx_dispatch_outbox_pending", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const plan = yield* sql` + EXPLAIN QUERY PLAN + SELECT dispatch_id, ticket_id, step_run_id, thread_id, + provider_instance, model, instruction, worktree_path, status + FROM workflow_dispatch_outbox + WHERE status = 'pending' + `; + assertIndexUsed(plan, "dispatch_outbox_pending", "idx_dispatch_outbox_pending"); + }), + ); +}); diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 051a7d20de0..eff5a531fce 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -39,6 +39,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + isThreadHidden: () => Effect.succeed(false), }); describe("ProjectSetupScriptRunner", () => { @@ -55,11 +56,13 @@ describe("ProjectSetupScriptRunner", () => { Layer.succeed(TerminalManager, { open, attachStream: () => Effect.die(new Error("unused")), + attachHistoryStream: () => Effect.die(new Error("unused")), write, resize: () => Effect.void, clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, + getSnapshot: () => Effect.succeed(null), subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), }), @@ -117,11 +120,13 @@ describe("ProjectSetupScriptRunner", () => { Layer.succeed(TerminalManager, { open, attachStream: () => Effect.die(new Error("unused")), + attachHistoryStream: () => Effect.die(new Error("unused")), write, resize: () => Effect.void, clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, + getSnapshot: () => Effect.succeed(null), subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), }), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 916c9d077dd..b2fe6d5e5cb 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -298,6 +298,14 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("declares session resume support in its capabilities", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + assert.equal(adapter.capabilities.supportsSessionResume, true); + }).pipe(Effect.provide(harness.layer)); + }); + it.effect("derives bypass permission mode from full-access runtime policy", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1891,6 +1899,90 @@ describe("ClaudeAdapterLive", () => { }, ); + it.effect( + "treats flat cumulative result usage without iterations as totals, not context usage", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-usage-flat-total", + description: "Thinking through the patch", + usage: { + total_tokens: 190000, + }, + session_id: "sdk-session-task-usage-flat-total", + uuid: "task-usage-progress-flat-total", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-result-usage-flat-total", + usage: { + input_tokens: 1200, + cache_creation_input_tokens: 33800, + cache_read_input_tokens: 480000, + output_tokens: 20000, + }, + modelUsage: { + "claude-opus-4-6": { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + const finalUsageEvent = usageEvents.at(-1); + assert.equal(finalUsageEvent?.type, "thread.token-usage.updated"); + if (finalUsageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(finalUsageEvent.payload, { + usage: { + usedTokens: 190000, + lastUsedTokens: 190000, + totalProcessedTokens: 535000, + maxTokens: 200000, + }, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect( "emits completion only after turn result when assistant frames arrive before deltas", () => { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index c91f305b174..6aac751bc35 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -771,6 +771,7 @@ function applyClaudeTaskToolResult( if (!Array.isArray(resultTasks)) { return false; } + const hadTasks = tasks.size > 0; tasks.clear(); for (const entry of resultTasks) { if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { @@ -789,7 +790,7 @@ function applyClaudeTaskToolResult( blockedBy: new Set(readStringArray(task.blockedBy)), }); } - return tasks.size > 0; + return tasks.size > 0 || hadTasks; } if (tool.toolName === "TaskCreate") { @@ -1926,13 +1927,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( : undefined; const hasResultUsageIteration = resultUsageRecord !== undefined && lastClaudeUsageIteration(resultUsageRecord) !== undefined; - const resultHasActiveUsage = - resultUsageRecord !== undefined && - (hasResultUsageIteration || - claudeUsageInputTokens(resultUsageRecord) + claudeUsageOutputTokens(resultUsageRecord) > 0); + // Without an `iterations` array, result.usage carries turn-cumulative + // totals (flat fields included), not the active context size — only an + // iteration snapshot is trusted for `usedTokens`. const resultTotalOnly = resultUsageRecord !== undefined && - !resultHasActiveUsage && + !hasResultUsageIteration && claudeTotalProcessedTokens(resultUsageRecord) !== undefined; const resultIterationSnapshot = resultUsageRecord ? normalizeClaudeActiveTokenUsage( @@ -3857,6 +3857,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + supportsSessionResume: true, }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 7fef85c42e0..3708a6b36c3 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -262,6 +262,12 @@ validationLayer("CodexAdapterLive validation", (it) => { assert.equal(validationRuntimeFactory.factory.mock.calls.length, 0); }), ); + it.effect("declares session resume support in its capabilities", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + assert.equal(adapter.capabilities.supportsSessionResume, true); + }), + ); it.effect("maps codex model options before starting a session", () => Effect.gen(function* () { validationRuntimeFactory.factory.mockClear(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 270126e934b..4521e6cf655 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1694,6 +1694,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + supportsSessionResume: true, }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0e21b76306b..fd8d3ced51e 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -64,6 +64,59 @@ it("maps current Codex model capability fields", () => { ]); }); +it("does not duplicate the default option when the catalog carries a 'default' tier", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: [], + defaultReasoningEffort: "medium", + defaultServiceTier: "default", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + serviceTiers: [ + { + id: "default", + name: "Standard", + description: "Balanced speed and cost.", + }, + { + id: "priority", + name: "Fast", + description: "Lower latency responses.", + }, + ], + supportedReasoningEfforts: [], + }); + + const serviceTier = capabilities.optionDescriptors?.find( + (descriptor) => descriptor.id === "serviceTier", + ); + assert.deepStrictEqual(serviceTier, { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { + id: "default", + label: "Standard", + description: "Balanced speed and cost.", + isDefault: true, + }, + { + id: "priority", + label: "Fast", + description: "Lower latency responses.", + }, + ], + currentValue: "default", + }); + const options = serviceTier?.type === "select" ? serviceTier.options : []; + assert.strictEqual(options.filter((option) => option.id === "default").length, 1); + assert.strictEqual(options.filter((option) => option.isDefault === true).length, 1); +}); + it("uses standard routing when the catalog has no default service tier", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: ["fast"], diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index fb2f36f6438..8323480732e 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -145,16 +145,24 @@ export function mapCodexModelCapabilities( }); } if (serviceTiers.length > 0) { + // Only synthesize the Standard option when the catalog doesn't already + // carry a 'default' tier — otherwise the catalog entry (mapped below with + // its own name/description) would be duplicated. + const hasCatalogDefaultTier = serviceTiers.some((tier) => tier.id === DEFAULT_SERVICE_TIER_ID); optionDescriptors.push({ id: "serviceTier", label: "Service Tier", type: "select", options: [ - { - id: DEFAULT_SERVICE_TIER_ID, - label: "Standard", - ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), - }, + ...(hasCatalogDefaultTier + ? [] + : [ + { + id: DEFAULT_SERVICE_TIER_ID, + label: "Standard", + ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), + }, + ]), ...serviceTiers.map((tier) => ({ id: tier.id, label: tier.name, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index c71c6964459..9585ee99ece 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -151,6 +151,12 @@ const cursorAdapterTestLayer = it.layer( ); cursorAdapterTestLayer("CursorAdapterLive", (it) => { + it.effect("declares session resume support in its capabilities", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + assert.equal(adapter.capabilities.supportsSessionResume, true); + }), + ); it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 1560332ad7f..7a36a340b22 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1160,7 +1160,7 @@ export function makeCursorAdapter( return { provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", supportsSessionResume: true }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index bfd5ae25755..f926fb94869 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -80,6 +80,13 @@ const makeTestAdapter = (binaryPath: string, options?: Parameters { + it.effect("declares session resume support in its capabilities", () => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + assert.equal(adapter.capabilities.supportsSessionResume, true); + }), + ); it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-mock-thread"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index a21a2bb9fc7..1eee3bcbe3f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -989,7 +989,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return { provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", supportsSessionResume: true }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 3f483d8fd7e..7106bc8e659 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -63,6 +63,29 @@ const runtimeMock = { closeError: null as Error | null, messages: [] as MessageEntry[], subscribedEvents: [] as unknown[], + // When true, the subscribed-event stream stays open after draining + // `subscribedEvents` and waits for `pushSubscribedEvent` calls, so tests + // can interleave SSE delivery with adapter calls. + subscribedEventsOpen: false, + notifySubscribedEvent: [] as Array<() => void>, + }, + pushSubscribedEvent(event: unknown) { + this.state.subscribedEvents.push(event); + for (const notify of this.state.notifySubscribedEvent.splice(0)) { + notify(); + } + }, + // Tests that set `subscribedEventsOpen` MUST close the stream before + // finishing (e.g. via Effect.ensuring) — a generator left suspended on the + // notify promise blocks the event-pump fiber's teardown at scope close. + // Note: pumps of sessions left over from earlier tests may also be + // suspended here (their lazy first pull can happen while the stream is + // open), which is why the waiter list must support multiple resolvers. + closeSubscribedEvents() { + this.state.subscribedEventsOpen = false; + for (const notify of this.state.notifySubscribedEvent.splice(0)) { + notify(); + } }, reset() { this.state.startCalls.length = 0; @@ -76,6 +99,7 @@ const runtimeMock = { this.state.closeError = null; this.state.messages = []; this.state.subscribedEvents = []; + this.closeSubscribedEvents(); }, }; @@ -161,8 +185,18 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { event: { subscribe: async () => ({ stream: (async function* () { - for (const event of runtimeMock.state.subscribedEvents) { - yield event; + let index = 0; + while (true) { + if (index < runtimeMock.state.subscribedEvents.length) { + yield runtimeMock.state.subscribedEvents[index++]; + continue; + } + if (!runtimeMock.state.subscribedEventsOpen) { + return; + } + await new Promise((resolve) => { + runtimeMock.state.notifySubscribedEvent.push(resolve); + }); } })(), }), @@ -228,6 +262,12 @@ const advanceTestClock = (ms: number) => TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { + it.effect("does not declare session resume support in its capabilities", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + assert.ok(!adapter.capabilities.supportsSessionResume); + }), + ); it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -460,20 +500,194 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { input: "actually run 15", modelSelection: { instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", + model: "anthropic/claude-sonnet-4-5", }, }) .pipe(Effect.flip); - // The original turn keeps running — only the steer prompt failed. + // The original turn keeps running — only the steer prompt failed, and + // the pre-steer model is restored instead of reporting the new one. assert.equal(error._tag, "ProviderAdapterRequestError"); const sessions = yield* adapter.listSessions(); const session = sessions.find((entry) => entry.threadId === threadId); assert.equal(session?.status, "running"); assert.equal(String(session?.activeTurnId), String(turn.turnId)); + assert.equal(session?.model, "openai/gpt-5"); + assert.equal(session?.lastError, "steer failed"); }), ); + it.effect("opens a fresh turn for a prompt sent right after an interrupt", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-then-prompt"); + const openCodeSessionId = "http://127.0.0.1:9999/session"; + const statusEvent = (status: Record) => ({ + type: "session.status", + properties: { sessionID: openCodeSessionId, status }, + }); + // Keep the SSE stream open so events can be delivered mid-test. + runtimeMock.state.subscribedEventsOpen = true; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run 5 commands", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", + }, + }); + + yield* adapter.interruptTurn(threadId, turn.turnId); + + // The interrupt settles the turn synchronously — without waiting for + // the async SSE idle event the session must already be ready. + const interruptedSessions = yield* adapter.listSessions(); + const interrupted = interruptedSessions.find((entry) => entry.threadId === threadId); + assert.equal(interrupted?.status, "ready"); + assert.equal(interrupted?.activeTurnId, undefined); + + // A prompt sent immediately after the interrupt is a fresh turn, not a + // steer of the aborted one. + const nextTurn = yield* adapter.sendTurn({ + threadId, + input: "try something else", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", + }, + }); + assert.notEqual(String(nextTurn.turnId), String(turn.turnId)); + + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + assert.equal(session?.status, "running"); + assert.equal(String(session?.activeTurnId), String(nextTurn.turnId)); + + // The abort of the interrupted turn makes the server emit a trailing + // idle. Deliver it AFTER the fresh turn has started: it must not + // settle the fresh turn. The retry event is an observable marker that + // proves the stale idle was processed without emitting turn.completed, + // and the busy + idle pair is the fresh turn's own lifecycle, which + // must still complete it. + const settleEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.completed" || event.type === "runtime.warning"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + runtimeMock.pushSubscribedEvent(statusEvent({ type: "idle" })); + runtimeMock.pushSubscribedEvent(statusEvent({ type: "retry", message: "stale-idle-marker" })); + runtimeMock.pushSubscribedEvent(statusEvent({ type: "busy" })); + runtimeMock.pushSubscribedEvent(statusEvent({ type: "idle" })); + + const settleEvents = Array.from( + yield* Fiber.join(settleEventsFiber).pipe(Effect.timeout("1 second")), + ); + // The stale abort-idle (processed before the marker warning) emitted no + // turn.completed; only the genuine idle completed the fresh turn. + assert.deepEqual( + settleEvents.map((event) => event.type), + ["runtime.warning", "turn.completed"], + ); + const completed = settleEvents[1]; + if (completed?.type === "turn.completed") { + assert.equal(String(completed.turnId), String(nextTurn.turnId)); + assert.equal(completed.payload.state, "completed"); + } + + const settledSessions = yield* adapter.listSessions(); + const settled = settledSessions.find((entry) => entry.threadId === threadId); + assert.equal(settled?.status, "ready"); + assert.equal(settled?.activeTurnId, undefined); + }).pipe( + // Close the live SSE stream so the event-pump fiber can wind down at + // scope close instead of hanging on the suspended mock generator. + Effect.ensuring(Effect.sync(() => runtimeMock.closeSubscribedEvents())), + ), + ); + + it.effect( + "re-arms genuine error handling after an interrupt even when no new turn starts (M5)", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-then-walk-away"); + const openCodeSessionId = "http://127.0.0.1:9999/session"; + const statusEvent = (status: Record) => ({ + type: "session.status", + properties: { sessionID: openCodeSessionId, status }, + }); + const errorEvent = (message: string) => ({ + type: "session.error", + properties: { + sessionID: openCodeSessionId, + error: { data: { message } }, + }, + }); + runtimeMock.state.subscribedEventsOpen = true; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId, + input: "run something", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", + }, + }); + + // Interrupt arms suppressSettleEventsUntilBusy=true. + yield* adapter.interruptTurn(threadId); + + // Collect the runtime.error emitted by the GENUINE error below. If the + // flag stayed stuck `true` (the M5 bug) no runtime.error would ever be + // emitted and this fiber would time out. + const errorEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "runtime.error"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + // The abort's trailing stale idle consumes + clears suppression. The + // user never sends a new prompt, so no `busy` ever arrives. + runtimeMock.pushSubscribedEvent(statusEvent({ type: "idle" })); + // A later GENUINE server error must NOT be swallowed. + runtimeMock.pushSubscribedEvent(errorEvent("server crashed")); + + const errorEvents = Array.from( + yield* Fiber.join(errorEventsFiber).pipe(Effect.timeout("1 second")), + ); + assert.equal(errorEvents.length, 1); + const runtimeError = errorEvents[0]; + if (runtimeError?.type === "runtime.error") { + assert.equal(runtimeError.payload.message, "server crashed"); + } + + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + assert.equal(session?.status, "error"); + assert.equal(session?.lastError, "server crashed"); + }).pipe(Effect.ensuring(Effect.sync(() => runtimeMock.closeSubscribedEvents()))), + ); + it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { const instanceId = ProviderInstanceId.make("opencode_zen"); const adapterLayer = Layer.effect( diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 1eb6e47bc19..23c5500ad32 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -81,6 +81,23 @@ interface OpenCodeSessionContext { activeTurnId: TurnId | undefined; activeAgent: string | undefined; activeVariant: string | undefined; + /** + * Set by `interruptTurn` after a successful abort: the abort makes the + * server emit a trailing idle (and possibly error) status for the aborted + * turn, which `interruptTurn` already settled synchronously. Those stale + * events must not settle a newer turn started right after the interrupt, + * so idle/error handling is suppressed until the next `busy` status — the + * server emits the abort-idle before the next turn's busy, so once busy is + * seen any later idle/error is genuine again. + * + * The flag is also cleared as soon as the abort's terminal stale idle is + * consumed, so it can never stick `true` forever: without that, interrupting + * and then walking away (no new turn, so no `busy` ever arrives) would + * silently swallow every later genuine session.error/idle (M5). The abort's + * trailing idle is expected to arrive regardless of whether a new turn was + * started, so consuming it is a reliable point to re-arm genuine handling. + */ + suppressSettleEventsUntilBusy: boolean; /** * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`. * The session lifecycle is owned by `sessionScope`; this Ref exists only @@ -875,6 +892,8 @@ export function makeOpenCodeAdapter( case "session.status": { if (event.properties.status.type === "busy") { + // A new turn is running: any idle/error from here on is genuine. + context.suppressSettleEventsUntilBusy = false; yield* updateProviderSession(context, { status: "running", activeTurnId: turnId, @@ -897,6 +916,19 @@ export function makeOpenCodeAdapter( break; } + if (event.properties.status.type === "idle" && context.suppressSettleEventsUntilBusy) { + // Stale idle caused by interruptTurn's abort — that turn was + // already settled there; ignore it so it cannot settle a newer + // turn started after the interrupt. The idle is the aborted turn's + // terminal status, so clear suppression now that we've consumed it: + // otherwise, if the user interrupts and never starts a new turn (no + // `busy` ever arrives), a later GENUINE session.error/idle would be + // swallowed forever (M5). A preceding stale `session.error` from the + // same abort is still suppressed by the error handler below. + context.suppressSettleEventsUntilBusy = false; + break; + } + if (event.properties.status.type === "idle" && turnId) { context.activeTurnId = undefined; yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); @@ -916,6 +948,12 @@ export function makeOpenCodeAdapter( } case "session.error": { + if (context.suppressSettleEventsUntilBusy) { + // Error fallout from interruptTurn's abort — that turn was + // already settled there; ignore it so it cannot fail a newer + // turn started after the interrupt. + break; + } const message = sessionErrorMessage(event.properties.error); const activeTurnId = context.activeTurnId; context.activeTurnId = undefined; @@ -1141,6 +1179,7 @@ export function makeOpenCodeAdapter( activeTurnId: undefined, activeAgent: undefined, activeVariant: undefined, + suppressSettleEventsUntilBusy: false, stopped: yield* Ref.make(false), sessionScope: started.sessionScope, }; @@ -1214,6 +1253,12 @@ export function makeOpenCodeAdapter( const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + // Snapshot the pre-prompt state so a failed steer can roll back to the + // still-running original turn's agent/variant/model. + const previousAgent = context.activeAgent; + const previousVariant = context.activeVariant; + const previousModel = context.session.model; + context.activeTurnId = turnId; context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); context.activeVariant = variant; @@ -1252,10 +1297,23 @@ export function makeOpenCodeAdapter( // session back to ready with lastError set, emit turn.aborted, then // let the typed error propagate. We don't need to rebuild the error // here — `toRequestError` already produced the right shape. A failed - // steer leaves the still-running original turn untouched. + // steer leaves the still-running original turn untouched, but the + // pre-prompt agent/variant/model mutations are rolled back so the + // adapter keeps reporting the running turn's state; no turn.aborted + // is emitted because that turn is still running. Effect.tapError((requestError) => steeringTurnId !== undefined - ? Effect.void + ? Effect.gen(function* () { + context.activeTurnId = steeringTurnId; + context.activeAgent = previousAgent; + context.activeVariant = previousVariant; + yield* updateProviderSession(context, { + status: "running", + activeTurnId: steeringTurnId, + ...(previousModel !== undefined ? { model: previousModel } : {}), + lastError: requestError.detail, + }); + }) : Effect.gen(function* () { context.activeTurnId = undefined; context.activeAgent = undefined; @@ -1295,11 +1353,18 @@ export function makeOpenCodeAdapter( yield* runOpenCodeSdk("session.abort", () => context.client.session.abort({ sessionID: context.openCodeSessionId }), ).pipe(Effect.mapError(toRequestError)); - if (turnId ?? context.activeTurnId) { + // The abort makes the server emit a trailing idle/error status for + // the aborted turn. We settle the turn synchronously below, so those + // stale events must be ignored until the next turn's busy status — + // otherwise a late abort-idle could settle a turn started right + // after this interrupt. + context.suppressSettleEventsUntilBusy = true; + const abortedTurnId = turnId ?? context.activeTurnId; + if (abortedTurnId) { yield* emit({ ...(yield* buildEventBase({ threadId, - turnId: turnId ?? context.activeTurnId, + turnId: abortedTurnId, })), type: "turn.aborted", payload: { @@ -1307,6 +1372,14 @@ export function makeOpenCodeAdapter( }, }); } + // Settle the turn synchronously instead of waiting for the async SSE + // idle event: a prompt sent right after an interrupt must open a fresh + // turn rather than be misclassified as a steer of the aborted one. + // Mirrors the idle handler's cleanup. + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); }, ); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 18e6166c1cd..18ba3723992 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -210,6 +210,7 @@ describe("ProviderSessionReaper", () => { : Option.none(), ), getThreadDetailById: () => Effect.die("unused"), + isThreadHidden: () => Effect.succeed(false), }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/provider/Services/ProviderAdapter.test.ts b/apps/server/src/provider/Services/ProviderAdapter.test.ts new file mode 100644 index 00000000000..da86927f1c7 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderAdapter.test.ts @@ -0,0 +1,20 @@ +import { assert, describe, it } from "@effect/vitest"; + +import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts"; + +describe("ProviderAdapterCapabilities maxInputChars", () => { + it("round-trips an explicit maxInputChars value", () => { + const capabilities: ProviderAdapterCapabilities = { + sessionModelSwitch: "in-session", + maxInputChars: 3000, + }; + assert.equal(capabilities.maxInputChars, 3000); + }); + + it("treats an omitted maxInputChars as undefined", () => { + const capabilities: ProviderAdapterCapabilities = { + sessionModelSwitch: "in-session", + }; + assert.equal(capabilities.maxInputChars, undefined); + }); +}); diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd..68000a7b5a6 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -30,6 +30,16 @@ export interface ProviderAdapterCapabilities { * Declares whether changing the model on an existing session is supported. */ readonly sessionModelSwitch: ProviderSessionModelSwitchMode; + /** + * Declares whether the adapter can resume a prior session via its durable + * resume cursor (re-using a stable workflow `threadId`). Optional — when + * unset it is treated as `false` everywhere. + */ + readonly supportsSessionResume?: boolean; + /** Max characters this provider accepts in a single turn's input. Omitted ⇒ the + * global PROVIDER_SEND_TURN_MAX_INPUT_CHARS (120k) cap applies. Set per adapter + * ONLY when a real, documented/measured per-turn input limit is known. */ + readonly maxInputChars?: number; } export interface ProviderThreadTurnSnapshot { diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts new file mode 100644 index 00000000000..02219742a06 --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -0,0 +1,32 @@ +/** + * No-tool / MCP-suppression guarantee for locally-spawned OpenCode servers. + * + * Every t3code-spawned OpenCode server must run with an EMPTY config + * (`OPENCODE_CONFIG_CONTENT="{}"`) so the user's opencode.json / global config — + * MCP servers, custom instructions, plugins — is never loaded. This is the + * OpenCode analog of the Claude `--strict-mcp-config --mcp-config "{}"` and + * Codex `--ignore-user-config` postures, and pairs with the per-session + * `permission "*" deny` rule asserted in OpenCodeTextGeneration.test.ts. + */ +import { describe, expect, it } from "vite-plus/test"; + +import { buildOpenCodeServeSpawn } from "./opencodeRuntime.ts"; + +describe("buildOpenCodeServeSpawn (MCP/config suppression)", () => { + it("forces an EMPTY config so no MCP servers, instructions, or plugins load", () => { + const { env } = buildOpenCodeServeSpawn({ + hostname: "127.0.0.1", + port: 4399, + environment: { PATH: "/usr/bin", OPENCODE_CONFIG: "/home/u/opencode.json" }, + }); + // The empty-config override wins regardless of inherited env. + expect(env.OPENCODE_CONFIG_CONTENT).toBe("{}"); + // Inherited env is otherwise preserved (auth/PATH still available). + expect(env.PATH).toBe("/usr/bin"); + }); + + it("serves on the requested hostname/port", () => { + const { args } = buildOpenCodeServeSpawn({ hostname: "127.0.0.1", port: 4399 }); + expect(args).toEqual(["serve", "--hostname=127.0.0.1", "--port=4399"]); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 365884da85d..d3580fe2309 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -36,6 +36,32 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; +/** + * Build the argv + env for spawning a local OpenCode server. + * + * SAFETY (no-tool guarantee): every t3code-spawned OpenCode server runs with + * `OPENCODE_CONFIG_CONTENT="{}"` — an EMPTY config — so the user's + * `opencode.json` / global config is NOT loaded. That means no MCP servers, no + * custom instructions/AGENTS.md, and no plugins reach the server. Combined with + * the per-session `permission "*" deny` posture, the board-proposal op (and all + * text-gen ops) cannot load or invoke any tool. This is the OpenCode analog of + * the Claude path's `--strict-mcp-config --mcp-config "{}"` and the Codex path's + * `--ignore-user-config`. + */ +export function buildOpenCodeServeSpawn(input: { + readonly hostname: string; + readonly port: number; + readonly environment?: NodeJS.ProcessEnv; +}): { readonly args: Array; readonly env: NodeJS.ProcessEnv } { + return { + args: ["serve", `--hostname=${input.hostname}`, `--port=${input.port}`], + env: { + ...(input.environment ?? process.env), + OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT, + }, + }; +} + const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; const DEFAULT_HOSTNAME = "127.0.0.1"; @@ -339,19 +365,25 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ), )); const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; - const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; - const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); + const spawn = buildOpenCodeServeSpawn({ + hostname, + port, + ...(input.environment ? { environment: input.environment } : {}), + }); + const spawnCommand = yield* resolveCommand(input.binaryPath, spawn.args, input.environment); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { detached: hostPlatform !== "win32", shell: spawnCommand.shell, - env: { - ...input.environment, - OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT, - }, - extendEnv: input.environment === undefined, + // Use the builder's env as the single source of truth for the + // no-tool guarantee. `buildOpenCodeServeSpawn` already merges the + // inherited env (falling back to `process.env` when none is given) + // and forces `OPENCODE_CONFIG_CONTENT="{}"`, so `extendEnv` is + // false here to avoid double-merging `process.env`. + env: spawn.env, + extendEnv: false, }), ) .pipe( diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 9e0ad364d97..490c4c4f0ce 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -455,6 +455,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { Effect.as(Option.some(thread)), ), getProjectShellById: () => Effect.succeed(Option.some(project)), + isThreadHidden: () => Effect.succeed(false), } as unknown as ProjectionSnapshotQueryShape; const descriptor = { @@ -635,6 +636,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { } satisfies OrchestrationShellSnapshot), getThreadShellById: () => Effect.succeed(Option.some(thread)), getProjectShellById: () => Effect.succeed(Option.some(project)), + isThreadHidden: () => Effect.succeed(false), } as unknown as ProjectionSnapshotQueryShape), ); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index d02c83d563e..cf9727fc6b2 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -355,7 +355,11 @@ const make = Effect.gen(function* () { }); }); - const thread = yield* snapshotQuery.getThreadShellById(threadId); + // Hidden (workflow-internal) threads are never published externally. + const threadHidden = yield* snapshotQuery.isThreadHidden(threadId); + const thread = threadHidden + ? Option.none() + : yield* snapshotQuery.getThreadShellById(threadId); const project = Option.isSome(thread) ? yield* snapshotQuery.getProjectShellById(thread.value.projectId) : Option.none(); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 77a4dbde25f..a7f155c12ce 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5,6 +5,7 @@ import * as NodeCrypto from "node:crypto"; import { AuthAccessTokenType, + AuthAdministrativeScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, CommandId, @@ -68,6 +69,7 @@ import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const administrativeScopeText = AuthAdministrativeScopes.join(" "); import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; @@ -111,6 +113,7 @@ import { ProjectSetupScriptRunnerError, type ProjectSetupScriptRunnerShape, } from "./project/Services/ProjectSetupScriptRunner.ts"; +import { ProjectScriptTrust } from "./workflow/Services/ProjectScriptTrust.ts"; import { RepositoryIdentityResolver, type RepositoryIdentityResolverShape, @@ -140,6 +143,23 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import { BoardRegistry } from "./workflow/Services/BoardRegistry.ts"; +import { BoardDiscovery } from "./workflow/Services/BoardDiscovery.ts"; +import { ProjectWorkspaceResolver } from "./workflow/Services/ProjectWorkspaceResolver.ts"; +import { TicketDiffQuery } from "./workflow/Services/TicketDiffQuery.ts"; +import { WorkflowBoardEvents } from "./workflow/Services/WorkflowBoardEvents.ts"; +import { WorkflowBoardSaveLocks } from "./workflow/Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardVersionStore } from "./workflow/Services/WorkflowBoardVersionStore.ts"; +import { WorkflowEngine } from "./workflow/Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "./workflow/Services/WorkflowEventStore.ts"; +import { WorkflowFileLoader } from "./workflow/Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "./workflow/Services/WorkflowReadModel.ts"; +import { WorkSourceConnectionStore } from "./workflow/Services/WorkSourceConnectionStore.ts"; +import { + WorkSourceAuthError, + WorkSourceProviderRegistry, +} from "./workflow/Services/WorkSourceProvider.ts"; +import { WorkflowSourceCommitter } from "./workflow/Services/WorkflowSourceCommitter.ts"; import * as Data from "effect/Data"; const defaultProjectId = ProjectId.make("project-default"); @@ -394,6 +414,7 @@ const buildAppUnderTest = (options?: { ...derivedPaths, staticDir: undefined, devUrl, + webBaseUrl: undefined, noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: defaultDesktopBootstrapToken, @@ -539,215 +560,297 @@ const buildAppUnderTest = (options?: { ...options.layers.vcsStatusBroadcaster, }) : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); + const workflowRouteServicesLayer = Layer.mergeAll( + Layer.mock(WorkflowEngine)({ + createTicket: () => Effect.die("unused workflow createTicket"), + moveTicket: () => Effect.die("unused workflow moveTicket"), + runLane: () => Effect.die("unused workflow runLane"), + resolveApproval: () => Effect.die("unused workflow resolveApproval"), + cancelStep: () => Effect.die("unused workflow cancelStep"), + cancelBoardPipelines: () => Effect.void, + completeRecoveredStep: () => Effect.die("unused workflow completeRecoveredStep"), + }), + Layer.mock(WorkflowReadModel)({ + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + getBoard: () => Effect.succeed(null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + listBoardsForProject: () => Effect.succeed([]), + }), + Layer.mock(WorkflowEventStore)({ + append: () => Effect.die("unused workflow event append"), + readByTicket: () => Stream.empty, + readFromSequence: () => Stream.empty, + readAll: () => Stream.empty, + deleteForBoard: () => Effect.void, + }), + Layer.mock(BoardRegistry)({ + register: () => Effect.die("unused workflow board register"), + getDefinition: () => Effect.succeed(null), + getLane: () => Effect.succeed(null), + }), + Layer.mock(TicketDiffQuery)({ + getTicketDiff: () => Effect.die("unused workflow ticket diff"), + }), + Layer.mock(WorkflowBoardEvents)({ + publish: () => Effect.void, + stream: () => Stream.empty, + }), + Layer.mock(WorkflowBoardSaveLocks)({ + withSaveLock: (_boardId, effect) => effect, + }), + Layer.mock(WorkflowBoardVersionStore)({ + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }), + Layer.mock(WorkflowFileLoader)({ + loadAndRegister: () => Effect.die("unused workflow file load"), + }), + Layer.mock(BoardDiscovery)({ + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectWorkspaceResolver)({ + resolve: () => Effect.succeed("/tmp/default-project"), + }), + Layer.mock(ProjectScriptTrust)({ + isTrusted: () => Effect.succeed(false), + setTrusted: () => Effect.void, + }), + Layer.mock(WorkSourceConnectionStore)({ + getToken: (connectionRef) => Effect.fail(new WorkSourceAuthError({ connectionRef })), + getConnectionAuth: (connectionRef) => Effect.fail(new WorkSourceAuthError({ connectionRef })), + create: () => Effect.die("unused work-source connection create"), + list: () => Effect.succeed([]), + remove: () => Effect.void, + }), + Layer.mock(WorkSourceProviderRegistry)({ + get: () => { + throw new Error("unused work-source provider registry get"); + }, + }), + Layer.mock(WorkflowSourceCommitter)({ + reconcileChunk: () => Effect.die("unused work-source committer reconcileChunk"), + }), + ); + // @effect-diagnostics-next-line unnecessaryPipeChain:off — split because a single pipe caps at 20 args; merging re-introduces TS2554 const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, disableLogger: true, - }).pipe( - Layer.provide( - Layer.mock(Keybindings)({ - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], + }) + .pipe( + Layer.provide( + Layer.mock(Keybindings)({ + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + ...options?.layers?.keybindings, }), - streamChanges: Stream.empty, - ...options?.layers?.keybindings, - }), - ), - Layer.provide( - Layer.mock(ProviderRegistry)({ - getProviders: Effect.succeed([]), - refresh: () => Effect.succeed([]), - refreshInstance: () => Effect.succeed([]), - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), - ), - setProviderMaintenanceActionState: () => Effect.succeed([]), - streamChanges: Stream.empty, - ...options?.layers?.providerRegistry, - }), - ), - Layer.provide( - Layer.mock(ServerSettingsService)({ - start: Effect.void, - ready: Effect.void, - getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), - updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), - streamChanges: Stream.empty, - ...options?.layers?.serverSettings, - }), - ), - Layer.provide( - Layer.mock(ExternalLauncher.ExternalLauncher)({ - resolveAvailableEditors: () => Effect.succeed([]), - ...options?.layers?.externalLauncher, - }), - ), - Layer.provide( - Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({ - read: Effect.succeed({ - serverPid: process.pid, - readAt: TEST_EPOCH, - processCount: 0, - totalRssBytes: 0, - totalCpuPercent: 0, - processes: [], - error: Option.none(), + ), + Layer.provide( + Layer.mock(ProviderRegistry)({ + getProviders: Effect.succeed([]), + refresh: () => Effect.succeed([]), + refreshInstance: () => Effect.succeed([]), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), + ), + setProviderMaintenanceActionState: () => Effect.succeed([]), + streamChanges: Stream.empty, + ...options?.layers?.providerRegistry, }), - signal: (input) => - Effect.succeed({ - pid: input.pid, - signal: input.signal, - signaled: true, - message: Option.none(), - }), - }), - ), - Layer.provide( - Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ - readHistory: (input) => - Effect.succeed({ - readAt: TEST_EPOCH, - windowMs: input.windowMs, - bucketMs: input.bucketMs, - sampleIntervalMs: 5_000, - retainedSampleCount: 0, - totalCpuSecondsApprox: 0, - buckets: [], - topProcesses: [], - error: Option.none(), - }), - }), - ), - Layer.provide( - Layer.mock(TraceDiagnostics.TraceDiagnostics)({ - read: () => - Effect.succeed({ - traceFilePath: "", - scannedFilePaths: [], + ), + Layer.provide( + Layer.mock(ServerSettingsService)({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), + updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), + streamChanges: Stream.empty, + ...options?.layers?.serverSettings, + }), + ), + Layer.provide( + Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), + ...options?.layers?.externalLauncher, + }), + ), + Layer.provide( + Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({ + read: Effect.succeed({ + serverPid: process.pid, readAt: TEST_EPOCH, - recordCount: 0, - parseErrorCount: 0, - firstSpanAt: Option.none(), - lastSpanAt: Option.none(), - failureCount: 0, - interruptionCount: 0, - slowSpanThresholdMs: 1_000, - slowSpanCount: 0, - logLevelCounts: {}, - topSpansByCount: [], - slowestSpans: [], - commonFailures: [], - latestFailures: [], - latestWarningAndErrorLogs: [], - partialFailure: Option.none(), + processCount: 0, + totalRssBytes: 0, + totalCpuPercent: 0, + processes: [], error: Option.none(), }), - }), - ), - Layer.provide(gitManagerLayer), - Layer.provide(gitVcsDriverLayer), - Layer.provide(gitWorkflowLayer), - Layer.provide(reviewLayer), - Layer.provide(vcsProvisioningLayer), - Layer.provide( - Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ - ...options?.layers?.sourceControlRepositoryService, - }), - ), - Layer.provideMerge(vcsStatusBroadcasterLayer), - Layer.provide( - Layer.mock(ProjectSetupScriptRunner)({ - runForThread: () => Effect.succeed({ status: "no-script" as const }), - ...options?.layers?.projectSetupScriptRunner, - }), - ), - Layer.provide( - Layer.mock(TerminalManager)({ - ...options?.layers?.terminalManager, - }), - ), - Layer.provide( - Layer.mergeAll( - Layer.mock(PreviewManager.PreviewManager)({ - open: () => Effect.die("PreviewManager not stubbed in this test"), - navigate: () => Effect.die("PreviewManager not stubbed in this test"), - reportStatus: () => Effect.void, - refresh: () => Effect.void, - close: () => Effect.void, - list: () => Effect.succeed({ sessions: [] }), - events: Stream.empty, - subscribeEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), + signal: (input) => + Effect.succeed({ + pid: input.pid, + signal: input.signal, + signaled: true, + message: Option.none(), + }), }), - Layer.mock(PortScanner.PortDiscovery)({ - scan: () => Effect.succeed([]), - subscribe: () => Effect.void, - retain: Effect.void, - registerTerminalProcesses: () => Effect.void, - unregisterTerminal: () => Effect.void, + ), + Layer.provide( + Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ + readHistory: (input) => + Effect.succeed({ + readAt: TEST_EPOCH, + windowMs: input.windowMs, + bucketMs: input.bucketMs, + sampleIntervalMs: 5_000, + retainedSampleCount: 0, + totalCpuSecondsApprox: 0, + buckets: [], + topProcesses: [], + error: Option.none(), + }), }), ), - ), - Layer.provide( - Layer.mock(OrchestrationEngineService)({ - readEvents: () => Stream.empty, - dispatch: () => Effect.succeed({ sequence: 0 }), - streamDomainEvents: Stream.empty, - ...options?.layers?.orchestrationEngine, - }), - ), - Layer.provide( - Layer.mock(ProjectionSnapshotQuery)({ - getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), - getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), - getShellSnapshot: () => - Effect.succeed({ - snapshotSequence: 0, - projects: [], - threads: [], - updatedAt: "1970-01-01T00:00:00.000Z", - }), - getArchivedShellSnapshot: () => - Effect.succeed({ - snapshotSequence: 0, - projects: [], - threads: [], - updatedAt: "1970-01-01T00:00:00.000Z", - }), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getProjectShellById: () => Effect.succeed(Option.none()), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - ...options?.layers?.projectionSnapshotQuery, - }), - ), - Layer.provide( - Layer.mock(CheckpointDiffQuery)({ - getTurnDiff: () => - Effect.succeed({ - threadId: defaultThreadId, - fromTurnCount: 0, - toTurnCount: 0, - diff: "", + Layer.provide( + Layer.mock(TraceDiagnostics.TraceDiagnostics)({ + read: () => + Effect.succeed({ + traceFilePath: "", + scannedFilePaths: [], + readAt: TEST_EPOCH, + recordCount: 0, + parseErrorCount: 0, + firstSpanAt: Option.none(), + lastSpanAt: Option.none(), + failureCount: 0, + interruptionCount: 0, + slowSpanThresholdMs: 1_000, + slowSpanCount: 0, + logLevelCounts: {}, + topSpansByCount: [], + slowestSpans: [], + commonFailures: [], + latestFailures: [], + latestWarningAndErrorLogs: [], + partialFailure: Option.none(), + error: Option.none(), + }), + }), + ), + Layer.provide(gitManagerLayer), + Layer.provide(gitVcsDriverLayer), + Layer.provide(gitWorkflowLayer), + Layer.provide(reviewLayer), + Layer.provide(vcsProvisioningLayer), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + ...options?.layers?.sourceControlRepositoryService, + }), + ), + Layer.provideMerge(vcsStatusBroadcasterLayer), + Layer.provide( + Layer.mock(ProjectSetupScriptRunner)({ + runForThread: () => Effect.succeed({ status: "no-script" as const }), + ...options?.layers?.projectSetupScriptRunner, + }), + ), + Layer.provide( + Layer.mock(TerminalManager)({ + ...options?.layers?.terminalManager, + }), + ), + Layer.provide( + Layer.mergeAll( + Layer.mock(PreviewManager.PreviewManager)({ + open: () => Effect.die("PreviewManager not stubbed in this test"), + navigate: () => Effect.die("PreviewManager not stubbed in this test"), + reportStatus: () => Effect.void, + refresh: () => Effect.void, + close: () => Effect.void, + list: () => Effect.succeed({ sessions: [] }), + events: Stream.empty, + subscribeEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), }), - getFullThreadDiff: () => - Effect.succeed({ - threadId: defaultThreadId, - fromTurnCount: 0, - toTurnCount: 0, - diff: "", + Layer.mock(PortScanner.PortDiscovery)({ + scan: () => Effect.succeed([]), + subscribe: () => Effect.void, + retain: Effect.void, + registerTerminalProcesses: () => Effect.void, + unregisterTerminal: () => Effect.void, }), - ...options?.layers?.checkpointDiffQuery, - }), - ), - ); + ), + ), + Layer.provide( + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 0 }), + streamDomainEvents: Stream.empty, + ...options?.layers?.orchestrationEngine, + }), + ), + Layer.provide( + Layer.mock(ProjectionSnapshotQuery)({ + getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), + getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "1970-01-01T00:00:00.000Z", + }), + getArchivedShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "1970-01-01T00:00:00.000Z", + }), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getProjectShellById: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + ...options?.layers?.projectionSnapshotQuery, + }), + ), + Layer.provide( + Layer.mock(CheckpointDiffQuery)({ + getTurnDiff: () => + Effect.succeed({ + threadId: defaultThreadId, + fromTurnCount: 0, + toTurnCount: 0, + diff: "", + }), + getFullThreadDiff: () => + Effect.succeed({ + threadId: defaultThreadId, + fromTurnCount: 0, + toTurnCount: 0, + diff: "", + }), + ...options?.layers?.checkpointDiffQuery, + }), + ), + // Split into a second `.pipe()`: a single pipe caps at 20 args, and + // upstream + the workflow provides together exceed that. + ) + .pipe(Layer.provide(workflowRouteServicesLayer)); const appLayer = servedRoutesLayer.pipe( Layer.provide( @@ -767,6 +870,7 @@ const buildAppUnderTest = (options?: { Layer.provide( Layer.mock(ServerRuntimeStartup)({ awaitCommandReady: Effect.void, + awaitWorkflowReady: Effect.void, markHttpListening: Effect.void, enqueueCommand: (effect) => effect, ...options?.layers?.serverRuntimeStartup, @@ -938,9 +1042,7 @@ const exchangeAccessToken = ( subject_token: credential, subject_token_type: AuthEnvironmentBootstrapTokenType, requested_token_type: AuthAccessTokenType, - scope: - options?.scope ?? - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", + scope: options?.scope ?? administrativeScopeText, ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), ...(options?.clientMetadata?.deviceType ? { client_device_type: options.clientMetadata.deviceType } @@ -1382,10 +1484,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(tokenResponse.status, 200); assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); assert.equal(tokenBody.token_type, "Bearer"); - assert.equal( - tokenBody.scope, - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", - ); + assert.equal(tokenBody.scope, administrativeScopeText); assert.equal(typeof tokenBody.access_token, "string"); const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); @@ -1403,16 +1502,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(sessionResponse.status, 200); assert.equal(sessionBody.authenticated, true); assert.equal(sessionBody.sessionMethod, "bearer-access-token"); - assert.deepEqual(sessionBody.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(sessionBody.scopes, [...AuthAdministrativeScopes]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 42a692c5394..57fef112356 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -5,6 +5,7 @@ import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { ServerConfig } from "./config.ts"; +import { workflowHooksRouteLayer } from "./workflow/webhookRoute.ts"; import { otlpTracesProxyRouteLayer, assetRouteLayer, @@ -16,6 +17,7 @@ import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import { ProjectionTurnRepositoryLive } from "./persistence/Layers/ProjectionTurns.ts"; import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; @@ -81,6 +83,7 @@ import * as CloudCliState from "./cloud/CliState.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import { WorkflowServerRuntimeLive } from "./workflow/WorkflowRuntimeLive.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -194,6 +197,15 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay Layer.provideMerge(VcsDriverRegistryLayerLive), ); +// The workflow PR steps need GitHubCli alongside the registry. Re-export +// GitHubCli as a peer output of the registry layer (which consumes it +// internally but does not surface it); GitHubCli's VcsProcess requirement is +// satisfied by the single VcsProcess.layer provided at makeServerLayer level, +// so no second ProcessRunner pool is created. +const SourceControlForWorkflowLive = SourceControlProviderRegistryLayerLive.pipe( + Layer.provideMerge(GitHubCli.layer), +); + const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), Layer.provideMerge(GitVcsDriver.layer), @@ -283,13 +295,27 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( +const WorkflowRuntimeLayerLive = WorkflowServerRuntimeLive.pipe( + Layer.provideMerge(CheckpointingLayerLive), + Layer.provideMerge(SourceControlForWorkflowLive), + Layer.provideMerge(GitLayerLive), + Layer.provideMerge(GitWorkflowLayerLive), + Layer.provideMerge(ProjectSetupScriptRunnerLive), + Layer.provideMerge(TerminalLayerLive), + Layer.provideMerge(ProviderRuntimeLayerLive), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(PersistenceLayerLive), + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), +); + +const RuntimeCoreEngineLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), + Layer.provideMerge(WorkflowRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), @@ -317,6 +343,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), +); + +const RuntimeCoreDependenciesLive = RuntimeCoreEngineLive.pipe( Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( @@ -357,6 +386,7 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), + workflowHooksRouteLayer, ).pipe(Layer.provide(browserApiCorsLayer)); export const makeServerLayer = Layer.unwrap( diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 90eebe33820..52a5c8216f9 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -103,6 +103,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), Effect.provideService(AnalyticsService, { record: () => Effect.void, @@ -165,6 +166,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + isThreadHidden: () => Effect.succeed(false), }), Effect.provideService(OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -207,6 +209,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + isThreadHidden: () => Effect.succeed(false), }), Effect.provideService(OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -255,6 +258,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + isThreadHidden: () => Effect.succeed(false), }), Effect.provideService(OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index cde308ffe42..79a79e62878 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -17,6 +17,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as Context from "effect/Context"; import * as Console from "effect/Console"; @@ -34,6 +35,13 @@ import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; +import { WorkflowBoardNotificationDispatcher } from "./workflow/Services/WorkflowBoardNotificationDispatcher.ts"; +import { WorkflowSourceSyncer } from "./workflow/Services/WorkflowSourceSyncer.ts"; +import { WorkflowOutboundDispatcher } from "./workflow/Services/WorkflowOutboundDispatcher.ts"; +import { WorkflowGitHubPoller } from "./workflow/Services/WorkflowGitHubPoller.ts"; +import { WorkflowRecovery } from "./workflow/Services/WorkflowRecovery.ts"; +import { WorkflowTerminalRetentionSweeper } from "./workflow/Services/WorkflowTerminalRetentionSweeper.ts"; +import { WorkflowWebhook } from "./workflow/Services/WorkflowWebhook.ts"; import { formatHeadlessServeOutput, formatHostForUrl, @@ -48,6 +56,12 @@ export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeSt export interface ServerRuntimeStartupShape { readonly awaitCommandReady: Effect.Effect; + // Workflow-specific readiness: resolves only after workflow recovery SUCCEEDS, + // and fails if recovery (or startup) failed. Mutating workflow RPCs await this in + // addition to command readiness so a failed recovery surfaces as a retryable error + // rather than mutating a half-recovered projection. Kept separate from command + // readiness so a workflow-recovery failure does NOT block core orchestration. + readonly awaitWorkflowReady: Effect.Effect; readonly markHttpListening: Effect.Effect; readonly enqueueCommand: ( effect: Effect.Effect, @@ -69,6 +83,9 @@ interface CommandGate { readonly awaitCommandReady: Effect.Effect; readonly signalCommandReady: Effect.Effect; readonly failCommandReady: (error: ServerRuntimeStartupError) => Effect.Effect; + readonly awaitWorkflowReady: Effect.Effect; + readonly signalWorkflowReady: Effect.Effect; + readonly failWorkflowReady: (error: ServerRuntimeStartupError) => Effect.Effect; readonly enqueueCommand: ( effect: Effect.Effect, ) => Effect.Effect; @@ -81,6 +98,7 @@ const settleQueuedCommand = (deferred: Deferred.Deferred, exit: Exit export const makeCommandGate = Effect.gen(function* () { const commandReady = yield* Deferred.make(); + const workflowReady = yield* Deferred.make(); const commandQueue = yield* Queue.unbounded(); const commandReadinessState = yield* Ref.make("pending"); @@ -100,6 +118,9 @@ export const makeCommandGate = Effect.gen(function* () { yield* Ref.set(commandReadinessState, error); yield* Deferred.fail(commandReady, error).pipe(Effect.orDie); }), + awaitWorkflowReady: Deferred.await(workflowReady), + signalWorkflowReady: Deferred.succeed(workflowReady, undefined).pipe(Effect.asVoid), + failWorkflowReady: (error) => Deferred.fail(workflowReady, error).pipe(Effect.asVoid), enqueueCommand: (effect: Effect.Effect) => Effect.gen(function* () { const readinessState = yield* Ref.get(commandReadinessState); @@ -286,9 +307,16 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { const keybindings = yield* Keybindings; const orchestrationReactor = yield* OrchestrationReactor; const providerSessionReaper = yield* ProviderSessionReaper; + const workflowTerminalRetentionSweeper = yield* WorkflowTerminalRetentionSweeper; + const workflowWebhook = yield* WorkflowWebhook; + const workflowGitHubPoller = yield* WorkflowGitHubPoller; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const serverEnvironment = yield* ServerEnvironment; + const workflowRecovery = yield* WorkflowRecovery; + const workflowBoardNotificationDispatcher = yield* WorkflowBoardNotificationDispatcher; + const workflowSourceSyncer = yield* WorkflowSourceSyncer; + const workflowOutboundDispatcher = yield* WorkflowOutboundDispatcher; const crypto = yield* Crypto.Crypto; const commandGate = yield* makeCommandGate; @@ -334,9 +362,109 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { Effect.gen(function* () { yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); + yield* workflowTerminalRetentionSweeper.start().pipe(Scope.provide(reactorScope)); + // Periodic prune of stale webhook dedup rows (migration 033's + // workflow_webhook_delivery) so the table cannot grow unbounded. + yield* workflowWebhook.start().pipe(Scope.provide(reactorScope)); }), ); + yield* Effect.logDebug("startup phase: recovering workflow runtime"); + // Recovery is non-fatal for the rest of startup (the server must still + // boot), but we capture whether it SUCCEEDED so we can gate the board + // notification dispatcher on it below. + const recovered = yield* runStartupPhase( + "workflow.recover", + workflowRecovery.recover().pipe( + Effect.retry(Schedule.exponential("500 millis").pipe(Schedule.both(Schedule.recurs(3)))), + Effect.as(true), + Effect.catch((cause) => + Effect.logWarning("workflow recovery failed during startup", { + cause, + }).pipe(Effect.as(false)), + ), + ), + ); + + // Publish workflow-recovery readiness so the WS gate can fail mutating + // workflow RPCs with a retryable error when recovery failed, instead of + // letting them mutate a half-recovered projection. Recovery failure stays + // non-fatal for the rest of startup (and does NOT block core orchestration). + if (recovered) { + yield* commandGate.signalWorkflowReady; + } else { + yield* commandGate.failWorkflowReady( + new ServerRuntimeStartupError({ + message: + "Workflow recovery failed during startup; mutating workflow RPCs are unavailable until the server restarts.", + }), + ); + } + + // Start the board notification dispatcher AFTER recovery SUCCEEDS: + // recovery may write outbox rows / fix projections that the dispatcher then + // drains, so starting before (or after a failed) recovery risks draining a + // half-recovered state — wrongly superseding a needed notification or + // publishing stale content. + if (recovered) { + yield* Effect.logDebug("startup phase: starting workflow board notification dispatcher"); + yield* runStartupPhase( + "workflow.board-notifications.start", + workflowBoardNotificationDispatcher.start().pipe(Scope.provide(reactorScope)), + ); + } else { + yield* Effect.logWarning( + "skipping board-notification dispatcher start: workflow recovery failed", + ); + } + + // Start the work-source syncer ONLY after recovery succeeds: the syncer + // creates/admits tickets from upstream sources, so it must not run against a + // half-recovered projection. Same recovery gate as the notification + // dispatcher above. + if (recovered) { + yield* Effect.logDebug("startup phase: starting workflow source syncer"); + yield* runStartupPhase( + "workflow.source-sync.start", + workflowSourceSyncer.start().pipe(Scope.provide(reactorScope)), + ); + } else { + yield* Effect.logWarning("skipping work-source syncer start: workflow recovery failed"); + } + + // Start the outbound-webhook dispatcher ONLY after recovery succeeds: the + // dispatcher drains durable `workflow_outbound_delivery` rows and POSTs + // them, so starting before (or after a failed) recovery risks draining a + // half-recovered state. Same recovery gate as the notification dispatcher + // and work-source syncer above. + if (recovered) { + yield* Effect.logDebug("startup phase: starting workflow outbound dispatcher"); + yield* runStartupPhase( + "workflow.outbound.start", + workflowOutboundDispatcher.start().pipe(Scope.provide(reactorScope)), + ); + } else { + yield* Effect.logWarning("skipping outbound dispatcher start: workflow recovery failed"); + } + + // Start the GitHub poller ONLY after recovery succeeds: its sweep drains + // pending `workflow_pr_observation` rows and calls engine.ingestExternalEvent, + // which moves tickets between lanes and starts/supersedes pipelines. Running + // it against a half-recovered projection (stranded pipelines not yet resumed, + // confirmed-running steps not yet settled, board WIP not yet recovered) + // corrupts lane/pipeline state — the same hazard that gates the dispatcher and + // syncer above. Previously this started in the pre-recovery `reactors.start` + // phase, ungated. + if (recovered) { + yield* Effect.logDebug("startup phase: starting workflow github poller"); + yield* runStartupPhase( + "workflow.github-poller.start", + workflowGitHubPoller.start().pipe(Scope.provide(reactorScope)), + ); + } else { + yield* Effect.logWarning("skipping github poller start: workflow recovery failed"); + } + const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); @@ -414,6 +542,10 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { }); yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause }); yield* commandGate.failCommandReady(error); + // If startup failed before reaching the recovery phase, workflowReady was + // never settled; fail it too so the workflow gate rejects rather than hangs. + // (No-op if recovery already signalled/failed it.) + yield* commandGate.failWorkflowReady(error); return; } @@ -459,6 +591,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { return { awaitCommandReady: commandGate.awaitCommandReady, + awaitWorkflowReady: commandGate.awaitWorkflowReady, markHttpListening: Deferred.succeed(httpListening, undefined), enqueueCommand: commandGate.enqueueCommand, } satisfies ServerRuntimeStartupShape; diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index fb765b352c2..5981cad4196 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -15,6 +15,18 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); +const processOutputWithExit = ( + stdout: string, + exitCode: number, + stderr = "", +): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), + stdout, + stderr, + stdoutTruncated: false, + stderrTruncated: false, +}); + const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( @@ -293,4 +305,306 @@ describe("GitHubCli.layer", () => { assert.equal(error.message.includes("Pull request not found"), true); }).pipe(Effect.provide(layer)), ); + + it.effect("creates a draft pull request when draft is true", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/x", + title: "My PR", + bodyFile: "/tmp/body.md", + draft: true, + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "create", + "--base", + "main", + "--head", + "feature/x", + "--title", + "My PR", + "--body-file", + "/tmp/body.md", + "--draft", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("merges a pull request with the requested strategy", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.mergePullRequest({ cwd: "/repo", number: 7, strategy: "squash" }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "7", "--squash"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("maps merge/rebase strategies to the gh flag", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.mergePullRequest({ cwd: "/repo", number: 7, strategy: "merge" }); + yield* gh.mergePullRequest({ cwd: "/repo", number: 8, strategy: "rebase" }); + + expect(mockRun).toHaveBeenNthCalledWith(1, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "7", "--merge"], + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(2, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "8", "--rebase"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("surfaces gh stderr when a merge fails", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh pr merge", + cwd: "/repo", + exitCode: 1, + detail: "Pull request is not mergeable: the base branch policy requires review.", + }), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .mergePullRequest({ cwd: "/repo", number: 7, strategy: "squash" }) + .pipe(Effect.flip); + + assert.equal(error.message.includes("not mergeable"), true); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads pull request detail json", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + state: "OPEN", + mergedAt: null, + reviewDecision: "CHANGES_REQUESTED", + headRefOid: "abc123", + url: "https://github.com/o/r/pull/7", + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequestDetail({ cwd: "/repo", number: 7 }); + + assert.deepStrictEqual(result, { + state: "OPEN", + mergedAt: null, + reviewDecision: "CHANGES_REQUESTED", + headRefOid: "abc123", + url: "https://github.com/o/r/pull/7", + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "view", "7", "--json", "state,mergedAt,reviewDecision,headRefOid,url"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("treats pr checks exit codes 0, 1 and 8 as success", () => + Effect.gen(function* () { + const checksJson = + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { name: "build", state: "SUCCESS", bucket: "pass", link: "https://x/runs/1" }, + { name: "test", state: "FAILURE", bucket: "fail", link: "https://x/runs/2" }, + ]); + mockRun.mockReturnValueOnce(Effect.succeed(processOutputWithExit(checksJson, 0))); + mockRun.mockReturnValueOnce(Effect.succeed(processOutputWithExit(checksJson, 1))); + mockRun.mockReturnValueOnce(Effect.succeed(processOutputWithExit(checksJson, 8))); + + const gh = yield* GitHubCli.GitHubCli; + const expected = [ + { name: "build", state: "SUCCESS", bucket: "pass", link: "https://x/runs/1" }, + { name: "test", state: "FAILURE", bucket: "fail", link: "https://x/runs/2" }, + ]; + + const r0 = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 7 }); + const r1 = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 7 }); + const r8 = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 7 }); + + assert.deepStrictEqual(r0, expected); + assert.deepStrictEqual(r1, expected); + assert.deepStrictEqual(r8, expected); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "checks", "7", "--json", "name,state,bucket,link"], + cwd: "/repo", + timeoutMs: 30_000, + allowNonZeroExit: true, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("returns an empty checks list when gh reports no checks", () => + Effect.gen(function* () { + // gh prints nothing and exits 0 when a PR has no checks configured. + mockRun.mockReturnValueOnce(Effect.succeed(processOutputWithExit("", 0))); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 7 }); + + assert.deepStrictEqual(result, []); + }).pipe(Effect.provide(layer)), + ); + + it.effect("fails pr checks on an unexpected exit code", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed(processOutputWithExit("boom", 2, "fatal: unexpected")), + ); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 7 }).pipe(Effect.flip); + + assert.equal(error.operation, "listPullRequestChecks"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads pull request reviews mapping gh shape", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + reviews: [ + { + id: "PRR_x", + author: { login: "alice" }, + state: "CHANGES_REQUESTED", + body: "please fix", + submittedAt: "2026-06-12T10:00:00Z", + }, + ], + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listPullRequestReviews({ cwd: "/repo", number: 7 }); + + assert.deepStrictEqual(result, [ + { + id: "PRR_x", + author: "alice", + state: "CHANGES_REQUESTED", + body: "please fix", + submittedAt: "2026-06-12T10:00:00Z", + }, + ]); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "view", "7", "--json", "reviews"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads pull request review comments via gh api", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + id: 555, + user: { login: "bob" }, + body: "nit", + path: "src/x.ts", + created_at: "2026-06-12T11:00:00Z", + }, + { + id: 556, + user: { login: "carol" }, + body: "general", + path: null, + created_at: "2026-06-12T12:00:00Z", + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listPullRequestReviewComments({ + cwd: "/repo", + repo: "octocat/codething-mvp", + number: 7, + }); + + assert.deepStrictEqual(result, [ + { + id: 555, + user: "bob", + body: "nit", + path: "src/x.ts", + createdAt: "2026-06-12T11:00:00Z", + }, + { + id: 556, + user: "carol", + body: "general", + path: null, + createdAt: "2026-06-12T12:00:00Z", + }, + ]); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["api", "repos/octocat/codething-mvp/pulls/7/comments"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index d6c858c28bd..783eb3af61c 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -44,6 +44,39 @@ export interface GitHubRepositoryCloneUrls { readonly sshUrl: string; } +export type GitHubMergeStrategy = "squash" | "merge" | "rebase"; + +export interface GitHubPullRequestDetail { + readonly state: string; + readonly mergedAt: string | null; + readonly reviewDecision: string | null; + readonly headRefOid: string; + readonly url: string; +} + +export interface GitHubPullRequestCheck { + readonly name: string; + readonly state: string; + readonly bucket: string; + readonly link: string; +} + +export interface GitHubPullRequestReview { + readonly id: string; + readonly author: string; + readonly state: string; + readonly body: string; + readonly submittedAt: string; +} + +export interface GitHubPullRequestReviewComment { + readonly id: number; + readonly user: string; + readonly body: string; + readonly path: string | null; + readonly createdAt: string; +} + export interface GitHubCliShape { readonly execute: (input: { readonly cwd: string; @@ -79,8 +112,36 @@ export interface GitHubCliShape { readonly headSelector: string; readonly title: string; readonly bodyFile: string; + readonly draft?: boolean; }) => Effect.Effect; + readonly mergePullRequest: (input: { + readonly cwd: string; + readonly number: number; + readonly strategy: GitHubMergeStrategy; + }) => Effect.Effect; + + readonly getPullRequestDetail: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect; + + readonly listPullRequestChecks: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect, GitHubCliError>; + + readonly listPullRequestReviews: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect, GitHubCliError>; + + readonly listPullRequestReviewComments: (input: { + readonly cwd: string; + readonly repo: string; + readonly number: number; + }) => Effect.Effect, GitHubCliError>; + readonly getDefaultBranch: (input: { readonly cwd: string; }) => Effect.Effect; @@ -161,6 +222,47 @@ const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ sshUrl: TrimmedNonEmptyString, }); +const RawGitHubPullRequestDetailSchema = Schema.Struct({ + state: Schema.String, + mergedAt: Schema.NullOr(Schema.String), + reviewDecision: Schema.NullOr(Schema.String), + headRefOid: Schema.String, + url: Schema.String, +}); + +const RawGitHubPullRequestCheckSchema = Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + bucket: Schema.optional(Schema.NullOr(Schema.String)), + link: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawGitHubPullRequestChecksSchema = Schema.Array(RawGitHubPullRequestCheckSchema); + +const RawGitHubPullRequestReviewsSchema = Schema.Struct({ + reviews: Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional( + Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.String) })), + ), + state: Schema.optional(Schema.NullOr(Schema.String)), + body: Schema.optional(Schema.NullOr(Schema.String)), + submittedAt: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); + +const RawGitHubPullRequestReviewCommentsSchema = Schema.Array( + Schema.Struct({ + id: Schema.Number, + user: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.String) }))), + body: Schema.optional(Schema.NullOr(Schema.String)), + path: Schema.optional(Schema.NullOr(Schema.String)), + created_at: Schema.optional(Schema.NullOr(Schema.String)), + }), +); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { @@ -211,7 +313,14 @@ function deriveRepositoryCloneUrlsFromCreateOutput( function decodeGitHubJson( raw: string, schema: S, - operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", + operation: + | "listOpenPullRequests" + | "getPullRequest" + | "getRepositoryCloneUrls" + | "getPullRequestDetail" + | "listPullRequestChecks" + | "listPullRequestReviews" + | "listPullRequestReviewComments", invalidDetail: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( @@ -352,8 +461,145 @@ export const make = Effect.fn("makeGitHubCli")(function* () { input.title, "--body-file", input.bodyFile, + ...(input.draft ? ["--draft"] : []), ], }).pipe(Effect.asVoid), + mergePullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "merge", + String(input.number), + input.strategy === "merge" + ? "--merge" + : input.strategy === "rebase" + ? "--rebase" + : "--squash", + ], + }).pipe(Effect.asVoid), + getPullRequestDetail: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + "--json", + "state,mergedAt,reviewDecision,headRefOid,url", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitHubJson( + raw, + RawGitHubPullRequestDetailSchema, + "getPullRequestDetail", + "GitHub CLI returned invalid pull request detail JSON.", + ), + ), + Effect.map((raw) => ({ + state: raw.state, + mergedAt: raw.mergedAt, + reviewDecision: raw.reviewDecision, + headRefOid: raw.headRefOid, + url: raw.url, + })), + ), + listPullRequestChecks: (input) => + // `gh pr checks` exits 8 while checks are pending and 1 when some fail, + // yet still prints valid JSON. Tolerate those exit codes (and 0) as long + // as stdout parses; any other exit code is a real failure. + process + .run({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "checks", String(input.number), "--json", "name,state,bucket,link"], + cwd: input.cwd, + timeoutMs: DEFAULT_TIMEOUT_MS, + allowNonZeroExit: true, + }) + .pipe( + Effect.mapError((error) => normalizeGitHubCliError("execute", error)), + Effect.flatMap((result) => { + const exitCode = result.exitCode as number; + if (exitCode !== 0 && exitCode !== 1 && exitCode !== 8) { + return Effect.fail( + new GitHubCliError({ + operation: "listPullRequestChecks", + detail: result.stderr.trim() || `gh pr checks exited with code ${exitCode}.`, + }), + ); + } + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed([] as ReadonlyArray); + } + return decodeGitHubJson( + raw, + RawGitHubPullRequestChecksSchema, + "listPullRequestChecks", + "GitHub CLI returned invalid pull request checks JSON.", + ).pipe( + Effect.map((checks) => + checks.map((check) => ({ + name: check.name ?? "", + state: check.state ?? "", + bucket: check.bucket ?? "", + link: check.link ?? "", + })), + ), + ); + }), + ), + listPullRequestReviews: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "view", String(input.number), "--json", "reviews"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitHubJson( + raw, + RawGitHubPullRequestReviewsSchema, + "listPullRequestReviews", + "GitHub CLI returned invalid pull request reviews JSON.", + ), + ), + Effect.map((decoded) => + decoded.reviews.map((review) => ({ + id: review.id ?? "", + author: review.author?.login ?? "", + state: review.state ?? "", + body: review.body ?? "", + submittedAt: review.submittedAt ?? "", + })), + ), + ), + listPullRequestReviewComments: (input) => + execute({ + cwd: input.cwd, + args: ["api", `repos/${input.repo}/pulls/${input.number}/comments`], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitHubJson( + raw, + RawGitHubPullRequestReviewCommentsSchema, + "listPullRequestReviewComments", + "GitHub CLI returned invalid pull request review comments JSON.", + ), + ), + Effect.map((decoded) => + decoded.map((comment) => ({ + id: comment.id, + user: comment.user?.login ?? "", + body: comment.body ?? "", + path: comment.path ?? null, + createdAt: comment.created_at ?? "", + })), + ), + ), getDefaultBranch: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 8b5aa3adbcd..4d8c96ba421 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -220,6 +220,27 @@ interface ManagerFixture { readonly getEvents: Effect.Effect>; } +interface TerminalHistoryAttachStreamEvent { + readonly type: string; + readonly snapshot?: { + readonly threadId: string; + readonly terminalId: string; + readonly history: string; + readonly status: string | null; + readonly exitCode?: number | null; + readonly exitSignal?: number | null; + readonly sequence?: number | undefined; + }; + readonly data?: string; +} + +type TerminalManagerWithHistory = TerminalManagerShape & { + readonly attachHistoryStream: ( + input: { readonly threadId: string; readonly terminalId: string }, + listener: (event: TerminalHistoryAttachStreamEvent) => Effect.Effect, + ) => Effect.Effect<() => void, unknown>; +}; + const createManager = ( historyLineLimit = 5, options: CreateManagerOptions = {}, @@ -356,6 +377,163 @@ it.layer( }), ); + it.effect("attaches to persisted terminal history without a cwd or shell spawn", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + const threadId = "script-thread-1"; + const terminalId = "script-terminal-1"; + + yield* manager.open(openInput({ threadId, terminalId })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("script output\n"); + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => + events.some( + (event) => + event.threadId === threadId && + event.terminalId === terminalId && + event.type === "exited", + ), + ), + "1200 millis", + ); + yield* manager.close({ threadId, terminalId }); + + const attachHistoryStream = (manager as TerminalManagerWithHistory).attachHistoryStream; + expect(typeof attachHistoryStream).toBe("function"); + + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* attachHistoryStream({ threadId, terminalId }, (event) => + Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + expect(yield* Ref.get(attachEvents)).toEqual([ + { + type: "snapshot", + snapshot: { + threadId, + terminalId, + history: "script output\n", + status: null, + exitCode: null, + exitSignal: null, + }, + }, + ]); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + }), + ); + + it.effect("streams live output after a history-only terminal snapshot", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + const threadId = "script-thread-live"; + const terminalId = "script-terminal-live"; + + yield* manager.open(openInput({ threadId, terminalId })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("before attach\n"); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some( + (event) => + event.threadId === threadId && + event.terminalId === terminalId && + event.type === "output" && + event.data === "before attach\n", + ), + ), + "1200 millis", + ); + + const attachHistoryStream = (manager as TerminalManagerWithHistory).attachHistoryStream; + expect(typeof attachHistoryStream).toBe("function"); + + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* attachHistoryStream({ threadId, terminalId }, (event) => + Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + process.emitData("after attach\n"); + yield* waitFor( + Effect.map(Ref.get(attachEvents), (events) => + events.some((event) => event.type === "output" && event.data === "after attach\n"), + ), + "1200 millis", + ); + + const events = yield* Ref.get(attachEvents); + expect(events[0]).toEqual({ + type: "snapshot", + snapshot: { + threadId, + terminalId, + history: "before attach\n", + status: "running", + exitCode: null, + exitSignal: null, + sequence: expect.any(Number), + }, + }); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + }), + ); + + it.effect("delivers history-attach output buffered during the snapshot callback once", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + ptyAdapter: new FakePtyAdapter("async"), + }); + const threadId = "script-thread-buffered"; + const terminalId = "script-terminal-buffered"; + + yield* manager.open(openInput({ threadId, terminalId })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const attachHistoryStream = (manager as TerminalManagerWithHistory).attachHistoryStream; + expect(typeof attachHistoryStream).toBe("function"); + + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* attachHistoryStream({ threadId, terminalId }, (event) => + Effect.gen(function* () { + yield* Ref.update(attachEvents, (events) => [...events, event]); + if (event.type === "snapshot") { + yield* Effect.sync(() => process.emitData("during snapshot\n")); + yield* Effect.yieldNow; + } + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* waitFor( + Effect.map(Ref.get(attachEvents), (events) => + events.some((event) => event.type === "output" && event.data === "during snapshot\n"), + ), + "1200 millis", + ); + + const events = yield* Ref.get(attachEvents); + const snapshotEvents = events.filter((event) => event.type === "snapshot"); + expect(snapshotEvents).toHaveLength(1); + expect(snapshotEvents[0]?.snapshot?.sequence).toEqual(expect.any(Number)); + expect( + events.filter((event) => event.type === "output" && event.data === "during snapshot\n"), + ).toHaveLength(1); + }), + ); + it.effect("restarts inactive sessions from attach only when requested", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index e33d9b4b290..fb0c40175c7 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -3,6 +3,7 @@ import { type TerminalAttachInput, type TerminalAttachStreamEvent, type TerminalEvent, + type TerminalHistoryAttachStreamEvent, type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalSessionSnapshot, @@ -266,6 +267,23 @@ function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamE } } +function terminalEventToHistoryAttachEvent( + event: TerminalEvent, +): TerminalHistoryAttachStreamEvent | null { + switch (event.type) { + case "output": + case "exited": + case "closed": + case "error": + case "cleared": + case "activity": + return event; + case "started": + case "restarted": + return null; + } +} + function isDuplicateAttachSnapshotEvent( event: TerminalEvent, initialSnapshot: TerminalSessionSnapshot, @@ -2164,6 +2182,44 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), ); + const readHistorySnapshot = (input: { + readonly threadId: string; + readonly terminalId: string; + }) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const session = yield* getSession(input.threadId, input.terminalId); + if (Option.isSome(session)) { + return { + threadId: session.value.threadId, + terminalId: session.value.terminalId, + history: session.value.history, + status: session.value.status, + exitCode: session.value.exitCode, + exitSignal: session.value.exitSignal, + sequence: session.value.eventSequence, + }; + } + + yield* flushPersist(input.threadId, input.terminalId); + const history = yield* readHistory(input.threadId, input.terminalId); + return { + threadId: input.threadId, + terminalId: input.terminalId, + history, + status: null, + exitCode: null, + exitSignal: null, + }; + }), + ); + + const getSnapshot: TerminalManagerShape["getSnapshot"] = (input) => + getSession(input.threadId, input.terminalId).pipe( + Effect.map((session) => (Option.isSome(session) ? snapshot(session.value) : null)), + ); + const subscribe: TerminalManagerShape["subscribe"] = (listener) => Effect.sync(() => { terminalEventListeners.add(listener); @@ -2229,6 +2285,67 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith ); }; + const attachHistoryStream: TerminalManagerShape["attachHistoryStream"] = (input, listener) => { + let unsubscribe: (() => void) | null = null; + + return Effect.gen(function* () { + const bufferedEvents: TerminalEvent[] = []; + let deliverLive = false; + + unsubscribe = yield* subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } + + if (!deliverLive) { + bufferedEvents.push(event); + return Effect.void; + } + + const attachEvent = terminalEventToHistoryAttachEvent(event); + return attachEvent ? listener(attachEvent) : Effect.void; + }); + + const initialSnapshot = yield* readHistorySnapshot(input); + + yield* listener({ + type: "snapshot", + snapshot: initialSnapshot, + }); + + for (const event of bufferedEvents) { + if ( + typeof event.sequence === "number" && + typeof initialSnapshot.sequence === "number" && + event.sequence <= initialSnapshot.sequence + ) { + continue; + } + + const attachEvent = terminalEventToHistoryAttachEvent(event); + if (attachEvent) { + yield* listener(attachEvent); + } + } + + deliverLive = true; + return () => { + unsubscribe?.(); + unsubscribe = null; + }; + }).pipe( + Effect.catchCause((cause) => + Effect.flatMap( + Effect.sync(() => { + unsubscribe?.(); + unsubscribe = null; + }), + () => Effect.failCause(cause), + ), + ), + ); + }; + const metadataEventFromTerminalEvent = ( event: TerminalEvent, ): Effect.Effect => { @@ -2468,11 +2585,13 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith return { open, attachStream, + attachHistoryStream, write, resize, clear, restart, close, + getSnapshot, subscribe, subscribeMetadata, } satisfies TerminalManagerShape; diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 51c66f49f7c..95da414b335 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -12,6 +12,8 @@ import { TerminalClearInput, TerminalCloseInput, TerminalEvent, + TerminalHistoryAttachInput, + TerminalHistoryAttachStreamEvent, TerminalCwdError, TerminalError, TerminalHistoryError, @@ -92,6 +94,15 @@ export interface TerminalManagerShape { listener: (event: TerminalAttachStreamEvent) => Effect.Effect, ) => Effect.Effect<() => void, TerminalError>; + /** + * Attach to persisted terminal history and stream live events if a matching + * session is still active. This never opens or restarts a shell. + */ + readonly attachHistoryStream: ( + input: TerminalHistoryAttachInput, + listener: (event: TerminalHistoryAttachStreamEvent) => Effect.Effect, + ) => Effect.Effect<() => void, TerminalError>; + /** * Write input bytes to a terminal session. */ @@ -123,6 +134,15 @@ export interface TerminalManagerShape { */ readonly close: (input: TerminalCloseInput) => Effect.Effect; + /** + * Read the current snapshot for a terminal session without opening or + * modifying it. Returns `null` if no session exists for the given ids. + */ + readonly getSnapshot: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect; + /** * Subscribe to terminal runtime events with a direct callback. * diff --git a/apps/server/src/textGeneration/BoardProposalNoTool.test.ts b/apps/server/src/textGeneration/BoardProposalNoTool.test.ts new file mode 100644 index 00000000000..5dd5b4dd989 --- /dev/null +++ b/apps/server/src/textGeneration/BoardProposalNoTool.test.ts @@ -0,0 +1,138 @@ +/** + * Architectural-safety tests for the no-tool `generateBoardProposal` op. + * + * The self-improving-boards meta-agent MUST run with tools/filesystem denied so + * it physically cannot write a board definition (only the human-gated + * `saveBoardDefinition` applies a proposal). These tests assert the no-tool + * guarantee at the layer that BUILDS the provider invocation, plus that the + * supported providers return a structured `{ proposedDefinition, rationale }`. + */ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildClaudeProposalArgs } from "./ClaudeTextGeneration.ts"; +import { buildCodexExecArgs } from "./CodexTextGeneration.ts"; +import { buildBoardProposalPrompt } from "./TextGenerationPrompts.ts"; +import { toJsonSchemaObject } from "./TextGenerationUtils.ts"; + +describe("buildCodexExecArgs (Codex no-tool guarantee)", () => { + const base = { + model: "gpt-5.5", + reasoningEffort: "high", + schemaPath: "/tmp/schema.json", + outputPath: "/tmp/out.txt", + } as const; + + it("board-proposal posture ignores the user config (no MCP, hooks, skills, or dev-instructions)", () => { + const args = buildCodexExecArgs({ ...base, ignoreUserConfig: true }); + // `--ignore-user-config` is the Codex analog of Claude's strict-mcp suppression, + // and additionally drops config-driven hooks/skills/developer_instructions — the + // arbitrary-execution surface a no-tool generation must not load. Auth still uses + // CODEX_HOME; model + effort are passed explicitly on the CLI, so they survive. + expect(args).toContain("--ignore-user-config"); + // Still sandboxed read-only and pointed at the explicit model/effort. + const sandboxIndex = args.indexOf("-s"); + expect(args[sandboxIndex + 1]).toBe("read-only"); + expect(args).toContain("--skip-git-repo-check"); + const modelIndex = args.indexOf("--model"); + expect(args[modelIndex + 1]).toBe("gpt-5.5"); + }); + + it("git-op posture does NOT ignore the user config (unchanged behavior)", () => { + const args = buildCodexExecArgs(base); + expect(args).not.toContain("--ignore-user-config"); + }); +}); + +describe("buildBoardProposalPrompt output schema (provider structured-output validity)", () => { + // Regression: OpenAI/Codex `text.format.schema` rejects any property that lacks + // a `type` key with `invalid_json_schema`. `Schema.Unknown` emitted `{}` (no + // type) for `proposedDefinition`, 400-ing every Codex board proposal. Every + // property in the wire schema MUST declare a `type`. + const { outputSchema } = buildBoardProposalPrompt({ prompt: "x" }); + const wire = toJsonSchemaObject(outputSchema) as { + readonly properties: Record; + }; + + it("gives every top-level property a `type` key", () => { + for (const [key, sub] of Object.entries(wire.properties)) { + expect(sub.type, `property "${key}" must declare a type`).toBeTypeOf("string"); + } + }); + + it("models proposedDefinition as a JSON string that decodes back to an object", () => { + expect(wire.properties.proposedDefinition?.type).toBe("string"); + // The provider returns the whole response as a JSON string; proposedDefinition + // is itself a JSON-encoded string that must decode into the definition object. + const decode = Schema.decodeUnknownSync(Schema.fromJsonString(outputSchema)); + const decoded = decode( + JSON.stringify({ + proposedDefinition: JSON.stringify({ name: "X", lanes: [{ key: "a" }] }), + rationale: "because", + }), + ) as { proposedDefinition: unknown; rationale: string }; + expect(decoded.proposedDefinition).toEqual({ name: "X", lanes: [{ key: "a" }] }); + expect(decoded.rationale).toBe("because"); + }); +}); + +describe("buildClaudeProposalArgs (Claude no-tool guarantee)", () => { + const base = { + jsonSchemaStr: '{"type":"object"}', + model: "claude-opus-4-6", + cliEffort: undefined, + settingsJson: undefined, + } as const; + + it("no-tool posture loads ZERO tools (no built-ins AND no MCP) and never skips permissions", () => { + const args = buildClaudeProposalArgs({ ...base, posture: "no-tool" }); + + // --tools "" disables every BUILT-IN tool (see `claude --help`: `--tools` + // affects "the built-in set" only). + const toolsIndex = args.indexOf("--tools"); + expect(toolsIndex).toBeGreaterThanOrEqual(0); + expect(args[toolsIndex + 1]).toBe(""); + + // --strict-mcp-config + --mcp-config "{}" suppress ALL MCP-server tools + // (which --tools "" does NOT cover) regardless of the machine's config. + expect(args).toContain("--strict-mcp-config"); + const mcpIndex = args.indexOf("--mcp-config"); + expect(mcpIndex).toBeGreaterThanOrEqual(0); + expect(args[mcpIndex + 1]).toBe("{}"); + + // The dangerous tool-granting flag MUST be absent. + expect(args).not.toContain("--dangerously-skip-permissions"); + + // Variadic-safety: `--tools ""` must be the LAST pair so no later flag is + // swallowed by its empty value. `--strict-mcp-config` (boolean) precedes + // `--mcp-config "{}"` which precedes `--tools ""`. + expect(args[args.length - 2]).toBe("--tools"); + expect(args[args.length - 1]).toBe(""); + expect(mcpIndex).toBeLessThan(toolsIndex); + expect(args.indexOf("--strict-mcp-config")).toBeLessThan(mcpIndex); + }); + + it("skip-permissions posture grants tools (the existing git-op behavior)", () => { + const args = buildClaudeProposalArgs({ ...base, posture: "skip-permissions" }); + expect(args).toContain("--dangerously-skip-permissions"); + expect(args).not.toContain("--tools"); + expect(args).not.toContain("--strict-mcp-config"); + expect(args).not.toContain("--mcp-config"); + }); + + it("honors model and effort/settings per call", () => { + const args = buildClaudeProposalArgs({ + jsonSchemaStr: '{"type":"object"}', + model: "claude-sonnet-4-6", + cliEffort: "high", + settingsJson: '{"alwaysThinkingEnabled":true}', + posture: "no-tool", + }); + const modelIndex = args.indexOf("--model"); + expect(args[modelIndex + 1]).toBe("claude-sonnet-4-6"); + const effortIndex = args.indexOf("--effort"); + expect(args[effortIndex + 1]).toBe("high"); + const settingsIndex = args.indexOf("--settings"); + expect(args[settingsIndex + 1]).toBe('{"alwaysThinkingEnabled":true}'); + }); +}); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 0c53dbecea0..7c9ccd010fe 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -318,6 +318,42 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { }), ); + it.effect("generates board proposals NO-TOOL (no built-ins, no MCP, no skip-permissions)", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + // proposedDefinition is now a JSON STRING on the wire (the provider + // schema types it as a string); the op decodes it back to an object. + proposedDefinition: JSON.stringify({ lanes: ["todo", "doing", "done"] }), + rationale: " Adds a doing lane to reduce WIP. ", + }, + }), + // SAFETY ASSERTION: the meta-agent must load ZERO tools — built-ins + // disabled (`--tools ""`) AND all MCP suppressed (`--strict-mcp-config + // --mcp-config {}`), ordered so --tools is last — and MUST NOT pass the + // tool-granting skip-permissions flag. (`$*` joins args with spaces; + // the empty `--tools` value is the trailing element.) + argsMustContain: "--strict-mcp-config --mcp-config {} --tools", + argsMustNotContain: "--dangerously-skip-permissions", + stdinMustContain: "propose an improved board definition", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateBoardProposal({ + prompt: "Metrics: tickets stuck in todo. Current def: {lanes:[todo,done]}.", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.proposedDefinition).toEqual({ lanes: ["todo", "doing", "done"] }); + expect(generated.rationale).toBe("Adds a doing lane to reduce WIP."); + }), + ), + ); + it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => withFakeClaudeEnv( { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 91ad90b786e..d2197a3c094 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -8,6 +8,7 @@ * @module ClaudeTextGeneration */ import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -20,6 +21,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { TextGenerationError } from "@t3tools/contracts"; import { type TextGenerationShape } from "./TextGeneration.ts"; import { + buildBoardProposalPrompt, buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, @@ -47,6 +49,67 @@ import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; +/** + * Permission posture for a Claude CLI invocation. + * + * - `"skip-permissions"` passes `--dangerously-skip-permissions`, which grants + * the agent full tool/filesystem access. Used for the git text-generation ops + * where the model only emits structured JSON but historically ran with + * skip-permissions. + * - `"no-tool"` loads ZERO tools regardless of the machine's permission/settings + * /MCP config. Per `claude --help`: + * - `--tools ""` disables all tools from the BUILT-IN set ONLY. It does NOT + * affect MCP-server tools (from `~/.claude.json` etc.), which would + * otherwise stay loaded and — under an auto-approve permission mode or a + * write/bash-capable MCP server — let the agent write `.t3/boards/*.json` + * and bypass the human approval gate. + * - `--strict-mcp-config` makes Claude "Only use MCP servers from + * --mcp-config, ignoring all other MCP configurations". + * - `--mcp-config "{}"` supplies an EMPTY MCP server set. + * Together (`--strict-mcp-config --mcp-config "{}" --tools ""`) NO built-in + * tools and NO MCP tools are loaded — independent of permission mode. This is + * the architectural guarantee for the self-improving-boards meta-agent: it can + * reason and emit a proposal but physically cannot apply it. + */ +export type ClaudePermissionPosture = "skip-permissions" | "no-tool"; + +/** + * Pure builder for the Claude CLI argument vector. Extracted so the no-tool + * guarantee can be unit-asserted without spawning a process (a live MCP test + * isn't possible in CI): a `"no-tool"` posture MUST emit + * `--strict-mcp-config`, `--mcp-config "{}"`, and `--tools ""`, and MUST NOT + * emit `--dangerously-skip-permissions`. + * + * NOTE on ordering: `--tools` and `--mcp-config` are variadic, so any flag + * placed AFTER them could be swallowed as a value. The no-tool flags are + * emitted LAST, with `--tools ""` the very last pair (only the stdin-fed prompt + * follows). `--strict-mcp-config` is a boolean flag (takes no value) so it is + * safe to place before `--mcp-config "{}"`. + */ +export const buildClaudeProposalArgs = (input: { + readonly jsonSchemaStr: string; + readonly model: string; + readonly cliEffort: string | undefined; + readonly settingsJson: string | undefined; + readonly posture: ClaudePermissionPosture; +}): ReadonlyArray => [ + "-p", + "--output-format", + "json", + "--json-schema", + input.jsonSchemaStr, + "--model", + input.model, + ...(input.cliEffort ? ["--effort", input.cliEffort] : []), + ...(input.settingsJson ? ["--settings", input.settingsJson] : []), + // SAFETY: the posture decides tool access. `"no-tool"` loads zero tools + // (no built-ins via `--tools ""`, no MCP via `--strict-mcp-config` + + // `--mcp-config "{}"`); `"skip-permissions"` grants full access. + ...(input.posture === "no-tool" + ? ["--strict-mcp-config", "--mcp-config", "{}", "--tools", ""] + : ["--dangerously-skip-permissions"]), +]; + /** * Schema for the wrapper JSON returned by `claude -p --output-format json`. * We only care about `structured_output`. @@ -64,6 +127,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const fileSystem = yield* FileSystem.FileSystem; const readStreamAsString = ( operation: string, @@ -85,7 +149,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateBoardProposal", value: unknown, detail: string, ): Effect.Effect => @@ -110,16 +175,24 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu prompt, outputSchemaJson, modelSelection, + posture = "skip-permissions", }: { operation: | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateBoardProposal"; cwd: string; prompt: string; outputSchemaJson: S; modelSelection: ModelSelection; + /** + * Permission posture. Defaults to `"skip-permissions"` (the existing git + * ops). The board-proposal op passes `"no-tool"` so the meta-agent cannot + * use any tools. + */ + posture?: ClaudePermissionPosture; }): Effect.fn.Return { const jsonSchemaStr = yield* encodeJsonForOperation( operation, @@ -160,16 +233,13 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", [ - "-p", - "--output-format", - "json", - "--json-schema", - jsonSchemaStr, - "--model", - resolveClaudeApiModelId(modelSelection), - ...(cliEffort ? ["--effort", cliEffort] : []), - ...(settingsJson ? ["--settings", settingsJson] : []), - "--dangerously-skip-permissions", + ...buildClaudeProposalArgs({ + jsonSchemaStr, + model: resolveClaudeApiModelId(modelSelection), + cliEffort, + settingsJson, + posture, + }), ], { env: claudeEnvironment }, ); @@ -357,10 +427,52 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu }; }); + const generateBoardProposal: TextGenerationShape["generateBoardProposal"] = Effect.fn( + "ClaudeTextGeneration.generateBoardProposal", + )(function* (input) { + const { prompt, outputSchema } = buildBoardProposalPrompt({ prompt: input.prompt }); + + // SAFETY (defense-in-depth): run the no-tool op in a throwaway temp dir + // rather than the repo root, which holds `.t3/boards/*.json`. The no-tool + // posture already loads zero tools, but pointing cwd away from the board + // files shrinks the blast radius if anything ever slips. The scoped temp + // dir is removed when the effect completes. + const generated = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3code-board-proposal-" }) + .pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateBoardProposal", + detail: "Failed to create sandbox working directory for board proposal.", + cause, + }), + ), + Effect.flatMap((sandboxCwd) => + runClaudeJson({ + operation: "generateBoardProposal", + cwd: sandboxCwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + // SAFETY: no-tool posture — the meta-agent cannot write a board def. + posture: "no-tool", + }), + ), + Effect.scoped, + ); + + return { + proposedDefinition: generated.proposedDefinition, + rationale: generated.rationale.trim(), + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateBoardProposal, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index cf0ad7d5781..688e0813b7e 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -37,6 +37,8 @@ function makeFakeCodexBinary( forbidReasoningEffort?: boolean; stdinMustContain?: string; stdinMustNotContain?: string; + /** If provided, the binary writes $PWD to this file so tests can assert cwd. */ + cwdRecordPath?: string; }, ) { return Effect.gen(function* () { @@ -148,6 +150,12 @@ function makeFakeCodexBinary( input.output, "__T3CODE_FAKE_CODEX_OUTPUT__", "fi", + ...(input.cwdRecordPath !== undefined + ? [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + `printf "%s" "$PWD" > ${JSON.stringify(input.cwdRecordPath)}`, + ] + : []), `exit ${input.exitCode ?? 0}`, "", ].join("\n"), @@ -168,6 +176,8 @@ function withFakeCodexEnv( forbidReasoningEffort?: boolean; stdinMustContain?: string; stdinMustNotContain?: string; + /** If provided, the binary writes $PWD to this file so tests can assert cwd. */ + cwdRecordPath?: string; }, effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, ) { @@ -602,4 +612,82 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { }), ), ); + + // ── Prompt-only egress: generateBoardProposal cwd isolation ────────────── + + it.effect("generateBoardProposal runs codex from an empty temp dir (not the repo cwd)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Create a named file that the binary will write $PWD into. + const cwdRecord = yield* fs.makeTempFileScoped({ + prefix: "t3code-codex-cwd-record-", + }); + + yield* withFakeCodexEnv( + { + // @effect-diagnostics-next-line preferSchemaOverJson:off + output: JSON.stringify({ + // proposedDefinition is a JSON STRING on the wire (provider schema + // types it as a string); the op decodes it back to an object. + // @effect-diagnostics-next-line preferSchemaOverJson:off + proposedDefinition: JSON.stringify({ + lanes: [], + name: "Test Board", + description: "", + triggers: [], + }), + rationale: "test rationale", + }), + cwdRecordPath: cwdRecord, + }, + (textGeneration) => + textGeneration.generateBoardProposal({ + prompt: "Create a simple kanban board.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }), + ); + + const recordedCwd = yield* fs.readFileString(cwdRecord); + // Must NOT be the repo root. + expect(recordedCwd).not.toBe(process.cwd()); + // Must be inside the OS temp dir hierarchy. + const osTmp = path.dirname(yield* fs.realPath(cwdRecord)); + // The recorded cwd is inside a freshly-created temp dir — it must share + // a common ancestor with the OS temp directory. + expect(recordedCwd).toContain("t3code-board-proposal-"); + }).pipe(Effect.scoped), + ); + + it.effect( + "generateCommitMessage (git op) keeps the caller-supplied repo cwd, not a temp dir", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwdRecord = yield* fs.makeTempFileScoped({ + prefix: "t3code-codex-cwd-record-", + }); + const repoCwd = process.cwd(); + + yield* withFakeCodexEnv( + { + // @effect-diagnostics-next-line preferSchemaOverJson:off + output: JSON.stringify({ subject: "Add important change", body: "" }), + cwdRecordPath: cwdRecord, + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: repoCwd, + branch: "feature/cwd-check", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }), + ); + + const recordedCwd = yield* fs.readFileString(cwdRecord); + // Git ops must use the repo cwd passed by the caller. + expect(recordedCwd).toBe(repoCwd); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 80b39af2584..c6ab862847f 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -21,6 +21,7 @@ import { type TextGenerationShape, } from "./TextGeneration.ts"; import { + buildBoardProposalPrompt, buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, @@ -39,6 +40,48 @@ import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); + +/** + * Build the `codex exec` argv for a structured-output text-generation run. + * + * `ignoreUserConfig` adds `--ignore-user-config`, which is the no-tool posture + * used by the board-proposal op: it stops Codex from loading + * `$CODEX_HOME/config.toml`, so configured MCP servers, hooks, skills, and + * `developer_instructions` are NOT loaded — the analog of the Claude path's + * `--strict-mcp-config --mcp-config "{}"` suppression, and broader. Auth still + * uses `CODEX_HOME`, and the model + reasoning effort (+ optional service tier) + * are passed explicitly here, so they survive the dropped config. Git ops keep + * the user config (they are not no-tool). + */ +export function buildCodexExecArgs(input: { + readonly model: string; + readonly reasoningEffort: string; + readonly serviceTier?: string | undefined; + readonly schemaPath: string; + readonly outputPath: string; + readonly imagePaths?: ReadonlyArray; + readonly ignoreUserConfig?: boolean; +}): Array { + return [ + "exec", + "--ephemeral", + "--skip-git-repo-check", + ...(input.ignoreUserConfig ? ["--ignore-user-config"] : []), + "-s", + "read-only", + "--model", + input.model, + "--config", + `model_reasoning_effort="${input.reasoningEffort}"`, + ...(input.serviceTier ? ["--config", `service_tier="${input.serviceTier}"`] : []), + "--output-schema", + input.schemaPath, + "--output-last-message", + input.outputPath, + ...(input.imagePaths ?? []).flatMap((imagePath) => ["--image", imagePath]), + "-", + ]; +} /** * Build a Codex text-generation closure bound to a specific `CodexSettings` * payload. See `makeCodexAdapter` for the overall per-instance rationale. @@ -101,7 +144,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateBoardProposal", value: unknown, ): Effect.Effect => encodeJsonString(value).pipe( @@ -120,7 +164,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateBoardProposal", attachments: BranchNameGenerationInput["attachments"], ): Effect.fn.Return { if (!attachments || attachments.length === 0) { @@ -157,18 +202,23 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func imagePaths = [], cleanupPaths = [], modelSelection, + ignoreUserConfig = false, }: { operation: | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateBoardProposal"; cwd: string; prompt: string; outputSchemaJson: S; imagePaths?: ReadonlyArray; cleanupPaths?: ReadonlyArray; modelSelection: ModelSelection; + // No-tool posture: drop $CODEX_HOME/config.toml (MCP/hooks/skills/dev-instructions). + // Only the board-proposal op sets this; git ops keep the user config. + ignoreUserConfig?: boolean; }): Effect.fn.Return { const schemaJson = yield* encodeJsonForOperation( operation, @@ -184,24 +234,15 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const serviceTier = getCodexServiceTierOptionValue(modelSelection); const spawnCommand = yield* resolveSpawnCommand( codexConfig.binaryPath || "codex", - [ - "exec", - "--ephemeral", - "--skip-git-repo-check", - "-s", - "read-only", - "--model", - modelSelection.model, - "--config", - `model_reasoning_effort="${reasoningEffort}"`, - ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []), - "--output-schema", + buildCodexExecArgs({ + model: modelSelection.model, + reasoningEffort, + serviceTier, schemaPath, - "--output-last-message", outputPath, - ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), - "-", - ], + imagePaths, + ignoreUserConfig, + }), { env: resolvedEnvironment }, ); const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { @@ -402,10 +443,56 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } satisfies ThreadTitleGenerationResult; }); + const generateBoardProposal: TextGenerationShape["generateBoardProposal"] = Effect.fn( + "CodexTextGeneration.generateBoardProposal", + )(function* (input) { + const { prompt, outputSchema } = buildBoardProposalPrompt({ prompt: input.prompt }); + + // SAFETY (defense-in-depth): run the board-proposal op from an empty + // throwaway temp dir rather than the repo root. `codex exec -s read-only` + // prevents writes, but the agent can still READ repo files from process.cwd(). + // Pointing cwd to an empty temp dir removes the repo from reach entirely, + // making this prompt-only egress (only the assembled prompt leaves the + // machine). The scoped temp dir is removed when the effect completes. + // NOTE: this is ONLY for generateBoardProposal — git ops (generateCommitMessage + // etc.) must keep the repo cwd they receive via input.cwd. + const generated = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3code-board-proposal-" }) + .pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateBoardProposal", + detail: "Failed to create sandbox working directory for board proposal.", + cause, + }), + ), + Effect.flatMap((sandboxCwd) => + runCodexJson({ + operation: "generateBoardProposal", + cwd: sandboxCwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + // No-tool clean room: no MCP servers, hooks, skills, or + // developer_instructions from the user's Codex config get loaded. + ignoreUserConfig: true, + }), + ), + Effect.scoped, + ); + + return { + proposedDefinition: generated.proposedDefinition, + rationale: generated.rationale.trim(), + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateBoardProposal, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 6d72178b8ae..d0468207c19 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -270,10 +270,22 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu } satisfies ThreadTitleGenerationResult; }); + const generateBoardProposal: TextGenerationShape["generateBoardProposal"] = () => + // UNSUPPORTED: the Cursor ACP runtime cannot be proven no-tool (its "ask" + // mode still exposes tools behind permission prompts), so we reject board + // proposals rather than ship a tool-enabled meta-agent. + Effect.fail( + new TextGenerationError({ + operation: "generateBoardProposal", + detail: "Cursor provider not supported for board proposals (no provable no-tool mode).", + }), + ); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateBoardProposal, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 6d7ff8e872d..437af87e97e 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -263,10 +263,21 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi } satisfies ThreadTitleGenerationResult; }); + const generateBoardProposal: TextGenerationShape["generateBoardProposal"] = () => + // UNSUPPORTED: the Grok ACP runtime has no provable no-tool mode, so we + // reject board proposals rather than ship a tool-enabled meta-agent. + Effect.fail( + new TextGenerationError({ + operation: "generateBoardProposal", + detail: "Grok provider not supported for board proposals (no provable no-tool mode).", + }), + ); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateBoardProposal, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index ba1f3a0435c..54a39c0375c 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -23,6 +23,10 @@ const runtimeMock = { startCalls: [] as string[], promptUrls: [] as string[], authHeaders: [] as Array, + /** The `directory` argument passed to createOpenCodeSdkClient for each call. */ + promptDirectories: [] as Array, + /** The args passed to session.create for each call (captures the `permission` posture). */ + sessionCreateArgs: [] as Array, closeCalls: [] as string[], promptResult: undefined as | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } @@ -32,6 +36,8 @@ const runtimeMock = { this.state.startCalls.length = 0; this.state.promptUrls.length = 0; this.state.authHeaders.length = 0; + this.state.promptDirectories.length = 0; + this.state.sessionCreateArgs.length = 0; this.state.closeCalls.length = 0; this.state.promptResult = undefined; }, @@ -62,15 +68,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { external: Boolean(serverUrl), }), runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), - createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => + createOpenCodeSdkClient: ({ baseUrl, serverPassword, directory }) => ({ session: { - create: async () => ({ data: { id: `${baseUrl}/session` } }), + create: async (args: unknown) => { + runtimeMock.state.sessionCreateArgs.push(args); + return { data: { id: `${baseUrl}/session` } }; + }, prompt: async () => { runtimeMock.state.promptUrls.push(baseUrl); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); + runtimeMock.state.promptDirectories.push(directory); return ( runtimeMock.state.promptResult ?? { data: { @@ -306,6 +316,115 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { }), ), ); + + // ── Prompt-only egress: generateBoardProposal cwd isolation ────────────── + + it.effect("generateBoardProposal passes an empty temp dir as directory (not the repo cwd)", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [ + { + type: "text", + // @effect-diagnostics-next-line preferSchemaOverJson:off + text: JSON.stringify({ + // proposedDefinition is a JSON STRING on the wire (provider + // schema types it as a string); the op decodes it to an object. + // @effect-diagnostics-next-line preferSchemaOverJson:off + proposedDefinition: JSON.stringify({ + lanes: [], + name: "Test Board", + description: "", + triggers: [], + }), + rationale: "test rationale", + }), + }, + ], + }, + }; + + yield* textGeneration.generateBoardProposal({ + prompt: "Create a simple kanban board.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.promptDirectories).toHaveLength(1); + const boardProposalDir = runtimeMock.state.promptDirectories[0]; + // Must NOT be the repo root. + expect(boardProposalDir).not.toBe(process.cwd()); + // Must be inside the OS temp dir hierarchy (carries the well-known prefix). + expect(boardProposalDir).toContain("t3code-board-proposal-"); + }), + ), + ); + + // ── No-tool guarantee: the session denies every tool permission ────────── + it.effect( + "generateBoardProposal opens a session that denies EVERY tool permission (no-tool)", + () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [ + { + type: "text", + // @effect-diagnostics-next-line preferSchemaOverJson:off + text: JSON.stringify({ + // @effect-diagnostics-next-line preferSchemaOverJson:off + proposedDefinition: JSON.stringify({ lanes: [], name: "X" }), + rationale: "r", + }), + }, + ], + }, + }; + + yield* textGeneration.generateBoardProposal({ + prompt: "Create a simple kanban board.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.sessionCreateArgs).toHaveLength(1); + const createArgs = runtimeMock.state.sessionCreateArgs[0] as { + readonly permission?: ReadonlyArray<{ + permission?: string; + pattern?: string; + action?: string; + }>; + }; + // The no-tool guarantee: a single deny-all rule, so the meta-agent + // cannot invoke ANY tool (built-in or MCP) — even if an external + // server had MCP servers connected. + expect(createArgs.permission).toEqual([ + { permission: "*", pattern: "*", action: "deny" }, + ]); + }), + ), + ); + + it.effect( + "generateCommitMessage (git op) passes the caller-supplied repo cwd, not a temp dir", + () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const repoCwd = process.cwd(); + yield* textGeneration.generateCommitMessage({ + cwd: repoCwd, + branch: "feature/opencode-cwd-check", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.promptDirectories).toHaveLength(1); + // Git ops must use the repo cwd passed by the caller. + expect(runtimeMock.state.promptDirectories[0]).toBe(repoCwd); + }), + ), + ); }); it.layer(OpenCodeTextGenerationExistingServerTestLayer)( diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 65d3854e945..37320a7a3b4 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -18,6 +19,7 @@ import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { ServerConfig } from "../config.ts"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { + buildBoardProposalPrompt, buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, @@ -104,6 +106,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; const resolvedEnvironment = environment ?? process.env; + const fileSystem = yield* FileSystem.FileSystem; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), ); @@ -161,7 +164,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateBoardProposal"; }) => sharedServerMutex.withPermit( Effect.gen(function* () { @@ -271,7 +275,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateBoardProposal"; readonly cwd: string; readonly prompt: string; readonly outputSchemaJson: S; @@ -304,6 +309,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); const session = await client.session.create({ title: `T3 Code ${input.operation}`, + // SAFETY: deny every tool permission. This is the no-tool guarantee + // for all OpenCode text-generation ops, including generateBoardProposal. permission: [{ permission: "*", pattern: "*", action: "deny" }], }); if (!session.data) { @@ -459,10 +466,53 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }; }); + const generateBoardProposal: TextGenerationShape["generateBoardProposal"] = Effect.fn( + "OpenCodeTextGeneration.generateBoardProposal", + )(function* (input) { + const { prompt, outputSchema } = buildBoardProposalPrompt({ prompt: input.prompt }); + + // SAFETY (defense-in-depth): run the board-proposal op from an empty + // throwaway temp dir rather than the repo root. OpenCode already denies all + // tool permissions (`permission deny *`) so file access via tools is blocked, + // but the cwd is still passed to the SDK client as the session's `directory`. + // Pointing it to an empty temp dir ensures prompt-only egress (only the + // assembled prompt leaves the machine) and is consistent with the Claude path. + // NOTE: this is ONLY for generateBoardProposal — git ops (generateCommitMessage + // etc.) must keep the repo cwd they receive via input.cwd. + const generated = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3code-board-proposal-" }) + .pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateBoardProposal", + detail: "Failed to create sandbox working directory for board proposal.", + cause, + }), + ), + Effect.flatMap((sandboxCwd) => + runOpenCodeJson({ + operation: "generateBoardProposal", + cwd: sandboxCwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }), + ), + Effect.scoped, + ); + + return { + proposedDefinition: generated.proposedDefinition, + rationale: generated.rationale.trim(), + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateBoardProposal, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index f186d934e52..efbb40b1f32 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -20,6 +20,8 @@ const makeStubTextGeneration = (overrides: Partial): TextGe generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + generateBoardProposal: () => + Effect.die("generateBoardProposal stub not configured for this test"), ...overrides, }); @@ -94,6 +96,36 @@ describe("makeTextGenerationFromRegistry", () => { }), ); + it.effect("delegates generateBoardProposal and returns the parsed proposal", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const recorded: Array<{ prompt: string; model: string }> = []; + const instance = makeStubInstance( + instanceId, + makeStubTextGeneration({ + generateBoardProposal: (input) => { + recorded.push({ prompt: input.prompt, model: input.modelSelection.model }); + return Effect.succeed({ + proposedDefinition: { lanes: ["a", "b"] }, + rationale: "because", + }); + }, + }), + ); + + const tg = makeTextGenerationFromRegistry(makeStubRegistry([instance])); + + const result = yield* tg.generateBoardProposal({ + prompt: "assembled metrics + def", + modelSelection: createModelSelection(instanceId, "claude-sonnet-4-6"), + }); + + expect(result.proposedDefinition).toEqual({ lanes: ["a", "b"] }); + expect(result.rationale).toBe("because"); + expect(recorded).toEqual([{ prompt: "assembled metrics + def", model: "claude-sonnet-4-6" }]); + }), + ); + it.effect("fails with TextGenerationError when the instance is unknown", () => Effect.gen(function* () { const tg = makeTextGenerationFromRegistry(makeStubRegistry([])); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index d5d28e638ed..7a932e40ae8 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -70,6 +70,29 @@ export interface ThreadTitleGenerationResult { title: string; } +export interface BoardProposalGenerationInput { + /** + * Fully assembled prompt (metrics + current board definition + instructions). + * The caller (the self-improving-boards meta-agent) builds this; the provider + * does NOT read any files — the underlying model invocation is no-tool / + * read-only so it physically cannot write a board definition. + */ + readonly prompt: string; + /** What model and provider to use for generation (model + effort/thinking). */ + readonly modelSelection: ModelSelection; +} + +export interface BoardProposalGenerationResult { + /** + * The proposed workflow/board definition. Returned as `unknown` here; the + * caller (Task E4) decodes it as a WorkflowDefinition. Schema-enforced via + * the provider's structured-output mechanism where supported. + */ + readonly proposedDefinition: unknown; + /** Human-readable explanation of why this proposal was made. */ + readonly rationale: string; +} + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, @@ -110,6 +133,20 @@ export interface TextGenerationShape { readonly generateThreadTitle: ( input: ThreadTitleGenerationInput, ) => Effect.Effect; + + /** + * Generate a structured board/workflow proposal from an assembled prompt. + * + * SAFETY: this op MUST run NO-TOOL / read-only. The underlying model + * invocation has all tools/filesystem access denied so the meta-agent + * cannot itself write a board definition — only the human-gated + * `saveBoardDefinition` path applies a proposal. Providers that cannot be + * proven no-tool fail with a `TextGenerationError` ("provider not supported + * for board proposals") rather than shipping a tool-enabled meta-agent. + */ + readonly generateBoardProposal: ( + input: BoardProposalGenerationInput, + ) => Effect.Effect; } /** @@ -123,7 +160,8 @@ type TextGenerationOp = | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateBoardProposal"; const resolveInstance = ( registry: ProviderInstanceRegistryShape, @@ -162,6 +200,10 @@ export const makeTextGenerationFromRegistry = ( resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), ), + generateBoardProposal: (input) => + resolveInstance(registry, "generateBoardProposal", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateBoardProposal(input)), + ), }); export const layer = Layer.effect( diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 6015e83b5d4..303c31b183b 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -216,3 +216,44 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { return { prompt, outputSchema }; } + +// --------------------------------------------------------------------------- +// Board proposal (self-improving boards meta-agent) +// --------------------------------------------------------------------------- + +export interface BoardProposalPromptInput { + /** + * Fully assembled prompt (metrics + current board definition + instructions) + * built by the caller. This builder only wraps it with the structured-output + * contract and supplies the output schema. + */ + prompt: string; +} + +export function buildBoardProposalPrompt(input: BoardProposalPromptInput) { + const prompt = [ + "You analyze workflow board metrics and propose an improved board definition.", + "Return a JSON object with keys: proposedDefinition, rationale.", + "Rules:", + // proposedDefinition is a STRING (the definition serialized with JSON.stringify), + // NOT a nested object. Provider structured-output schemas (OpenAI/Codex + // `text.format.schema`) reject a property with no concrete `type`, so the full + // recursive WorkflowDefinition cannot be expressed inline — the model returns it + // as a JSON string and the server parses it back. + "- proposedDefinition must be a STRING containing the complete workflow/board definition serialized as JSON (i.e. JSON.stringify of the definition object)", + "- rationale must concisely explain why the proposal improves the board", + "- do not attempt to apply, save, or write the definition anywhere; only return it", + "", + input.prompt, + ].join("\n"); + + const outputSchema = Schema.Struct({ + // `fromJsonString(Unknown)`: wire/JSON-schema type is `string` (valid for every + // provider's structured-output validator), and decode JSON.parses it back into + // the definition object so callers receive an object exactly as before. + proposedDefinition: Schema.fromJsonString(Schema.Unknown), + rationale: Schema.String, + }); + + return { prompt, outputSchema }; +} diff --git a/apps/server/src/workflow/Layers/ApprovalGate.test.ts b/apps/server/src/workflow/Layers/ApprovalGate.test.ts new file mode 100644 index 00000000000..4697a4fe47f --- /dev/null +++ b/apps/server/src/workflow/Layers/ApprovalGate.test.ts @@ -0,0 +1,21 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; + +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; + +const layer = it.layer(ApprovalGateLive); + +layer("ApprovalGate", (it) => { + it.effect("await resolves once resolve is called", () => + Effect.gen(function* () { + const gate = yield* ApprovalGate; + const fiber = yield* Effect.forkChild(gate.await("sr-1" as never)); + yield* Effect.yieldNow; + yield* gate.resolve("sr-1" as never, true); + const approved = yield* Fiber.join(fiber); + assert.equal(approved, true); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/ApprovalGate.ts b/apps/server/src/workflow/Layers/ApprovalGate.ts new file mode 100644 index 00000000000..bdef75e4764 --- /dev/null +++ b/apps/server/src/workflow/Layers/ApprovalGate.ts @@ -0,0 +1,86 @@ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { ApprovalGate } from "../Services/ApprovalGate.ts"; + +export const ApprovalGateLive = Layer.effect( + ApprovalGate, + Effect.gen(function* () { + const pending = yield* Ref.make(new Map>()); + const activeWaiters = yield* Ref.make(new Map()); + + const getOrCreate = (stepRunId: string) => + Effect.gen(function* () { + // Created speculatively, registered atomically: two concurrent + // callers must end up waiting on the SAME deferred or the loser's + // waiter could never be resolved. + const fresh = yield* Deferred.make(); + return yield* Ref.modify(pending, (current) => { + const existing = current.get(stepRunId); + if (existing) { + return [existing, current] as const; + } + return [fresh, new Map(current).set(stepRunId, fresh)] as const; + }); + }); + + const incrementWaiter = (stepRunId: string) => + Ref.update(activeWaiters, (current) => { + const next = new Map(current); + next.set(stepRunId, (next.get(stepRunId) ?? 0) + 1); + return next; + }); + + const decrementWaiter = (stepRunId: string) => + Ref.update(activeWaiters, (current) => { + const next = new Map(current); + const count = (next.get(stepRunId) ?? 0) - 1; + if (count <= 0) { + next.delete(stepRunId); + } else { + next.set(stepRunId, count); + } + return next; + }); + + // stepRunIds are unique per attempt and resolve is terminal (the engine + // commits StepUserResolved before any further await could occur), so once a + // wait is resolved its deferred is dead. Drop it to keep `pending` from + // growing unbounded for the process lifetime. Any in-flight Deferred.await + // already captured the deferred reference in getOrCreate, so the delete is + // safe — it only evicts the resolved entry from the lookup map. + const prune = (stepRunId: string) => + Ref.update(pending, (current) => { + if (!current.has(stepRunId)) { + return current; + } + const next = new Map(current); + next.delete(stepRunId); + return next; + }); + + return ApprovalGate.of({ + park: (stepRunId) => getOrCreate(stepRunId).pipe(Effect.asVoid), + await: (stepRunId) => + Effect.gen(function* () { + const id = stepRunId as string; + const deferred = yield* getOrCreate(id); + return yield* incrementWaiter(id).pipe( + Effect.andThen(Deferred.await(deferred)), + Effect.ensuring(decrementWaiter(id)), + ); + }), + resolve: (stepRunId, approved) => + Effect.gen(function* () { + const id = stepRunId as string; + const deferred = yield* getOrCreate(id); + const liveWaiters = (yield* Ref.get(activeWaiters)).get(id) ?? 0; + yield* Deferred.succeed(deferred, approved); + yield* prune(id); + return liveWaiters > 0; + }), + }); + }), +); diff --git a/apps/server/src/workflow/Layers/AsanaProvider.test.ts b/apps/server/src/workflow/Layers/AsanaProvider.test.ts new file mode 100644 index 00000000000..5fcab1f6911 --- /dev/null +++ b/apps/server/src/workflow/Layers/AsanaProvider.test.ts @@ -0,0 +1,605 @@ +import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { AsanaProvider as AsanaProviderTag } from "../Services/WorkSourceProvider.ts"; +import { WorkSourceConnectionStore } from "../Services/WorkSourceConnectionStore.ts"; +import { AsanaProviderLive } from "./AsanaProvider.ts"; + +// --------------------------------------------------------------------------- +// Canned Asana API responses +// --------------------------------------------------------------------------- + +/** Task 1: open/incomplete */ +const taskOpen = { + gid: "task-gid-1", + name: "Fix the bug", + notes: "Detailed description here", + completed: false, + completed_at: null, + assignee: { name: "Alice" }, + tags: [{ name: "urgent" }, { name: "backend" }], + permalink_url: "https://app.asana.com/0/project/task-gid-1", + modified_at: "2024-02-01T10:00:00.000Z", +}; + +/** Task 2: completed */ +const taskCompleted = { + gid: "task-gid-2", + name: "Write the docs", + notes: null, + completed: true, + completed_at: "2024-02-02T12:00:00.000Z", + assignee: null, + tags: [], + permalink_url: "https://app.asana.com/0/project/task-gid-2", + modified_at: "2024-02-02T12:00:00.000Z", +}; + +// --------------------------------------------------------------------------- +// Helper: build a test layer with mocked HttpClient + connection store +// --------------------------------------------------------------------------- + +function makeTestLayer(input: { + readonly responseBody: unknown; + readonly responseStatus?: number; + readonly responseHeaders?: Record; + readonly pat?: string; +}) { + const pat = input.pat ?? "test-asana-pat"; + const status = input.responseStatus ?? 200; + const headers = input.responseHeaders ?? {}; + + const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(input.responseBody), { + status, + headers: { + "content-type": "application/json", + ...headers, + }, + }), + ), + ), + ); + + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => execute(request)), + ); + + const connectionStoreLayer = Layer.succeed(WorkSourceConnectionStore, { + getToken: (_connectionRef, _expectedProvider) => Effect.succeed(pat), + getConnectionAuth: (_connectionRef, _expectedProvider) => + Effect.succeed({ token: pat, authMode: "pat", baseUrl: null, email: null }), + create: (_input) => Effect.die("not needed in test"), + list: () => Effect.die("not needed in test"), + remove: (_connectionRef) => Effect.die("not needed in test"), + }); + + const testLayer = AsanaProviderLive.pipe( + Layer.provide(httpClientLayer), + Layer.provide(connectionStoreLayer), + ); + + return { execute, testLayer }; +} + +// Helper: a canned page response wrapping tasks +function pageResponse( + tasks: unknown[], + nextOffset?: string, +): { data: unknown[]; next_page: unknown } { + return { + data: tasks, + next_page: nextOffset + ? { offset: nextOffset, path: "/tasks?offset=" + nextOffset, uri: "https://app.asana.com" } + : null, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("AsanaProvider", () => { + describe("listPage", () => { + it.effect("maps incomplete task → open lifecycle, completed → closed lifecycle", () => { + const { testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen, taskCompleted]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 50, + }); + + expect(page.items).toHaveLength(2); + + // Task 1: open + expect(page.items[0]!.externalId).toBe("task-gid-1"); + expect(page.items[0]!.lifecycle).toBe("open"); + expect(page.items[0]!.provider).toBe("asana"); + + // Task 2: completed → closed + expect(page.items[1]!.externalId).toBe("task-gid-2"); + expect(page.items[1]!.lifecycle).toBe("closed"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect( + "maps fields: name→title, notes→description, assignee.name→assignees, tags→labels, permalink_url→url", + () => { + const { testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 50, + }); + + const item = page.items[0]!; + expect(item.fields.title).toBe("Fix the bug"); + expect(item.fields.description).toBe("Detailed description here"); + expect(item.fields.assignees).toEqual(["Alice"]); + expect(item.fields.labels).toEqual(["urgent", "backend"]); + expect(item.url).toBe("https://app.asana.com/0/project/task-gid-1"); + expect(item.version.updatedAt).toBe("2024-02-01T10:00:00.000Z"); + }).pipe(Effect.provide(testLayer)); + }, + ); + + it.effect("task with null notes → description undefined", () => { + const { testLayer } = makeTestLayer({ + responseBody: pageResponse([taskCompleted]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 50, + }); + + expect(page.items[0]!.fields.description).toBeUndefined(); + // No assignee → assignees undefined + expect(page.items[0]!.fields.assignees).toBeUndefined(); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("pagination: next_page.offset becomes nextPageToken when present", () => { + const { testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen], "PAGE_TOKEN_ABC"), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 10, + }); + expect(page.nextPageToken).toBe("PAGE_TOKEN_ABC"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("pagination: null next_page → nextPageToken undefined", () => { + const { testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 10, + }); + expect(page.nextPageToken).toBeUndefined(); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("includeCompleted:false adds completed_since=now to the request", () => { + const { execute, testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123", includeCompleted: false }, + pageSize: 20, + }); + + const request = execute.mock.calls[0]?.[0]; + expect(request).toBeDefined(); + // urlParams is a UrlParams object with a .params ReadonlyArray + const params: ReadonlyArray = request!.urlParams.params; + const completedSinceParam = params.find(([k]) => k === "completed_since"); + expect(completedSinceParam).toBeDefined(); + expect(completedSinceParam![1]).toBe("now"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("includeCompleted:true (default) does NOT add completed_since", () => { + const { execute, testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + yield* provider.listPage({ + connectionRef: "conn", + // Omit includeCompleted — defaults to true + selector: { projectGid: "proj-123" }, + pageSize: 20, + }); + + const request = execute.mock.calls[0]?.[0]; + expect(request).toBeDefined(); + const params: ReadonlyArray = request!.urlParams.params; + const completedSinceParam = params.find(([k]) => k === "completed_since"); + expect(completedSinceParam).toBeUndefined(); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect( + "sectionGid/tagGid set → still returns full mapped page (warning is non-fatal)", + () => { + const { testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen, taskCompleted]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + // sectionGid/tagGid set — v1 does NOT filter; warning emitted but behavior unchanged + selector: { projectGid: "proj-123", sectionGid: "sect-1", tagGid: "tag-1" }, + pageSize: 50, + }); + + // Full project page returned, not filtered down + expect(page.items.map((i) => i.externalId)).toEqual(["task-gid-1", "task-gid-2"]); + }).pipe(Effect.provide(testLayer)); + }, + ); + + it.effect("429 + Retry-After:2 → WorkSourceRateLimitError{retryAfterMs:2000}", () => { + // it.effect uses an internal test clock pinned at epoch 0 — the + // Asana 429 path reads Retry-After in seconds and multiplies by 1000, + // so Retry-After:2 → retryAfterMs:2000 deterministically. + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "Rate Limited" }] }, + responseStatus: 429, + responseHeaders: { "retry-after": "2" }, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceRateLimitError"); + expect((failure as { retryAfterMs?: number }).retryAfterMs).toBe(2000); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("429 without Retry-After → WorkSourceRateLimitError with fallback 60_000ms", () => { + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "Rate Limited" }] }, + responseStatus: 429, + responseHeaders: {}, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceRateLimitError"); + expect((failure as { retryAfterMs?: number }).retryAfterMs).toBe(60_000); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("401 → WorkSourceAuthError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "Not Authorized" }] }, + responseStatus: 401, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "my-conn", + selector: { projectGid: "proj-123" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceAuthError"); + expect((failure as { connectionRef?: string }).connectionRef).toBe("my-conn"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("403 (PAT lacks project access) → WorkSourceAuthError (NOT transient)", () => { + // Fix L5: an Asana 403 from an authenticated PAT that lacks access to the + // project is a stable permission failure → auth, not transient backoff. + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "Forbidden" }] }, + responseStatus: 403, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "scoped-conn", + selector: { projectGid: "proj-123" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceAuthError"); + expect((failure as { connectionRef?: string }).connectionRef).toBe("scoped-conn"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("invalid selector → WorkSourceConfigError", () => { + const { testLayer } = makeTestLayer({ responseBody: pageResponse([]) }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + // missing required projectGid + selector: { includeCompleted: false }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceConfigError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("sends Authorization header with PAT", () => { + const { execute, testLayer } = makeTestLayer({ + responseBody: pageResponse([]), + pat: "secret-asana-pat-xyz", + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-999" }, + pageSize: 10, + }); + + const request = execute.mock.calls[0]?.[0]; + expect(request).toBeDefined(); + expect(request!.headers["authorization"]).toBe("Bearer secret-asana-pat-xyz"); + }).pipe(Effect.provide(testLayer)); + }); + }); + + describe("getItem", () => { + it.effect("returns a mapped ExternalWorkItem for an existing task gid", () => { + const { testLayer } = makeTestLayer({ + responseBody: { data: taskOpen }, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const item = yield* provider.getItem({ + connectionRef: "conn", + selector: { projectGid: "p" }, + externalId: "task-gid-1", + }); + + expect(item).not.toBeNull(); + expect(item!.externalId).toBe("task-gid-1"); + expect(item!.lifecycle).toBe("open"); + expect(item!.fields.title).toBe("Fix the bug"); + expect(item!.fields.description).toBe("Detailed description here"); + expect(item!.fields.assignees).toEqual(["Alice"]); + expect(item!.fields.labels).toEqual(["urgent", "backend"]); + expect(item!.url).toBe("https://app.asana.com/0/project/task-gid-1"); + expect(item!.provider).toBe("asana"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("returns null when getItem receives a 404 (task deleted)", () => { + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "task: Not a recognized ID" }] }, + responseStatus: 404, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const result = yield* provider.getItem({ + connectionRef: "conn", + selector: { projectGid: "p" }, + externalId: "nonexistent-gid", + }); + expect(result).toBeNull(); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("getItem 401 → WorkSourceAuthError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "Not Authorized" }] }, + responseStatus: 401, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* Effect.flip( + provider.getItem({ + connectionRef: "bad-conn", + selector: { projectGid: "p" }, + externalId: "some-gid", + }), + ); + expect(failure._tag).toBe("WorkSourceAuthError"); + expect((failure as { connectionRef?: string }).connectionRef).toBe("bad-conn"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("getItem 403 → WorkSourceAuthError (NOT transient)", () => { + // Fix L5: 403 in getItem is a stable permission failure, not transient. + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "Forbidden" }] }, + responseStatus: 403, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* Effect.flip( + provider.getItem({ + connectionRef: "scoped-conn", + selector: { projectGid: "p" }, + externalId: "some-gid", + }), + ); + expect(failure._tag).toBe("WorkSourceAuthError"); + expect((failure as { connectionRef?: string }).connectionRef).toBe("scoped-conn"); + }).pipe(Effect.provide(testLayer)); + }); + }); + + describe("Fix 6: malformed response body → WorkSourceTransientError (not a defect)", () => { + it.effect("listPage: 200 body missing the data array → WorkSourceTransientError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { not_data: "garbage" }, + responseStatus: 200, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* provider + .listPage({ connectionRef: "conn", selector: { projectGid: "p" }, pageSize: 100 }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("listPage: 200 body where data is not an array → WorkSourceTransientError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { data: "not-an-array" }, + responseStatus: 200, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* provider + .listPage({ connectionRef: "conn", selector: { projectGid: "p" }, pageSize: 100 }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("getItem: 200 body missing the data object → WorkSourceTransientError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { not_data: "garbage" }, + responseStatus: 200, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* provider + .getItem({ connectionRef: "conn", selector: { projectGid: "p" }, externalId: "g" }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + }); + + describe("AsanaProvider import methods", () => { + it.effect("toImportableView uses empty displayRef + projectGid as container", () => { + const { testLayer } = makeTestLayer({ responseBody: pageResponse([]) }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const parts = provider.toImportableView({ + selector: { projectGid: "111", includeCompleted: false }, + item: { + provider: "asana", + externalId: "task-gid-1", + url: "https://app.asana.com/0/111/task-gid-1", + lifecycle: "open", + version: {}, + fields: { title: "task" }, + }, + }); + assert.equal(parts.displayRef, ""); + assert.equal(parts.container, "111"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("toImportableView falls back to 'Asana' when projectGid is absent", () => { + const { testLayer } = makeTestLayer({ responseBody: pageResponse([]) }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const parts = provider.toImportableView({ + selector: {}, + item: { + provider: "asana", + externalId: "task-gid-2", + url: "https://app.asana.com/0/0/task-gid-2", + lifecycle: "open", + version: {}, + fields: { title: "task" }, + }, + }); + assert.equal(parts.displayRef, ""); + assert.equal(parts.container, "Asana"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("viewer returns the user's gid + display name alias", () => { + const { testLayer } = makeTestLayer({ + responseBody: { data: { gid: "me-gid", name: "Jo" } }, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const v = yield* provider.viewer({ connectionRef: "c" }); + assert.deepEqual(v, { id: "me-gid", aliases: ["Jo"] }); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("viewer returns null on non-200 status", () => { + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "Not Authorized" }] }, + responseStatus: 401, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const v = yield* provider.viewer({ connectionRef: "c" }); + assert.equal(v, null); + }).pipe(Effect.provide(testLayer)); + }); + }); +}); diff --git a/apps/server/src/workflow/Layers/AsanaProvider.ts b/apps/server/src/workflow/Layers/AsanaProvider.ts new file mode 100644 index 00000000000..cd3220362cf --- /dev/null +++ b/apps/server/src/workflow/Layers/AsanaProvider.ts @@ -0,0 +1,363 @@ +/** + * AsanaProvider — raw-HTTP Asana Tasks work-source provider. + * + * Uses `HttpClient` from `effect/unstable/http` with a PAT fetched from + * `WorkSourceConnectionStore.getToken`. Mirrors the structure of + * `GithubIssuesProvider` closely. + * + * ### externalId strategy + * `externalId = gid` — Asana's globally unique task GID is stable and lets + * `getItem` issue a simple `GET /tasks/:gid` lookup. Unlike GitHub, we have + * the full identifier in the `getItem` signature, so orphan-confirmation is + * properly implemented (not deferred). + * + * ### nextPageToken strategy + * Asana's response wraps results in `{ data: [...], next_page: { offset, path, uri } | null }`. + * `nextPageToken = body.next_page?.offset` (a string token); absent/null → undefined. + * + * ### includeCompleted + * Asana includes completed tasks by default. To EXCLUDE completed tasks, we + * pass `completed_since=now` (an ISO string in the past forces Asana to return + * only tasks modified since that date that are NOT yet completed). Actually, + * the documented approach is: `completed_since=now` makes Asana return only + * incomplete tasks. When `selector.includeCompleted === true` (the default), + * we omit the parameter. When `selector.includeCompleted === false`, we add + * `completed_since=now`. + * + * ### sectionGid / tagGid (v1 limitation) + * The `AsanaSelector` schema accepts `sectionGid` and `tagGid` for future + * filtering. In v1 we always list the whole project via `project=projectGid` + * and do NOT apply section or tag filtering. These fields are reserved for + * future use and are documented here as deferred. To implement: + * - `sectionGid`: use `GET /sections/:gid/tasks` instead of `/tasks?project=` + * - `tagGid`: use `GET /tasks?tag=:gid` (no `project=` in that case) + * Both require restructuring the `listPage` URL; post-fetch filtering is not + * sufficient because Asana does not return `memberships` by default. + * + * ### getItem + * `GET /tasks/:gid?opt_fields=...` — proper orphan-confirmation (unlike GitHub + * v1 which returns null). 404 → null (task deleted on Asana side). + */ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { AsanaSelector } from "@t3tools/contracts/workSource"; + +import { + AsanaProvider as AsanaProviderTag, + WorkSourceAuthError, + WorkSourceConfigError, + WorkSourceRateLimitError, + WorkSourceTransientError, + type ExternalWorkItem, + type ImportableViewParts, + type WorkSourcePage, + type WorkSourceProvider, +} from "../Services/WorkSourceProvider.ts"; +import { WorkSourceConnectionStore } from "../Services/WorkSourceConnectionStore.ts"; + +const ASANA_API_BASE = "https://app.asana.com/api/1.0"; + +const ASANA_TASK_OPT_FIELDS = + "name,notes,completed,completed_at,assignee.name,tags.name,permalink_url,modified_at,gid"; + +// --------------------------------------------------------------------------- +// Rate-limit helper +// --------------------------------------------------------------------------- + +function parseAsanaRateLimitRetryMs(headers: Record): number { + // Asana always sends Retry-After on 429 (seconds) + const retryAfter = headers["retry-after"]; + if (retryAfter) { + const seconds = Number(retryAfter); + if (!Number.isNaN(seconds) && seconds > 0) return seconds * 1000; + } + return 60_000; // fallback: 1 minute +} + +// --------------------------------------------------------------------------- +// Raw Asana JSON shapes (loose — only fields we use) +// --------------------------------------------------------------------------- + +interface RawAsanaAssignee { + readonly name: string; +} + +interface RawAsanaTag { + readonly name: string; +} + +interface RawAsanaTask { + readonly gid: string; + readonly name: string; + readonly notes: string | null; + readonly completed: boolean; + readonly completed_at: string | null; + readonly assignee: RawAsanaAssignee | null; + readonly tags: ReadonlyArray | null; + readonly permalink_url: string; + readonly modified_at: string; +} + +interface RawAsanaPage { + readonly data: ReadonlyArray; + readonly next_page: { + readonly offset: string; + readonly path: string; + readonly uri: string; + } | null; +} + +function mapTask(raw: RawAsanaTask): ExternalWorkItem { + const assignees = raw.assignee ? [raw.assignee.name] : undefined; + const labels = raw.tags && raw.tags.length > 0 ? raw.tags.map((t) => t.name) : undefined; + return { + provider: "asana", + externalId: raw.gid, + url: raw.permalink_url, + lifecycle: raw.completed ? "closed" : "open", + version: { updatedAt: raw.modified_at }, + fields: { + title: raw.name, + // exactOptionalPropertyTypes: only spread when value is defined/truthy + ...(raw.notes != null && raw.notes !== "" && { description: raw.notes }), + ...(assignees !== undefined && { assignees }), + ...(labels !== undefined && { labels }), + }, + }; +} + +// --------------------------------------------------------------------------- +// Provider implementation +// --------------------------------------------------------------------------- + +const make = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const connectionStore = yield* WorkSourceConnectionStore; + + function buildHeaders(pat: string): Record { + return { + authorization: `Bearer ${pat}`, + accept: "application/json", + }; + } + + const provider: WorkSourceProvider = { + provider: "asana", + selectorSchema: AsanaSelector, + + listPage: (input) => + Effect.gen(function* () { + // Decode selector + const selector = yield* Schema.decodeUnknownEffect(AsanaSelector)(input.selector).pipe( + Effect.mapError( + (e) => new WorkSourceConfigError({ message: `Invalid Asana selector: ${e.message}` }), + ), + ); + + // v1 ops signal: section/tag filtering is not applied (we list the + // whole project). Warn so an operator notices if a user scoped a source + // to a section/tag expecting it to limit the synced tickets. + if (selector.sectionGid || selector.tagGid) { + yield* Effect.logWarning( + "asana source: sectionGid/tagGid filtering is not applied in v1; syncing the entire project", + { projectGid: selector.projectGid }, + ); + } + + const pat = yield* connectionStore.getToken(input.connectionRef, "asana"); + + const { projectGid, includeCompleted } = selector; + + // Build URL params + const urlParams: Array = [ + ["project", projectGid], + ["opt_fields", ASANA_TASK_OPT_FIELDS], + ["limit", String(input.pageSize)], + ]; + if (input.since) urlParams.push(["modified_since", input.since]); + if (input.pageToken) urlParams.push(["offset", input.pageToken]); + // When includeCompleted is false, pass completed_since=now to get only + // incomplete tasks. When true (the default), omit the param. + if (includeCompleted === false) { + urlParams.push(["completed_since", "now"]); + } + // v1: sectionGid and tagGid are not yet applied — see file header. + + const request = HttpClientRequest.get(`${ASANA_API_BASE}/tasks`, { urlParams }).pipe( + HttpClientRequest.setHeaders(buildHeaders(pat)), + ); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Asana HTTP network error: ${String(cause)}`, + }), + ), + ); + + const { status, headers } = response; + + // 401 (bad/expired PAT) and 403 (PAT authenticates but lacks access to + // the target project / insufficient scope) are both stable permission + // failures — surface them as auth, NOT transient. Asana does not use + // x-ratelimit headers (it signals rate limits via 429 + Retry-After), so + // a 403 here is never a rate limit; classifying it transient would back + // the source off and retry forever instead of flagging the permission + // problem. (Mirrors GithubIssuesProvider's 403→auth handling.) + if (status === 401 || status === 403) { + return yield* new WorkSourceAuthError({ connectionRef: input.connectionRef }); + } + if (status === 429) { + return yield* new WorkSourceRateLimitError({ + retryAfterMs: parseAsanaRateLimitRetryMs(headers), + }); + } + if (status < 200 || status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* new WorkSourceTransientError({ + message: `Asana API returned HTTP ${status}: ${body.trim() || "(no body)"}`, + }); + } + + const rawBody = (yield* response.json.pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Failed to parse Asana JSON response: ${String(cause)}`, + }), + ), + )) as unknown; + + // Guard the shape before iterating: a malformed/unexpected success body + // (missing or non-array `data`) → transient failure (source backs off) + // rather than a thrown defect that only the syncer's log-only catch sees. + if ( + rawBody === null || + typeof rawBody !== "object" || + !Array.isArray((rawBody as { readonly data?: unknown }).data) + ) { + return yield* new WorkSourceTransientError({ + message: "Asana /tasks response did not contain a data array", + }); + } + + const page0 = rawBody as RawAsanaPage; + const items: Array = []; + for (const raw of page0.data) { + items.push(mapTask(raw)); + } + + const nextPageToken = page0.next_page?.offset ?? undefined; + + const page: WorkSourcePage = { + items, + ...(nextPageToken !== undefined && { nextPageToken }), + }; + return page; + }), + + getItem: (input) => + Effect.gen(function* () { + const pat = yield* connectionStore.getToken(input.connectionRef, "asana"); + + const urlParams: Array = [["opt_fields", ASANA_TASK_OPT_FIELDS]]; + + const request = HttpClientRequest.get( + `${ASANA_API_BASE}/tasks/${encodeURIComponent(input.externalId)}`, + { urlParams }, + ).pipe(HttpClientRequest.setHeaders(buildHeaders(pat))); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Asana HTTP network error (getItem): ${String(cause)}`, + }), + ), + ); + + const { status } = response; + + if (status === 404) { + return null; + } + // 401/403 → stable permission failure (see listPage). Not transient. + if (status === 401 || status === 403) { + return yield* new WorkSourceAuthError({ connectionRef: input.connectionRef }); + } + if (status === 429) { + return yield* new WorkSourceRateLimitError({ + retryAfterMs: parseAsanaRateLimitRetryMs(response.headers), + }); + } + if (status < 200 || status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* new WorkSourceTransientError({ + message: `Asana API returned HTTP ${status} (getItem): ${body.trim() || "(no body)"}`, + }); + } + + const rawBody = (yield* response.json.pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Failed to parse Asana getItem JSON response: ${String(cause)}`, + }), + ), + )) as unknown; + + // Guard the shape: the single-task endpoint returns `{ data: {...} }`. + if ( + rawBody === null || + typeof rawBody !== "object" || + typeof (rawBody as { readonly data?: unknown }).data !== "object" || + (rawBody as { readonly data?: unknown }).data === null + ) { + return yield* new WorkSourceTransientError({ + message: "Asana /tasks/:gid response did not contain a data object", + }); + } + + return mapTask((rawBody as { readonly data: RawAsanaTask }).data); + }), + + toImportableView: ({ selector, item: _item }): ImportableViewParts => { + const s = selector as { projectGid?: string }; + return { displayRef: "", container: s.projectGid ?? "Asana" }; + }, + + viewer: ({ connectionRef }) => + Effect.gen(function* () { + const pat = yield* connectionStore.getToken(connectionRef, "asana"); + const request = HttpClientRequest.get(`${ASANA_API_BASE}/users/me`).pipe( + HttpClientRequest.setHeaders(buildHeaders(pat)), + ); + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Asana viewer network error: ${String(cause)}`, + }), + ), + ); + if (response.status !== 200) return null; // best-effort: never fail the read RPC + const body = yield* response.json.pipe(Effect.orElseSucceed(() => ({}) as unknown)); + const data = (body as { data?: { gid?: unknown; name?: unknown } }).data; + const gid = typeof data?.gid === "string" ? data.gid : null; + if (gid === null) return null; + const name = typeof data?.name === "string" ? data.name : ""; + return { id: gid, aliases: name ? [name] : [] }; + }), + }; + + return provider; +}); + +export const AsanaProviderLive: Layer.Layer< + AsanaProviderTag, + never, + HttpClient.HttpClient | WorkSourceConnectionStore +> = Layer.effect(AsanaProviderTag, make); diff --git a/apps/server/src/workflow/Layers/BoardDiscovery.test.ts b/apps/server/src/workflow/Layers/BoardDiscovery.test.ts new file mode 100644 index 00000000000..6400a4f1a09 --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardDiscovery.test.ts @@ -0,0 +1,561 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { BoardId, type ProjectId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { BoardDiscovery } from "../Services/BoardDiscovery.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardVersionStore } from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowProviderInstancePort } from "../Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { defaultBoardDefinition } from "../defaultBoard.ts"; +import { encodeWorkflowDefinitionJson } from "../workflowFile.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { BoardDiscoveryLive } from "./BoardDiscovery.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardVersionStoreLive } from "./WorkflowBoardVersionStore.ts"; +import { WorkflowEventStoreLive } from "./WorkflowEventStore.ts"; +import { WorkflowFileLoaderLive, WorkflowFilePortLive } from "./WorkflowFileLoader.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; + +const projectId = "project-discovery" as ProjectId; + +const boardFile = (name: string) => + encodeWorkflowDefinitionJson( + defaultBoardDefinition({ + name, + agent: { instance: "codex_main", model: "gpt-5.5" }, + }), + ); + +const workflowEngineStub = Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused"), + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.die("unused"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused"), + answerTicketStep: () => Effect.die("unused"), + postTicketMessage: () => Effect.die("unused"), + editTicketMessage: () => Effect.die("unused"), + cancelStep: () => Effect.die("unused"), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.die("unused"), +}); + +it.layer(NodeServices.layer)("BoardDiscovery", (it) => { + it.effect( + "discovers boards, reports invalid files, and retains history across absent files", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-board-discovery-", + }); + const boardsDir = path.join(workspaceRoot, ".t3/boards"); + yield* fs.makeDirectory(boardsDir, { recursive: true }); + yield* fs.writeFileString(path.join(boardsDir, "alpha.json"), boardFile("Alpha")); + yield* fs.writeFileString(path.join(boardsDir, "beta.json"), boardFile("Beta")); + yield* fs.writeFileString(path.join(boardsDir, "broken.json"), "{"); + + const layer = BoardDiscoveryLive.pipe( + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed(workspaceRoot), + }), + ), + Layer.provideMerge(WorkflowFileLoaderLive), + Layer.provideMerge(WorkflowFilePortLive), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => Effect.succeed(instanceId === "codex_main"), + providerInstanceSupportsResume: (instanceId) => + Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(workflowEngineStub), + Layer.provideMerge(WorkflowEventStoreLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardVersionStoreLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const discovery = yield* BoardDiscovery; + const read = yield* WorkflowReadModel; + const registry = yield* BoardRegistry; + const versions = yield* WorkflowBoardVersionStore; + const sql = yield* SqlClient.SqlClient; + const alphaBoardId = `${projectId}__alpha` as never; + + const entries = yield* discovery.discover(projectId); + assert.equal(entries.length, 3); + assert.isTrue( + entries.some( + (entry) => + entry.boardId === `${projectId}__alpha` && + entry.filePath === ".t3/boards/alpha.json" && + entry.error === null, + ), + ); + assert.isTrue( + entries.some( + (entry) => entry.boardId === `${projectId}__broken` && entry.error !== null, + ), + ); + assert.deepEqual(yield* versions.list(alphaBoardId), []); + + const boards = yield* read.listBoardsForProject(projectId); + assert.deepEqual( + boards.map((board) => board.boardId), + [`${projectId}__alpha`, `${projectId}__beta`], + ); + + yield* versions.record({ + boardId: alphaBoardId, + versionHash: "hash-alpha", + contentJson: '{"name":"Alpha"}\n', + source: "import", + }); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-alpha-stale', + ${alphaBoardId}, + 'Stale alpha ticket', + 'backlog', + 'idle', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'evt-alpha-stale', + 'ticket-alpha-stale', + 0, + 'TicketCreated', + '2026-06-07T00:00:00.000Z', + ${`{"boardId":"${alphaBoardId}","title":"Stale alpha ticket","laneKey":"backlog"}`} + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-alpha-stale', + 'ticket-alpha-stale', + 'step-alpha-stale', + 'thread-alpha-stale', + 'codex', + 'gpt-5.5', + 'stale dispatch', + '/tmp/alpha-stale', + 'pending', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES ( + 'setup-alpha-stale', + 'ticket-alpha-stale', + 'worktree-alpha-stale', + 'running', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* fs.writeFileString(path.join(boardsDir, "alpha.json"), "{"); + const afterInvalid = yield* discovery.discover(projectId); + assert.isTrue( + afterInvalid.some( + (entry) => entry.boardId === `${projectId}__alpha` && entry.error !== null, + ), + ); + assert.isNotNull(yield* registry.getDefinition(`${projectId}__alpha` as never)); + assert.deepEqual( + (yield* versions.list(alphaBoardId)).map((version) => version.versionHash), + ["hash-alpha"], + ); + assert.isTrue( + (yield* read.listBoardsForProject(projectId)).some( + (board) => board.boardId === `${projectId}__alpha`, + ), + ); + + yield* fs.remove(path.join(boardsDir, "alpha.json")); + const afterAbsent = yield* discovery.discover(projectId); + assert.isFalse(afterAbsent.some((entry) => entry.boardId === `${projectId}__alpha`)); + assert.isNull(yield* registry.getDefinition(`${projectId}__alpha` as never)); + assert.deepEqual( + (yield* versions.list(alphaBoardId)).map((version) => version.versionHash), + [], + ); + assert.deepEqual( + (yield* read.listBoardsForProject(projectId)).map((board) => board.boardId), + [`${projectId}__beta`], + ); + const staleRows = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE board_id = ${alphaBoardId} + UNION ALL + SELECT 'workflow_events' AS tableName, COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = 'ticket-alpha-stale' + UNION ALL + SELECT 'workflow_dispatch_outbox' AS tableName, COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE ticket_id = 'ticket-alpha-stale' + UNION ALL + SELECT 'workflow_setup_run' AS tableName, COUNT(*) AS count + FROM workflow_setup_run + WHERE ticket_id = 'ticket-alpha-stale' + `; + assert.deepEqual( + staleRows.map((row) => [row.tableName, row.count]), + [ + ["projection_ticket", 0], + ["workflow_events", 0], + ["workflow_dispatch_outbox", 0], + ["workflow_setup_run", 0], + ], + ); + + yield* fs.writeFileString(path.join(boardsDir, "alpha.json"), boardFile("Alpha")); + const afterReappear = yield* discovery.discover(projectId); + assert.isTrue(afterReappear.some((entry) => entry.boardId === `${projectId}__alpha`)); + assert.deepEqual( + (yield* versions.list(alphaBoardId)).map((version) => version.versionHash), + [], + ); + assert.deepEqual(yield* read.listTickets(alphaBoardId), []); + }).pipe(Effect.provide(layer)); + }), + ), + ); + + it.effect("does not register a board that is deleted after directory listing", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-board-discovery-race-", + }); + const boardsDir = path.join(workspaceRoot, ".t3/boards"); + const alphaPath = path.join(boardsDir, "alpha.json"); + const alphaBoardId = BoardId.make(`${projectId}__alpha`); + const staleAlpha = boardFile("Alpha"); + const listed = yield* Deferred.make>(); + const deleted = yield* Deferred.make(); + yield* fs.makeDirectory(boardsDir, { recursive: true }); + yield* fs.writeFileString(alphaPath, staleAlpha); + + const staleFileSystemLayer = Layer.succeed(FileSystem.FileSystem, { + ...fs, + readDirectory: (target, options) => + target === boardsDir + ? Effect.gen(function* () { + const entries = yield* fs.readDirectory(target, options); + yield* Deferred.succeed(listed, entries).pipe(Effect.ignore); + yield* Deferred.await(deleted); + return entries; + }) + : fs.readDirectory(target, options), + readFileString: (target, encoding) => + target === alphaPath ? Effect.succeed(staleAlpha) : fs.readFileString(target, encoding), + } satisfies FileSystem.FileSystem); + + const layer = BoardDiscoveryLive.pipe( + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed(workspaceRoot), + }), + ), + Layer.provideMerge(WorkflowFileLoaderLive), + Layer.provideMerge(WorkflowFilePortLive), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => Effect.succeed(instanceId === "codex_main"), + providerInstanceSupportsResume: (instanceId) => + Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(workflowEngineStub), + Layer.provideMerge(WorkflowEventStoreLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardVersionStoreLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(staleFileSystemLayer), + ); + + yield* Effect.gen(function* () { + const discovery = yield* BoardDiscovery; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const saveLocks = yield* WorkflowBoardSaveLocks; + + yield* registry.register( + alphaBoardId, + defaultBoardDefinition({ + name: "Alpha", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }), + ); + yield* read.registerBoard({ + boardId: alphaBoardId, + projectId, + name: "Alpha", + workflowFilePath: ".t3/boards/alpha.json", + workflowVersionHash: "hash-alpha-before-delete", + maxConcurrentTickets: 3, + }); + + const discoverFiber = yield* Effect.forkChild(discovery.discover(projectId)); + assert.deepEqual(yield* Deferred.await(listed), ["alpha.json"]); + + yield* saveLocks.withSaveLock( + alphaBoardId, + Effect.gen(function* () { + yield* fs.remove(alphaPath); + yield* registry.unregister(alphaBoardId); + yield* read.deleteBoard(alphaBoardId); + }), + ); + yield* Deferred.succeed(deleted, undefined); + + const entries = yield* Fiber.join(discoverFiber); + assert.isFalse(entries.some((entry) => entry.boardId === alphaBoardId)); + assert.isNull(yield* registry.getDefinition(alphaBoardId)); + assert.isNull(yield* read.getBoard(alphaBoardId)); + }).pipe(Effect.provide(layer)); + }), + ), + ); + + it.effect("cascades a persisted board whose file is missing without a cache entry", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-board-discovery-persisted-missing-", + }); + const boardsDir = path.join(workspaceRoot, ".t3/boards"); + const boardId = BoardId.make(`${projectId}__persisted-missing`); + yield* fs.makeDirectory(boardsDir, { recursive: true }); + + const layer = BoardDiscoveryLive.pipe( + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed(workspaceRoot), + }), + ), + Layer.provideMerge(WorkflowFileLoaderLive), + Layer.provideMerge(WorkflowFilePortLive), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => Effect.succeed(instanceId === "codex_main"), + providerInstanceSupportsResume: (instanceId) => + Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(workflowEngineStub), + Layer.provideMerge(WorkflowEventStoreLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardVersionStoreLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const discovery = yield* BoardDiscovery; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const versions = yield* WorkflowBoardVersionStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* registry.register( + boardId, + defaultBoardDefinition({ + name: "Persisted missing", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }), + ); + yield* read.registerBoard({ + boardId, + projectId, + name: "Persisted missing", + workflowFilePath: ".t3/boards/persisted-missing.json", + workflowVersionHash: "hash-persisted-missing", + maxConcurrentTickets: 1, + }); + yield* versions.record({ + boardId, + versionHash: "hash-persisted-missing", + contentJson: '{"name":"Persisted missing"}\n', + source: "import", + }); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-persisted-missing', + ${boardId}, + 'Persisted missing ticket', + 'backlog', + 'idle', + ${now}, + ${now} + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'evt-persisted-missing', + 'ticket-persisted-missing', + 0, + 'TicketCreated', + ${now}, + ${`{"boardId":"${boardId}","title":"Persisted missing ticket","laneKey":"backlog"}`} + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-persisted-missing', + 'ticket-persisted-missing', + 'step-persisted-missing', + 'thread-persisted-missing', + 'codex', + 'gpt-5.5', + 'stale persisted dispatch', + '/tmp/persisted-missing', + 'pending', + ${now} + ) + `; + + const entries = yield* discovery.discover(projectId).pipe(Effect.timeout("1 second")); + + assert.isFalse(entries.some((entry) => entry.boardId === boardId)); + assert.isNull(yield* registry.getDefinition(boardId)); + assert.isNull(yield* read.getBoard(boardId)); + assert.deepEqual(yield* versions.list(boardId), []); + const staleRows = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE board_id = ${boardId} + UNION ALL + SELECT 'workflow_events' AS tableName, COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = 'ticket-persisted-missing' + UNION ALL + SELECT 'workflow_dispatch_outbox' AS tableName, COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE ticket_id = 'ticket-persisted-missing' + `; + assert.deepEqual( + staleRows.map((row) => [row.tableName, row.count]), + [ + ["projection_ticket", 0], + ["workflow_events", 0], + ["workflow_dispatch_outbox", 0], + ], + ); + }).pipe(Effect.provide(layer)); + }), + ), + ); +}); diff --git a/apps/server/src/workflow/Layers/BoardDiscovery.ts b/apps/server/src/workflow/Layers/BoardDiscovery.ts new file mode 100644 index 00000000000..69c3423aab6 --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardDiscovery.ts @@ -0,0 +1,281 @@ +import { + BoardId, + WorkflowDefinition, + WorkflowRpcError, + type BoardListEntry, + type ProjectId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { BoardDiscovery, type BoardDiscoveryShape } from "../Services/BoardDiscovery.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { WorkflowBoardVersionStore } from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowFileLoader } from "../Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowAgentSessionStore } from "../Services/WorkflowAgentSessionStore.ts"; +import { WorkflowThreadJanitor } from "../Services/WorkflowThreadJanitor.ts"; +import { WorkflowWebhook } from "../Services/WorkflowWebhook.ts"; +import { WorkflowWorktreeJanitor } from "../Services/WorkflowWorktreeJanitor.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { deleteWorkflowBoardOwnedState } from "../boardDeletion.ts"; + +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); + +const toWorkflowRpcError = (message: string) => (cause: unknown) => + new WorkflowRpcError({ message, cause }); + +const errorMessage = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +const isJsonBoardFile = (name: string) => name.endsWith(".json"); + +const boardSlugFromFileName = (fileName: string): string => fileName.slice(0, -".json".length); + +const boardIdFor = (projectId: ProjectId, slug: string) => BoardId.make(`${projectId}__${slug}`); + +const makeEntry = (input: { + readonly boardId: BoardId; + readonly name: string; + readonly relativePath: string; + readonly error: string | null; +}): BoardListEntry => ({ + boardId: input.boardId, + name: input.name, + filePath: input.relativePath, + error: input.error, +}); + +interface RemovedBoardCandidate { + readonly boardId: BoardId; + readonly filePath: string; +} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolver = yield* ProjectWorkspaceResolver; + const loader = yield* WorkflowFileLoader; + const registry = yield* BoardRegistry; + const readModel = yield* WorkflowReadModel; + const saveLocks = yield* WorkflowBoardSaveLocks; + const engine = yield* WorkflowEngine; + const eventStore = yield* WorkflowEventStore; + const versionStore = yield* WorkflowBoardVersionStore; + const sql = yield* SqlClient.SqlClient; + const worktreeJanitor = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowWorktreeJanitor, + ); + // Resolved optionally so leaner test stacks (and any layer wired without the + // janitor) still build; when present, board-file GC reclaims the hidden + // provider threads instead of leaking them, matching the RPC deleteBoard path. + const threadJanitor = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowThreadJanitor, + ); + const webhook = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowWebhook, + ); + // Optional per-agent session teardown for board-file GC, matching the RPC + // deleteBoard path (A8). + const agentSessions = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowAgentSessionStore, + ); + const providerService = Context.getOption( + (yield* Effect.context()) as Context.Context, + ProviderService, + ); + const cache = yield* Ref.make>>(new Map()); + + const discoverFile = (input: { + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly fileName: string; + }) => { + const slug = boardSlugFromFileName(input.fileName); + const boardId = boardIdFor(input.projectId, slug); + const relativePath = `.t3/boards/${input.fileName}`; + const absolutePath = path.join(input.workspaceRoot, relativePath); + + return saveLocks.withSaveLock( + boardId, + Effect.gen(function* () { + const stillExists = yield* fileSystem + .exists(absolutePath) + .pipe( + Effect.mapError(toWorkflowRpcError(`Failed to check workflow board ${relativePath}`)), + ); + if (!stillExists) { + return null; + } + + return yield* fileSystem.readFileString(absolutePath).pipe( + Effect.mapError(toWorkflowRpcError(`Failed to read workflow board ${relativePath}`)), + Effect.flatMap((raw) => + decodeWorkflowDefinitionJson(raw).pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.succeed( + makeEntry({ + boardId, + name: slug, + relativePath, + error: errorMessage(cause), + }), + ), + onSuccess: (definition) => + loader + .loadAndRegister({ + boardId, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + relativePath, + }) + .pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.succeed( + makeEntry({ + boardId, + name: definition.name, + relativePath, + error: errorMessage(cause), + }), + ), + onSuccess: () => + Effect.succeed( + makeEntry({ + boardId, + name: definition.name, + relativePath, + error: null, + }), + ), + }), + ), + }), + ), + ), + ); + }), + ); + }; + + const discover: BoardDiscoveryShape["discover"] = (projectId) => + Effect.gen(function* () { + const workspaceRoot = yield* resolver + .resolve(projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + const boardsDir = path.join(workspaceRoot, ".t3/boards"); + const exists = yield* fileSystem + .exists(boardsDir) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to check workflow boards directory"))); + const fileNames = exists + ? yield* fileSystem + .readDirectory(boardsDir) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list workflow boards directory"))) + : []; + const boardFileNames = fileNames.filter(isJsonBoardFile).sort(); + const discoveredEntries = yield* Effect.forEach(boardFileNames, (fileName) => + discoverFile({ projectId, workspaceRoot, fileName }), + ); + const entries = discoveredEntries.filter((entry): entry is BoardListEntry => entry !== null); + + const presentBoardIds = new Set(entries.map((entry) => entry.boardId as string)); + const presentFilePaths = new Set(boardFileNames.map((fileName) => `.t3/boards/${fileName}`)); + const cachedEntries = (yield* Ref.get(cache)).get(projectId as string) ?? []; + const persistedBoards = yield* readModel + .listBoardsForProject(projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list persisted workflow boards"))); + const removedCandidates = new Map(); + + for (const board of persistedBoards) { + if (!presentFilePaths.has(board.filePath)) { + removedCandidates.set(board.boardId as string, { + boardId: board.boardId as BoardId, + filePath: board.filePath, + }); + } + } + + for (const entry of cachedEntries) { + if (!presentBoardIds.has(entry.boardId as string)) { + removedCandidates.set(entry.boardId as string, { + boardId: entry.boardId, + filePath: entry.filePath, + }); + } + } + + yield* Effect.forEach( + removedCandidates.values(), + (candidate) => + saveLocks + .withSaveLock( + candidate.boardId, + Effect.gen(function* () { + const stillExists = yield* fileSystem + .exists(path.join(workspaceRoot, candidate.filePath)) + .pipe( + Effect.mapError( + toWorkflowRpcError(`Failed to check workflow board ${candidate.filePath}`), + ), + ); + if (stillExists) { + return; + } + + yield* deleteWorkflowBoardOwnedState( + { + boardRegistry: registry, + engine, + eventStore, + readModel, + versionStore, + sql, + ...(Option.isSome(worktreeJanitor) + ? { worktreeJanitor: worktreeJanitor.value } + : {}), + ...(Option.isSome(threadJanitor) ? { threadJanitor: threadJanitor.value } : {}), + ...(Option.isSome(webhook) ? { webhook: webhook.value } : {}), + ...(Option.isSome(agentSessions) ? { agentSessions: agentSessions.value } : {}), + ...(Option.isSome(providerService) ? { provider: providerService.value } : {}), + }, + candidate.boardId, + ); + }), + ) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to unregister workflow board"))), + { discard: true }, + ); + + yield* Ref.update(cache, (current) => new Map(current).set(projectId as string, entries)); + return entries; + }); + + const list: BoardDiscoveryShape["list"] = (projectId) => + Ref.get(cache).pipe( + Effect.flatMap((current) => { + const cached = current.get(projectId as string); + return cached === undefined ? discover(projectId) : Effect.succeed(cached); + }), + ); + + return { discover, list } satisfies BoardDiscoveryShape; +}); + +export const BoardDiscoveryLive = Layer.effect(BoardDiscovery, make); diff --git a/apps/server/src/workflow/Layers/BoardRegistry.test.ts b/apps/server/src/workflow/Layers/BoardRegistry.test.ts new file mode 100644 index 00000000000..2de4c83c4de --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardRegistry.test.ts @@ -0,0 +1,109 @@ +import { assert, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; + +const layer = it.layer(BoardRegistryLive); + +const def = { + name: "wf", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +layer("BoardRegistry", (it) => { + it.effect("registers a definition and resolves lanes", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-1" as never, def); + const lane = yield* registry.getLane("b-1" as never, "impl" as never); + assert.equal(lane?.entry, "auto"); + assert.equal(lane?.pipeline?.length, 1); + }), + ); + + it.effect("rejects an invalid definition", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const result = yield* Effect.exit( + registry.register("b-2" as never, { + name: "bad", + lanes: [{ key: "a", name: "A", entry: "auto", on: { success: "ghost" } }], + }), + ); + assert.equal(result._tag, "Failure"); + }), + ); + + it.effect("rejects invalid WIP limits during registration", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const result = yield* Effect.exit( + registry.register("b-invalid-wip" as never, { + name: "bad wip", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual", wipLimit: 0 }, + { key: "done", name: "Done", entry: "manual", terminal: true, wipLimit: 1 }, + ], + }), + ); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(result.cause.toString().includes("invalid_wip_limit")); + } + }), + ); + + it.effect("registers an already-decoded workflow definition with retention duration", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-retention" as never, { + name: "retention", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "done", + name: "Done", + entry: "manual", + terminal: true, + retention: Duration.days(7), + }, + ], + }); + + const lane = yield* registry.getLane("b-retention" as never, "done" as never); + assert.equal( + Duration.toMillis((lane as any)?.retention), + Duration.toMillis(Duration.days(7)), + ); + }), + ); + + it.effect("unregister removes a registered definition", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-3" as never, def); + yield* registry.unregister("b-3" as never); + assert.isNull(yield* registry.getDefinition("b-3" as never)); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/BoardRegistry.ts b/apps/server/src/workflow/Layers/BoardRegistry.ts new file mode 100644 index 00000000000..943da5f15c4 --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardRegistry.ts @@ -0,0 +1,83 @@ +import { WorkflowDefinition } from "@t3tools/contracts"; +import { AsanaSelector, GithubSelector, JiraSelector } from "@t3tools/contracts/workSource"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import { + BoardRegistry, + BoardRegistryError, + type BoardRegistryShape, +} from "../Services/BoardRegistry.ts"; +import { lintWorkflowDefinition } from "../workflowFile.ts"; + +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); +const isWorkflowDefinition = Schema.is(WorkflowDefinition); + +const make = Effect.gen(function* () { + const store = yield* Ref.make>(new Map()); + + const register: BoardRegistryShape["register"] = (boardId, raw) => + Effect.gen(function* () { + const definition = isWorkflowDefinition(raw) + ? raw + : yield* decodeWorkflowDefinition(raw).pipe( + Effect.mapError( + (cause) => new BoardRegistryError({ message: `Invalid workflow: ${String(cause)}` }), + ), + ); + const errors = lintWorkflowDefinition(definition, { + providerInstanceExists: () => true, + // The registry has no provider-capability info; the strict loader lint + // is the real continueSession resume gate. Stay permissive here. + providerInstanceSupportsResume: () => true, + instructionFileExists: () => true, + selectorSchemaFor: (p) => + p === "github" ? GithubSelector : p === "asana" ? AsanaSelector : p === "jira" ? JiraSelector : null, + }); + if (errors.length > 0) { + return yield* new BoardRegistryError({ + message: `Workflow lint failed: ${errors.map((error) => error.code).join(", ")}`, + }); + } + + yield* Ref.update(store, (current) => new Map(current).set(boardId as string, definition)); + return definition; + }); + + const getDefinition: BoardRegistryShape["getDefinition"] = (boardId) => + Ref.get(store).pipe(Effect.map((current) => current.get(boardId as string) ?? null)); + + const listDefinitions: BoardRegistryShape["listDefinitions"] = () => + Ref.get(store).pipe( + Effect.map((current) => + Array.from(current.entries()).map(([boardId, definition]) => ({ + boardId: boardId as never, + definition, + })), + ), + ); + + const unregister: BoardRegistryShape["unregister"] = (boardId) => + Ref.update(store, (current) => { + const next = new Map(current); + next.delete(boardId as string); + return next; + }); + + const getLane: BoardRegistryShape["getLane"] = (boardId, laneKey) => + getDefinition(boardId).pipe( + Effect.map((definition) => definition?.lanes.find((lane) => lane.key === laneKey) ?? null), + ); + + return { + register, + unregister, + getDefinition, + listDefinitions, + getLane, + } satisfies BoardRegistryShape; +}); + +export const BoardRegistryLive = Layer.effect(BoardRegistry, make); diff --git a/apps/server/src/workflow/Layers/CapturedStepOutputReader.test.ts b/apps/server/src/workflow/Layers/CapturedStepOutputReader.test.ts new file mode 100644 index 00000000000..f3475907e0e --- /dev/null +++ b/apps/server/src/workflow/Layers/CapturedStepOutputReader.test.ts @@ -0,0 +1,242 @@ +import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { PersistenceSqlError } from "../../persistence/Errors.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; +import { CapturedStepOutputReaderLive } from "./CapturedStepOutputReader.ts"; + +const layer = it.layer( + CapturedStepOutputReaderLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderDispatchOutbox, { + confirmStep: () => Effect.void, + ensureStarted: () => Effect.succeed({ turnId: "turn-captured-output" as never }), + getDispatchForStep: () => + Effect.succeed({ + threadId: "thread-captured-output" as never, + turnId: "turn-captured-output" as never, + }), + awaitTerminal: () => Effect.succeed({ ok: true }), + awaitStepTerminal: () => Effect.succeed({ ok: true }), + recoverPending: () => Effect.void, + }), + ), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const seedAssistantMessage = (text: string) => + seedAssistantMessageFor({ + threadId: "thread-captured-output", + turnId: "turn-captured-output", + messageId: "message-captured-output", + text, + }); + +const seedAssistantMessageFor = (input: { + readonly threadId: string; + readonly turnId: string; + readonly messageId: string; + readonly text: string; +}) => + Effect.gen(function* () { + const turns = yield* ProjectionTurnRepository; + const messages = yield* ProjectionThreadMessageRepository; + yield* turns.upsertByTurnId({ + threadId: input.threadId as never, + turnId: input.turnId as never, + pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: input.messageId as never, + state: "completed", + requestedAt: "2026-06-07T00:00:00.000Z" as never, + startedAt: "2026-06-07T00:00:00.000Z" as never, + completedAt: "2026-06-07T00:00:01.000Z" as never, + checkpointTurnCount: null, + checkpointRef: null, + checkpointStatus: null, + checkpointFiles: [], + }); + yield* messages.upsert({ + messageId: input.messageId as never, + threadId: input.threadId as never, + turnId: input.turnId as never, + role: "assistant", + text: input.text, + isStreaming: false, + createdAt: "2026-06-07T00:00:01.000Z" as never, + updatedAt: "2026-06-07T00:00:01.000Z" as never, + }); + }); + +layer("CapturedStepOutputReader", (it) => { + it.effect("returns the last object from a fenced JSON block", () => + Effect.gen(function* () { + const reader = yield* CapturedStepOutputReader; + yield* seedAssistantMessage( + [ + "Earlier:", + "```json", + '{"verdict":"ignore"}', + "```", + "Final:", + "```json", + '{"verdict":"pass","score":0.98}', + "```", + ].join("\n"), + ); + + const output = yield* reader.read({ + stepRunId: "step-captured-output" as never, + threadId: "thread-captured-output" as never, + turnId: "turn-captured-output" as never, + }); + + assert.deepEqual(output, { verdict: "pass", score: 0.98 }); + }), + ); + + it.effect("reads the assistant message for the exact awaited turn, not the latest dispatch", () => + Effect.gen(function* () { + const reader = yield* CapturedStepOutputReader; + yield* seedAssistantMessage('Latest dispatch.\n```json\n{"verdict":"latest"}\n```'); + yield* seedAssistantMessageFor({ + threadId: "thread-captured-output", + turnId: "turn-awaited", + messageId: "message-awaited", + text: 'Awaited turn.\n```json\n{"verdict":"awaited"}\n```', + }); + + const output = yield* reader.read({ + stepRunId: "step-captured-output" as never, + threadId: "thread-captured-output" as never, + turnId: "turn-awaited" as never, + } as never); + + assert.deepEqual(output, { verdict: "awaited" }); + }), + ); + + it.effect("returns undefined when no valid object block exists", () => + Effect.gen(function* () { + const reader = yield* CapturedStepOutputReader; + yield* seedAssistantMessage("Done without structured output."); + + const output = yield* reader.read({ + stepRunId: "step-captured-output" as never, + threadId: "thread-captured-output" as never, + turnId: "turn-captured-output" as never, + }); + + assert.equal(output, undefined); + }), + ); + + it.effect("falls back to earlier messages in the turn when the final one has no block", () => + Effect.gen(function* () { + const reader = yield* CapturedStepOutputReader; + const messages = yield* ProjectionThreadMessageRepository; + // The turn's recorded final message is a closing remark; the verdict + // was emitted in an earlier message of the same multi-message turn. + yield* seedAssistantMessage("All set — see the verdict above."); + yield* messages.upsert({ + messageId: "message-earlier-verdict" as never, + threadId: "thread-captured-output" as never, + turnId: "turn-captured-output" as never, + role: "assistant", + text: 'Findings reviewed.\n```json\n{"verdict":"approve"}\n```', + isStreaming: false, + createdAt: "2026-06-07T00:00:00.500Z" as never, + updatedAt: "2026-06-07T00:00:00.500Z" as never, + }); + // A different turn's verdict must never bleed in. + yield* messages.upsert({ + messageId: "message-other-turn" as never, + threadId: "thread-captured-output" as never, + turnId: "turn-unrelated" as never, + role: "assistant", + text: '```json\n{"verdict":"unrelated"}\n```', + isStreaming: false, + createdAt: "2026-06-07T00:00:00.900Z" as never, + updatedAt: "2026-06-07T00:00:00.900Z" as never, + }); + + const output = yield* reader.read({ + stepRunId: "step-captured-output" as never, + threadId: "thread-captured-output" as never, + turnId: "turn-captured-output" as never, + }); + + assert.deepEqual(output, { verdict: "approve" }); + }), + ); +}); + +it.effect( + "CapturedStepOutputReader propagates repository lookup errors instead of returning undefined", + () => + Effect.gen(function* () { + const readerErrorLayer = CapturedStepOutputReaderLive.pipe( + Layer.provideMerge( + Layer.succeed(ProjectionTurnRepository, { + upsertByTurnId: () => Effect.void, + replacePendingTurnStart: () => Effect.void, + getPendingTurnStartByThreadId: () => Effect.succeed(Option.none()), + deletePendingTurnStartByThreadId: () => Effect.void, + listByThreadId: () => Effect.succeed([]), + getByTurnId: () => + Effect.fail( + new PersistenceSqlError({ + operation: "ProjectionTurnRepository.getByTurnId:test", + detail: "simulated lookup failure", + }), + ), + clearCheckpointTurnConflict: () => Effect.void, + deleteByThreadId: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectionThreadMessageRepository, { + upsert: () => Effect.void, + getByMessageId: () => Effect.succeed(Option.none()), + listByThreadId: () => Effect.succeed([]), + deleteByThreadId: () => Effect.void, + }), + ), + ); + + const exit = yield* Effect.gen(function* () { + const reader = yield* CapturedStepOutputReader; + return yield* reader.read({ + stepRunId: "step-error" as never, + threadId: "thread-error" as never, + turnId: "turn-error" as never, + }); + }).pipe(Effect.provide(readerErrorLayer), Effect.exit); + + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + assert.equal((error as { readonly _tag?: string })._tag, "WorkflowEventStoreError"); + assert.equal( + (error as { readonly message?: string }).message, + "structured output turn lookup failed", + ); + } + }), +); diff --git a/apps/server/src/workflow/Layers/CapturedStepOutputReader.ts b/apps/server/src/workflow/Layers/CapturedStepOutputReader.ts new file mode 100644 index 00000000000..fea75b1bd3a --- /dev/null +++ b/apps/server/src/workflow/Layers/CapturedStepOutputReader.ts @@ -0,0 +1,94 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { + CapturedStepOutputReader, + type CapturedStepOutputReaderShape, +} from "../Services/CapturedStepOutputReader.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; + +const decodeCapturedJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); + +const findLastJsonBlock = (text: string) => { + const jsonBlock = /```json\s*([\s\S]*?)```/gi; + let last: string | undefined; + let match: RegExpExecArray | null = null; + while ((match = jsonBlock.exec(text)) !== null) { + last = match[1]?.trim(); + } + return last; +}; + +const parseCapturedOutput = (text: string): Effect.Effect => { + const block = findLastJsonBlock(text); + if (block === undefined) { + return Effect.void; + } + return decodeCapturedJson(block).pipe( + Effect.map((value) => + typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined, + ), + Effect.orElseSucceed(() => undefined), + ); +}; + +const toReaderError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const make = Effect.gen(function* () { + const projectionTurns = yield* ProjectionTurnRepository; + const threadMessages = yield* ProjectionThreadMessageRepository; + + const read: CapturedStepOutputReaderShape["read"] = (input) => + Effect.gen(function* () { + const turn = yield* projectionTurns + .getByTurnId({ + threadId: input.threadId, + turnId: input.turnId, + }) + .pipe(Effect.mapError(toReaderError("structured output turn lookup failed"))); + if (Option.isNone(turn) || turn.value.assistantMessageId === null) { + return undefined; + } + + const message = yield* threadMessages + .getByMessageId({ messageId: turn.value.assistantMessageId }) + .pipe(Effect.mapError(toReaderError("structured output message lookup failed"))); + if (Option.isNone(message)) { + return undefined; + } + + const fromFinalMessage = yield* parseCapturedOutput(message.value.text); + if (fromFinalMessage !== undefined) { + return fromFinalMessage; + } + + // Agents with multi-message turns (progress notes, skill-driven + // formats) sometimes emit the fenced block before their closing + // remark — scan the turn's earlier assistant messages, newest first. + const allMessages = yield* threadMessages + .listByThreadId({ threadId: input.threadId }) + .pipe(Effect.mapError(toReaderError("structured output turn messages lookup failed"))); + const turnAssistantMessages = allMessages.filter( + (candidate) => + candidate.turnId === (input.turnId as string) && + candidate.role === "assistant" && + candidate.messageId !== turn.value.assistantMessageId, + ); + for (const candidate of [...turnAssistantMessages].toReversed()) { + const parsed = yield* parseCapturedOutput(candidate.text); + if (parsed !== undefined) { + return parsed; + } + } + return undefined; + }); + + return { read } satisfies CapturedStepOutputReaderShape; +}); + +export const CapturedStepOutputReaderLive = Layer.effect(CapturedStepOutputReader, make); diff --git a/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts b/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts new file mode 100644 index 00000000000..66f0d7329ac --- /dev/null +++ b/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts @@ -0,0 +1,456 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { DurableApprovalResume } from "../Services/DurableApprovalResume.ts"; +import { ProviderResponsePort } from "../Services/ProviderResponsePort.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowEventStoreLive } from "./WorkflowEventStore.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { DurableApprovalResumeLive } from "./DurableApprovalResume.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const eventStoreLayer = WorkflowEventStoreLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), +); + +it.effect("parks unresolved workflow approval waits during recovery", () => + Effect.gen(function* () { + const parked = yield* Ref.make>([]); + const layer = DurableApprovalResumeLive.pipe( + Layer.provideMerge(eventStoreLayer), + Layer.provideMerge( + Layer.succeed(ApprovalGate, { + await: () => Effect.die("unused"), + resolve: () => Effect.succeed(false), + park: (stepRunId) => + Ref.update(parked, (ids) => [...ids, stepRunId as string]).pipe(Effect.asVoid), + }), + ), + ); + + yield* Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const resume = yield* DurableApprovalResume; + yield* store.append({ + type: "StepAwaitingUser", + eventId: "evt-await" as never, + ticketId: "ticket-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { stepRunId: "step-run-1" as never, waitingReason: "Approve?" }, + }); + + yield* resume.resume(); + + assert.deepEqual(yield* Ref.get(parked), ["step-run-1"]); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("resets provider-backed waits and clears stale projected turns during recovery", () => + Effect.gen(function* () { + const parked = yield* Ref.make>([]); + const layer = DurableApprovalResumeLive.pipe( + Layer.provideMerge(eventStoreLayer), + Layer.provideMerge( + Layer.succeed(ApprovalGate, { + await: () => Effect.die("unused"), + resolve: () => Effect.succeed(false), + park: (stepRunId) => + Ref.update(parked, (ids) => [...ids, stepRunId as string]).pipe(Effect.asVoid), + }), + ), + ); + + yield* Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const resume = yield* DurableApprovalResume; + const sql = yield* SqlClient.SqlClient; + yield* store.append({ + type: "StepAwaitingUser", + eventId: "evt-provider-await-stale" as never, + ticketId: "ticket-provider-stale" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + stepRunId: "step-run-provider-stale" as never, + waitingReason: "Provider is waiting for user input", + providerThreadId: "thread-provider-stale" as never, + providerRequestId: "request-provider-stale" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-provider-stale", + }, + }); + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-provider-stale', + 'ticket-provider-stale', + 'step-run-provider-stale', + 'thread-provider-stale', + 'codex', + 'gpt-5.5', + 'ask again', + '/tmp/provider-stale', + 'started', + 'turn-provider-stale', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z' + ) + `; + 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-provider-stale', + 'turn-provider-stale', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + + yield* resume.resume(); + + const dispatchRows = yield* sql<{ + readonly status: string; + readonly turnId: string | null; + readonly startedAt: string | null; + }>` + SELECT + status, + turn_id AS "turnId", + started_at AS "startedAt" + FROM workflow_dispatch_outbox + WHERE dispatch_id = 'dispatch-provider-stale' + `; + assert.deepEqual(dispatchRows[0], { + status: "pending", + turnId: null, + startedAt: null, + }); + + const turnRows = yield* sql<{ + readonly state: string; + readonly completedAt: string | null; + }>` + SELECT + state, + completed_at AS "completedAt" + FROM projection_turns + WHERE thread_id = 'thread-provider-stale' + AND turn_id = 'turn-provider-stale' + `; + assert.equal(turnRows[0]?.state, "interrupted"); + assert.isString(turnRows[0]?.completedAt); + assert.deepEqual(yield* Ref.get(parked), []); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("routes provider-question approval resolution to the provider response port", () => + Effect.gen(function* () { + const responses = yield* Ref.make>([]); + const layer = WorkflowEngineLayer.pipe( + Layer.provideMerge(eventStoreLayer), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(responses, (values) => [...values, input]), + }), + ), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ApprovalGate, { + await: () => Effect.die("unused"), + resolve: () => Effect.succeed(false), + park: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEventCommitter, { + commit: () => Effect.void, + commitMany: () => Effect.void, + appendManyUnlocked: () => Effect.succeed([]), + publishTicketView: () => Effect.void, + }), + ), + Layer.provideMerge(Layer.succeed(StepExecutor, { execute: () => Effect.die("unused") })), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge( + Layer.succeed(WorkflowReadModel, { + registerBoard: () => Effect.void, + getBoard: () => Effect.succeed(null), + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + listTickets: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + getTicketDetail: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + getBoardMetrics: () => + Effect.succeed({ + windowDays: 7, + generatedAt: "2026-06-07T00:00:00.000Z", + throughput: { created: 0, shipped: 0 }, + cycleTime: { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }, + wipByLane: [], + statusBreakdown: {}, + attention: { blocked: 0, waitingOnUser: 0, oldest: [] }, + routeOutcomes: [], + manualMoveCount: 0, + stepStats: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listNeedsAttentionTickets: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + getTicketPrState: () => Effect.succeed(null), + recordBoardProposal: () => Effect.void, + listBoardProposals: () => Effect.succeed([]), + getBoardProposal: () => Effect.succeed(null), + listLiveOccupiedLanes: () => Effect.succeed([]), + resolveBoardProposalStatus: () => Effect.succeed(1), + listWorkSourceMappingsForBoard: () => Effect.succeed([]), + }), + ), + Layer.provideMerge( + Layer.succeed(BoardRegistry, { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + ); + + yield* Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const engine = yield* WorkflowEngine; + yield* store.append({ + type: "StepAwaitingUser", + eventId: "evt-provider-await" as never, + ticketId: "ticket-provider" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + stepRunId: "step-run-provider" as never, + waitingReason: "Provider needs approval", + providerThreadId: "thread-provider" as never, + providerRequestId: "request-provider" as never, + providerResponseKind: "request", + }, + }); + + yield* engine.resolveApproval("step-run-provider" as never, true); + + assert.deepEqual(yield* Ref.get(responses), [ + { + threadId: "thread-provider", + requestId: "request-provider", + responseKind: "request", + approved: true, + }, + ]); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("rejects resolveApproval for provider user-input waits without responding", () => + Effect.gen(function* () { + const responses = yield* Ref.make>([]); + const layer = WorkflowEngineLayer.pipe( + Layer.provideMerge(eventStoreLayer), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(responses, (values) => [...values, input]), + }), + ), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ApprovalGate, { + await: () => Effect.die("unused"), + resolve: () => Effect.succeed(false), + park: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEventCommitter, { + commit: () => Effect.void, + commitMany: () => Effect.void, + appendManyUnlocked: () => Effect.succeed([]), + publishTicketView: () => Effect.void, + }), + ), + Layer.provideMerge(Layer.succeed(StepExecutor, { execute: () => Effect.die("unused") })), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge( + Layer.succeed(WorkflowReadModel, { + registerBoard: () => Effect.void, + getBoard: () => Effect.succeed(null), + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + listTickets: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + getTicketDetail: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + getBoardMetrics: () => + Effect.succeed({ + windowDays: 7, + generatedAt: "2026-06-07T00:00:00.000Z", + throughput: { created: 0, shipped: 0 }, + cycleTime: { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }, + wipByLane: [], + statusBreakdown: {}, + attention: { blocked: 0, waitingOnUser: 0, oldest: [] }, + routeOutcomes: [], + manualMoveCount: 0, + stepStats: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listNeedsAttentionTickets: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + getTicketPrState: () => Effect.succeed(null), + recordBoardProposal: () => Effect.void, + listBoardProposals: () => Effect.succeed([]), + getBoardProposal: () => Effect.succeed(null), + listLiveOccupiedLanes: () => Effect.succeed([]), + resolveBoardProposalStatus: () => Effect.succeed(1), + listWorkSourceMappingsForBoard: () => Effect.succeed([]), + }), + ), + Layer.provideMerge( + Layer.succeed(BoardRegistry, { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + ); + + yield* Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const engine = yield* WorkflowEngine; + yield* store.append({ + type: "StepAwaitingUser", + eventId: "evt-provider-user-input-await" as never, + ticketId: "ticket-provider-user-input" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + stepRunId: "step-run-provider-user-input" as never, + waitingReason: "Which API should I use?", + providerThreadId: "thread-provider-user-input" as never, + providerRequestId: "request-provider-user-input" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-provider-user-input", + }, + }); + + const error = yield* Effect.flip( + engine.resolveApproval("step-run-provider-user-input" as never, true), + ); + + assert.include(error.message, "answerTicketStep"); + assert.deepEqual(yield* Ref.get(responses), []); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/workflow/Layers/DurableApprovalResume.ts b/apps/server/src/workflow/Layers/DurableApprovalResume.ts new file mode 100644 index 00000000000..8d7f3e33f5f --- /dev/null +++ b/apps/server/src/workflow/Layers/DurableApprovalResume.ts @@ -0,0 +1,90 @@ +import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { + DurableApprovalResume, + type DurableApprovalResumeShape, +} from "../Services/DurableApprovalResume.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; + +interface PendingWaitRow { + readonly providerRequestId: string | null; + readonly providerThreadId: string | null; + readonly stepRunId: string; +} + +const toResumeError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toResumeError("approval resume sql failed"))); +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const make = Effect.gen(function* () { + const approvals = yield* ApprovalGate; + const sql = yield* SqlClient.SqlClient; + + const resetProviderDispatch = (stepRunId: string) => + Effect.gen(function* () { + const interruptedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE projection_turns + SET state = 'interrupted', + completed_at = ${interruptedAt} + WHERE state IN ('pending', 'running') + AND EXISTS ( + SELECT 1 + FROM workflow_dispatch_outbox AS outbox + WHERE outbox.step_run_id = ${stepRunId} + AND outbox.status != 'confirmed' + AND outbox.thread_id = projection_turns.thread_id + AND outbox.turn_id = projection_turns.turn_id + ) + `); + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'pending', + turn_id = NULL, + started_at = NULL, + confirmed_at = NULL + WHERE step_run_id = ${stepRunId} + AND status != 'confirmed' + `); + }); + + const resume: DurableApprovalResumeShape["resume"] = () => + Effect.gen(function* () { + const pendingWaits = yield* wrapSql(sql` + SELECT + json_extract(await.payload_json, '$.providerRequestId') AS "providerRequestId", + json_extract(await.payload_json, '$.providerThreadId') AS "providerThreadId", + json_extract(await.payload_json, '$.stepRunId') AS "stepRunId" + FROM workflow_events AS await + WHERE await.event_type = 'StepAwaitingUser' + AND NOT EXISTS ( + SELECT 1 + FROM workflow_events AS resolved + WHERE resolved.event_type = 'StepUserResolved' + AND json_extract(resolved.payload_json, '$.stepRunId') + = json_extract(await.payload_json, '$.stepRunId') + ) + ORDER BY await.sequence ASC + `); + + for (const pending of pendingWaits) { + if (pending.providerThreadId && pending.providerRequestId) { + yield* resetProviderDispatch(pending.stepRunId); + } else { + yield* approvals.park(pending.stepRunId as never); + } + } + }); + + return { resume } satisfies DurableApprovalResumeShape; +}); + +export const DurableApprovalResumeLive = Layer.effect(DurableApprovalResume, make); diff --git a/apps/server/src/workflow/Layers/GitHubPort.test.ts b/apps/server/src/workflow/Layers/GitHubPort.test.ts new file mode 100644 index 00000000000..7a40a39dab8 --- /dev/null +++ b/apps/server/src/workflow/Layers/GitHubPort.test.ts @@ -0,0 +1,817 @@ +import { assert, afterEach, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + GitHubCli, + GitHubCliError, + type GitHubCliShape, + type GitHubPullRequestCheck, + type GitHubPullRequestDetail, + type GitHubPullRequestReview, + type GitHubPullRequestReviewComment, + type GitHubPullRequestSummary, +} from "../../sourceControl/GitHubCli.ts"; +import { + SourceControlProviderRegistry, + type SourceControlProviderHandle, +} from "../../sourceControl/SourceControlProviderRegistry.ts"; +import { MergeGitPort, type MergeGitResult } from "../Services/TicketMergeService.ts"; +import { GitHubPort } from "../Services/GitHubPort.ts"; +import { GitHubPortLive } from "./GitHubPort.ts"; + +// --------------------------------------------------------------------------- +// Stubs +// --------------------------------------------------------------------------- + +const ghError = (detail: string): GitHubCliError => + new GitHubCliError({ operation: "execute", detail }); + +const unimplemented = (name: string) => () => + Effect.fail(new GitHubCliError({ operation: "execute", detail: `unexpected ${name}` })); + +const githubHandle = (remoteUrl: string, remoteName = "origin"): SourceControlProviderHandle => ({ + provider: {} as SourceControlProviderHandle["provider"], + context: { + provider: { kind: "github", name: "GitHub", baseUrl: "https://github.com" }, + remoteName, + remoteUrl, + }, +}); + +const registryLayer = (handle: SourceControlProviderHandle | null) => + Layer.succeed(SourceControlProviderRegistry, { + get: unimplemented("get") as never, + resolve: unimplemented("resolve") as never, + discover: Effect.succeed([]), + resolveHandle: () => + handle === null + ? Effect.succeed({ + provider: {} as SourceControlProviderHandle["provider"], + context: null, + }) + : Effect.succeed(handle), + }); + +interface GhStubs { + readonly execute?: GitHubCliShape["execute"]; + readonly getDefaultBranch?: GitHubCliShape["getDefaultBranch"]; + readonly listOpenPullRequests?: GitHubCliShape["listOpenPullRequests"]; + readonly createPullRequest?: GitHubCliShape["createPullRequest"]; + readonly mergePullRequest?: GitHubCliShape["mergePullRequest"]; + readonly getPullRequestDetail?: GitHubCliShape["getPullRequestDetail"]; + readonly listPullRequestChecks?: GitHubCliShape["listPullRequestChecks"]; + readonly listPullRequestReviews?: GitHubCliShape["listPullRequestReviews"]; + readonly listPullRequestReviewComments?: GitHubCliShape["listPullRequestReviewComments"]; + readonly getRepositoryCloneUrls?: GitHubCliShape["getRepositoryCloneUrls"]; +} + +const ghLayer = (stubs: GhStubs) => + Layer.succeed(GitHubCli, { + execute: stubs.execute ?? (unimplemented("execute") as never), + listOpenPullRequests: + stubs.listOpenPullRequests ?? (unimplemented("listOpenPullRequests") as never), + getPullRequest: unimplemented("getPullRequest") as never, + getRepositoryCloneUrls: + stubs.getRepositoryCloneUrls ?? (unimplemented("getRepositoryCloneUrls") as never), + createRepository: unimplemented("createRepository") as never, + createPullRequest: stubs.createPullRequest ?? (unimplemented("createPullRequest") as never), + getDefaultBranch: stubs.getDefaultBranch ?? (unimplemented("getDefaultBranch") as never), + checkoutPullRequest: unimplemented("checkoutPullRequest") as never, + mergePullRequest: stubs.mergePullRequest ?? (unimplemented("mergePullRequest") as never), + getPullRequestDetail: + stubs.getPullRequestDetail ?? (unimplemented("getPullRequestDetail") as never), + listPullRequestChecks: + stubs.listPullRequestChecks ?? (unimplemented("listPullRequestChecks") as never), + listPullRequestReviews: + stubs.listPullRequestReviews ?? (unimplemented("listPullRequestReviews") as never), + listPullRequestReviewComments: + stubs.listPullRequestReviewComments ?? + (unimplemented("listPullRequestReviewComments") as never), + }); + +interface RecordedGitCall { + readonly cwd: string; + readonly args: ReadonlyArray; +} + +const gitLayer = ( + script: (input: { cwd: string; args: ReadonlyArray }) => MergeGitResult, + calls: RecordedGitCall[], +) => + Layer.succeed(MergeGitPort, { + run: (input) => { + calls.push({ cwd: input.cwd, args: input.args }); + return Effect.succeed(script({ cwd: input.cwd, args: input.args })); + }, + }); + +const gitResult = (overrides: Partial = {}): MergeGitResult => ({ + exitCode: 0, + stdout: "", + stderr: "", + ...overrides, +}); + +const tempFiles: Array<{ path: string; content: string }> = []; + +const fsLayer = Layer.mock(FileSystem.FileSystem)({ + makeTempFileScoped: () => Effect.succeed("/tmp/t3-pr-body-stub"), + writeFileString: (path: string, content: string) => + Effect.sync(() => { + tempFiles.push({ path, content }); + }), +} as never); + +const detail = (overrides: Partial = {}): GitHubPullRequestDetail => ({ + state: "OPEN", + mergedAt: null, + reviewDecision: null, + headRefOid: "sha-abc", + url: "https://github.com/o/r/pull/7", + ...overrides, +}); + +const check = (overrides: Partial = {}): GitHubPullRequestCheck => ({ + name: "build", + state: "SUCCESS", + bucket: "pass", + link: "", + ...overrides, +}); + +const review = (overrides: Partial = {}): GitHubPullRequestReview => ({ + id: "PRR_1", + author: "alice", + state: "COMMENTED", + body: "looks ok", + submittedAt: "2026-06-12T10:00:00Z", + ...overrides, +}); + +const comment = ( + overrides: Partial = {}, +): GitHubPullRequestReviewComment => ({ + id: 1, + user: "bob", + body: "nit", + path: "src/x.ts", + createdAt: "2026-06-12T09:00:00Z", + ...overrides, +}); + +const prSummary = ( + overrides: Partial = {}, +): GitHubPullRequestSummary => ({ + number: 7, + title: "My PR", + url: "https://github.com/o/r/pull/7", + baseRefName: "main", + headRefName: "feature/x", + ...overrides, +}); + +afterEach(() => { + tempFiles.length = 0; +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("GitHubPortLive", () => { + describe("resolveRemote", () => { + it.effect("derives repo from the remote url", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.resolveRemote("/repo"); + assert.deepStrictEqual(result, { remoteName: "origin", repo: "octocat/repo" }); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide(ghLayer({})), + Layer.provide(registryLayer(githubHandle("https://github.com/octocat/repo.git"))), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("falls back to gh repo view for unparseable urls", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.resolveRemote("/repo"); + assert.deepStrictEqual(result, { remoteName: "origin", repo: "octocat/ghe-repo" }); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + getRepositoryCloneUrls: () => + Effect.succeed({ + nameWithOwner: "octocat/ghe-repo", + url: "https://ghe.corp/octocat/ghe-repo", + sshUrl: "git@ghe.corp:octocat/ghe-repo.git", + }), + }), + ), + Layer.provide(registryLayer(githubHandle("https://ghe.corp/octocat/ghe-repo.git"))), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); + + describe("preflight", () => { + it.effect("returns ok when gh auth status succeeds", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.preflight("/repo"); + assert.deepStrictEqual(result, { ok: true }); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + execute: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "ok", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("returns not-ok on auth failure", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.preflight("/repo"); + assert.equal(result.ok, false); + if (result.ok === false) { + assert.equal(result.reason.includes("not authenticated"), true); + } + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + execute: () => + Effect.fail( + ghError("GitHub CLI is not authenticated. Run `gh auth login` and retry."), + ), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("propagates unexpected gh failures to the error channel", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const error = yield* port.preflight("/repo").pipe(Effect.flip); + assert.equal(error.message.includes("preflight"), true); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + execute: () => Effect.fail(ghError("network unreachable")), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); + + describe("openPr", () => { + const openInput = { + cwd: "/repo", + branch: "feature/x", + base: "main", + title: "My PR", + body: "the body", + draft: false, + }; + + it.effect("adopts an existing PR without creating one", () => { + const calls: RecordedGitCall[] = []; + return Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.openPr(openInput); + assert.deepStrictEqual(result, { + number: 7, + url: "https://github.com/o/r/pull/7", + adopted: true, + }); + // push happened, no create attempted (createPullRequest stub fails) + assert.equal(calls.length, 1); + assert.deepStrictEqual(calls[0]!.args, [ + "push", + "-u", + "origin", + "HEAD:refs/heads/feature/x", + ]); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + listOpenPullRequests: () => Effect.succeed([prSummary()]), + createPullRequest: () => + Effect.fail(ghError("createPullRequest should not be called")), + }), + ), + Layer.provide(registryLayer(githubHandle("https://github.com/octocat/repo.git"))), + Layer.provide(gitLayer(() => gitResult(), calls)), + Layer.provide(fsLayer), + ), + ), + ); + }); + + it.effect("creates a draft PR via a temp body file when none exists", () => { + const createCalls: Array<{ bodyFile: string; draft: boolean | undefined }> = []; + let listCount = 0; + return Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.openPr({ ...openInput, draft: true }); + assert.deepStrictEqual(result, { + number: 7, + url: "https://github.com/o/r/pull/7", + adopted: false, + }); + assert.equal(createCalls.length, 1); + assert.equal(createCalls[0]!.draft, true); + assert.equal(createCalls[0]!.bodyFile, "/tmp/t3-pr-body-stub"); + assert.deepStrictEqual(tempFiles, [{ path: "/tmp/t3-pr-body-stub", content: "the body" }]); + assert.equal(listCount, 2); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + listOpenPullRequests: () => + Effect.sync(() => { + listCount += 1; + return listCount === 1 ? [] : [prSummary()]; + }), + createPullRequest: (input) => + Effect.sync(() => { + createCalls.push({ bodyFile: input.bodyFile, draft: input.draft }); + }), + }), + ), + Layer.provide(registryLayer(githubHandle("https://github.com/octocat/repo.git"))), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ); + }); + + it.effect("maps a rejected push to branch diverged", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const error = yield* port.openPr(openInput).pipe(Effect.flip); + assert.equal(error.message.startsWith("branch diverged"), true); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide(ghLayer({})), + Layer.provide(registryLayer(githubHandle("https://github.com/octocat/repo.git"))), + Layer.provide( + gitLayer( + () => + gitResult({ + exitCode: 1, + stderr: "! [rejected] feature/x -> feature/x (non-fast-forward)", + }), + [], + ), + ), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); + + describe("findPrForBranch", () => { + it.effect("returns the first open PR for the head selector", () => { + const selectors: Array = []; + return Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.findPrForBranch({ cwd: "/repo", branch: "workflow/ticket-x" }); + assert.deepStrictEqual(result, { number: 7, url: "https://github.com/o/r/pull/7" }); + assert.deepStrictEqual(selectors, ["workflow/ticket-x"]); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + listOpenPullRequests: (input) => + Effect.sync(() => { + selectors.push(input.headSelector); + return [prSummary({ number: 7 }), prSummary({ number: 9 })]; + }), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ); + }); + + it.effect("returns null when no open PR matches the branch", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.findPrForBranch({ cwd: "/repo", branch: "workflow/ticket-x" }); + assert.equal(result, null); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide(ghLayer({ listOpenPullRequests: () => Effect.succeed([]) })), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); + + describe("prDetail ciState mapping", () => { + const runDetail = (input: { + detail?: Partial; + checks: ReadonlyArray; + }) => + Effect.gen(function* () { + const port = yield* GitHubPort; + return yield* port.prDetail({ cwd: "/repo", prNumber: 7 }); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + getPullRequestDetail: () => Effect.succeed(detail(input.detail)), + listPullRequestChecks: () => Effect.succeed(input.checks), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ); + + it.effect("empty checks → success", () => + Effect.gen(function* () { + const result = yield* runDetail({ checks: [] }); + assert.equal(result.ciState, "success"); + }), + ); + + it.effect("any fail bucket → failure", () => + Effect.gen(function* () { + const result = yield* runDetail({ + checks: [check({ bucket: "pass" }), check({ name: "t", bucket: "fail" })], + }); + assert.equal(result.ciState, "failure"); + }), + ); + + it.effect("cancel bucket → failure", () => + Effect.gen(function* () { + const result = yield* runDetail({ checks: [check({ bucket: "cancel" })] }); + assert.equal(result.ciState, "failure"); + }), + ); + + it.effect("pending bucket (no failures) → pending", () => + Effect.gen(function* () { + const result = yield* runDetail({ + checks: [check({ bucket: "pass" }), check({ name: "t", bucket: "pending" })], + }); + assert.equal(result.ciState, "pending"); + }), + ); + + it.effect("all pass/skipping → success", () => + Effect.gen(function* () { + const result = yield* runDetail({ + checks: [check({ bucket: "pass" }), check({ name: "t", bucket: "skipping" })], + }); + assert.equal(result.ciState, "success"); + }), + ); + + it.effect("maps state and reviewDecision", () => + Effect.gen(function* () { + const merged = yield* runDetail({ + detail: { state: "OPEN", mergedAt: "2026-06-12T10:00:00Z" }, + checks: [], + }); + assert.equal(merged.state, "merged"); + + const approved = yield* runDetail({ + detail: { reviewDecision: "APPROVED" }, + checks: [], + }); + assert.equal(approved.reviewDecision, "approved"); + + const changes = yield* runDetail({ + detail: { reviewDecision: "CHANGES_REQUESTED" }, + checks: [], + }); + assert.equal(changes.reviewDecision, "changes_requested"); + + const closed = yield* runDetail({ detail: { state: "CLOSED" }, checks: [] }); + assert.equal(closed.state, "closed"); + assert.equal(closed.reviewDecision, "none"); + }), + ); + }); + + describe("mergePr", () => { + const mergeInput = { + cwd: "/repo", + prNumber: 7, + strategy: "squash" as const, + deleteBranch: false, + branch: "feature/x", + remoteName: "origin", + }; + + it.effect("returns ok:false when gh reports not mergeable", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.mergePr(mergeInput); + assert.equal(result.ok, false); + if (result.ok === false) { + assert.equal(result.reason.toLowerCase().includes("branch protection"), true); + } + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + mergePullRequest: () => + Effect.fail(ghError("Pull request is not mergeable: branch protection rules.")), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("deletes the remote branch best-effort on success", () => { + const calls: RecordedGitCall[] = []; + return Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.mergePr({ ...mergeInput, deleteBranch: true }); + assert.deepStrictEqual(result, { ok: true }); + assert.equal(calls.length, 1); + assert.deepStrictEqual(calls[0]!.args, ["push", "origin", "--delete", "feature/x"]); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + mergePullRequest: () => Effect.void, + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), calls)), + Layer.provide(fsLayer), + ), + ), + ); + }); + + it.effect("propagates unexpected merge failures to the error channel", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const error = yield* port.mergePr(mergeInput).pipe(Effect.flip); + assert.equal(error.message.includes("merge"), true); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + mergePullRequest: () => Effect.fail(ghError("boom unexpected internal error")), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("treats a transient error mentioning pending as infra, not not-mergeable", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const error = yield* port.mergePr(mergeInput).pipe(Effect.flip); + assert.equal(error.message.includes("merge"), true); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + mergePullRequest: () => + Effect.fail(ghError("network error: request pending, timed out")), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("does not reclassify an infra fault that merely mentions 'checks'", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + // An API/network fault that happens to contain the word "checks" must + // surface as an error, not a blocked (not-mergeable) outcome. + const error = yield* port.mergePr(mergeInput).pipe(Effect.flip); + assert.equal(error.message.includes("merge"), true); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + mergePullRequest: () => + Effect.fail(ghError("could not query required status checks: API unavailable")), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); + + describe("failingCheckLogs", () => { + it.effect("returns null when no checks fail", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.failingCheckLogs({ cwd: "/repo", prNumber: 7 }); + assert.equal(result, null); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ listPullRequestChecks: () => Effect.succeed([check({ bucket: "pass" })]) }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("parses a run id from the failing check link and fetches log tail", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.failingCheckLogs({ cwd: "/repo", prNumber: 7 }); + assert.equal(result !== null && result.length === 10_000, true); + assert.equal(result?.endsWith("END"), true); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + listPullRequestChecks: () => + Effect.succeed([ + check({ + name: "test", + bucket: "fail", + link: "https://github.com/o/r/actions/runs/9988/job/1", + }), + ]), + execute: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: `${"x".repeat(10_050)}END`, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("falls back to check names when no run id is parseable", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.failingCheckLogs({ cwd: "/repo", prNumber: 7 }); + assert.equal(result, "lint, typecheck"); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + listPullRequestChecks: () => + Effect.succeed([ + check({ name: "lint", bucket: "fail", link: "https://example.com/no-run" }), + check({ name: "typecheck", bucket: "fail", link: "" }), + ]), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); + + describe("listReviewFeedback", () => { + it.effect("merges reviews and comments, skips empties, sorts ascending", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.listReviewFeedback({ + cwd: "/repo", + prNumber: 7, + repo: "o/r", + }); + assert.deepStrictEqual(result, [ + { + id: "comment:1", + author: "bob", + body: "nit", + submittedAt: "2026-06-12T09:00:00Z", + }, + { + id: "PRR_1", + author: "alice", + body: "looks ok", + submittedAt: "2026-06-12T10:00:00Z", + }, + ]); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + listPullRequestReviews: () => + Effect.succeed([ + review(), + review({ id: "PRR_2", body: " ", submittedAt: "2026-06-12T11:00:00Z" }), + ]), + listPullRequestReviewComments: () => + Effect.succeed([ + comment(), + comment({ id: 2, body: "", createdAt: "2026-06-12T08:00:00Z" }), + ]), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); +}); diff --git a/apps/server/src/workflow/Layers/GitHubPort.ts b/apps/server/src/workflow/Layers/GitHubPort.ts new file mode 100644 index 00000000000..8ad10355689 --- /dev/null +++ b/apps/server/src/workflow/Layers/GitHubPort.ts @@ -0,0 +1,382 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl } from "@t3tools/shared/git"; + +import { + GitHubCli, + GitHubCliError, + type GitHubPullRequestCheck, +} from "../../sourceControl/GitHubCli.ts"; +import { SourceControlProviderRegistry } from "../../sourceControl/SourceControlProviderRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + GitHubPort, + type GitHubPortShape, + type GitHubPrDetail, + type GitHubReviewItem, +} from "../Services/GitHubPort.ts"; +import { MergeGitPort } from "../Services/TicketMergeService.ts"; + +const FAILING_CHECK_LOG_CAP = 10_000; + +const firstLine = (text: string): string => text.trim().split("\n")[0] ?? ""; + +const eventStoreError = (message: string, cause?: unknown): WorkflowEventStoreError => + new WorkflowEventStoreError(cause === undefined ? { message } : { message, cause }); + +/** + * `gh` reports a missing binary or a logged-out account through the + * GitHubCli error-normalization layer. Those two conditions are expected + * infrastructure states the caller handles (step blocked), not bugs — so + * `preflight` returns `{ ok: false }` for them. Anything else is a real fault. + */ +const isExpectedAuthFailure = (error: GitHubCliError): boolean => { + const detail = error.detail.toLowerCase(); + return ( + detail.includes("not authenticated") || + detail.includes("not available on path") || + detail.includes("gh auth login") + ); +}; + +// Phrases gh prints when a merge is blocked by a human-fixable mergeability +// state (branch protection / review / conflict). Kept specific so a transient +// infra fault whose message merely *contains* a bare word like "checks" or +// "conflict" — e.g. "could not query required status checks: API unavailable", +// or "merge conflict resolution service timed out" — is NOT silently +// reclassified as a blocked merge and is instead surfaced on the error channel. +const NOT_MERGEABLE_PATTERNS = [ + "not mergeable", + "not in a mergeable state", + "branch protection", + "protected branch", + // NB: a bare "merge conflict" substring is deliberately NOT listed — it matches + // infra faults like "merge conflict resolution service timed out". A genuine + // conflict block surfaces as "not mergeable" / "has conflicts that must be + // resolved", both covered above/below. + "has conflicts", + "review required", + "review is required", + "changes requested", + "approving review", + "changes to the base branch", + // "...status checks are expected" / "...status checks have not succeeded" are + // genuine block reasons, but match the FULL phrase so an infra fault like + // "could not query required status checks: API unavailable" is not swept in. + "status checks are expected", + "status checks have not succeeded", + "required status checks have not passed", +]; + +const looksNotMergeable = (text: string): boolean => { + const lower = text.toLowerCase(); + return NOT_MERGEABLE_PATTERNS.some((pattern) => lower.includes(pattern)); +}; + +const normalizeReviewDecision = (value: string | null): GitHubPrDetail["reviewDecision"] => { + const normalized = value?.trim().toUpperCase(); + if (normalized === "CHANGES_REQUESTED") return "changes_requested"; + if (normalized === "APPROVED") return "approved"; + return "none"; +}; + +const normalizeState = (input: { + state: string; + mergedAt: string | null; +}): GitHubPrDetail["state"] => { + const normalized = input.state.trim().toUpperCase(); + if (normalized === "MERGED" || (input.mergedAt !== null && input.mergedAt.trim().length > 0)) { + return "merged"; + } + if (normalized === "CLOSED") return "closed"; + return "open"; +}; + +/** + * Reduce gh's per-check buckets to a single CI signal: + * - any failed/cancelled check → "failure" + * - any still-pending check → "pending" + * - otherwise (all pass/skip) → "success" + * + * An EMPTY checks list maps to "success": a repository with no CI configured + * has nothing to wait on, so boards gating on `ci.passed` get an immediate + * pass rather than stalling forever on a check that never fires. + */ +const ciStateFromChecks = ( + checks: ReadonlyArray, +): GitHubPrDetail["ciState"] => { + if (checks.length === 0) return "success"; + let pending = false; + for (const check of checks) { + const bucket = check.bucket.trim().toLowerCase(); + if (bucket === "fail" || bucket === "cancel") return "failure"; + if (bucket === "pending") pending = true; + } + return pending ? "pending" : "success"; +}; + +const make = Effect.gen(function* () { + const gh = yield* GitHubCli; + const git = yield* MergeGitPort; + const registry = yield* SourceControlProviderRegistry; + const fileSystem = yield* FileSystem.FileSystem; + + const mapGhError = + (message: string) => + (error: GitHubCliError): WorkflowEventStoreError => + eventStoreError(`${message}: ${error.detail}`, error); + + const resolveRemote: GitHubPortShape["resolveRemote"] = (cwd) => + registry.resolveHandle({ cwd }).pipe( + Effect.mapError((error) => eventStoreError("failed to resolve source control remote", error)), + Effect.flatMap((handle) => { + const context = handle.context; + if (context === null) { + return Effect.fail(eventStoreError(`no source control remote detected for ${cwd}`)); + } + const parsed = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(context.remoteUrl); + if (parsed !== null) { + return Effect.succeed({ remoteName: context.remoteName, repo: parsed }); + } + // Self-hosted / non-canonical URLs the parser cannot read: ask gh for + // the canonical nameWithOwner of the configured remote. + return gh.getRepositoryCloneUrls({ cwd, repository: context.remoteName }).pipe( + Effect.map((urls) => ({ remoteName: context.remoteName, repo: urls.nameWithOwner })), + Effect.mapError(mapGhError("failed to resolve repository name")), + ); + }), + ); + + const preflight: GitHubPortShape["preflight"] = (cwd) => + gh.execute({ cwd, args: ["auth", "status"] }).pipe( + Effect.as({ ok: true } as { ok: true } | { ok: false; reason: string }), + Effect.catchTag("GitHubCliError", (error) => + isExpectedAuthFailure(error) + ? Effect.succeed({ ok: false, reason: error.detail } as + | { ok: true } + | { ok: false; reason: string }) + : Effect.fail(eventStoreError("github preflight failed", error)), + ), + ); + + const defaultBranch: GitHubPortShape["defaultBranch"] = (cwd) => + gh.getDefaultBranch({ cwd }).pipe( + Effect.mapError(mapGhError("failed to resolve default branch")), + Effect.flatMap((branch) => + branch === null + ? Effect.fail(eventStoreError("github returned no default branch")) + : Effect.succeed(branch), + ), + ); + + const findPr = (input: { cwd: string; branch: string }) => + gh + .listOpenPullRequests({ cwd: input.cwd, headSelector: input.branch }) + .pipe(Effect.mapError(mapGhError("failed to list open pull requests"))); + + const openPr: GitHubPortShape["openPr"] = (input) => + Effect.gen(function* () { + // Push the worktree branch to the resolved remote. A rejected push means + // the remote moved ahead of us — surface it as "branch diverged" so the + // open action can map it to a blocked outcome. + const remote = yield* resolveRemote(input.cwd); + const push = yield* git + .run({ + cwd: input.cwd, + args: ["push", "-u", remote.remoteName, `HEAD:refs/heads/${input.branch}`], + allowNonZeroExit: true, + }) + .pipe(Effect.mapError((error) => eventStoreError("failed to push branch", error))); + if (push.exitCode !== 0) { + const combined = `${push.stderr}\n${push.stdout}`.toLowerCase(); + if ( + combined.includes("non-fast-forward") || + combined.includes("fetch first") || + (combined.includes("[rejected]") && !combined.includes("[remote rejected]")) + ) { + return yield* eventStoreError( + `branch diverged: ${firstLine(push.stderr) || firstLine(push.stdout) || "remote push rejected"}`, + ); + } + return yield* eventStoreError( + `failed to push branch: ${firstLine(push.stderr) || firstLine(push.stdout) || "push exited non-zero"}`, + ); + } + + // Idempotency: adopt an existing PR for this branch rather than creating + // a duplicate (recovery / retry safe). + const existing = yield* findPr({ cwd: input.cwd, branch: input.branch }); + const adoptedPr = existing[0]; + if (adoptedPr !== undefined) { + return { number: adoptedPr.number, url: adoptedPr.url, adopted: true }; + } + + yield* Effect.scoped( + Effect.gen(function* () { + const bodyFile = yield* fileSystem.makeTempFileScoped({ prefix: "t3-pr-body-" }).pipe( + Effect.tap((path) => fileSystem.writeFileString(path, input.body)), + Effect.mapError((cause) => eventStoreError("failed to write PR body file", cause)), + ); + yield* gh + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.base, + headSelector: input.branch, + title: input.title, + bodyFile, + draft: input.draft, + }) + .pipe(Effect.mapError(mapGhError("failed to create pull request"))); + }), + ); + + const created = yield* findPr({ cwd: input.cwd, branch: input.branch }); + const createdPr = created[0]; + if (createdPr === undefined) { + return yield* eventStoreError("pull request created but could not be located by branch"); + } + return { number: createdPr.number, url: createdPr.url, adopted: false }; + }); + + const findPrForBranch: GitHubPortShape["findPrForBranch"] = (input) => + findPr({ cwd: input.cwd, branch: input.branch }).pipe( + Effect.map((prs) => { + const pr = prs[0]; + return pr === undefined ? null : { number: pr.number, url: pr.url }; + }), + ); + + const prDetail: GitHubPortShape["prDetail"] = (input) => + Effect.gen(function* () { + const detail = yield* gh + .getPullRequestDetail({ cwd: input.cwd, number: input.prNumber }) + .pipe(Effect.mapError(mapGhError("failed to read pull request detail"))); + const checks = yield* gh + .listPullRequestChecks({ cwd: input.cwd, number: input.prNumber }) + .pipe(Effect.mapError(mapGhError("failed to read pull request checks"))); + + return { + number: input.prNumber, + url: detail.url, + state: normalizeState({ state: detail.state, mergedAt: detail.mergedAt }), + headSha: detail.headRefOid.trim().length > 0 ? detail.headRefOid : null, + reviewDecision: normalizeReviewDecision(detail.reviewDecision), + ciState: ciStateFromChecks(checks), + } satisfies GitHubPrDetail; + }); + + const mergePr: GitHubPortShape["mergePr"] = (input) => + gh.mergePullRequest({ cwd: input.cwd, number: input.prNumber, strategy: input.strategy }).pipe( + Effect.matchEffect({ + onFailure: (error) => + looksNotMergeable(error.detail) + ? Effect.succeed({ ok: false, reason: firstLine(error.detail) } as + | { ok: true } + | { ok: false; reason: string }) + : Effect.fail(eventStoreError("failed to merge pull request", error)), + onSuccess: () => + Effect.gen(function* () { + if (input.deleteBranch) { + // Best-effort remote-branch cleanup. NEVER `gh --delete-branch`: + // the local branch backs a live worktree. + yield* git + .run({ + cwd: input.cwd, + args: ["push", input.remoteName, "--delete", input.branch], + allowNonZeroExit: true, + }) + .pipe(Effect.ignore); + } + return { ok: true } as { ok: true } | { ok: false; reason: string }; + }), + }), + ); + + const failingCheckLogs: GitHubPortShape["failingCheckLogs"] = (input) => + Effect.gen(function* () { + const checks = yield* gh + .listPullRequestChecks({ cwd: input.cwd, number: input.prNumber }) + .pipe(Effect.mapError(mapGhError("failed to read pull request checks"))); + const failing = checks.filter((check) => { + const bucket = check.bucket.trim().toLowerCase(); + return bucket === "fail" || bucket === "cancel"; + }); + if (failing.length === 0) { + return null; + } + + const firstFailing = failing[0]!; + const runIdMatch = /\/actions\/runs\/(\d+)/.exec(firstFailing.link); + const runId = runIdMatch?.[1]; + if (runId === undefined) { + // No parseable run id — return the failed check names as a summary. + return failing + .map((check) => check.name) + .filter((name) => name.length > 0) + .join(", "); + } + + const output = yield* gh + .execute({ cwd: input.cwd, args: ["run", "view", runId, "--log-failed"] }) + .pipe(Effect.mapError(mapGhError("failed to read failing check logs"))); + const stdout = output.stdout; + return stdout.length > FAILING_CHECK_LOG_CAP + ? stdout.slice(stdout.length - FAILING_CHECK_LOG_CAP) + : stdout; + }); + + const listReviewFeedback: GitHubPortShape["listReviewFeedback"] = (input) => + Effect.gen(function* () { + const reviews = yield* gh + .listPullRequestReviews({ cwd: input.cwd, number: input.prNumber }) + .pipe(Effect.mapError(mapGhError("failed to read pull request reviews"))); + const comments = yield* gh + .listPullRequestReviewComments({ + cwd: input.cwd, + repo: input.repo, + number: input.prNumber, + }) + .pipe(Effect.mapError(mapGhError("failed to read pull request review comments"))); + + const items: Array = []; + for (const review of reviews) { + if (review.body.trim().length === 0) continue; + items.push({ + id: review.id, + author: review.author, + body: review.body, + submittedAt: review.submittedAt, + sortKey: review.submittedAt, + }); + } + for (const comment of comments) { + if (comment.body.trim().length === 0) continue; + items.push({ + id: `comment:${comment.id}`, + author: comment.user, + body: comment.body, + submittedAt: comment.createdAt, + sortKey: comment.createdAt, + }); + } + + items.sort((a, b) => (a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0)); + return items.map(({ sortKey: _sortKey, ...item }) => item); + }); + + return { + preflight, + resolveRemote, + defaultBranch, + openPr, + findPrForBranch, + prDetail, + mergePr, + failingCheckLogs, + listReviewFeedback, + } satisfies GitHubPortShape; +}); + +export const GitHubPortLive = Layer.effect(GitHubPort, make); diff --git a/apps/server/src/workflow/Layers/GithubIssuesProvider.test.ts b/apps/server/src/workflow/Layers/GithubIssuesProvider.test.ts new file mode 100644 index 00000000000..1ccaf0dff9e --- /dev/null +++ b/apps/server/src/workflow/Layers/GithubIssuesProvider.test.ts @@ -0,0 +1,561 @@ +import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { GithubIssuesProvider as GithubIssuesProviderTag } from "../Services/WorkSourceProvider.ts"; +import { WorkSourceConnectionStore } from "../Services/WorkSourceConnectionStore.ts"; +import { GithubIssuesProviderLive } from "./GithubIssuesProvider.ts"; + +// --------------------------------------------------------------------------- +// Canned GitHub API responses +// --------------------------------------------------------------------------- + +/** Issue-1: open issue (should be included) */ +const issueOpen = { + number: 1, + state: "open", + title: "Bug: something broken", + body: "Describe the bug", + html_url: "https://github.com/o/r/issues/1", + updated_at: "2024-01-01T00:00:00Z", + assignees: [{ login: "alice" }], + labels: [{ name: "bug" }], +}; + +/** Issue-2: pull request — should be FILTERED OUT */ +const pullRequest = { + number: 2, + state: "open", + title: "PR: add feature", + body: null, + html_url: "https://github.com/o/r/pull/2", + updated_at: "2024-01-02T00:00:00Z", + assignees: [], + labels: [], + pull_request: { url: "https://api.github.com/repos/o/r/pulls/2" }, +}; + +/** Issue-3: closed issue (should be included, lifecycle=closed) */ +const issueClosed = { + number: 3, + state: "closed", + title: "Fixed: something", + body: null, + html_url: "https://github.com/o/r/issues/3", + updated_at: "2024-01-03T00:00:00Z", + assignees: [], + labels: [{ name: "fixed" }], +}; + +// --------------------------------------------------------------------------- +// Helper: build a test layer with mocked HttpClient + connection store +// --------------------------------------------------------------------------- + +function makeTestLayer(input: { + readonly responseBody: unknown; + readonly responseStatus?: number; + readonly responseHeaders?: Record; + readonly pat?: string; +}) { + const pat = input.pat ?? "test-pat-12345"; + const status = input.responseStatus ?? 200; + const headers = input.responseHeaders ?? {}; + + const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(input.responseBody), { + status, + headers: { + "content-type": "application/json", + ...headers, + }, + }), + ), + ), + ); + + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => execute(request)), + ); + + const connectionStoreLayer = Layer.succeed(WorkSourceConnectionStore, { + getToken: (_connectionRef, _expectedProvider) => Effect.succeed(pat), + getConnectionAuth: (_connectionRef, _expectedProvider) => + Effect.succeed({ token: pat, authMode: "pat", baseUrl: null, email: null }), + create: (_input) => Effect.die("not needed in test"), + list: () => Effect.die("not needed in test"), + remove: (_connectionRef) => Effect.die("not needed in test"), + }); + + const testLayer = GithubIssuesProviderLive.pipe( + Layer.provide(httpClientLayer), + Layer.provide(connectionStoreLayer), + ); + + return { execute, testLayer }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("GithubIssuesProvider", () => { + describe("listPage", () => { + it.effect("lists issues, filters PRs, maps lifecycle + pagination", () => { + const linkHeader = + '; rel="next", ; rel="last"'; + + const { testLayer } = makeTestLayer({ + responseBody: [issueOpen, pullRequest, issueClosed], + responseHeaders: { link: linkHeader }, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r", state: "all" }, + pageSize: 50, + }); + + // PR (issue-2) should be filtered + expect(page.items.map((i) => i.externalId)).toEqual(["1", "3"]); + + // open issue lifecycle + expect(page.items[0]!.lifecycle).toBe("open"); + // closed issue lifecycle + expect(page.items[1]!.lifecycle).toBe("closed"); + + // version.updatedAt is mapped + expect(page.items[0]!.version.updatedAt).toBe("2024-01-01T00:00:00Z"); + expect(page.items[1]!.version.updatedAt).toBe("2024-01-03T00:00:00Z"); + + // fields are mapped + expect(page.items[0]!.fields.title).toBe("Bug: something broken"); + expect(page.items[0]!.fields.description).toBe("Describe the bug"); + expect(page.items[0]!.fields.assignees).toEqual(["alice"]); + expect(page.items[0]!.fields.labels).toEqual(["bug"]); + + // closed issue body=null → description=undefined + expect(page.items[1]!.fields.description).toBeUndefined(); + + // nextPageToken parsed from Link header + expect(page.nextPageToken).toBe("2"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("returns no nextPageToken when Link header is absent", () => { + const { testLayer } = makeTestLayer({ + responseBody: [issueOpen], + responseHeaders: {}, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r", state: "open" }, + pageSize: 50, + }); + + expect(page.nextPageToken).toBeUndefined(); + expect(page.items).toHaveLength(1); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("applies GithubSelector defaulting (state defaults to 'all')", () => { + const { execute, testLayer } = makeTestLayer({ responseBody: [] }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + // Omit 'state' — should default to 'all' via GithubSelector + yield* provider.listPage({ + connectionRef: "conn", + selector: { owner: "myorg", repo: "myrepo" }, + pageSize: 25, + }); + + const request = execute.mock.calls[0]?.[0]; + expect(request).toBeDefined(); + // URL should contain per_page and state params + expect(request!.url).toContain("myorg"); + expect(request!.url).toContain("myrepo"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps provider field on items", () => { + const { testLayer } = makeTestLayer({ responseBody: [issueOpen] }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }); + + expect(page.items[0]!.provider).toBe("github"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("sends Authorization header with PAT", () => { + const { execute, testLayer } = makeTestLayer({ + responseBody: [], + pat: "my-secret-pat", + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + yield* provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }); + + const request = execute.mock.calls[0]?.[0]; + expect(request).toBeDefined(); + expect(request!.headers["authorization"]).toBe("Bearer my-secret-pat"); + expect(request!.headers["accept"]).toBe("application/vnd.github+json"); + expect(request!.headers["x-github-api-version"]).toBe("2022-11-28"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps 401 to WorkSourceAuthError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { message: "Bad credentials" }, + responseStatus: 401, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "my-conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceAuthError"); + expect((failure as { connectionRef?: string }).connectionRef).toBe("my-conn"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps 429 with retry-after to WorkSourceRateLimitError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { message: "rate limited" }, + responseStatus: 429, + responseHeaders: { "retry-after": "30" }, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceRateLimitError"); + expect((failure as { retryAfterMs?: number }).retryAfterMs).toBe(30_000); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps 403 with x-ratelimit-remaining:0 to WorkSourceRateLimitError", () => { + // Use a far-future epoch so the delta is always positive + const futureResetEpochSec = 9_999_999_999; + const { testLayer } = makeTestLayer({ + responseBody: { message: "API rate limit exceeded" }, + responseStatus: 403, + responseHeaders: { + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(futureResetEpochSec), + }, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceRateLimitError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps 403 without rate-limit headers to WorkSourceAuthError", () => { + // Common misconfigured-PAT case: 403 with no rate-limit headers at all. + const { testLayer } = makeTestLayer({ + responseBody: { message: "Resource not accessible by personal access token" }, + responseStatus: 403, + responseHeaders: {}, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "bad-pat-conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceAuthError"); + expect((failure as { connectionRef?: string }).connectionRef).toBe("bad-pat-conn"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect( + "Fix L6: 403 secondary rate limit (retry-after present, x-ratelimit-remaining > 0) → WorkSourceRateLimitError", + () => { + // Secondary/abuse limits return 403 with a retry-after header but keep + // x-ratelimit-remaining non-zero. Must map to rate-limit (honoring the + // server's retry-after), NOT a generic transient/auth error. + const { testLayer } = makeTestLayer({ + responseBody: { message: "You have exceeded a secondary rate limit" }, + responseStatus: 403, + responseHeaders: { + "retry-after": "45", + "x-ratelimit-remaining": "4999", + }, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceRateLimitError"); + // retry-after honored verbatim (45s → 45_000ms). + expect((failure as { retryAfterMs?: number }).retryAfterMs).toBe(45_000); + }).pipe(Effect.provide(testLayer)); + }, + ); + + it.effect("computes retryAfterMs from x-ratelimit-reset epoch math", () => { + // `it.effect` runs with the default Effect clock at epoch 0, and the + // provider reads `DateTime.now`. Pin the reset epoch 120s past epoch 0 so + // the computed delta (resetMs - nowMs) is a deterministic ~120_000ms. + // A future epoch->ms unit regression (e.g. forgetting the *1000) would + // collapse this to ~120ms and fail the lower bound. + const resetEpochSec = 120; + const { testLayer } = makeTestLayer({ + responseBody: { message: "API rate limit exceeded" }, + responseStatus: 403, + responseHeaders: { + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(resetEpochSec), + }, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceRateLimitError"); + const retryAfterMs = (failure as { retryAfterMs?: number }).retryAfterMs; + // ~120_000ms (120s * 1000). Wide enough to absorb any small clock slack, + // tight enough that a missing *1000 (=> ~120ms) fails the lower bound. + expect(retryAfterMs).toBeGreaterThan(60_000); + expect(retryAfterMs).toBeLessThanOrEqual(130_000); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("fails with WorkSourceConfigError for invalid selector", () => { + const { testLayer } = makeTestLayer({ responseBody: [] }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + // missing required 'owner' and 'repo' + selector: { state: "open" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceConfigError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect( + "Fix 6: 200 body that is not an array → WorkSourceTransientError (not a defect)", + () => { + const { testLayer } = makeTestLayer({ + responseBody: { message: "garbage" }, + responseStatus: 200, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* provider + .listPage({ connectionRef: "conn", selector: { owner: "o", repo: "r" }, pageSize: 10 }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }, + ); + }); + + describe("getItem", () => { + const selector = { owner: "o", repo: "r" }; + + it.effect("404 → null (genuinely deleted upstream)", () => { + const { testLayer } = makeTestLayer({ + responseBody: { message: "Not Found" }, + responseStatus: 404, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const result = yield* provider.getItem({ + connectionRef: "conn", + selector, + externalId: "42", + }); + expect(result).toBeNull(); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("200 → the mapped item (still exists; fell out of a filter, NOT deleted)", () => { + const { testLayer } = makeTestLayer({ responseBody: issueOpen, responseStatus: 200 }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const result = yield* provider.getItem({ + connectionRef: "conn", + selector, + externalId: "1", + }); + expect(result).not.toBeNull(); + expect(result!.externalId).toBe("1"); + expect(result!.fields.title).toBe("Bug: something broken"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("401 → WorkSourceAuthError (typed failure, NOT null)", () => { + const { testLayer } = makeTestLayer({ + responseBody: { message: "Bad credentials" }, + responseStatus: 401, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* provider + .getItem({ connectionRef: "conn", selector, externalId: "1" }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceAuthError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("500 → WorkSourceTransientError (typed failure, NOT null)", () => { + const { testLayer } = makeTestLayer({ + responseBody: { message: "boom" }, + responseStatus: 500, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* provider + .getItem({ connectionRef: "conn", selector, externalId: "1" }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("malformed 200 body (array, not an object) → WorkSourceTransientError", () => { + const { testLayer } = makeTestLayer({ responseBody: [1, 2, 3], responseStatus: 200 }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* provider + .getItem({ connectionRef: "conn", selector, externalId: "1" }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + }); + + describe("GithubIssuesProvider import methods", () => { + it.effect("toImportableView formats # and owner/repo from the selector", () => { + const { testLayer } = makeTestLayer({ responseBody: [] }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const parts = provider.toImportableView({ + selector: { owner: "acme", repo: "app", state: "open" }, + item: { + provider: "github", + externalId: "82", + url: "https://github.com/acme/app/issues/82", + lifecycle: "open", + version: {}, + fields: { title: "x" }, + }, + }); + assert.equal(parts.displayRef, "#82"); + assert.equal(parts.container, "acme/app"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("toImportableView falls back to '?' when owner/repo are absent", () => { + const { testLayer } = makeTestLayer({ responseBody: [] }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const parts = provider.toImportableView({ + selector: {}, + item: { + provider: "github", + externalId: "9", + url: "https://github.com/x/y/issues/9", + lifecycle: "open", + version: {}, + fields: { title: "x" }, + }, + }); + assert.equal(parts.displayRef, "#9"); + assert.equal(parts.container, "?/?"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("viewer returns the login as id + alias", () => { + const { testLayer } = makeTestLayer({ responseBody: { login: "octocat" } }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const v = yield* provider.viewer({ connectionRef: "c" }); + assert.deepEqual(v, { id: "octocat", aliases: ["octocat"] }); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("viewer returns null on non-200 status", () => { + const { testLayer } = makeTestLayer({ + responseBody: { message: "Not Found" }, + responseStatus: 404, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const v = yield* provider.viewer({ connectionRef: "c" }); + assert.equal(v, null); + }).pipe(Effect.provide(testLayer)); + }); + }); +}); diff --git a/apps/server/src/workflow/Layers/GithubIssuesProvider.ts b/apps/server/src/workflow/Layers/GithubIssuesProvider.ts new file mode 100644 index 00000000000..ecd6ad02290 --- /dev/null +++ b/apps/server/src/workflow/Layers/GithubIssuesProvider.ts @@ -0,0 +1,370 @@ +/** + * GithubIssuesProvider — raw-HTTP GitHub Issues work-source provider. + * + * Uses `HttpClient` from `effect/unstable/http` (NOT the `gh` CLI) with a PAT + * fetched from `WorkSourceConnectionStore.getToken`. + * + * ### externalId strategy + * `externalId = String(number)` — the issue number is stable per repo and lets + * `getItem` issue a simple `GET /repos/{owner}/{repo}/issues/:number` lookup. + * + * ### nextPageToken strategy + * Parse the `Link` response header for `rel="next"` and extract the `page` + * query-parameter value. Fall back to the page-count heuristic + * (`items.length === pageSize ? String(Number(pageToken ?? 1) + 1) : undefined`) + * only if the header is absent (GitHub always emits it when another page + * exists). + * + * ### getItem + * `getItem` decodes the source `selector` for owner/repo, then issues + * `GET /repos/{owner}/{repo}/issues/{externalId}` (externalId = issue number). + * 404 → null (genuinely deleted upstream → the syncer may terminal-route). + * 200 → the mapped item (it STILL EXISTS — it merely fell out of a label/ + * assignee/state filter, so it must NOT be confirmed-deleted). Auth/rate-limit/ + * transient map to their typed errors so the syncer treats them as + * "cannot confirm" (no deletion) and backs the source off. + */ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { GithubSelector } from "@t3tools/contracts/workSource"; + +import { + GithubIssuesProvider as GithubIssuesProviderTag, + WorkSourceAuthError, + WorkSourceConfigError, + WorkSourceRateLimitError, + WorkSourceTransientError, + type ExternalWorkItem, + type ImportableViewParts, + type Viewer, + type WorkSourcePage, + type WorkSourceProvider, +} from "../Services/WorkSourceProvider.ts"; +import { WorkSourceConnectionStore } from "../Services/WorkSourceConnectionStore.ts"; + +const GITHUB_API_BASE = "https://api.github.com"; +const GITHUB_API_VERSION = "2022-11-28"; +const USER_AGENT = "t3code-work-source/1.0"; + +// --------------------------------------------------------------------------- +// Link-header parser +// --------------------------------------------------------------------------- + +/** + * Parse GitHub's `Link` header and return the `page` value for `rel="next"`, + * or `undefined` if no next page. + * + * Example header value: + * ; rel="next", + * ; rel="last" + */ +function parseNextPageFromLinkHeader(linkHeader: string | undefined): string | undefined { + if (!linkHeader) return undefined; + // Split on commas that separate link entries + for (const part of linkHeader.split(",")) { + const nextMatch = part.match(/rel="next"/u); + if (!nextMatch) continue; + const urlMatch = part.match(/<([^>]+)>/u); + if (!urlMatch?.[1]) continue; + try { + const pageParam = new URL(urlMatch[1]).searchParams.get("page"); + return pageParam ?? undefined; + } catch { + return undefined; + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Rate-limit helper +// --------------------------------------------------------------------------- + +function parseRateLimitRetryMs(headers: Record, nowMs: number): number { + // retry-after is in seconds + const retryAfter = headers["retry-after"]; + if (retryAfter) { + const seconds = Number(retryAfter); + if (!Number.isNaN(seconds)) return seconds * 1000; + } + // x-ratelimit-reset is an epoch timestamp in seconds + const resetEpoch = headers["x-ratelimit-reset"]; + if (resetEpoch) { + const resetMs = Number(resetEpoch) * 1000; + const delta = resetMs - nowMs; + return delta > 0 ? delta : 5000; + } + return 60_000; // fallback: 1 minute +} + +// --------------------------------------------------------------------------- +// Raw GitHub JSON shapes (loose — we only need the fields we use) +// --------------------------------------------------------------------------- + +interface RawGithubIssue { + readonly number: number; + readonly state: string; + readonly title: string; + readonly body: string | null; + readonly html_url: string; + readonly updated_at: string; + readonly pull_request?: unknown; + readonly assignees?: ReadonlyArray<{ readonly login: string }>; + readonly labels?: ReadonlyArray<{ readonly name: string }>; +} + +function mapIssue(raw: RawGithubIssue): ExternalWorkItem { + const assignees = raw.assignees?.map((a) => a.login); + const labels = raw.labels?.map((l) => l.name); + return { + provider: "github", + externalId: String(raw.number), + url: raw.html_url, + lifecycle: raw.state === "open" ? "open" : "closed", + version: { updatedAt: raw.updated_at }, + fields: { + title: raw.title, + // exactOptionalPropertyTypes: only spread when value is defined + ...(raw.body != null && { description: raw.body }), + ...(assignees !== undefined && { assignees }), + ...(labels !== undefined && { labels }), + }, + }; +} + +// --------------------------------------------------------------------------- +// Provider implementation +// --------------------------------------------------------------------------- + +const make = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const connectionStore = yield* WorkSourceConnectionStore; + + function buildHeaders(pat: string): Record { + return { + authorization: `Bearer ${pat}`, + accept: "application/vnd.github+json", + "x-github-api-version": GITHUB_API_VERSION, + "user-agent": USER_AGENT, + }; + } + + const provider: WorkSourceProvider = { + provider: "github", + selectorSchema: GithubSelector, + + listPage: (input) => + Effect.gen(function* () { + // Decode selector + const selector = yield* Schema.decodeUnknownEffect(GithubSelector)(input.selector).pipe( + Effect.mapError( + (e) => new WorkSourceConfigError({ message: `Invalid GitHub selector: ${e.message}` }), + ), + ); + + const pat = yield* connectionStore.getToken(input.connectionRef, "github"); + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + + const { owner, repo, labels, assignee, state } = selector; + + // Build URL params + const urlParams: Array = [ + ["state", state], + ["per_page", String(input.pageSize)], + ["page", String(input.pageToken ?? "1")], + ]; + if (input.since) urlParams.push(["since", input.since]); + if (labels && labels.length > 0) urlParams.push(["labels", labels.join(",")]); + if (assignee) urlParams.push(["assignee", assignee]); + + const request = HttpClientRequest.get( + `${GITHUB_API_BASE}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, + { urlParams }, + ).pipe(HttpClientRequest.setHeaders(buildHeaders(pat))); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `GitHub HTTP network error: ${String(cause)}`, + }), + ), + ); + + const { status, headers } = response; + + // Status error mapping. + // Detect rate-limit FIRST so a secondary/abuse 403 (carries retry-after + // but keeps x-ratelimit-remaining > 0) is not swallowed by the auth + // fallback below. A 403 is a rate limit when GitHub signals one: primary + // limit exhausted (x-ratelimit-remaining === "0") OR a secondary limit + // (retry-after present). + if ( + status === 429 || + (status === 403 && + (headers["x-ratelimit-remaining"] === "0" || headers["retry-after"] !== undefined)) + ) { + return yield* new WorkSourceRateLimitError({ + retryAfterMs: parseRateLimitRetryMs(headers, nowMs), + }); + } + if (status === 401 || (status === 403 && !headers["x-ratelimit-remaining"])) { + // 401 always auth; 403 without rate-limit headers → auth/permission + return yield* new WorkSourceAuthError({ connectionRef: input.connectionRef }); + } + if (status < 200 || status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* new WorkSourceTransientError({ + message: `GitHub API returned HTTP ${status}: ${body.trim() || "(no body)"}`, + }); + } + + const rawItems = yield* response.json.pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Failed to parse GitHub JSON response: ${String(cause)}`, + }), + ), + ); + + if (!Array.isArray(rawItems)) { + return yield* new WorkSourceTransientError({ + message: "GitHub /issues response was not an array", + }); + } + + const items: Array = []; + for (const raw of rawItems as RawGithubIssue[]) { + // Skip pull requests (GitHub includes PRs in /issues endpoint) + if (raw.pull_request !== undefined) continue; + items.push(mapIssue(raw)); + } + + const linkHeader = headers["link"]; + const nextPageToken = parseNextPageFromLinkHeader(linkHeader); + + // exactOptionalPropertyTypes: only include nextPageToken when present + const page: WorkSourcePage = { + items, + ...(nextPageToken !== undefined && { nextPageToken }), + }; + return page; + }), + + getItem: (input) => + Effect.gen(function* () { + const selector = yield* Schema.decodeUnknownEffect(GithubSelector)(input.selector).pipe( + Effect.mapError( + (e) => new WorkSourceConfigError({ message: `Invalid GitHub selector: ${e.message}` }), + ), + ); + + const pat = yield* connectionStore.getToken(input.connectionRef, "github"); + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + + const { owner, repo } = selector; + + const request = HttpClientRequest.get( + `${GITHUB_API_BASE}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${encodeURIComponent(input.externalId)}`, + ).pipe(HttpClientRequest.setHeaders(buildHeaders(pat))); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `GitHub HTTP network error (getItem): ${String(cause)}`, + }), + ), + ); + + const { status, headers } = response; + + // 404 → genuinely deleted upstream. + if (status === 404) { + return null; + } + // Detect rate-limit FIRST so a secondary/abuse 403 (retry-after present, + // x-ratelimit-remaining > 0) is not misread as auth — see listPage. + if ( + status === 429 || + (status === 403 && + (headers["x-ratelimit-remaining"] === "0" || headers["retry-after"] !== undefined)) + ) { + return yield* new WorkSourceRateLimitError({ + retryAfterMs: parseRateLimitRetryMs(headers, nowMs), + }); + } + if (status === 401 || (status === 403 && !headers["x-ratelimit-remaining"])) { + return yield* new WorkSourceAuthError({ connectionRef: input.connectionRef }); + } + if (status < 200 || status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* new WorkSourceTransientError({ + message: `GitHub API returned HTTP ${status} (getItem): ${body.trim() || "(no body)"}`, + }); + } + + const rawItem = yield* response.json.pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Failed to parse GitHub getItem JSON response: ${String(cause)}`, + }), + ), + ); + + // Guard the shape: the single-issue endpoint returns an object, not an + // array. A non-conforming body → transient (back off, never confirm). + if (rawItem === null || typeof rawItem !== "object" || Array.isArray(rawItem)) { + return yield* new WorkSourceTransientError({ + message: "GitHub /issues/:number response was not an object", + }); + } + + // The item still exists upstream (it merely fell out of the filter): + // return it so the syncer leaves confirmedDeleted=false. + return mapIssue(rawItem as unknown as RawGithubIssue); + }), + + toImportableView: ({ selector, item }): ImportableViewParts => { + const s = selector as { owner?: string; repo?: string }; + return { displayRef: `#${item.externalId}`, container: `${s.owner ?? "?"}/${s.repo ?? "?"}` }; + }, + + viewer: ({ connectionRef }) => + Effect.gen(function* () { + const pat = yield* connectionStore.getToken(connectionRef, "github"); + const request = HttpClientRequest.get(`${GITHUB_API_BASE}/user`).pipe( + HttpClientRequest.setHeaders(buildHeaders(pat)), + ); + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `GitHub viewer network error: ${String(cause)}`, + }), + ), + ); + if (response.status !== 200) return null; // best-effort: never fail the read RPC + const body = yield* response.json.pipe(Effect.orElseSucceed(() => ({}) as unknown)); + const login = (body as { login?: unknown }).login; + return typeof login === "string" && login.length > 0 + ? { id: login, aliases: [login] } + : null; + }), + }; + + return provider; +}); + +export const GithubIssuesProviderLive: Layer.Layer< + GithubIssuesProviderTag, + never, + HttpClient.HttpClient | WorkSourceConnectionStore +> = Layer.effect(GithubIssuesProviderTag, make); diff --git a/apps/server/src/workflow/Layers/JiraProvider.test.ts b/apps/server/src/workflow/Layers/JiraProvider.test.ts new file mode 100644 index 00000000000..6aa360e1b50 --- /dev/null +++ b/apps/server/src/workflow/Layers/JiraProvider.test.ts @@ -0,0 +1,326 @@ +import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientRequest, HttpClientResponse, UrlParams } from "effect/unstable/http"; + +import { JiraProvider as JiraProviderTag } from "../Services/WorkSourceProvider.ts"; +import { WorkSourceConnectionStore } from "../Services/WorkSourceConnectionStore.ts"; +import { JiraProviderLive } from "./JiraProvider.ts"; + +/** Get the decoded JQL parameter from a captured request. */ +function getJql(request: HttpClientRequest.HttpClientRequest): string { + const qs = UrlParams.toString(request.urlParams); + return new URLSearchParams(qs).get("jql") ?? ""; +} + +const issue = (over: Record = {}) => ({ + key: "ENG-1", + fields: { + summary: "Bug: broken", + description: "Steps to reproduce", + status: { statusCategory: { key: "indeterminate" } }, + assignee: { displayName: "Alice Smith" }, + labels: ["backend"], + updated: "2024-01-01T00:00:00.000+0000", + ...over, + }, +}); + +function makeTestLayer(input: { + readonly responseBody: unknown; + readonly responseStatus?: number; + readonly responseHeaders?: Record; + readonly auth?: { token: string; authMode: string; baseUrl: string | null; email: string | null }; +}) { + const status = input.responseStatus ?? 200; + const headers = input.responseHeaders ?? {}; + const auth = input.auth ?? { + token: "jira-tok", + authMode: "basic", + baseUrl: "https://acme.atlassian.net", + email: "me@acme.test", + }; + + const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(input.responseBody), { + status, + headers: { "content-type": "application/json", ...headers }, + }), + ), + ), + ); + + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => execute(request)), + ); + + const connectionStoreLayer = Layer.succeed(WorkSourceConnectionStore, { + getToken: (_ref, _p) => Effect.succeed(auth.token), + getConnectionAuth: (_ref, _p) => + Effect.succeed({ + token: auth.token, + authMode: auth.authMode as "pat" | "basic" | "bearer", + baseUrl: auth.baseUrl, + email: auth.email, + }), + create: (_input) => Effect.die("not needed in test"), + list: () => Effect.die("not needed in test"), + remove: (_ref) => Effect.die("not needed in test"), + }); + + const testLayer = JiraProviderLive.pipe( + Layer.provide(httpClientLayer), + Layer.provide(connectionStoreLayer), + ); + return { execute, testLayer }; +} + +describe("JiraProvider", () => { + describe("listPage", () => { + it.effect("maps an issue, builds Basic auth, and assembles JQL", () => { + const { execute, testLayer } = makeTestLayer({ + responseBody: { issues: [issue()], startAt: 0, maxResults: 50, total: 1 }, + }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { projectKey: "ENG" }, + pageSize: 50, + }); + + expect(page.items).toHaveLength(1); + const item = page.items[0]!; + expect(item.provider).toBe("jira"); + expect(item.externalId).toBe("ENG-1"); + expect(item.url).toBe("https://acme.atlassian.net/browse/ENG-1"); + expect(item.lifecycle).toBe("open"); + expect(item.version.updatedAt).toBe("2024-01-01T00:00:00.000+0000"); + expect(item.fields.title).toBe("Bug: broken"); + expect(item.fields.description).toBe("Steps to reproduce"); + expect(item.fields.assignees).toEqual(["Alice Smith"]); + expect(item.fields.labels).toEqual(["backend"]); + // no next page (startAt 0 + 1 item >= total 1) + expect(page.nextPageToken).toBeUndefined(); + + // Basic auth header = base64("me@acme.test:jira-tok") + const request = execute.mock.calls[0]?.[0]; + const expected = `Basic ${Buffer.from("me@acme.test:jira-tok").toString("base64")}`; + expect(request!.headers["authorization"]).toBe(expected); + // JQL contains project clause + ORDER BY (urlParams carry the query string) + const jql = getJql(request!); + expect(jql).toContain('project = "ENG"'); + expect(jql).toContain("ORDER BY updated ASC"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("uses Bearer auth for Server/DC (authMode=bearer)", () => { + const { execute, testLayer } = makeTestLayer({ + responseBody: { issues: [], startAt: 0, maxResults: 50, total: 0 }, + auth: { token: "pat-123", authMode: "bearer", baseUrl: "https://jira.corp", email: null }, + }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + yield* provider.listPage({ connectionRef: "c", selector: { projectKey: "OPS" }, pageSize: 50 }); + const request = execute.mock.calls[0]?.[0]; + expect(request!.headers["authorization"]).toBe("Bearer pat-123"); + expect(request!.url).toContain("https://jira.corp/rest/api/2/search"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("AND-combines user JQL and emits a next page token when more remain", () => { + const { execute, testLayer } = makeTestLayer({ + responseBody: { issues: [issue(), issue({ summary: "x" })], startAt: 0, maxResults: 2, total: 5 }, + }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const page = yield* provider.listPage({ + connectionRef: "c", + selector: { projectKey: "ENG", jql: "labels = backend" }, + pageSize: 2, + }); + expect(page.nextPageToken).toBe("2"); + const jql = getJql(execute.mock.calls[0]![0]); + expect(jql).toContain('project = "ENG" AND (labels = backend)'); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("includes a 'since' clause with the T→space date format", () => { + const { execute, testLayer } = makeTestLayer({ + responseBody: { issues: [], startAt: 0, maxResults: 50, total: 0 }, + }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + yield* provider.listPage({ + connectionRef: "c", + selector: { projectKey: "ENG" }, + since: "2024-01-01T00:00:00Z", + pageSize: 50, + }); + const jql = getJql(execute.mock.calls[0]![0]); + expect(jql).toContain('updated >= "2024-01-01 00:00"'); + expect(jql).toContain("ORDER BY updated ASC"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps statusCategory 'done' to lifecycle closed", () => { + const { testLayer } = makeTestLayer({ + responseBody: { + issues: [issue({ status: { statusCategory: { key: "done" } } })], + startAt: 0, + maxResults: 50, + total: 1, + }, + }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const page = yield* provider.listPage({ connectionRef: "c", selector: { projectKey: "ENG" }, pageSize: 50 }); + expect(page.items[0]!.lifecycle).toBe("closed"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps 401 to WorkSourceAuthError", () => { + const { testLayer } = makeTestLayer({ responseBody: { message: "no" }, responseStatus: 401 }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ connectionRef: "my-conn", selector: { projectKey: "ENG" }, pageSize: 10 }), + ); + expect(failure._tag).toBe("WorkSourceAuthError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps 429 with retry-after to WorkSourceRateLimitError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { message: "slow down" }, + responseStatus: 429, + responseHeaders: { "retry-after": "30" }, + }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ connectionRef: "c", selector: { projectKey: "ENG" }, pageSize: 10 }), + ); + expect(failure._tag).toBe("WorkSourceRateLimitError"); + expect((failure as { retryAfterMs?: number }).retryAfterMs).toBe(30_000); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("treats a 302 redirect as a typed error (manual-redirect SSRF guard)", () => { + // With redirect:"manual" the runtime client surfaces a redirect as a 3xx + // status (or opaqueredirect status 0). The provider's non-2xx handling + // rejects it, so a redirect from an allowed host can never auto-pivot the + // request to the (internal) Location target — no items are returned. + const { testLayer } = makeTestLayer({ + responseBody: { message: "moved" }, + responseStatus: 302, + responseHeaders: { location: "http://169.254.169.254/" }, + }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ connectionRef: "c", selector: { projectKey: "ENG" }, pageSize: 10 }), + ); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("fails with WorkSourceConfigError for an invalid selector", () => { + const { testLayer } = makeTestLayer({ responseBody: { issues: [] } }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ connectionRef: "c", selector: { projectKey: "" }, pageSize: 10 }), + ); + expect(failure._tag).toBe("WorkSourceConfigError"); + }).pipe(Effect.provide(testLayer)); + }); + }); + + describe("getItem", () => { + it.effect("404 → null", () => { + const { testLayer } = makeTestLayer({ responseBody: { message: "Not Found" }, responseStatus: 404 }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const result = yield* provider.getItem({ + connectionRef: "c", + selector: { projectKey: "ENG" }, + externalId: "ENG-9", + }); + expect(result).toBeNull(); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("200 → the mapped item (still exists)", () => { + const { testLayer } = makeTestLayer({ responseBody: issue({}), responseStatus: 200 }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const result = yield* provider.getItem({ + connectionRef: "c", + selector: { projectKey: "ENG" }, + externalId: "ENG-1", + }); + expect(result).not.toBeNull(); + expect(result!.externalId).toBe("ENG-1"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("500 → WorkSourceTransientError (NOT null)", () => { + const { testLayer } = makeTestLayer({ responseBody: { message: "boom" }, responseStatus: 500 }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const failure = yield* provider + .getItem({ connectionRef: "c", selector: { projectKey: "ENG" }, externalId: "ENG-1" }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + }); + + describe("import methods", () => { + it.effect("toImportableView uses the key as displayRef and projectKey as container", () => { + const { testLayer } = makeTestLayer({ responseBody: { issues: [] } }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const parts = provider.toImportableView({ + selector: { projectKey: "ENG" }, + item: { + provider: "jira", + externalId: "ENG-42", + url: "https://acme.atlassian.net/browse/ENG-42", + lifecycle: "open", + version: {}, + fields: { title: "x" }, + }, + }); + assert.equal(parts.displayRef, "ENG-42"); + assert.equal(parts.container, "ENG"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("viewer returns accountId as id with displayName among aliases", () => { + const { testLayer } = makeTestLayer({ + responseBody: { accountId: "acc-1", displayName: "Alice Smith", emailAddress: "alice@acme.test" }, + }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const v = yield* provider.viewer({ connectionRef: "c" }); + expect(v!.id).toBe("acc-1"); + expect(v!.aliases).toContain("Alice Smith"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("viewer returns null on non-200", () => { + const { testLayer } = makeTestLayer({ responseBody: {}, responseStatus: 403 }); + return Effect.gen(function* () { + const provider = yield* JiraProviderTag; + const v = yield* provider.viewer({ connectionRef: "c" }); + assert.equal(v, null); + }).pipe(Effect.provide(testLayer)); + }); + }); +}); diff --git a/apps/server/src/workflow/Layers/JiraProvider.ts b/apps/server/src/workflow/Layers/JiraProvider.ts new file mode 100644 index 00000000000..f0f4fef1474 --- /dev/null +++ b/apps/server/src/workflow/Layers/JiraProvider.ts @@ -0,0 +1,347 @@ +/** + * JiraProvider — raw-HTTP Jira work-source provider (Cloud + Server/Data Center). + * + * Uses `HttpClient` with credentials from `WorkSourceConnectionStore.getConnectionAuth`. + * + * ### auth + * authMode "basic" (Cloud) → `Authorization: Basic base64(email:token)`. + * authMode "bearer" (Server/DC) → `Authorization: Bearer token`. + * Base URL comes from the connection (Cloud site or self-hosted host). + * + * ### API version + * Uses `/rest/api/2` on BOTH deployments. Cloud supports v2 and returns + * `description` as a plain string — deliberately avoiding v3 ADF parsing. + * + * ### externalId strategy + * `externalId = issue.key` (e.g. "ENG-123"). Keys are stable within a project; + * they only change when an issue is MOVED to another project, which also drops + * it from this source's `project = "KEY"` selector. Accepted v1 limitation. + * + * ### pagination + * Offset-based: `pageToken` encodes `startAt`. `nextPageToken = startAt + + * issues.length` while `startAt + issues.length < total`. The token is opaque + * to the syncer, so a later switch to Cloud's token-based `/search/jql` is + * internal-only. + * + * ### since / timezone + * The ISO→JQL `since` conversion drops the timezone offset (`slice(0, 16)`), so + * the resulting `updated >= "..."` clause is interpreted in Jira's configured + * server timezone, not UTC. Acceptable v1 trade-off because the syncer supplies + * a UTC timestamp; the small skew only risks re-fetching (never skipping) a few + * boundary issues, which the reconcile pass deduplicates. + */ +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { JiraSelector } from "@t3tools/contracts/workSource"; + +import { + JiraProvider as JiraProviderTag, + WorkSourceAuthError, + WorkSourceConfigError, + WorkSourceRateLimitError, + WorkSourceTransientError, + type ExternalWorkItem, + type ImportableViewParts, + type Viewer, + type WorkSourcePage, + type WorkSourceProvider, +} from "../Services/WorkSourceProvider.ts"; +import { WorkSourceConnectionStore } from "../Services/WorkSourceConnectionStore.ts"; +import { isBlockedHost } from "../blockedHost.ts"; + +const USER_AGENT = "t3code-work-source/1.0"; +const JIRA_MAX_RESULTS_CAP = 100; +const ISSUE_FIELDS = "summary,description,status,assignee,labels,updated"; + +const trimUrl = (u: string) => u.replace(/\/+$/u, ""); + +function parseJiraRateLimitRetryMs(headers: Record): number { + const retryAfter = headers["retry-after"]; + if (retryAfter) { + const seconds = Number(retryAfter); + if (!Number.isNaN(seconds) && seconds > 0) return seconds * 1000; + } + return 60_000; +} + +interface ConnAuth { + readonly token: string; + readonly authMode: string; + readonly baseUrl: string | null; + readonly email: string | null; +} + +function requireBaseUrl(auth: ConnAuth): Effect.Effect { + const base = auth.baseUrl?.trim(); + if (!base) { + return Effect.fail(new WorkSourceConfigError({ message: "Jira connection is missing a base URL" })); + } + // Defense-in-depth: the stored baseUrl is what actually gets requested, so + // re-check it against the SSRF blocklist at request time (not just at create + // time). Parse inside Effect.try per the lint rule; a parse failure also + // surfaces as a config error. + return Effect.try({ + try: () => new URL(base).hostname, + catch: () => new WorkSourceConfigError({ message: "Jira base URL is not a valid URL" }), + }).pipe( + Effect.flatMap((hostname) => + isBlockedHost(hostname) + ? Effect.fail(new WorkSourceConfigError({ message: "Jira base URL host is not allowed" })) + : Effect.succeed(trimUrl(base)), + ), + ); +} + +// Returns an Effect because Basic auth fails (WorkSourceConfigError) when email is absent. +function buildHeaders(auth: ConnAuth): Effect.Effect, WorkSourceConfigError> { + const common = { accept: "application/json", "user-agent": USER_AGENT }; + if (auth.authMode === "basic") { + if (!auth.email) { + return Effect.fail( + new WorkSourceConfigError({ message: "Jira Cloud connection is missing an email for Basic auth" }), + ); + } + const b64 = Encoding.encodeBase64(`${auth.email}:${auth.token}`); + return Effect.succeed({ ...common, authorization: `Basic ${b64}` }); + } + // "bearer" (Server/DC) and any other mode default to Bearer + return Effect.succeed({ ...common, authorization: `Bearer ${auth.token}` }); +} + +function buildJql(selector: { projectKey: string; jql?: string | undefined }, since?: string): string { + const clauses: Array = [`project = "${selector.projectKey.replace(/"/gu, '\\"')}"`]; + if (selector.jql && selector.jql.trim().length > 0) clauses.push(`(${selector.jql.trim()})`); + if (since) { + // ISO "2024-01-01T00:00:00Z" → JQL datetime "2024-01-01 00:00" + const jiraDate = since.slice(0, 16).replace("T", " "); + clauses.push(`updated >= "${jiraDate}"`); + } + return `${clauses.join(" AND ")} ORDER BY updated ASC`; +} + +interface RawJiraFields { + readonly summary: string; + readonly description?: string | null; + readonly status?: { readonly statusCategory?: { readonly key?: string } | null } | null; + readonly assignee?: { readonly displayName?: string | null; readonly name?: string | null } | null; + readonly labels?: ReadonlyArray | null; + readonly updated?: string | null; +} +interface RawJiraIssue { + readonly key: string; + readonly fields: RawJiraFields; +} +interface RawJiraSearch { + readonly issues?: ReadonlyArray | null; + readonly startAt?: number; + readonly total?: number; +} + +function mapIssue(raw: RawJiraIssue, baseUrl: string): ExternalWorkItem { + const statusKey = raw.fields.status?.statusCategory?.key; + const assigneeName = raw.fields.assignee?.displayName ?? raw.fields.assignee?.name; + const labels = + raw.fields.labels && raw.fields.labels.length > 0 ? raw.fields.labels.slice() : undefined; + return { + provider: "jira", + externalId: raw.key, + url: `${baseUrl}/browse/${raw.key}`, + lifecycle: statusKey === "done" ? "closed" : "open", + version: raw.fields.updated ? { updatedAt: raw.fields.updated } : {}, + fields: { + title: raw.fields.summary, + ...(raw.fields.description != null && raw.fields.description !== "" && { + description: raw.fields.description, + }), + ...(assigneeName != null && { assignees: [assigneeName] }), + ...(labels !== undefined && { labels }), + }, + }; +} + +// Hoisted: the compiled decoder is built once, not rebuilt on every listPage call. +const decodeJiraSelector = Schema.decodeUnknownEffect(JiraSelector); + +const make = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const connectionStore = yield* WorkSourceConnectionStore; + + const provider: WorkSourceProvider = { + provider: "jira", + selectorSchema: JiraSelector, + + listPage: (input) => + Effect.gen(function* () { + const selector = yield* decodeJiraSelector(input.selector).pipe( + Effect.mapError( + (e) => new WorkSourceConfigError({ message: `Invalid Jira selector: ${e.message}` }), + ), + ); + const auth = yield* connectionStore.getConnectionAuth(input.connectionRef, "jira"); + const baseUrl = yield* requireBaseUrl(auth); + const headers = yield* buildHeaders(auth); + + const startAt = Number(input.pageToken ?? "0"); + const maxResults = Math.min(input.pageSize, JIRA_MAX_RESULTS_CAP); + const jql = buildJql(selector, input.since); + + const urlParams: Array = [ + ["jql", jql], + ["startAt", String(Number.isNaN(startAt) ? 0 : startAt)], + ["maxResults", String(maxResults)], + ["fields", ISSUE_FIELDS], + ]; + + const request = HttpClientRequest.get(`${baseUrl}/rest/api/2/search`, { urlParams }).pipe( + HttpClientRequest.setHeaders(headers), + ); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => new WorkSourceTransientError({ message: `Jira HTTP network error: ${String(cause)}` }), + ), + ); + + const { status, headers: respHeaders } = response; + if (status === 429) { + return yield* new WorkSourceRateLimitError({ retryAfterMs: parseJiraRateLimitRetryMs(respHeaders) }); + } + if (status === 401 || status === 403) { + return yield* new WorkSourceAuthError({ connectionRef: input.connectionRef }); + } + if (status < 200 || status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* new WorkSourceTransientError({ + message: `Jira API returned HTTP ${status}: ${body.trim() || "(no body)"}`, + }); + } + + const raw = yield* response.json.pipe( + Effect.mapError( + (cause) => new WorkSourceTransientError({ message: `Failed to parse Jira JSON: ${String(cause)}` }), + ), + ); + + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return yield* new WorkSourceTransientError({ message: "Jira /search response was not an object" }); + } + const search = raw as RawJiraSearch; + const rawIssues = Array.isArray(search.issues) ? search.issues : []; + const items = rawIssues.map((i) => mapIssue(i, baseUrl)); + + const effectiveStartAt = Number.isNaN(startAt) ? 0 : startAt; + const total = typeof search.total === "number" ? search.total : effectiveStartAt + items.length; + const nextStart = effectiveStartAt + items.length; + const hasMore = items.length > 0 && nextStart < total; + + const page: WorkSourcePage = { + items, + ...(hasMore && { nextPageToken: String(nextStart) }), + }; + return page; + }), + + getItem: (input) => + Effect.gen(function* () { + const auth = yield* connectionStore.getConnectionAuth(input.connectionRef, "jira"); + const baseUrl = yield* requireBaseUrl(auth); + const headers = yield* buildHeaders(auth); + + const request = HttpClientRequest.get( + `${baseUrl}/rest/api/2/issue/${encodeURIComponent(input.externalId)}`, + { urlParams: [["fields", ISSUE_FIELDS]] }, + ).pipe(HttpClientRequest.setHeaders(headers)); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ message: `Jira HTTP network error (getItem): ${String(cause)}` }), + ), + ); + + const { status, headers: respHeaders } = response; + if (status === 404) return null; + if (status === 429) { + return yield* new WorkSourceRateLimitError({ retryAfterMs: parseJiraRateLimitRetryMs(respHeaders) }); + } + if (status === 401 || status === 403) { + return yield* new WorkSourceAuthError({ connectionRef: input.connectionRef }); + } + if (status < 200 || status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* new WorkSourceTransientError({ + message: `Jira API returned HTTP ${status} (getItem): ${body.trim() || "(no body)"}`, + }); + } + + const raw = yield* response.json.pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ message: `Failed to parse Jira getItem JSON: ${String(cause)}` }), + ), + ); + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return yield* new WorkSourceTransientError({ message: "Jira /issue response was not an object" }); + } + const candidate = raw as unknown as RawJiraIssue; + if ( + typeof candidate.key !== "string" || + typeof candidate.fields !== "object" || + candidate.fields === null + ) { + return yield* new WorkSourceTransientError({ + message: "Jira /issue response missing key or fields", + }); + } + return mapIssue(candidate, baseUrl); + }), + + toImportableView: ({ selector, item }): ImportableViewParts => { + const s = selector as { projectKey?: string }; + return { displayRef: item.externalId, container: s.projectKey ?? "?" }; + }, + + viewer: ({ connectionRef }) => + Effect.gen(function* () { + const auth = yield* connectionStore.getConnectionAuth(connectionRef, "jira"); + const baseUrl = yield* requireBaseUrl(auth); + const headers = yield* buildHeaders(auth); + const request = HttpClientRequest.get(`${baseUrl}/rest/api/2/myself`).pipe( + HttpClientRequest.setHeaders(headers), + ); + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => new WorkSourceTransientError({ message: `Jira viewer network error: ${String(cause)}` }), + ), + ); + if (response.status !== 200) return null; + const body = yield* response.json.pipe(Effect.orElseSucceed(() => ({}) as unknown)); + const b = body as { + accountId?: unknown; + name?: unknown; + key?: unknown; + displayName?: unknown; + emailAddress?: unknown; + }; + const asStr = (v: unknown) => (typeof v === "string" && v.length > 0 ? v : undefined); + const id = asStr(b.accountId) ?? asStr(b.name) ?? asStr(b.key); + if (id === undefined) return null; + const aliases = [b.accountId, b.displayName, b.name, b.key, b.emailAddress].filter( + (v): v is string => typeof v === "string" && v.length > 0, + ); + const viewer: Viewer = { id, aliases }; + return viewer; + }), + }; + + return provider; +}); + +export const JiraProviderLive: Layer.Layer< + JiraProviderTag, + never, + HttpClient.HttpClient | WorkSourceConnectionStore +> = Layer.effect(JiraProviderTag, make); diff --git a/apps/server/src/workflow/Layers/MockAcpProvider.ts b/apps/server/src/workflow/Layers/MockAcpProvider.ts new file mode 100644 index 00000000000..4a44ed32c67 --- /dev/null +++ b/apps/server/src/workflow/Layers/MockAcpProvider.ts @@ -0,0 +1,94 @@ +import type { TurnId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { TurnProjectionPort } from "../Services/TurnStateReader.ts"; + +type MockTurnState = "running" | "completed" | "error"; + +interface MockTurn { + readonly threadId: string; + readonly turnId: TurnId; + readonly state: MockTurnState; +} + +interface MockAcpState { + readonly startedCount: number; + readonly turns: ReadonlyMap; +} + +export interface MockAcpProviderShape { + readonly startedCount: Effect.Effect; + readonly completeAllRunning: () => Effect.Effect; +} + +export class MockAcpProvider extends Context.Service()( + "t3/workflow/Layers/MockAcpProvider", +) {} + +export const MockAcpProviderLive = Layer.unwrap( + Effect.gen(function* () { + const state = yield* Ref.make({ + startedCount: 0, + turns: new Map(), + }); + + const providerTurnPort = ProviderTurnPort.of({ + ensureTurnStarted: (request) => + Ref.modify(state, (current) => { + const existing = current.turns.get(request.threadId as string); + if (existing) { + return [{ turnId: existing.turnId }, current] as const; + } + + const turn = { + threadId: request.threadId as string, + turnId: `turn-${request.threadId}` as TurnId, + state: "running" as const, + } satisfies MockTurn; + const turns = new Map(current.turns); + turns.set(turn.threadId, turn); + return [ + { turnId: turn.turnId }, + { startedCount: current.startedCount + 1, turns }, + ] as const; + }), + }); + + const turnProjectionPort = TurnProjectionPort.of({ + getLatestTurnState: (threadId) => + Ref.get(state).pipe( + Effect.map((current) => { + const turn = current.turns.get(threadId as string); + return { + state: turn?.state ?? "pending", + completed: turn?.state === "completed" || turn?.state === "error", + }; + }), + ), + }); + + const mock = MockAcpProvider.of({ + startedCount: Ref.get(state).pipe(Effect.map((current) => current.startedCount)), + completeAllRunning: () => + Ref.update(state, (current) => { + const turns = new Map(current.turns); + for (const [threadId, turn] of turns) { + if (turn.state === "running") { + turns.set(threadId, { ...turn, state: "completed" }); + } + } + return { ...current, turns }; + }), + }); + + return Layer.mergeAll( + Layer.succeed(MockAcpProvider, mock), + Layer.succeed(ProviderTurnPort, providerTurnPort), + Layer.succeed(TurnProjectionPort, turnProjectionPort), + ); + }), +); diff --git a/apps/server/src/workflow/Layers/PredicateEvaluator.test.ts b/apps/server/src/workflow/Layers/PredicateEvaluator.test.ts new file mode 100644 index 00000000000..e5826e93209 --- /dev/null +++ b/apps/server/src/workflow/Layers/PredicateEvaluator.test.ts @@ -0,0 +1,69 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { PredicateEvaluationError, PredicateEvaluator } from "../Services/PredicateEvaluator.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; + +const layer = it.layer(PredicateEvaluatorLive); + +layer("PredicateEvaluator", (it) => { + it.effect("evaluates allowlisted JSONLogic and reports referenced paths", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const evaluation = yield* evaluator.evaluate( + { + and: [ + { "==": [{ var: "steps.tests.exitCode" }, 0] }, + { in: ["pass", { var: "steps.review.output.verdict" }] }, + { "!=": [{ var: "pipeline.result" }, "failure"] }, + { "!": { var: "steps.review.output.blocked" } }, + ], + }, + { + pipeline: { result: "success" }, + status: "running", + steps: { + tests: { exitCode: 0, status: "completed" }, + review: { status: "completed", output: { verdict: "pass", blocked: false } }, + }, + }, + ); + + assert.equal(evaluation.result, true); + assert.deepEqual(evaluation.matchedPaths, [ + "steps.tests.exitCode", + "steps.review.output.verdict", + "pipeline.result", + "steps.review.output.blocked", + ]); + }), + ); + + it.effect("rejects unsupported operators before evaluation", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const result = yield* Effect.exit(evaluator.evaluate({ cat: ["x", "y"] }, {})); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue( + result.cause.toString().includes(PredicateEvaluationError.name) || + result.cause.toString().includes("unsupported JSONLogic operator"), + ); + } + }), + ); + + it.effect("rejects var defaults and non-string var paths", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const withDefault = yield* Effect.exit( + evaluator.evaluate({ "==": [{ var: ["status", "idle"] }, "idle"] }, {}), + ); + const nonString = yield* Effect.exit(evaluator.evaluate({ var: 123 }, {})); + + assert.equal(withDefault._tag, "Failure"); + assert.equal(nonString._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/PredicateEvaluator.ts b/apps/server/src/workflow/Layers/PredicateEvaluator.ts new file mode 100644 index 00000000000..ef7617f1ce8 --- /dev/null +++ b/apps/server/src/workflow/Layers/PredicateEvaluator.ts @@ -0,0 +1,53 @@ +import { createRequire } from "node:module"; + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { + PredicateEvaluationError, + PredicateEvaluator, + type PredicateEvaluatorShape, +} from "../Services/PredicateEvaluator.ts"; +import { inspectJsonLogicRule } from "../jsonLogicRule.ts"; + +interface JsonLogicModule { + readonly apply: (rule: unknown, data?: unknown) => unknown; + readonly truthy: (value: unknown) => boolean; +} + +const require = createRequire(import.meta.url); +const jsonLogic = require("json-logic-js") as JsonLogicModule; +const isPredicateEvaluationError = Schema.is(PredicateEvaluationError); + +const makePredicateError = (message: string, cause?: unknown) => + new PredicateEvaluationError({ + message, + ...(cause === undefined ? {} : { cause }), + }); + +const evaluateRule = (rule: unknown, context: unknown) => + Effect.try({ + try: () => { + const inspection = inspectJsonLogicRule(rule); + const issue = inspection.issues[0]; + if (issue !== undefined) { + throw makePredicateError(issue.message); + } + const raw = jsonLogic.apply(rule, context); + return { + result: jsonLogic.truthy(raw), + matchedPaths: inspection.variablePaths, + }; + }, + catch: (cause) => + isPredicateEvaluationError(cause) + ? cause + : makePredicateError("JSONLogic evaluation failed", cause), + }); + +const make = Effect.succeed({ + evaluate: evaluateRule, +} satisfies PredicateEvaluatorShape); + +export const PredicateEvaluatorLive = Layer.effect(PredicateEvaluator, make); diff --git a/apps/server/src/workflow/Layers/ProjectScriptTrust.test.ts b/apps/server/src/workflow/Layers/ProjectScriptTrust.test.ts new file mode 100644 index 00000000000..fe649a88079 --- /dev/null +++ b/apps/server/src/workflow/Layers/ProjectScriptTrust.test.ts @@ -0,0 +1,27 @@ +import { assert, it } from "@effect/vitest"; +import { ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProjectScriptTrust } from "../Services/ProjectScriptTrust.ts"; +import { ProjectScriptTrustLive } from "./ProjectScriptTrust.ts"; + +const layer = it.layer(Layer.provide(ProjectScriptTrustLive, SqlitePersistenceMemory)); + +layer("ProjectScriptTrustLive", (it) => { + it.effect("persists per-project trust grants and revocations", () => + Effect.gen(function* () { + const trust = yield* ProjectScriptTrust; + const projectId = ProjectId.make("project-trust"); + + assert.isFalse(yield* trust.isTrusted(projectId)); + + yield* trust.setTrusted(projectId, true); + assert.isTrue(yield* trust.isTrusted(projectId)); + + yield* trust.setTrusted(projectId, false); + assert.isFalse(yield* trust.isTrusted(projectId)); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/ProjectScriptTrust.ts b/apps/server/src/workflow/Layers/ProjectScriptTrust.ts new file mode 100644 index 00000000000..52f0fbe7aef --- /dev/null +++ b/apps/server/src/workflow/Layers/ProjectScriptTrust.ts @@ -0,0 +1,52 @@ +import type { ProjectId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ProjectScriptTrust, + type ProjectScriptTrustShape, +} from "../Services/ProjectScriptTrust.ts"; + +const toTrustError = (cause: unknown) => + new WorkflowEventStoreError({ message: "project script trust failed", cause }); + +const wrap = (effect: Effect.Effect) => effect.pipe(Effect.mapError(toTrustError)); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const isTrusted: ProjectScriptTrustShape["isTrusted"] = (projectId: ProjectId) => + wrap(sql<{ readonly trusted: number }>` + SELECT 1 AS trusted + FROM workflow_project_trust + WHERE project_id = ${projectId} + LIMIT 1 + `).pipe(Effect.map((rows) => rows.length > 0)); + + const setTrusted: ProjectScriptTrustShape["setTrusted"] = (projectId, trusted) => { + if (!trusted) { + return wrap(sql` + DELETE FROM workflow_project_trust + WHERE project_id = ${projectId} + `).pipe(Effect.asVoid); + } + + return Effect.gen(function* () { + const trustedAt = DateTime.formatIso(yield* DateTime.now); + yield* wrap(sql` + INSERT INTO workflow_project_trust (project_id, trusted_at) + VALUES (${projectId}, ${trustedAt}) + ON CONFLICT(project_id) DO UPDATE SET + trusted_at = excluded.trusted_at + `); + }).pipe(Effect.asVoid); + }; + + return { isTrusted, setTrusted } satisfies ProjectScriptTrustShape; +}); + +export const ProjectScriptTrustLive = Layer.effect(ProjectScriptTrust, make); diff --git a/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.test.ts b/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.test.ts new file mode 100644 index 00000000000..cf018055cae --- /dev/null +++ b/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.test.ts @@ -0,0 +1,70 @@ +import { assert, it } from "@effect/vitest"; +import type { ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { ProjectionSnapshotQueryShape } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + ProjectWorkspaceResolver, + ProjectWorkspaceResolverError, +} from "../Services/ProjectWorkspaceResolver.ts"; +import { ProjectWorkspaceResolverLive } from "./ProjectWorkspaceResolver.ts"; + +const projectId = "project-1" as ProjectId; + +const queryLayer = (getProjectShellById: ProjectionSnapshotQueryShape["getProjectShellById"]) => + Layer.succeed(ProjectionSnapshotQuery, { + getProjectShellById, + } as unknown as ProjectionSnapshotQueryShape); + +it.effect("ProjectWorkspaceResolver resolves a project workspaceRoot", () => + Effect.gen(function* () { + const layer = ProjectWorkspaceResolverLive.pipe( + Layer.provide( + queryLayer(() => + Effect.succeed( + Option.some({ + id: projectId, + title: "Project", + workspaceRoot: "/tmp/t3-project", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-07T00:00:00.000Z" as never, + updatedAt: "2026-06-07T00:00:00.000Z" as never, + }), + ), + ), + ), + ); + + const workspaceRoot = yield* Effect.gen(function* () { + const resolver = yield* ProjectWorkspaceResolver; + return yield* resolver.resolve(projectId); + }).pipe(Effect.provide(layer)); + + assert.equal(workspaceRoot, "/tmp/t3-project"); + }), +); + +it.effect("ProjectWorkspaceResolver fails with a typed error for an unknown project", () => + Effect.gen(function* () { + const layer = ProjectWorkspaceResolverLive.pipe( + Layer.provide(queryLayer(() => Effect.succeed(Option.none()))), + ); + + const result = yield* Effect.exit( + Effect.gen(function* () { + const resolver = yield* ProjectWorkspaceResolver; + return yield* resolver.resolve("missing-project" as ProjectId); + }).pipe(Effect.provide(layer)), + ); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(String(result.cause).includes(ProjectWorkspaceResolverError.name)); + } + }), +); diff --git a/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.ts b/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.ts new file mode 100644 index 00000000000..c7f0769da3e --- /dev/null +++ b/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + ProjectWorkspaceResolver, + ProjectWorkspaceResolverError, + type ProjectWorkspaceResolverShape, +} from "../Services/ProjectWorkspaceResolver.ts"; + +const toResolverError = (message: string) => (cause: unknown) => + new ProjectWorkspaceResolverError({ message, cause }); + +const make = Effect.gen(function* () { + const projects = yield* ProjectionSnapshotQuery; + + const resolve: ProjectWorkspaceResolverShape["resolve"] = (projectId) => + projects.getProjectShellById(projectId).pipe( + Effect.mapError(toResolverError(`Failed to resolve workspace for project ${projectId}`)), + Effect.flatMap((project) => + Option.match(project, { + onNone: () => + Effect.fail( + new ProjectWorkspaceResolverError({ + message: `Project ${projectId} was not found`, + }), + ), + onSome: (shell) => Effect.succeed(shell.workspaceRoot as string), + }), + ), + ); + + return { resolve } satisfies ProjectWorkspaceResolverShape; +}); + +export const ProjectWorkspaceResolverLive = Layer.effect(ProjectWorkspaceResolver, make); diff --git a/apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts new file mode 100644 index 00000000000..83ac0c2c724 --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts @@ -0,0 +1,393 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { + ProviderDispatchOutbox, + ProviderTurnPort, + type DispatchRequest, +} from "../Services/ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { ProviderDispatchOutboxLive } from "./ProviderDispatchOutbox.ts"; + +const request = { + dispatchId: "dispatch-1" as never, + ticketId: "ticket-1" as never, + stepRunId: "step-run-1" as never, + threadId: "thread-1" as never, + providerInstance: "codex", + model: "gpt-5.5", + instruction: "Implement the next workflow step", + worktreePath: "/tmp/workflow-ticket-1", +} satisfies DispatchRequest; + +it.effect("starts provider dispatch idempotently and confirms from terminal turn state", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make(0); + const turnReads = yield* Ref.make(0); + + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => + Ref.update(providerCalls, (count) => count + 1).pipe( + Effect.as({ turnId: "turn-1" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => + Ref.updateAndGet(turnReads, (count) => count + 1).pipe( + Effect.map((count) => + count === 1 ? ({ _tag: "running" } as const) : ({ _tag: "completed" } as const), + ), + ), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + const sql = yield* SqlClient.SqlClient; + + yield* outbox.ensureStarted(request); + yield* outbox.ensureStarted(request); + + assert.equal(yield* Ref.get(providerCalls), 1); + + const started = yield* sql<{ readonly status: string; readonly turnId: string | null }>` + SELECT status, turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE dispatch_id = ${request.dispatchId} + `; + assert.equal(started[0]?.status, "started"); + assert.equal(started[0]?.turnId, "turn-1"); + + const terminalFiber = yield* Effect.forkChild( + outbox.awaitTerminal(request.dispatchId, request.threadId), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust("500 millis"); + const terminal = yield* Fiber.join(terminalFiber); + assert.deepEqual(terminal, { ok: true }); + + const confirmed = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = ${request.dispatchId} + `; + assert.equal(confirmed[0]?.status, "confirmed"); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("confirms the outbox row when the terminal wait times out", () => + Effect.gen(function* () { + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "running" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + const sql = yield* SqlClient.SqlClient; + + yield* outbox.ensureStarted(request); + + const terminalFiber = yield* Effect.forkChild( + outbox.awaitTerminal(request.dispatchId, request.threadId), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust("30 minutes"); + const terminal = yield* Fiber.join(terminalFiber); + assert.deepEqual(terminal, { + ok: false, + error: "turn did not reach a terminal state before timeout", + }); + + // The timed-out row must be settled so restart recovery never + // re-dispatches a step the pipeline already failed. + const confirmed = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = ${request.dispatchId} + `; + assert.equal(confirmed[0]?.status, "confirmed"); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("looks up dispatch thread and turn by step run", () => + Effect.gen(function* () { + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + + yield* outbox.ensureStarted(request); + + const dispatch = yield* outbox.getDispatchForStep(request.stepRunId); + assert.deepEqual(dispatch, { + threadId: "thread-1", + turnId: "turn-1", + }); + }).pipe(Effect.provide(layer)); + }), +); + +const agentOptions = [ + { id: "effort", value: "high" }, + { id: "thinking", value: true }, +]; + +it.effect("persists agent option selections as JSON on dispatch", () => + Effect.gen(function* () { + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + const sql = yield* SqlClient.SqlClient; + + yield* outbox.ensureStarted({ ...request, options: agentOptions }); + + const stored = yield* sql<{ readonly optionsJson: string | null }>` + SELECT options_json AS "optionsJson" + FROM workflow_dispatch_outbox + WHERE dispatch_id = ${request.dispatchId} + `; + const optionsJson = stored[0]?.optionsJson ?? null; + assert.isNotNull(optionsJson); + // @effect-diagnostics-next-line preferSchemaOverJson:off - test asserts the persisted JSON shape. + assert.deepEqual(JSON.parse(optionsJson!), agentOptions); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("replays persisted agent options to the provider on recovery", () => + Effect.gen(function* () { + const replayed = yield* Ref.make>([]); + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: (req: DispatchRequest) => + Ref.update(replayed, (all) => [...all, req]).pipe( + Effect.as({ turnId: "turn-1" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT INTO projection_board ( + board_id, project_id, name, workflow_file_path, workflow_version_hash, max_concurrent_tickets + ) + VALUES ('board-1', 'project-1', 'Board', '.t3/board.toml', 'hash-1', 1) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ( + ${request.ticketId}, 'board-1', 'Ticket', 'implement', 'active', + '2026-06-09T00:00:00.000Z', '2026-06-09T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, ticket_id, step_run_id, thread_id, + provider_instance, model, instruction, worktree_path, options_json, status, created_at + ) + VALUES ( + ${request.dispatchId}, ${request.ticketId}, ${request.stepRunId}, ${request.threadId}, + ${request.providerInstance}, ${request.model}, ${request.instruction}, ${request.worktreePath}, + '[{"id":"effort","value":"high"},{"id":"thinking","value":true}]', + 'pending', '2026-06-09T00:00:00.000Z' + ) + `; + + yield* outbox.recoverPending(); + + const all = yield* Ref.get(replayed); + assert.equal(all.length, 1); + assert.deepEqual(all[0]?.options, agentOptions); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("recovers dispatches without options as plain requests", () => + Effect.gen(function* () { + const replayed = yield* Ref.make>([]); + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: (req: DispatchRequest) => + Ref.update(replayed, (all) => [...all, req]).pipe( + Effect.as({ turnId: "turn-1" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT INTO projection_board ( + board_id, project_id, name, workflow_file_path, workflow_version_hash, max_concurrent_tickets + ) + VALUES ('board-1', 'project-1', 'Board', '.t3/board.toml', 'hash-1', 1) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ( + ${request.ticketId}, 'board-1', 'Ticket', 'implement', 'active', + '2026-06-09T00:00:00.000Z', '2026-06-09T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, ticket_id, step_run_id, thread_id, + provider_instance, model, instruction, worktree_path, options_json, status, created_at + ) + VALUES ( + ${request.dispatchId}, ${request.ticketId}, ${request.stepRunId}, ${request.threadId}, + ${request.providerInstance}, ${request.model}, ${request.instruction}, ${request.worktreePath}, + NULL, 'pending', '2026-06-09T00:00:00.000Z' + ) + `; + + yield* outbox.recoverPending(); + + const all = yield* Ref.get(replayed); + assert.equal(all.length, 1); + assert.equal(all[0]?.options, undefined); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("deletes pending dispatches whose ticket projection no longer exists", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make(0); + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => + Ref.update(providerCalls, (count) => count + 1).pipe( + Effect.as({ turnId: "turn-orphan" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-orphan', + 'ticket-orphan', + 'step-orphan', + 'thread-orphan', + 'codex', + 'gpt-5.5', + 'do not start', + '/tmp/orphan', + 'pending', + '2026-06-07T00:00:00.000Z' + ) + `; + + yield* outbox.recoverPending(); + + assert.equal(yield* Ref.get(providerCalls), 0); + const remaining = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE dispatch_id = 'dispatch-orphan' + `; + assert.equal(remaining[0]?.count, 0); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts new file mode 100644 index 00000000000..d8d8b4edc8b --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts @@ -0,0 +1,481 @@ +import { + ProviderInstanceId, + ProviderOptionSelections, + TrimmedNonEmptyString, + type ModelSelection, + type ProviderSendTurnInput, + type ProviderSessionStartInput, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ProviderDispatchOutbox, + ProviderTurnPort, + type DispatchRequest, + type ProviderDispatchTerminalResult, + type ProviderDispatchOutboxShape, + type ProviderTurnPortShape, +} from "../Services/ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); +const TERMINAL_WAIT_TIMEOUT = Duration.minutes(30); + +const toDispatchError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toDispatchError("dispatch op failed"))); + +interface DispatchStatusRow { + readonly status: "pending" | "started" | "confirmed"; + readonly turnId: string | null; +} + +interface RecoverDispatchRow extends Omit< + DispatchRequest, + "options" | "projectId" | "threadTitle" | "runtimeMode" +> { + readonly status: "pending" | "started" | "confirmed"; + readonly optionsJson: string | null; + readonly projectId: string | null; + readonly threadTitle: string | null; + readonly runtimeMode: string | null; +} + +const dispatchOptionsJson = Schema.fromJsonString(ProviderOptionSelections); +const encodeDispatchOptionsJson = Schema.encodeEffect(dispatchOptionsJson); +const decodeDispatchOptionsJson = Schema.decodeEffect(dispatchOptionsJson); + +// Tolerant decode: an unparseable/legacy row should not abort recovery of the +// remaining pending dispatches, so a decode failure degrades to "no options". +const recoverDispatchRowToRequest = (row: RecoverDispatchRow): Effect.Effect => + Effect.gen(function* () { + const options = + row.optionsJson === null || row.optionsJson.length === 0 + ? undefined + : yield* decodeDispatchOptionsJson(row.optionsJson).pipe( + Effect.orElseSucceed(() => undefined), + ); + const runtimeMode = + row.runtimeMode === "approval-required" || + row.runtimeMode === "auto-accept-edits" || + row.runtimeMode === "full-access" + ? row.runtimeMode + : undefined; + return { + dispatchId: row.dispatchId, + ticketId: row.ticketId, + stepRunId: row.stepRunId, + threadId: row.threadId, + providerInstance: row.providerInstance, + model: row.model, + instruction: row.instruction, + worktreePath: row.worktreePath, + ...(options === undefined ? {} : { options }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + ...(row.threadTitle === null ? {} : { threadTitle: row.threadTitle }), + ...(runtimeMode === undefined ? {} : { runtimeMode }), + }; + }); + +interface StepDispatchRow { + readonly dispatchId: string; +} + +interface DispatchForStepRow { + readonly threadId: string; + readonly turnId: string | null; +} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const provider = yield* ProviderTurnPort; + const turns = yield* TurnStateReader; + + const getDispatchStatus = (dispatchId: string) => + wrapSql(sql` + SELECT + status, + turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE dispatch_id = ${dispatchId} + `).pipe(Effect.map((rows) => rows[0] ?? null)); + + const confirmStep: ProviderDispatchOutboxShape["confirmStep"] = (stepRunId) => + Effect.gen(function* () { + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE step_run_id = ${stepRunId} + AND status != 'confirmed' + `); + }); + + const ensureStarted: ProviderDispatchOutboxShape["ensureStarted"] = (req) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + const optionsJson = + req.options === undefined + ? null + : yield* encodeDispatchOptionsJson(req.options).pipe( + Effect.mapError(toDispatchError("dispatch options encode failed")), + ); + yield* wrapSql(sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + options_json, + project_id, + thread_title, + runtime_mode, + status, + created_at + ) + VALUES ( + ${req.dispatchId}, + ${req.ticketId}, + ${req.stepRunId}, + ${req.threadId}, + ${req.providerInstance}, + ${req.model}, + ${req.instruction}, + ${req.worktreePath}, + ${optionsJson}, + ${req.projectId ?? null}, + ${req.threadTitle ?? null}, + ${req.runtimeMode ?? null}, + 'pending', + ${createdAt} + ) + ON CONFLICT(dispatch_id) DO NOTHING + `); + + const status = yield* getDispatchStatus(req.dispatchId); + if ( + (status?.status === "started" || status?.status === "confirmed") && + status.turnId !== null + ) { + return { turnId: status.turnId as never }; + } + + const { turnId } = yield* provider.ensureTurnStarted(req); + const startedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'started', + turn_id = ${turnId}, + started_at = ${startedAt} + WHERE dispatch_id = ${req.dispatchId} + `); + return { turnId }; + }); + + const getDispatchForStep: ProviderDispatchOutboxShape["getDispatchForStep"] = (stepRunId) => + wrapSql(sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE step_run_id = ${stepRunId} + ORDER BY created_at DESC, dispatch_id DESC + LIMIT 1 + `).pipe( + Effect.map((rows) => { + const row = rows[0]; + if (!row || row.turnId === null) { + return null; + } + return { + threadId: row.threadId as never, + turnId: row.turnId as never, + }; + }), + ); + + const awaitTerminal: ProviderDispatchOutboxShape["awaitTerminal"] = (dispatchId, threadId) => { + const waitForTerminal: Effect.Effect = + Effect.gen(function* () { + let state = yield* turns.read(threadId); + while (state._tag === "running") { + yield* Effect.sleep("500 millis"); + state = yield* turns.read(threadId); + } + if (state._tag === "awaiting_user") { + return { + ok: false, + awaitingUser: true, + waitingReason: state.waitingReason, + providerThreadId: state.providerThreadId, + providerRequestId: state.providerRequestId, + providerResponseKind: state.providerResponseKind, + ...(state.providerQuestionId === undefined + ? {} + : { providerQuestionId: state.providerQuestionId }), + } satisfies ProviderDispatchTerminalResult; + } + + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE dispatch_id = ${dispatchId} + `); + + return state._tag === "completed" + ? ({ ok: true } satisfies ProviderDispatchTerminalResult) + : ({ ok: false, error: state.error } satisfies ProviderDispatchTerminalResult); + }); + + return waitForTerminal.pipe( + Effect.timeoutOption(TERMINAL_WAIT_TIMEOUT), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.gen(function* () { + // The pipeline treats this timeout as the step's terminal + // failure, so settle the outbox row too — otherwise restart + // recovery would re-dispatch/re-monitor a step the pipeline + // already routed on. + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE dispatch_id = ${dispatchId} + `); + return { + ok: false, + error: "turn did not reach a terminal state before timeout", + } satisfies ProviderDispatchTerminalResult; + }), + onSome: Effect.succeed, + }), + ), + ); + }; + + const awaitStepTerminal: ProviderDispatchOutboxShape["awaitStepTerminal"] = ( + stepRunId, + threadId, + ) => + Effect.gen(function* () { + const rows = yield* wrapSql(sql` + SELECT dispatch_id AS "dispatchId" + FROM workflow_dispatch_outbox + WHERE step_run_id = ${stepRunId} + ORDER BY created_at DESC, dispatch_id DESC + LIMIT 1 + `); + const dispatchId = rows[0]?.dispatchId; + if (!dispatchId) { + return yield* new WorkflowEventStoreError({ + message: `dispatch not found for step ${stepRunId}`, + }); + } + return yield* awaitTerminal(dispatchId as never, threadId); + }); + + const deleteOrphanDispatches = wrapSql(sql` + DELETE FROM workflow_dispatch_outbox + WHERE NOT EXISTS ( + SELECT 1 + FROM projection_ticket AS ticket + INNER JOIN projection_board AS board + ON board.board_id = ticket.board_id + WHERE ticket.ticket_id = workflow_dispatch_outbox.ticket_id + ) + `).pipe(Effect.asVoid); + + // A dispatch row is only worth restarting while its pipeline still owns the + // ticket: a manual move (or re-route) hands out a new lane entry token, and + // restarting the superseded dispatch would let a stale agent mutate the + // worktree after the user moved on. + const tombstoneStaleDispatches = Effect.gen(function* () { + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE status != 'confirmed' + AND EXISTS ( + SELECT 1 + FROM projection_step_run AS step + INNER JOIN projection_pipeline_run AS pipeline + ON pipeline.pipeline_run_id = step.pipeline_run_id + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = pipeline.ticket_id + WHERE step.step_run_id = workflow_dispatch_outbox.step_run_id + AND ( + ticket.current_lane_entry_token IS NULL + OR pipeline.lane_entry_token != ticket.current_lane_entry_token + ) + ) + `); + }); + + const recoverPending: ProviderDispatchOutboxShape["recoverPending"] = () => + Effect.gen(function* () { + yield* deleteOrphanDispatches; + yield* tombstoneStaleDispatches; + const rows = yield* wrapSql(sql` + SELECT + dispatch_id AS "dispatchId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId", + thread_id AS "threadId", + provider_instance AS "providerInstance", + model, + instruction, + worktree_path AS "worktreePath", + options_json AS "optionsJson", + project_id AS "projectId", + thread_title AS "threadTitle", + runtime_mode AS "runtimeMode", + status + FROM workflow_dispatch_outbox + WHERE status != 'confirmed' + `); + + yield* Effect.forEach( + rows, + (row) => + row.status === "pending" + ? recoverDispatchRowToRequest(row).pipe(Effect.flatMap(ensureStarted)) + : Effect.void, + { discard: true }, + ); + }); + + return { + confirmStep, + ensureStarted, + getDispatchForStep, + awaitTerminal, + awaitStepTerminal, + recoverPending, + } satisfies ProviderDispatchOutboxShape; +}); + +export const ProviderDispatchOutboxLive = Layer.effect(ProviderDispatchOutbox, make); + +export const ProviderTurnPortLive = Layer.effect( + ProviderTurnPort, + Effect.gen(function* () { + const providerSvc = yield* ProviderService; + const turns = yield* ProjectionTurnRepository; + const orchestration = yield* Effect.serviceOption(OrchestrationEngineService); + + // Provider runtime ingestion (and the orchestration decider behind it) + // only accepts events for threads that exist in the orchestration domain. + // Workflow dispatch threads are not user chat threads, so create them as + // hidden threads through the real command path before the session starts; + // without this every dispatch turn is invisible and never reaches a + // terminal state from the workflow's perspective. + const ensureHiddenThreadShell = (req: DispatchRequest, modelSelection: ModelSelection) => + Effect.gen(function* () { + if (req.projectId === undefined || Option.isNone(orchestration)) { + return; + } + const now = yield* nowIso; + yield* orchestration.value + .dispatch({ + type: "thread.create", + commandId: `workflow-thread-${req.threadId}` as never, + threadId: req.threadId, + projectId: req.projectId as never, + title: req.threadTitle ?? "Workflow dispatch", + modelSelection, + runtimeMode: req.runtimeMode ?? "full-access", + interactionMode: "default", + branch: null, + worktreePath: req.worktreePath as never, + createdAt: now as never, + hidden: true, + }) + .pipe( + Effect.catchCause((cause) => { + // Re-dispatch after recovery hits the already-exists invariant — + // that one is a benign no-op. Anything else means the provider + // session would run invisibly, so fail the dispatch loudly. + if ( + Cause.squash(cause) instanceof Error && + String(Cause.squash(cause)).includes("already exists") + ) { + return Effect.void; + } + return Effect.logWarning("workflow thread create failed", { cause }).pipe( + Effect.andThen( + Effect.fail( + new WorkflowEventStoreError({ + message: "workflow thread create failed", + cause: Cause.squash(cause), + }), + ), + ), + ); + }), + ); + }).pipe(Effect.mapError(toDispatchError("workflow thread create failed"))); + + const ensureTurnStarted: ProviderTurnPortShape["ensureTurnStarted"] = (req) => + Effect.gen(function* () { + const existingTurns = yield* turns + .listByThreadId({ threadId: req.threadId }) + .pipe(Effect.orElseSucceed(() => [])); + const existingTurn = existingTurns.findLast( + (turn) => turn.turnId !== null && (turn.state === "pending" || turn.state === "running"), + ); + if (existingTurn?.turnId !== undefined && existingTurn.turnId !== null) { + return { turnId: existingTurn.turnId }; + } + + const providerInstanceId = ProviderInstanceId.make(req.providerInstance); + const modelSelection = { + instanceId: providerInstanceId, + model: TrimmedNonEmptyString.make(req.model), + ...(req.options === undefined ? {} : { options: req.options }), + }; + yield* ensureHiddenThreadShell(req, modelSelection); + const sessionInput = { + threadId: req.threadId, + providerInstanceId, + cwd: TrimmedNonEmptyString.make(req.worktreePath), + modelSelection, + runtimeMode: req.runtimeMode ?? "full-access", + } satisfies ProviderSessionStartInput; + const sendInput = { + threadId: req.threadId, + input: TrimmedNonEmptyString.make(req.instruction), + modelSelection, + } satisfies ProviderSendTurnInput; + + yield* providerSvc.startSession(req.threadId, sessionInput); + const turn = yield* providerSvc.sendTurn(sendInput); + return { turnId: turn.turnId }; + }).pipe(Effect.mapError(toDispatchError("provider start failed"))); + + return { ensureTurnStarted } satisfies ProviderTurnPortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/ProviderResponsePort.test.ts b/apps/server/src/workflow/Layers/ProviderResponsePort.test.ts new file mode 100644 index 00000000000..e7314e52b4d --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderResponsePort.test.ts @@ -0,0 +1,94 @@ +import { assert, it } from "@effect/vitest"; +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProviderResponsePort } from "../Services/ProviderResponsePort.ts"; +import { ProviderResponsePortLive } from "./ProviderResponsePort.ts"; + +it.effect("ProviderResponsePortLive keys user-input answers by the awaiting question id", () => + Effect.gen(function* () { + const userInputResponses = yield* Ref.make>([]); + const providerLayer = Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.die("unused"), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: (input) => Ref.update(userInputResponses, (calls) => [...calls, input]), + stopSession: () => Effect.die("unused"), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + }); + + const program = Effect.gen(function* () { + const port = yield* ProviderResponsePort; + yield* port.respond({ + threadId: ThreadId.make("thread-ticket-answer"), + requestId: ApprovalRequestId.make("request-ticket-answer"), + responseKind: "user-input", + approved: true, + questionId: "Which API should I use?", + text: "Use the sandbox endpoint.", + } as never); + }); + + yield* program.pipe( + Effect.provide(ProviderResponsePortLive.pipe(Layer.provide(providerLayer))), + ); + + assert.deepEqual(yield* Ref.get(userInputResponses), [ + { + threadId: "thread-ticket-answer", + requestId: "request-ticket-answer", + answers: { + "Which API should I use?": "Use the sandbox endpoint.", + }, + }, + ]); + }), +); + +it.effect("ProviderResponsePortLive rejects text user-input answers without a question id", () => + Effect.gen(function* () { + const userInputResponses = yield* Ref.make>([]); + const providerLayer = Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.die("unused"), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: (input) => Ref.update(userInputResponses, (calls) => [...calls, input]), + stopSession: () => Effect.die("unused"), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + }); + + const program = Effect.gen(function* () { + const port = yield* ProviderResponsePort; + const error = yield* Effect.flip( + port.respond({ + threadId: ThreadId.make("thread-ticket-answer-missing-question"), + requestId: ApprovalRequestId.make("request-ticket-answer-missing-question"), + responseKind: "user-input", + approved: true, + text: "Use the sandbox endpoint.", + } as never), + ); + assert.include(error.message, "question id"); + }); + + yield* program.pipe( + Effect.provide(ProviderResponsePortLive.pipe(Layer.provide(providerLayer))), + ); + + assert.deepEqual(yield* Ref.get(userInputResponses), []); + }), +); diff --git a/apps/server/src/workflow/Layers/ProviderResponsePort.ts b/apps/server/src/workflow/Layers/ProviderResponsePort.ts new file mode 100644 index 00000000000..d205d72d455 --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderResponsePort.ts @@ -0,0 +1,55 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ProviderResponsePort, + type ProviderResponsePortShape, +} from "../Services/ProviderResponsePort.ts"; + +const toResponseError = (cause: unknown) => + new WorkflowEventStoreError({ message: "provider response failed", cause }); + +export const ProviderResponsePortLive = Layer.effect( + ProviderResponsePort, + Effect.gen(function* () { + const provider = yield* ProviderService; + + const respond: ProviderResponsePortShape["respond"] = (input) => { + if (input.responseKind === "request") { + return provider + .respondToRequest({ + threadId: input.threadId, + requestId: input.requestId, + decision: input.approved ? "accept" : "decline", + }) + .pipe(Effect.mapError(toResponseError)); + } + + if ( + input.text !== undefined && + (input.questionId === undefined || input.questionId.trim().length === 0) + ) { + return Effect.fail( + new WorkflowEventStoreError({ + message: "provider user-input text response requires a question id", + }), + ); + } + + return provider + .respondToUserInput({ + threadId: input.threadId, + requestId: input.requestId, + answers: + input.questionId === undefined || input.text === undefined + ? {} + : { [input.questionId]: input.text }, + }) + .pipe(Effect.mapError(toResponseError)); + }; + + return { respond } satisfies ProviderResponsePortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/ProviderTurnPort.test.ts b/apps/server/src/workflow/Layers/ProviderTurnPort.test.ts new file mode 100644 index 00000000000..a91074aaa63 --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderTurnPort.test.ts @@ -0,0 +1,171 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import type { + ProviderSendTurnInput, + ProviderSessionStartInput, + ThreadId, +} from "@t3tools/contracts"; + +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../orchestration/Services/OrchestrationEngine.ts"; +import { + ProjectionTurnRepository, + type ProjectionTurnRepositoryShape, +} from "../../persistence/Services/ProjectionTurns.ts"; +import { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; +import { ProviderTurnPort, type DispatchRequest } from "../Services/ProviderDispatchOutbox.ts"; +import { ProviderTurnPortLive } from "./ProviderDispatchOutbox.ts"; + +const baseRequest = { + dispatchId: "dispatch-1" as never, + ticketId: "ticket-1" as never, + stepRunId: "step-run-1" as never, + threadId: "thread-1" as never, + providerInstance: "claudeAgent", + model: "claude-opus-4-6", + instruction: "Do the workflow step", + worktreePath: "/tmp/workflow-ticket-1", +} satisfies DispatchRequest; + +interface Captured { + readonly start: Ref.Ref; + readonly send: Ref.Ref; + readonly commands?: Array>; +} + +const makeLayer = (captured: Captured) => + ProviderTurnPortLive.pipe( + Layer.provideMerge( + Layer.succeed(OrchestrationEngineService, { + dispatch: (command: Record) => + Effect.sync(() => { + captured.commands?.push(command); + return { sequence: 1 }; + }), + } as unknown as OrchestrationEngineShape), + ), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: (_threadId: ThreadId, input: ProviderSessionStartInput) => + Ref.set(captured.start, input).pipe( + Effect.as({ + provider: "claudeAgent", + status: "ready", + runtimeMode: "full-access", + threadId: input.threadId, + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + }), + ), + sendTurn: (input: ProviderSendTurnInput) => + Ref.set(captured.send, input).pipe( + Effect.as({ threadId: input.threadId, turnId: "turn-1" }), + ), + } as unknown as ProviderServiceShape), + ), + Layer.provideMerge( + Layer.succeed(ProjectionTurnRepository, { + listByThreadId: () => Effect.succeed([]), + } as unknown as ProjectionTurnRepositoryShape), + ), + ); + +it.effect("forwards agent option selections into the provider model selection", () => + Effect.gen(function* () { + const captured: Captured = { + start: yield* Ref.make(null), + send: yield* Ref.make(null), + }; + const options = [ + { id: "effort", value: "high" }, + { id: "thinking", value: true }, + ] as const; + + yield* Effect.gen(function* () { + const port = yield* ProviderTurnPort; + yield* port.ensureTurnStarted({ ...baseRequest, options }); + }).pipe(Effect.provide(makeLayer(captured))); + + const send = yield* Ref.get(captured.send); + const start = yield* Ref.get(captured.start); + assert.deepEqual(send?.modelSelection?.options, options); + assert.deepEqual(start?.modelSelection?.options, options); + }), +); + +it.effect("creates a hidden orchestration thread so ingestion projects the dispatch turn", () => + Effect.gen(function* () { + const commands: Array> = []; + const captured: Captured = { + start: yield* Ref.make(null), + send: yield* Ref.make(null), + commands, + }; + + yield* Effect.gen(function* () { + const port = yield* ProviderTurnPort; + yield* port.ensureTurnStarted({ + ...baseRequest, + projectId: "project-1", + threadTitle: "Workflow step review · ticket-1", + runtimeMode: "approval-required", + }); + }).pipe(Effect.provide(makeLayer(captured))); + + assert.equal(commands.length, 1); + const command = commands[0]; + assert.equal(command?.["type"], "thread.create"); + assert.equal(command?.["threadId"], "thread-1"); + assert.equal(command?.["projectId"], "project-1"); + assert.equal(command?.["title"], "Workflow step review · ticket-1"); + assert.equal(command?.["hidden"], true); + assert.equal(command?.["runtimeMode"], "approval-required"); + const start = yield* Ref.get(captured.start); + assert.equal(start?.runtimeMode, "approval-required"); + }), +); + +it.effect("skips thread creation when no project id is provided", () => + Effect.gen(function* () { + const commands: Array> = []; + const captured: Captured = { + start: yield* Ref.make(null), + send: yield* Ref.make(null), + commands, + }; + + yield* Effect.gen(function* () { + const port = yield* ProviderTurnPort; + yield* port.ensureTurnStarted(baseRequest); + }).pipe(Effect.provide(makeLayer(captured))); + + assert.equal(commands.length, 0); + const start = yield* Ref.get(captured.start); + assert.equal(start?.runtimeMode, "full-access"); + }), +); + +it.effect("omits model selection options when the agent step has none", () => + Effect.gen(function* () { + const captured: Captured = { + start: yield* Ref.make(null), + send: yield* Ref.make(null), + }; + + yield* Effect.gen(function* () { + const port = yield* ProviderTurnPort; + yield* port.ensureTurnStarted(baseRequest); + }).pipe(Effect.provide(makeLayer(captured))); + + const send = yield* Ref.get(captured.send); + assert.equal(send?.modelSelection?.options, undefined); + }), +); diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.test.ts b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts new file mode 100644 index 00000000000..b5f22899492 --- /dev/null +++ b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts @@ -0,0 +1,2476 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { StepExecutionContext } from "../Services/StepExecutor.ts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { + ProviderDispatchOutbox, + ProviderTurnPort, + type ProviderDispatchTerminalResult, +} from "../Services/ProviderDispatchOutbox.ts"; +import { ProjectScriptTrust } from "../Services/ProjectScriptTrust.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { ScriptCommandRunner, type ScriptCommandResult } from "../Services/ScriptCommandRunner.ts"; +import { SetupRunService } from "../Services/SetupRunService.ts"; +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { WorkflowAgentSessionStore } from "../Services/WorkflowAgentSessionStore.ts"; +import { agentKey } from "../agentSessionKey.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorktreePort } from "../Services/WorktreePort.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorktreeLeaseServiceLive } from "./WorktreeLeaseService.ts"; +import { ProviderDispatchOutboxLive } from "./ProviderDispatchOutbox.ts"; +import { RealStepExecutorLive } from "./RealStepExecutor.ts"; +import { ScriptStepExecutorLive } from "./ScriptStepExecutor.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { TicketMergeService } from "../Services/TicketMergeService.ts"; +import { TicketPullRequestService } from "../Services/TicketPullRequestService.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { ticketBaseRef } from "../ticketRefs.ts"; + +const context: StepExecutionContext = { + ticketId: "ticket-1" as never, + boardId: "board-1" as never, + pipelineRunId: "pipeline-run-1" as never, + stepRunId: "step-run-1" as never, + laneEntryToken: "lane-token-1" as never, + laneKey: "lane-1" as never, + laneStepKeys: ["agent-step"] as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: "Implement the ticket", + }, +}; + +const optionSelections = [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, +]; + +const optionsContext: StepExecutionContext = { + ...context, + ticketId: "ticket-options" as never, + stepRunId: "step-run-options" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + options: optionSelections as never, + }, + instruction: "Implement the ticket", + }, +}; + +const fileInstructionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-file-instruction" as never, + stepRunId: "step-run-file-instruction" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: { file: "missing-instruction.md" }, + }, +}; + +const unsafeFileInstructionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-unsafe-file-instruction" as never, + stepRunId: "step-run-unsafe-file-instruction" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: { file: "../t3-unsafe-instruction-escape.md" }, + }, +}; + +const symlinkFileInstructionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-symlink-file-instruction" as never, + stepRunId: "step-run-symlink-file-instruction" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: { file: "symlink-instruction.md" }, + }, +}; + +const normalFileInstructionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-normal-file-instruction" as never, + stepRunId: "step-run-normal-file-instruction" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: { file: "instructions/normal.md" }, + }, +}; + +const canonicalFileInstructionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-canonical-file-instruction" as never, + stepRunId: "step-run-canonical-file-instruction" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: { file: "instructions/link.md" }, + }, +}; + +const templateContext: StepExecutionContext = { + ...context, + ticketId: "ticket-template" as never, + stepRunId: "step-run-template" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: + "Work on {{ticket.title}} ({{ticket.id}}). Diff base: {{ ticket.baseRef }}. Desc:[{{ticket.description}}] Keep {{ticket.unknown}} and {{other}}.", + }, +}; + +const discussionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-discussion" as never, + stepRunId: "step-run-discussion" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: "Implement the ticket", + }, +}; + +const discussionPlaceholderContext: StepExecutionContext = { + ...context, + ticketId: "ticket-discussion-placeholder" as never, + stepRunId: "step-run-discussion-placeholder" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: "Implement the ticket.\nDiscussion:\n{{ticket.discussion}}", + }, +}; + +const scriptContext: StepExecutionContext = { + ...context, + ticketId: "ticket-script" as never, + stepRunId: "step-run-script" as never, + step: { + key: "script-step" as never, + type: "script", + run: "echo ready", + }, +}; + +const captureContext: StepExecutionContext = { + ...context, + ticketId: "ticket-capture" as never, + stepRunId: "step-run-capture" as never, + step: { + ...context.step, + captureOutput: true, + } as never, +}; + +const checkpointCalls: Array = []; +const setupCalls: Array = []; +const capturedReadInputs: Array = []; +const dispatchStartInputs: Array = []; +const preRef = "refs/t3/tickets/dC0x/steps/c3RlcC1ydW4tMQ/pre"; +const postRef = "refs/t3/tickets/dC0x/steps/c3RlcC1ydW4tMQ/post"; + +const mergeServiceCalls: Array = []; +const StubTicketMergeServiceLayer = Layer.succeed(TicketMergeService, { + merge: (input) => + Effect.sync(() => { + mergeServiceCalls.push(input); + return { _tag: "completed" } as const; + }), +}); + +const mergeContext: StepExecutionContext = { + ...context, + ticketId: "ticket-merge-step" as never, + stepRunId: "step-run-merge-step" as never, + step: { + key: "land" as never, + type: "merge", + target: "main" as never, + }, +}; + +const pullRequestServiceCalls: Array<{ readonly action: string; readonly input: unknown }> = []; +const StubTicketPullRequestServiceLayer = Layer.succeed(TicketPullRequestService, { + open: (input) => + Effect.sync(() => { + pullRequestServiceCalls.push({ action: "open", input }); + return { _tag: "completed", output: { prNumber: 1, url: "https://example/pull/1" } } as const; + }), + land: (input) => + Effect.sync(() => { + pullRequestServiceCalls.push({ action: "land", input }); + return { _tag: "completed" } as const; + }), +}); + +const openPrContext: StepExecutionContext = { + ...context, + ticketId: "ticket-open-pr" as never, + stepRunId: "step-run-open-pr" as never, + step: { + key: "open-pr" as never, + type: "pullRequest", + action: "open" as never, + }, +}; + +const landPrContext: StepExecutionContext = { + ...context, + ticketId: "ticket-land-pr" as never, + stepRunId: "step-run-land-pr" as never, + step: { + key: "land-pr" as never, + type: "pullRequest", + action: "land" as never, + }, +}; + +const realStepExecutorTestSupport = WorkflowFoundationLive.pipe( + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), +); + +const mk = ( + terminal: ProviderDispatchTerminalResult, + options: { + readonly projectTrusted?: boolean; + readonly scriptCommandResult?: ScriptCommandResult; + readonly fileSystemLayer?: Layer.Layer; + readonly capturedOutputForRead?: (input: { readonly threadId: string }) => unknown; + readonly providerServiceLayer?: Layer.Layer; + } = {}, +) => + it.layer( + RealStepExecutorLive.pipe( + Layer.provideMerge(options.providerServiceLayer ?? Layer.empty), + Layer.provideMerge( + Layer.succeed(WorktreePort, { + ensureWorktree: () => + Effect.succeed({ + repoRoot: "/tmp/repo-ticket-1", + worktreeRef: "wt-ticket-1", + path: "/tmp/wt-ticket-1", + projectId: "project-script", + }), + }), + ), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge( + Layer.succeed(SetupRunService, { + runSetup: (_ticketId, _worktreeRef, worktreePath) => + Effect.sync(() => { + setupCalls.push(worktreePath); + return { status: "completed", exitCode: 0 } as const; + }), + }), + ), + Layer.provideMerge(ScriptStepExecutorLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectScriptTrust, { + isTrusted: () => Effect.succeed(options.projectTrusted ?? true), + setTrusted: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ScriptCommandRunner, { + run: () => + Effect.succeed( + options.scriptCommandResult ?? { outcome: "exited", exitCode: 0, signal: null }, + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: (_ticketId, cwd) => + Effect.sync(() => { + checkpointCalls.push(`hasBaseline:${cwd}`); + return false; + }), + captureBaseline: (_ticketId, cwd) => + Effect.sync(() => { + checkpointCalls.push(`captureBaseline:${cwd}`); + return "refs/t3/tickets/dC0x/base"; + }), + captureStep: (_ticketId, stepRunId, cwd, kind) => + Effect.sync(() => { + checkpointCalls.push(`captureStep:${stepRunId}:${cwd}:${kind}`); + return kind === "pre" ? preRef : postRef; + }), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderDispatchOutbox, { + confirmStep: () => Effect.void, + ensureStarted: (input) => + Effect.sync(() => { + dispatchStartInputs.push(input); + return { turnId: "turn-stub" as never }; + }), + getDispatchForStep: () => Effect.succeed(null), + awaitTerminal: () => Effect.succeed(terminal), + awaitStepTerminal: () => Effect.succeed(terminal), + recoverPending: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: (input) => + options.capturedOutputForRead === undefined + ? Effect.void + : Effect.sync(() => + options.capturedOutputForRead?.({ threadId: input.threadId as string }), + ), + }), + ), + Layer.provideMerge(StubTicketMergeServiceLayer), + Layer.provideMerge(StubTicketPullRequestServiceLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(realStepExecutorTestSupport), + Layer.provideMerge(options.fileSystemLayer ?? Layer.empty), + Layer.provideMerge(NodeServices.layer), + ), + ); + +const captureLayer = (capturedOutput: unknown | undefined) => + it.layer( + RealStepExecutorLive.pipe( + Layer.provideMerge( + Layer.succeed(WorktreePort, { + ensureWorktree: () => + Effect.succeed({ + repoRoot: "/tmp/repo-ticket-1", + worktreeRef: "wt-ticket-1", + path: "/tmp/wt-ticket-1", + projectId: "project-script", + }), + }), + ), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge( + Layer.succeed(SetupRunService, { + runSetup: () => Effect.succeed({ status: "completed", exitCode: 0 }), + }), + ), + Layer.provideMerge(ScriptStepExecutorLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectScriptTrust, { + isTrusted: () => Effect.succeed(true), + setTrusted: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ScriptCommandRunner, { + run: () => Effect.succeed({ outcome: "exited", exitCode: 0, signal: null }), + }), + ), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: () => Effect.succeed(false), + captureBaseline: () => Effect.succeed("refs/t3/tickets/dC0x/base"), + captureStep: (_ticketId, _stepRunId, _cwd, kind) => + Effect.succeed(kind === "pre" ? preRef : postRef), + }), + ), + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.effect( + ProviderTurnPort, + Effect.gen(function* () { + const turns = yield* ProjectionTurnRepository; + const messages = yield* ProjectionThreadMessageRepository; + return ProviderTurnPort.of({ + ensureTurnStarted: (req) => + Effect.gen(function* () { + yield* turns.upsertByTurnId({ + threadId: req.threadId, + turnId: "turn-capture" as never, + pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: "message-capture" as never, + state: "completed", + requestedAt: "2026-06-07T00:00:00.000Z" as never, + startedAt: "2026-06-07T00:00:00.000Z" as never, + completedAt: "2026-06-07T00:00:01.000Z" as never, + checkpointTurnCount: null, + checkpointRef: null, + checkpointStatus: null, + checkpointFiles: [], + }); + yield* messages.upsert({ + messageId: "message-capture" as never, + threadId: req.threadId, + turnId: "turn-capture" as never, + role: "assistant", + text: "unused structured output fixture", + isStreaming: false, + createdAt: "2026-06-07T00:00:01.000Z" as never, + updatedAt: "2026-06-07T00:00:01.000Z" as never, + }); + return { turnId: "turn-capture" as never }; + }).pipe( + Effect.mapError( + (cause) => new WorkflowEventStoreError({ message: "seed turn failed", cause }), + ), + ), + }); + }), + ), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: (input) => + Effect.sync(() => { + capturedReadInputs.push(input); + return capturedOutput; + }), + }), + ), + Layer.provideMerge(StubTicketMergeServiceLayer), + Layer.provideMerge(StubTicketPullRequestServiceLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(realStepExecutorTestSupport), + Layer.provideMerge(NodeServices.layer), + ), + ); + +const seedBoardAndTicket = (ctx: StepExecutionContext) => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + yield* registry.register(ctx.boardId, { + name: "Executor board", + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }); + yield* read.registerBoard({ + boardId: ctx.boardId, + projectId: "project-script" as never, + name: "Script board", + workflowFilePath: ".t3/boards/script.json", + workflowVersionHash: "hash", + maxConcurrentTickets: 1, + }); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + ${ctx.ticketId}, + ${ctx.boardId}, + 'Executor ticket', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + ON CONFLICT(ticket_id) DO NOTHING + `; + }); + +const seedTicketMessages = ( + ctx: StepExecutionContext, + messages: ReadonlyArray<{ + readonly author: "agent" | "user"; + readonly body: string; + readonly attachments: number; + }>, +) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* Effect.forEach(messages, (message, index) => { + const attachments = Array.from({ length: message.attachments }, (_, i) => ({ + kind: "image" as const, + id: `attachment-${index}-${i}`, + name: `attachment-${index}-${i}.png`, + mimeType: "image/png" as const, + sizeBytes: 4, + dataUrl: "data:image/png;base64,AAAA", + })); + return sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES ( + ${`message-${ctx.ticketId}-${index}`}, + ${ctx.ticketId}, + NULL, + ${message.author}, + ${message.body}, + ${JSON.stringify(attachments)}, + ${`2026-06-07T00:0${index}:00.000Z`} + ) + `; + }); + }); + +const seedTicketDescription = (ctx: StepExecutionContext, description: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + UPDATE projection_ticket + SET description = ${description} + WHERE ticket_id = ${ctx.ticketId} + `; + }); + +/** A ProviderService stub whose getCapabilities returns the given maxInputChars. */ +const providerServiceLayerWithMaxInput = (maxInputChars: number | undefined) => + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused startSession"), + sendTurn: () => Effect.die("unused sendTurn"), + interruptTurn: () => Effect.void, + respondToRequest: () => Effect.die("unused respondToRequest"), + respondToUserInput: () => Effect.die("unused respondToUserInput"), + stopSession: () => Effect.void, + listSessions: () => Effect.succeed([]), + getCapabilities: () => + Effect.succeed( + maxInputChars === undefined + ? ({ sessionModelSwitch: "in-session" } as const) + : ({ sessionModelSwitch: "in-session", maxInputChars } as const), + ), + getInstanceInfo: () => Effect.die("unused getInstanceInfo"), + rollbackConversation: () => Effect.die("unused rollbackConversation"), + streamEvents: Stream.empty, + } as never); + +const seedStepStartedFor = (ctx: StepExecutionContext, eventId: string) => + Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + yield* seedBoardAndTicket(ctx); + yield* committer.commit({ + type: "StepStarted", + eventId: eventId as never, + ticketId: ctx.ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + pipelineRunId: ctx.pipelineRunId, + stepRunId: ctx.stepRunId, + stepKey: ctx.step.key, + stepType: ctx.step.type, + }, + }); + }); + +const seedStepStarted = seedStepStartedFor(context, "event-step-started"); + +const seedBoard = seedBoardAndTicket(context); + +const assertProjectedStepRefs = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const events = yield* sql<{ readonly type: string }>` + SELECT event_type AS "type" + FROM workflow_events + WHERE ticket_id = ${context.ticketId} + AND event_type = 'StepRefsCaptured' + `; + const rows = yield* sql<{ + readonly preCheckpointRef: string | null; + readonly postCheckpointRef: string | null; + }>` + SELECT + pre_checkpoint_ref AS "preCheckpointRef", + post_checkpoint_ref AS "postCheckpointRef" + FROM projection_step_run + WHERE step_run_id = ${context.stepRunId} + `; + + assert.equal(events.length, 1); + assert.equal(rows[0]?.preCheckpointRef, preRef); + assert.equal(rows[0]?.postCheckpointRef, postRef); +}); + +const seedFileInstructionStepStarted = seedStepStartedFor( + fileInstructionContext, + "event-step-started-file-instruction", +); +const seedUnsafeFileInstructionStepStarted = seedStepStartedFor( + unsafeFileInstructionContext, + "event-step-started-unsafe-file-instruction", +); +const seedSymlinkFileInstructionStepStarted = seedStepStartedFor( + symlinkFileInstructionContext, + "event-step-started-symlink-file-instruction", +); +const seedNormalFileInstructionStepStarted = seedStepStartedFor( + normalFileInstructionContext, + "event-step-started-normal-file-instruction", +); +const seedCanonicalFileInstructionStepStarted = seedStepStartedFor( + canonicalFileInstructionContext, + "event-step-started-canonical-file-instruction", +); + +const canonicalInstructionReadPaths: string[] = []; +const CanonicalInstructionFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return { + ...fileSystem, + realPath: (filePath) => + Effect.sync(() => { + const value = String(filePath); + if (value === "/tmp/repo-ticket-1/instructions/link.md") { + return "/tmp/repo-ticket-1/instructions/target.md"; + } + return value; + }), + readFileString: (filePath) => + Effect.sync(() => { + const value = String(filePath); + canonicalInstructionReadPaths.push(value); + return value === "/tmp/repo-ticket-1/instructions/target.md" + ? "Canonical instruction" + : "Original path instruction"; + }), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +// --- Inter-agent handoff fixtures (B2) ---------------------------------------- + +const seedHandoffStepRun = (input: { + readonly stepRunId: string; + readonly pipelineRunId: string; + readonly ticketId: string; + readonly laneKey: string; + readonly laneEntryToken: string; + readonly stepKey: string; + readonly output: unknown; + readonly finishedAt: string; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const outputJson = yield* Schema.encodeUnknownEffect(Schema.UnknownFromJsonString)( + input.output, + ); + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, ticket_id, lane_key, lane_entry_token, status, started_at, finished_at + ) VALUES ( + ${input.pipelineRunId}, ${input.ticketId}, ${input.laneKey}, ${input.laneEntryToken}, + 'completed', ${input.finishedAt}, ${input.finishedAt} + ) + ON CONFLICT(pipeline_run_id) DO NOTHING + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, pipeline_run_id, ticket_id, step_key, step_type, status, + started_at, finished_at, output_json + ) VALUES ( + ${input.stepRunId}, ${input.pipelineRunId}, ${input.ticketId}, ${input.stepKey}, 'agent', + 'completed', ${input.finishedAt}, ${input.finishedAt}, ${outputJson} + ) + ON CONFLICT(step_run_id) DO NOTHING + `; + }); + +const handoffBaseContext: StepExecutionContext = { + ...context, + laneKey: "review-loop" as never, + laneStepKeys: ["spec", "implement", "review"] as never, +}; + +const prevHandoffContext: StepExecutionContext = { + ...handoffBaseContext, + ticketId: "ticket-handoff-prev" as never, + pipelineRunId: "pipeline-handoff-prev" as never, + stepRunId: "step-run-handoff-prev" as never, + step: { + key: "implement" as never, + type: "agent", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, + instruction: "Implement using the spec:\n{{prev.output}}", + }, +}; + +const stepRefHandoffContext: StepExecutionContext = { + ...handoffBaseContext, + ticketId: "ticket-handoff-step" as never, + pipelineRunId: "pipeline-handoff-step-2" as never, + stepRunId: "step-run-handoff-step" as never, + step: { + key: "implement" as never, + type: "agent", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, + instruction: "Address the latest review:\n{{step.review.output}}", + }, +}; + +const forwardRefHandoffContext: StepExecutionContext = { + ...handoffBaseContext, + ticketId: "ticket-handoff-forward" as never, + pipelineRunId: "pipeline-handoff-forward" as never, + stepRunId: "step-run-handoff-forward" as never, + step: { + key: "implement" as never, + type: "agent", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, + instruction: "Address the latest review:\n{{step.review.output}}", + }, +}; + +const spillHandoffContext: StepExecutionContext = { + ...handoffBaseContext, + ticketId: "ticket-handoff-spill" as never, + pipelineRunId: "pipeline-handoff-spill" as never, + stepRunId: "step-run-handoff-spill" as never, + step: { + key: "implement" as never, + type: "agent", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, + instruction: "Address the latest review:\n{{step.review.output}}", + }, +}; + +// --- Description spill fixtures ----------------------------------------------- + +const descSpillContext: StepExecutionContext = { + ...context, + ticketId: "ticket-desc-spill" as never, + pipelineRunId: "pipeline-desc-spill" as never, + stepRunId: "step-run-desc-spill" as never, + step: { + key: "implement" as never, + type: "agent", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, + instruction: "Implement this:\n{{ticket.description}}\n\nAlso: {{ticket.description}}", + }, +}; + +const descSpillWithHandoffContext: StepExecutionContext = { + ...context, + ticketId: "ticket-desc-spill-handoff" as never, + pipelineRunId: "pipeline-desc-spill-handoff" as never, + stepRunId: "step-run-desc-spill-handoff" as never, + laneKey: "review-loop" as never, + laneStepKeys: ["spec", "implement", "review"] as never, + step: { + key: "implement" as never, + type: "agent", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, + instruction: "Implement:\n{{ticket.description}}\n\nReview:\n{{step.review.output}}", + }, +}; + +const descSpillDiscussionInlineContext: StepExecutionContext = { + ...context, + ticketId: "ticket-desc-discussion-inline" as never, + pipelineRunId: "pipeline-desc-discussion-inline" as never, + stepRunId: "step-run-desc-discussion-inline" as never, + step: { + key: "implement" as never, + type: "agent", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, + instruction: "Desc:\n{{ticket.description}}\n\nThread:\n{{ticket.discussion}}", + }, +}; + +mk({ ok: true })("RealStepExecutor success", (it) => { + it.effect("completes an agent step and releases the worktree lease", () => + Effect.gen(function* () { + checkpointCalls.length = 0; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedStepStarted; + + const outcome = yield* executor.execute(context); + + assert.equal(outcome._tag, "completed"); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + assert.deepEqual(checkpointCalls, [ + "hasBaseline:/tmp/wt-ticket-1", + "captureBaseline:/tmp/wt-ticket-1", + "captureStep:step-run-1:/tmp/wt-ticket-1:pre", + "captureStep:step-run-1:/tmp/wt-ticket-1:post", + ]); + yield* assertProjectedStepRefs; + }), + ); + + it.effect("runs merge steps through the merge service without project setup", () => + Effect.gen(function* () { + mergeServiceCalls.length = 0; + setupCalls.length = 0; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedStepStartedFor(mergeContext, "event-step-started-merge-step"); + + const outcome = yield* executor.execute(mergeContext); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(setupCalls, []); + assert.equal(mergeServiceCalls.length, 1); + const call = mergeServiceCalls[0] as { + readonly repoRoot: string; + readonly worktreeRef: string; + readonly step: { readonly target?: string }; + }; + assert.equal(call.repoRoot, "/tmp/repo-ticket-1"); + assert.equal(call.worktreeRef, "wt-ticket-1"); + assert.equal(call.step.target, "main"); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + }), + ); + + it.effect("routes a pullRequest open step through the PR service without project setup", () => + Effect.gen(function* () { + pullRequestServiceCalls.length = 0; + setupCalls.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(openPrContext, "event-step-started-open-pr"); + + const outcome = yield* executor.execute(openPrContext); + + assert.deepEqual(outcome, { + _tag: "completed", + output: { prNumber: 1, url: "https://example/pull/1" }, + }); + assert.deepEqual(setupCalls, []); + assert.equal(pullRequestServiceCalls.length, 1); + const call = pullRequestServiceCalls[0] as { + readonly action: string; + readonly input: { + readonly ticketId: string; + readonly stepRunId: string; + readonly repoRoot: string; + readonly worktreePath: string; + readonly worktreeRef: string; + readonly step: { readonly action: string }; + }; + }; + assert.equal(call.action, "open"); + assert.equal(call.input.ticketId, "ticket-open-pr"); + assert.equal(call.input.stepRunId, "step-run-open-pr"); + assert.equal(call.input.repoRoot, "/tmp/repo-ticket-1"); + assert.equal(call.input.worktreePath, "/tmp/wt-ticket-1"); + assert.equal(call.input.worktreeRef, "wt-ticket-1"); + assert.equal(call.input.step.action, "open"); + }), + ); + + it.effect("routes a pullRequest land step through the PR service", () => + Effect.gen(function* () { + pullRequestServiceCalls.length = 0; + setupCalls.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(landPrContext, "event-step-started-land-pr"); + + const outcome = yield* executor.execute(landPrContext); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(setupCalls, []); + assert.equal(pullRequestServiceCalls.length, 1); + const call = pullRequestServiceCalls[0] as { + readonly action: string; + readonly input: { readonly worktreeRef: string }; + }; + assert.equal(call.action, "land"); + assert.equal(call.input.worktreeRef, "wt-ticket-1"); + }), + ); + + it.effect("blocks agent steps once the ticket's token budget is reached", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + const budgetContext = { + ...context, + ticketId: "ticket-budget" as never, + stepRunId: "step-run-budget" as never, + }; + yield* seedStepStartedFor(budgetContext, "event-step-started-budget"); + yield* sql` + UPDATE projection_ticket + SET token_budget = 1000 + WHERE ticket_id = ${budgetContext.ticketId} + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, pipeline_run_id, ticket_id, step_key, step_type, + status, started_at, finished_at, total_tokens + ) + VALUES ( + 'step-run-budget-spent', 'pipeline-budget', ${budgetContext.ticketId}, 'prior', 'agent', + 'completed', '2026-06-07T00:00:00.000Z', '2026-06-07T00:01:00.000Z', 1500 + ) + `; + + const outcome = yield* executor.execute(budgetContext); + + assert.equal(outcome._tag, "blocked"); + if (outcome._tag === "blocked") { + assert.include(outcome.reason, "token budget reached"); + assert.include(outcome.reason, "1,500"); + assert.include(outcome.reason, "1,000"); + } + // No provider dispatch may have started. + assert.equal(dispatchStartInputs.length, 0); + }), + ); + + it.effect("substitutes ticket template placeholders into the dispatched instruction", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(templateContext, "event-step-started-template"); + + const outcome = yield* executor.execute(templateContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.equal( + dispatched.instruction, + `Work on Executor ticket (ticket-template). Diff base: ${ticketBaseRef( + "ticket-template" as never, + )}. Desc:[] Keep {{ticket.unknown}} and {{other}}.`, + ); + }), + ); + + it.effect("appends the ticket discussion to the dispatched instruction", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(discussionContext, "event-step-started-discussion"); + yield* seedTicketMessages(discussionContext, [ + { author: "user", body: "Use the existing retry helper", attachments: 0 }, + { author: "agent", body: "Understood", attachments: 1 }, + ]); + + const outcome = yield* executor.execute(discussionContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.match(dispatched.instruction, /^Implement the ticket\n\n## Ticket discussion\n\n/); + assert.include(dispatched.instruction, "### User — "); + assert.include(dispatched.instruction, "Use the existing retry helper"); + assert.include(dispatched.instruction, "### Agent — "); + assert.include(dispatched.instruction, "[1 attachment omitted]"); + }), + ); + + it.effect("substitutes the discussion placeholder without appending a second section", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor( + discussionPlaceholderContext, + "event-step-started-discussion-placeholder", + ); + yield* seedTicketMessages(discussionPlaceholderContext, [ + { author: "user", body: "Ship it", attachments: 0 }, + ]); + + const outcome = yield* executor.execute(discussionPlaceholderContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.match(dispatched.instruction, /^Implement the ticket\.\nDiscussion:\n### User — /); + assert.include(dispatched.instruction, "Ship it"); + assert.notInclude(dispatched.instruction, "## Ticket discussion"); + assert.notInclude(dispatched.instruction, "{{ticket.discussion}}"); + }), + ); + + it.effect("substitutes an empty-discussion marker when there are no messages", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor( + { ...discussionPlaceholderContext, ticketId: "ticket-discussion-empty" as never }, + "event-step-started-discussion-empty", + ); + + const outcome = yield* executor.execute({ + ...discussionPlaceholderContext, + ticketId: "ticket-discussion-empty" as never, + }); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.include(dispatched.instruction, "Discussion:\n(no discussion yet)"); + }), + ); + + it.effect("runs a trusted script step through the shared prepared worktree path", () => + Effect.gen(function* () { + checkpointCalls.length = 0; + setupCalls.length = 0; + const fileSystem = yield* FileSystem.FileSystem; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* fileSystem.makeDirectory("/tmp/wt-ticket-1", { recursive: true }); + yield* seedBoard; + yield* seedStepStartedFor(scriptContext, "event-step-started-script"); + + const outcome = yield* executor.execute(scriptContext); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(setupCalls, ["/tmp/wt-ticket-1"]); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + assert.deepEqual(checkpointCalls, [ + "hasBaseline:/tmp/wt-ticket-1", + "captureBaseline:/tmp/wt-ticket-1", + "captureStep:step-run-script:/tmp/wt-ticket-1:pre", + "captureStep:step-run-script:/tmp/wt-ticket-1:post", + ]); + }), + ); + + it.effect("releases the worktree lease when instruction file resolution fails", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedFileInstructionStepStarted; + + const outcome = yield* executor.execute(fileInstructionContext); + + assert.equal(outcome._tag, "failed"); + assert.match((outcome as { readonly error: string }).error, /^executor error: /); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + }), + ); + + it.effect("fails unsafe instruction file paths without reading escaped files", () => + Effect.gen(function* () { + const escapePath = "/tmp/t3-unsafe-instruction-escape.md"; + const fileSystem = yield* FileSystem.FileSystem; + const executor = yield* StepExecutor; + yield* fileSystem.writeFileString(escapePath, "Escaped instruction"); + yield* seedUnsafeFileInstructionStepStarted; + + const outcome = yield* executor.execute(unsafeFileInstructionContext); + + assert.equal(outcome._tag, "failed"); + assert.match((outcome as { readonly error: string }).error, /^executor error: /); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem + .remove("/tmp/t3-unsafe-instruction-escape.md") + .pipe(Effect.catch(() => Effect.void)); + }), + ), + ), + ); + + it.effect("fails symlinked instruction files that resolve outside the repo root", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const repoRoot = "/tmp/repo-ticket-1"; + const escapePath = "/tmp/t3-symlink-instruction-escape.md"; + const fileSystem = yield* FileSystem.FileSystem; + const executor = yield* StepExecutor; + yield* fileSystem.makeDirectory(repoRoot, { recursive: true }); + yield* fileSystem.writeFileString(escapePath, "Escaped symlink instruction"); + yield* fileSystem.symlink(escapePath, `${repoRoot}/symlink-instruction.md`); + yield* seedSymlinkFileInstructionStepStarted; + + const outcome = yield* executor.execute(symlinkFileInstructionContext); + + assert.deepEqual(outcome, { + _tag: "failed", + error: 'Instruction file resolves outside the project root: "symlink-instruction.md"', + }); + assert.deepEqual(dispatchStartInputs, []); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem + .remove("/tmp/repo-ticket-1/symlink-instruction.md") + .pipe(Effect.catch(() => Effect.void)); + yield* fileSystem + .remove("/tmp/t3-symlink-instruction-escape.md") + .pipe(Effect.catch(() => Effect.void)); + }), + ), + ), + ); + + it.effect("forwards agent option selections to the provider dispatch", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(optionsContext, "event-step-started-options"); + + const outcome = yield* executor.execute(optionsContext); + + assert.equal(outcome._tag, "completed"); + assert.deepEqual( + (dispatchStartInputs[0] as { readonly options?: unknown } | undefined)?.options, + optionSelections, + ); + }), + ); + + it.effect("reads normal instruction files that resolve inside the repo root", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const repoRoot = "/tmp/repo-ticket-1"; + const instructionPath = `${repoRoot}/instructions/normal.md`; + const fileSystem = yield* FileSystem.FileSystem; + const executor = yield* StepExecutor; + yield* fileSystem.makeDirectory(`${repoRoot}/instructions`, { recursive: true }); + yield* fileSystem.writeFileString(instructionPath, "Normal in-repo instruction"); + yield* seedNormalFileInstructionStepStarted; + + const outcome = yield* executor.execute(normalFileInstructionContext); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.equal( + (dispatchStartInputs[0] as { readonly instruction?: string } | undefined)?.instruction, + "Normal in-repo instruction", + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem + .remove("/tmp/repo-ticket-1/instructions/normal.md") + .pipe(Effect.catch(() => Effect.void)); + }), + ), + ), + ); +}); + +mk({ ok: true }, { fileSystemLayer: CanonicalInstructionFileSystemLayer })( + "RealStepExecutor canonical instruction read", + (it) => { + it.effect("reads the canonical real instruction path after validation", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + canonicalInstructionReadPaths.length = 0; + const executor = yield* StepExecutor; + yield* seedCanonicalFileInstructionStepStarted; + + const outcome = yield* executor.execute(canonicalFileInstructionContext); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(canonicalInstructionReadPaths, [ + "/tmp/repo-ticket-1/instructions/target.md", + ]); + assert.equal( + (dispatchStartInputs[0] as { readonly instruction?: string } | undefined)?.instruction, + "Canonical instruction", + ); + }), + ); + }, +); + +mk({ ok: true })("RealStepExecutor handoff", (it) => { + it.effect("inlines {{prev.output}} from the preceding step's current-pass output", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(prevHandoffContext, "event-step-started-handoff-prev"); + yield* seedHandoffStepRun({ + stepRunId: "spec-run-prev", + pipelineRunId: prevHandoffContext.pipelineRunId as string, + ticketId: prevHandoffContext.ticketId as string, + laneKey: prevHandoffContext.laneKey as string, + laneEntryToken: prevHandoffContext.laneEntryToken as string, + stepKey: "spec", + output: "SPEC: build the widget", + finishedAt: "2026-06-07T00:00:01.000Z", + }); + + const outcome = yield* executor.execute(prevHandoffContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.include(dispatched.instruction, "Implement using the spec:"); + assert.include(dispatched.instruction, "SPEC: build the widget"); + assert.notInclude(dispatched.instruction, "{{prev.output}}"); + }), + ); + + it.effect("inlines handoff output verbatim even when it contains $ sequences", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor( + { + ...prevHandoffContext, + ticketId: "ticket-handoff-dollar" as never, + pipelineRunId: "pipeline-handoff-dollar" as never, + }, + "event-step-started-handoff-dollar", + ); + yield* seedHandoffStepRun({ + stepRunId: "spec-run-dollar", + pipelineRunId: "pipeline-handoff-dollar", + ticketId: "ticket-handoff-dollar", + laneKey: prevHandoffContext.laneKey as string, + laneEntryToken: prevHandoffContext.laneEntryToken as string, + stepKey: "spec", + output: "cost is $5 and $& $1 placeholders", + finishedAt: "2026-06-07T00:00:01.000Z", + }); + + const outcome = yield* executor.execute({ + ...prevHandoffContext, + ticketId: "ticket-handoff-dollar" as never, + pipelineRunId: "pipeline-handoff-dollar" as never, + }); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.include(dispatched.instruction, "cost is $5 and $& $1 placeholders"); + }), + ); + + it.effect("inlines {{step..output}} from the latest completed prior pass (loop)", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(stepRefHandoffContext, "event-step-started-handoff-step"); + // An older pass' review (should be superseded by the newer one). + yield* seedHandoffStepRun({ + stepRunId: "review-run-old", + pipelineRunId: "pipeline-handoff-step-1", + ticketId: stepRefHandoffContext.ticketId as string, + laneKey: stepRefHandoffContext.laneKey as string, + laneEntryToken: stepRefHandoffContext.laneEntryToken as string, + stepKey: "review", + output: { summary: "OLD review" }, + finishedAt: "2026-06-07T00:00:01.000Z", + }); + // The newer pass' review (latest by finished_at). + yield* seedHandoffStepRun({ + stepRunId: "review-run-new", + pipelineRunId: "pipeline-handoff-step-1b", + ticketId: stepRefHandoffContext.ticketId as string, + laneKey: stepRefHandoffContext.laneKey as string, + laneEntryToken: stepRefHandoffContext.laneEntryToken as string, + stepKey: "review", + output: { summary: "NEW review: fix the bug" }, + finishedAt: "2026-06-07T00:05:00.000Z", + }); + + const outcome = yield* executor.execute(stepRefHandoffContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.include(dispatched.instruction, "NEW review: fix the bug"); + assert.notInclude(dispatched.instruction, "OLD review"); + assert.notInclude(dispatched.instruction, "{{step.review.output}}"); + }), + ); + + it.effect("substitutes the no-prior-output marker for a forward reference on pass 1", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(forwardRefHandoffContext, "event-step-started-handoff-forward"); + + const outcome = yield* executor.execute(forwardRefHandoffContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.include(dispatched.instruction, "(no prior output yet)"); + assert.notInclude(dispatched.instruction, "{{step.review.output}}"); + }), + ); + + it.effect("spills over-budget handoff output to a per-ticket scratch file + path ref", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + const fileSystem = yield* FileSystem.FileSystem; + const huge = "X".repeat(200_000); + yield* seedStepStartedFor(spillHandoffContext, "event-step-started-handoff-spill"); + yield* seedHandoffStepRun({ + stepRunId: "review-run-spill", + pipelineRunId: "pipeline-handoff-spill-prior", + ticketId: spillHandoffContext.ticketId as string, + laneKey: spillHandoffContext.laneKey as string, + laneEntryToken: spillHandoffContext.laneEntryToken as string, + stepKey: "review", + output: huge, + finishedAt: "2026-06-07T00:00:01.000Z", + }); + + const outcome = yield* executor.execute(spillHandoffContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + const spillPath = ".t3/ticket/ticket-handoff-spill/handoff/review.md"; + assert.include(dispatched.instruction, spillPath); + // The huge payload must NOT be inlined into the prompt. + assert.notInclude(dispatched.instruction, huge); + assert.isBelow(dispatched.instruction.length, 120_000); + // The full output landed in the worktree scratch file. + const written = yield* fileSystem.readFileString(`/tmp/wt-ticket-1/${spillPath}`); + assert.equal(written, huge); + yield* fileSystem + .remove("/tmp/wt-ticket-1/.t3/ticket/ticket-handoff-spill", { recursive: true }) + .pipe(Effect.catch(() => Effect.void)); + }), + ); +}); + +mk({ ok: true }, { providerServiceLayer: providerServiceLayerWithMaxInput(undefined) })( + "RealStepExecutor description spill (120k provider)", + (it) => { + it.effect("inlines a short description and writes no DESCRIPTION.md", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + const fileSystem = yield* FileSystem.FileSystem; + yield* seedStepStartedFor(descSpillContext, "event-step-started-desc-short"); + yield* seedTicketDescription(descSpillContext, "Short and sweet description."); + + const outcome = yield* executor.execute(descSpillContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.include(dispatched.instruction, "Short and sweet description."); + assert.notInclude(dispatched.instruction, "DESCRIPTION.md"); + const exists = yield* fileSystem.exists( + "/tmp/wt-ticket-1/.t3/ticket/ticket-desc-spill/DESCRIPTION.md", + ); + assert.isFalse(exists); + }), + ); + + it.effect("preserves handoff-like syntax inside an inlined description verbatim", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + const ctx = { ...descSpillContext, ticketId: "ticket-desc-handoff-syntax" as never }; + yield* seedStepStartedFor(ctx, "event-step-started-desc-handoff-syntax"); + // A description that literally contains handoff placeholder syntax. The + // handoff resolver runs against the instruction skeleton (description still + // a marker), so this text must reach the agent verbatim — never matched or + // replaced as if it were one of the instruction's own handoff references. + yield* seedTicketDescription( + ctx, + "Use {{prev.output}} and {{step.review.output}} as the input.", + ); + + const outcome = yield* executor.execute(ctx); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.include( + dispatched.instruction, + "Use {{prev.output}} and {{step.review.output}} as the input.", + ); + }), + ); + + it.effect("uses the 120k budget when no maxInputChars is declared (today's behavior)", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + const fileSystem = yield* FileSystem.FileSystem; + const desc = "Y".repeat(50_000); + const ctx = { ...descSpillContext, ticketId: "ticket-desc-120k" as never }; + yield* seedStepStartedFor(ctx, "event-step-started-desc-120k"); + yield* seedTicketDescription(ctx, desc); + + const outcome = yield* executor.execute(ctx); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + // 50k * 2 occurrences = 100k < 120k budget → inlined, no spill. + assert.include(dispatched.instruction, desc); + const exists = yield* fileSystem.exists( + "/tmp/wt-ticket-1/.t3/ticket/ticket-desc-120k/DESCRIPTION.md", + ); + assert.isFalse(exists); + }), + ); + + it.effect("does not append a second discussion section when inlined via placeholder", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor( + descSpillDiscussionInlineContext, + "event-step-started-desc-discussion-inline", + ); + yield* seedTicketDescription(descSpillDiscussionInlineContext, "Short desc."); + yield* seedTicketMessages(descSpillDiscussionInlineContext, [ + { author: "user", body: "Use the retry helper", attachments: 0 }, + ]); + + const outcome = yield* executor.execute(descSpillDiscussionInlineContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.include(dispatched.instruction, "Short desc."); + assert.include(dispatched.instruction, "Use the retry helper"); + // Inlined via {{ticket.discussion}} — never double-appended. + assert.notInclude(dispatched.instruction, "## Ticket discussion"); + assert.notInclude(dispatched.instruction, "{{ticket.discussion}}"); + }), + ); + }, +); + +mk({ ok: true }, { providerServiceLayer: providerServiceLayerWithMaxInput(800) })( + "RealStepExecutor description spill (tight provider)", + (it) => { + it.effect("spills a long description to DESCRIPTION.md and replaces all occurrences", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + const fileSystem = yield* FileSystem.FileSystem; + const longDescription = "D".repeat(5_000); + yield* seedStepStartedFor(descSpillContext, "event-step-started-desc-spill"); + yield* seedTicketDescription(descSpillContext, longDescription); + + const outcome = yield* executor.execute(descSpillContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + const spillPath = ".t3/ticket/ticket-desc-spill/DESCRIPTION.md"; + // The pointer replaces the body; the raw body is NOT inlined. + assert.include(dispatched.instruction, spillPath); + assert.notInclude(dispatched.instruction, longDescription); + // Both {{ticket.description}} occurrences were replaced (no literal left). + assert.notInclude(dispatched.instruction, "{{ticket.description}}"); + // The spill file holds a single-line `# ` header then the body. + const written = yield* fileSystem.readFileString(`/tmp/wt-ticket-1/${spillPath}`); + assert.equal(written, `# Executor ticket\n\n${longDescription}`); + yield* fileSystem + .remove("/tmp/wt-ticket-1/.t3/ticket/ticket-desc-spill", { recursive: true }) + .pipe(Effect.catch(() => Effect.void)); + }), + ); + + it.effect("spills both the description and an over-budget handoff, staying within budget", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + const fileSystem = yield* FileSystem.FileSystem; + const longDescription = "D".repeat(5_000); + const longReview = "R".repeat(5_000); + yield* seedStepStartedFor(descSpillWithHandoffContext, "event-step-started-desc-handoff"); + yield* seedTicketDescription(descSpillWithHandoffContext, longDescription); + yield* seedHandoffStepRun({ + stepRunId: "review-run-desc-handoff", + pipelineRunId: "pipeline-desc-spill-handoff-prior", + ticketId: descSpillWithHandoffContext.ticketId as string, + laneKey: descSpillWithHandoffContext.laneKey as string, + laneEntryToken: descSpillWithHandoffContext.laneEntryToken as string, + stepKey: "review", + output: longReview, + finishedAt: "2026-06-07T00:00:01.000Z", + }); + + const outcome = yield* executor.execute(descSpillWithHandoffContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + // Description spilled. + assert.include( + dispatched.instruction, + ".t3/ticket/ticket-desc-spill-handoff/DESCRIPTION.md", + ); + assert.notInclude(dispatched.instruction, longDescription); + // Over-budget handoff spilled. + assert.include( + dispatched.instruction, + ".t3/ticket/ticket-desc-spill-handoff/handoff/review.md", + ); + assert.notInclude(dispatched.instruction, longReview); + // Final prompt fits the 800-char provider budget after both spills. + assert.isAtMost(dispatched.instruction.length, 800); + yield* fileSystem + .remove("/tmp/wt-ticket-1/.t3/ticket/ticket-desc-spill-handoff", { recursive: true }) + .pipe(Effect.catch(() => Effect.void)); + }), + ); + + it.effect( + "spills an inlinable description when handoff overhead pushes the body over budget", + () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + const fileSystem = yield* FileSystem.FileSystem; + // 750 chars fits inline on its own under the 800 budget, so the up-front + // decision keeps it inline. Only the post-handoff fallback spills it, once + // the spilled-handoff pointer overhead makes the assembled body overflow. + const inlinableDescription = "D".repeat(750); + const longReview = "R".repeat(5_000); + const ctx = { + ...descSpillWithHandoffContext, + ticketId: "ticket-desc-fallback" as never, + pipelineRunId: "pipeline-desc-fallback" as never, + stepRunId: "step-run-desc-fallback" as never, + }; + yield* seedStepStartedFor(ctx, "event-step-started-desc-fallback"); + yield* seedTicketDescription(ctx, inlinableDescription); + yield* seedHandoffStepRun({ + stepRunId: "review-run-desc-fallback", + pipelineRunId: "pipeline-desc-fallback-prior", + ticketId: ctx.ticketId as string, + laneKey: ctx.laneKey as string, + laneEntryToken: ctx.laneEntryToken as string, + stepKey: "review", + output: longReview, + finishedAt: "2026-06-07T00:00:01.000Z", + }); + + const outcome = yield* executor.execute(ctx); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + // The fallback spilled the description even though it fit inline alone. + assert.include(dispatched.instruction, ".t3/ticket/ticket-desc-fallback/DESCRIPTION.md"); + assert.notInclude(dispatched.instruction, inlinableDescription); + assert.isAtMost(dispatched.instruction.length, 800); + yield* fileSystem + .remove("/tmp/wt-ticket-1/.t3/ticket/ticket-desc-fallback", { recursive: true }) + .pipe(Effect.catch(() => Effect.void)); + }), + ); + }, +); + +mk({ ok: true }, { providerServiceLayer: providerServiceLayerWithMaxInput(20) })( + "RealStepExecutor description spill (pointer guard)", + (it) => { + it.effect("inlines a description shorter than the pointer even when over budget", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + const fileSystem = yield* FileSystem.FileSystem; + // Over the 20-char body budget, but far shorter than the ~100-char pointer, + // so spilling would only enlarge the prompt → inline instead. + const smallDescription = "just a little over budget"; + const ctx = { ...descSpillContext, ticketId: "ticket-desc-guard" as never }; + yield* seedStepStartedFor(ctx, "event-step-started-desc-guard"); + yield* seedTicketDescription(ctx, smallDescription); + + const outcome = yield* executor.execute(ctx); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.include(dispatched.instruction, smallDescription); + assert.notInclude(dispatched.instruction, "DESCRIPTION.md"); + const exists = yield* fileSystem.exists( + "/tmp/wt-ticket-1/.t3/ticket/ticket-desc-guard/DESCRIPTION.md", + ); + assert.isFalse(exists); + }), + ); + }, +); + +captureLayer({ verdict: "pass", score: 0.98 })("RealStepExecutor output capture", (it) => { + it.effect("appends the capture instruction, persists it, and returns the last JSON block", () => + Effect.gen(function* () { + capturedReadInputs.length = 0; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedStepStartedFor(captureContext, "event-step-started-capture"); + + const outcome = yield* executor.execute(captureContext); + + assert.deepEqual(outcome, { + _tag: "completed", + output: { verdict: "pass", score: 0.98 }, + }); + + const rows = yield* sql<{ readonly instruction: string }>` + SELECT instruction + FROM workflow_dispatch_outbox + WHERE step_run_id = ${captureContext.stepRunId} + `; + assert.include(rows[0]?.instruction ?? "", "Implement the ticket"); + assert.include( + rows[0]?.instruction ?? "", + "End your final message with a single fenced ```json block containing your result object.", + ); + }), + ); + + it.effect("passes the exact started thread and turn to the output reader", () => + Effect.gen(function* () { + capturedReadInputs.length = 0; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedStepStartedFor(captureContext, "event-step-started-capture-exact-turn"); + + const outcome = yield* executor.execute(captureContext); + + assert.equal(outcome._tag, "completed"); + const rows = yield* sql<{ readonly threadId: string; readonly turnId: string | null }>` + SELECT thread_id AS "threadId", turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE step_run_id = ${captureContext.stepRunId} + `; + const capturedInput = capturedReadInputs[0] as + | { readonly stepRunId: string; readonly threadId: string; readonly turnId: string | null } + | undefined; + const row = rows.find((candidate) => candidate.threadId === capturedInput?.threadId); + assert.deepEqual(capturedReadInputs, [ + { + stepRunId: captureContext.stepRunId, + threadId: row?.threadId, + turnId: row?.turnId, + }, + ]); + }), + ); +}); + +captureLayer(undefined)("RealStepExecutor missing output capture", (it) => { + it.effect("fails a captureOutput step when the assistant message has no valid JSON block", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + yield* seedStepStartedFor(captureContext, "event-step-started-capture-missing"); + + const outcome = yield* executor.execute(captureContext); + + assert.deepEqual(outcome, { + _tag: "failed", + error: "missing or invalid structured output", + }); + }), + ); +}); + +const panelVerdictQueue: unknown[] = []; +mk( + { ok: true }, + { + capturedOutputForRead: () => panelVerdictQueue.shift(), + }, +)("RealStepExecutor review panel", (it) => { + it.effect("takes the strict-majority verdict across panel reviewers", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + panelVerdictQueue.length = 0; + panelVerdictQueue.push( + { verdict: "approve", notes: "ok" }, + { verdict: "revise" }, + { verdict: "approve" }, + ); + const executor = yield* StepExecutor; + const panelContext: StepExecutionContext = { + ...context, + ticketId: "ticket-panel" as never, + stepRunId: "step-run-panel" as never, + step: { + key: "review" as never, + type: "agent", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, + instruction: "Review the work", + captureOutput: true, + panel: 3, + } as never, + }; + yield* seedStepStartedFor(panelContext, "event-step-started-panel"); + + const outcome = yield* executor.execute(panelContext); + + assert.equal(outcome._tag, "completed"); + if (outcome._tag === "completed") { + const output = outcome.output as { + readonly verdict: string; + readonly votes: ReadonlyArray<{ readonly verdict: string | null }>; + }; + assert.equal(output.verdict, "approve"); + assert.equal(output.votes.length, 3); + assert.deepEqual( + output.votes.map((vote) => vote.verdict), + ["approve", "revise", "approve"], + ); + } + assert.equal(dispatchStartInputs.length, 3); + const titles = dispatchStartInputs.map( + (input) => (input as { readonly threadTitle?: string }).threadTitle ?? "", + ); + assert.isTrue(titles.some((title) => title.includes("reviewer 1/3"))); + // Each member must run on its own dispatch thread. + const threads = new Set( + dispatchStartInputs.map((input) => (input as { readonly threadId: string }).threadId), + ); + assert.equal(threads.size, 3); + }), + ); + + it.effect("fails without a strict majority", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + panelVerdictQueue.length = 0; + panelVerdictQueue.push({ verdict: "approve" }, { verdict: "revise" }); + const executor = yield* StepExecutor; + const panelContext: StepExecutionContext = { + ...context, + ticketId: "ticket-panel-split" as never, + stepRunId: "step-run-panel-split" as never, + step: { + key: "review" as never, + type: "agent", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, + instruction: "Review the work", + captureOutput: true, + panel: 2, + } as never, + }; + yield* seedStepStartedFor(panelContext, "event-step-started-panel-split"); + + const outcome = yield* executor.execute(panelContext); + + assert.equal(outcome._tag, "failed"); + if (outcome._tag === "failed") { + assert.include(outcome.error, "did not reach a majority"); + } + }), + ); +}); + +mk({ ok: true }, { projectTrusted: false })("RealStepExecutor untrusted script", (it) => { + it.effect("blocks before setup, lease, checkpoints, or command execution", () => + Effect.gen(function* () { + checkpointCalls.length = 0; + setupCalls.length = 0; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedBoard; + yield* seedStepStartedFor(scriptContext, "event-step-started-untrusted-script"); + + const outcome = yield* executor.execute(scriptContext); + + assert.deepEqual(outcome, { + _tag: "blocked", + reason: "Project not trusted to run scripts", + }); + assert.deepEqual(setupCalls, []); + assert.deepEqual(checkpointCalls, [ + "hasBaseline:/tmp/wt-ticket-1", + "captureBaseline:/tmp/wt-ticket-1", + ]); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.deepEqual(rows, []); + }), + ); +}); + +mk({ ok: true }, { projectTrusted: false })("RealStepExecutor untrusted agent", (it) => { + it.effect("runs the agent step but SKIPS the untrusted project's setup script", () => + Effect.gen(function* () { + checkpointCalls.length = 0; + setupCalls.length = 0; + const executor = yield* StepExecutor; + yield* seedBoard; + yield* seedStepStarted; + + // The project's setup shell is arbitrary code, so on an untrusted project it + // is SKIPPED (never executed). But — unlike a script step, whose whole + // purpose is the untrusted script — the agent itself is not the untrusted + // surface, so the agent step still runs and is NOT blocked. + const outcome = yield* executor.execute(context); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(setupCalls, []); + }), + ); +}); + +mk({ ok: false, error: "provider failed" })("RealStepExecutor failure", (it) => { + it.effect("fails an agent step when provider dispatch fails", () => + Effect.gen(function* () { + checkpointCalls.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStarted; + + const outcome = yield* executor.execute(context); + + assert.deepEqual(outcome, { _tag: "failed", error: "provider failed" }); + assert.deepEqual(checkpointCalls, [ + "hasBaseline:/tmp/wt-ticket-1", + "captureBaseline:/tmp/wt-ticket-1", + "captureStep:step-run-1:/tmp/wt-ticket-1:pre", + "captureStep:step-run-1:/tmp/wt-ticket-1:post", + ]); + yield* assertProjectedStepRefs; + }), + ); +}); + +const preCheckpointFailureLayer = it.layer( + RealStepExecutorLive.pipe( + Layer.provideMerge( + Layer.succeed(WorktreePort, { + ensureWorktree: () => + Effect.succeed({ + repoRoot: "/tmp/repo-ticket-1", + worktreeRef: "wt-ticket-1", + path: "/tmp/wt-ticket-1", + }), + }), + ), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge( + Layer.succeed(SetupRunService, { + runSetup: () => Effect.succeed({ status: "completed", exitCode: 0 }), + }), + ), + Layer.provideMerge(ScriptStepExecutorLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectScriptTrust, { + isTrusted: () => Effect.succeed(true), + setTrusted: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ScriptCommandRunner, { + run: () => Effect.succeed({ outcome: "exited", exitCode: 0, signal: null }), + }), + ), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: () => Effect.succeed(true), + captureBaseline: () => Effect.succeed("refs/t3/tickets/dC0x/base"), + captureStep: (_ticketId, _stepRunId, _cwd, kind) => + kind === "pre" + ? Effect.fail(new WorkflowEventStoreError({ message: "pre checkpoint failed" })) + : Effect.succeed(postRef), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderDispatchOutbox, { + confirmStep: () => Effect.void, + ensureStarted: () => Effect.succeed({ turnId: "turn-stub" as never }), + getDispatchForStep: () => Effect.succeed(null), + awaitTerminal: () => Effect.succeed({ ok: true }), + awaitStepTerminal: () => Effect.succeed({ ok: true }), + recoverPending: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.mergeAll( + StubTicketMergeServiceLayer, + StubTicketPullRequestServiceLayer, + WorkflowEventCommitterLive, + ), + ), + Layer.provideMerge(Layer.merge(BoardRegistryLive, PredicateEvaluatorLive)), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), + ), +); + +preCheckpointFailureLayer("RealStepExecutor pre-dispatch failure", (it) => { + it.effect("releases the worktree lease when pre-step checkpoint capture fails", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedStepStarted; + + const outcome = yield* executor.execute(context); + + assert.equal(outcome._tag, "failed"); + assert.match((outcome as { readonly error: string }).error, /^executor error: /); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + }), + ); +}); + +const providerSessionCalls: Array<string> = []; +const timeoutDispatchInputs: Array<unknown> = []; + +const terminalTimeoutLayer = it.layer( + RealStepExecutorLive.pipe( + Layer.provideMerge( + Layer.succeed(WorktreePort, { + ensureWorktree: () => + Effect.succeed({ + repoRoot: "/tmp/repo-ticket-1", + worktreeRef: "wt-ticket-1", + path: "/tmp/wt-ticket-1", + }), + }), + ), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge( + Layer.succeed(SetupRunService, { + runSetup: () => Effect.succeed({ status: "completed", exitCode: 0 }), + }), + ), + Layer.provideMerge(ScriptStepExecutorLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectScriptTrust, { + isTrusted: () => Effect.succeed(true), + setTrusted: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ScriptCommandRunner, { + run: () => Effect.succeed({ outcome: "exited", exitCode: 0, signal: null }), + }), + ), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: () => Effect.succeed(true), + captureBaseline: () => Effect.succeed("refs/t3/tickets/dC0x/base"), + captureStep: (_ticketId, _stepRunId, _cwd, kind) => + Effect.succeed(kind === "pre" ? preRef : postRef), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderDispatchOutbox, { + confirmStep: () => Effect.void, + ensureStarted: (input) => + Effect.sync(() => { + timeoutDispatchInputs.push(input); + return { turnId: "turn-stub" as never }; + }), + getDispatchForStep: () => Effect.succeed(null), + awaitTerminal: () => + Effect.succeed({ + ok: false, + error: "turn did not reach a terminal state before timeout", + }), + awaitStepTerminal: () => + Effect.succeed({ + ok: false, + error: "turn did not reach a terminal state before timeout", + }), + recoverPending: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused startSession"), + sendTurn: () => Effect.die("unused sendTurn"), + interruptTurn: (input) => + Effect.sync(() => { + providerSessionCalls.push( + `interrupt:${input.threadId as string}:${input.turnId as string}`, + ); + }), + respondToRequest: () => Effect.die("unused respondToRequest"), + respondToUserInput: () => Effect.die("unused respondToUserInput"), + stopSession: (input) => + Effect.sync(() => { + providerSessionCalls.push(`stop:${input.threadId as string}`); + }), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" } as const), + getInstanceInfo: () => Effect.die("unused getInstanceInfo"), + rollbackConversation: () => Effect.die("unused rollbackConversation"), + streamEvents: Stream.empty, + }), + ), + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.mergeAll( + StubTicketMergeServiceLayer, + StubTicketPullRequestServiceLayer, + WorkflowEventCommitterLive, + ), + ), + Layer.provideMerge(Layer.merge(BoardRegistryLive, PredicateEvaluatorLive)), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(realStepExecutorTestSupport), + Layer.provideMerge(NodeServices.layer), + ), +); + +terminalTimeoutLayer("RealStepExecutor terminal-wait timeout", (it) => { + it.effect("stops the provider session when the turn never reached a terminal state", () => + Effect.gen(function* () { + providerSessionCalls.length = 0; + timeoutDispatchInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStarted; + + const outcome = yield* executor.execute(context); + + assert.deepEqual(outcome, { + _tag: "failed", + error: "turn did not reach a terminal state before timeout", + }); + // The still-live agent must be interrupted and its session stopped so + // it cannot keep mutating the worktree after the pipeline routed on. + const threadId = (timeoutDispatchInputs[0] as { readonly threadId: string }).threadId; + assert.deepEqual(providerSessionCalls, [ + `interrupt:${threadId}:turn-stub`, + `stop:${threadId}`, + ]); + }), + ); +}); + +// --- A7: resume-or-create a stable per-agent thread for continueSession steps --- + +const continueDispatchInputs: Array<{ readonly threadId: string }> = []; +const continueUpsertCalls: Array<{ + readonly ticketId: string; + readonly laneKey: string; + readonly agentKey: string; + readonly threadId: string; +}> = []; +const continueProviderCalls: Array<string> = []; +// Pre-seeded stored threads keyed by `${ticketId}|${laneKey}|${agentKey}`. +const storedThreads = new Map<string, string>(); + +const continueAgent = { instance: "codex", model: "gpt-5.5" } as const; +const continueAgentKey = agentKey(continueAgent.instance, continueAgent.model); + +const continueContext: StepExecutionContext = { + ...context, + ticketId: "ticket-continue" as never, + stepRunId: "step-run-continue" as never, + laneKey: "lane-continue" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { instance: continueAgent.instance as never, model: continueAgent.model as never }, + instruction: "Implement the ticket", + continueSession: true, + } as never, +}; + +const plainContext: StepExecutionContext = { + ...context, + ticketId: "ticket-plain" as never, + stepRunId: "step-run-plain" as never, + laneKey: "lane-plain" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { instance: continueAgent.instance as never, model: continueAgent.model as never }, + instruction: "Implement the ticket", + } as never, +}; + +const StubWorkflowAgentSessionStoreLayer = Layer.succeed(WorkflowAgentSessionStore, { + upsert: (ticketId, laneKey, agentKeyValue, threadId) => + Effect.sync(() => { + continueUpsertCalls.push({ + ticketId: ticketId as string, + laneKey: laneKey as string, + agentKey: agentKeyValue, + threadId, + }); + storedThreads.set(`${ticketId as string}|${laneKey as string}|${agentKeyValue}`, threadId); + }), + getThreadId: (ticketId, laneKey, agentKeyValue) => + Effect.sync( + () => + storedThreads.get(`${ticketId as string}|${laneKey as string}|${agentKeyValue}`) ?? null, + ), + listByTicket: () => Effect.succeed([]), + deleteByTicket: () => Effect.void, + listByBoard: () => Effect.succeed([]), + deleteByBoard: () => Effect.void, +}); + +const continueSessionLayer = (terminal: ProviderDispatchTerminalResult) => + it.layer( + RealStepExecutorLive.pipe( + Layer.provideMerge(StubWorkflowAgentSessionStoreLayer), + Layer.provideMerge( + Layer.succeed(WorktreePort, { + ensureWorktree: () => + Effect.succeed({ + repoRoot: "/tmp/repo-ticket-1", + worktreeRef: "wt-ticket-1", + path: "/tmp/wt-ticket-1", + projectId: "project-script", + }), + }), + ), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge( + Layer.succeed(SetupRunService, { + runSetup: () => Effect.succeed({ status: "completed", exitCode: 0 }), + }), + ), + Layer.provideMerge(ScriptStepExecutorLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectScriptTrust, { + isTrusted: () => Effect.succeed(true), + setTrusted: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ScriptCommandRunner, { + run: () => Effect.succeed({ outcome: "exited", exitCode: 0, signal: null }), + }), + ), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: () => Effect.succeed(true), + captureBaseline: () => Effect.succeed("refs/t3/tickets/dC0x/base"), + captureStep: (_ticketId, _stepRunId, _cwd, kind) => + Effect.succeed(kind === "pre" ? preRef : postRef), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderDispatchOutbox, { + confirmStep: () => Effect.void, + ensureStarted: (input) => + Effect.sync(() => { + continueDispatchInputs.push({ threadId: input.threadId as string }); + return { turnId: "turn-stub" as never }; + }), + getDispatchForStep: () => Effect.succeed(null), + awaitTerminal: () => Effect.succeed(terminal), + awaitStepTerminal: () => Effect.succeed(terminal), + recoverPending: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused startSession"), + sendTurn: () => Effect.die("unused sendTurn"), + interruptTurn: (input) => + Effect.sync(() => { + continueProviderCalls.push( + `interrupt:${input.threadId as string}:${input.turnId as string}`, + ); + }), + respondToRequest: () => Effect.die("unused respondToRequest"), + respondToUserInput: () => Effect.die("unused respondToUserInput"), + stopSession: (input) => + Effect.sync(() => { + continueProviderCalls.push(`stop:${input.threadId as string}`); + }), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" } as const), + getInstanceInfo: () => Effect.die("unused getInstanceInfo"), + rollbackConversation: () => Effect.die("unused rollbackConversation"), + streamEvents: Stream.empty, + }), + ), + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: () => Effect.void, + }), + ), + Layer.provideMerge(StubTicketMergeServiceLayer), + Layer.provideMerge(StubTicketPullRequestServiceLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(realStepExecutorTestSupport), + Layer.provideMerge(NodeServices.layer), + ), + ); + +continueSessionLayer({ ok: true })("RealStepExecutor continueSession resume", (it) => { + it.effect("mints + upserts a stable thread on the first continueSession run", () => + Effect.gen(function* () { + continueDispatchInputs.length = 0; + continueUpsertCalls.length = 0; + storedThreads.clear(); + const executor = yield* StepExecutor; + yield* seedStepStartedFor(continueContext, "event-step-started-continue-miss"); + + const outcome = yield* executor.execute(continueContext); + + assert.equal(outcome._tag, "completed"); + // Miss: a fresh thread is minted, dispatched, AND recorded for resume. + assert.equal(continueDispatchInputs.length, 1); + const mintedThread = continueDispatchInputs[0]?.threadId; + assert.isDefined(mintedThread); + assert.equal(continueUpsertCalls.length, 1); + assert.deepEqual(continueUpsertCalls[0], { + ticketId: "ticket-continue", + laneKey: "lane-continue", + agentKey: continueAgentKey, + threadId: mintedThread, + }); + }), + ); + + it.effect("dispatches the stored thread (not a fresh eventId) when one exists", () => + Effect.gen(function* () { + continueDispatchInputs.length = 0; + continueUpsertCalls.length = 0; + storedThreads.clear(); + storedThreads.set( + `ticket-continue|lane-continue|${continueAgentKey}`, + "stored-thread-resume", + ); + const executor = yield* StepExecutor; + yield* seedStepStartedFor(continueContext, "event-step-started-continue-hit"); + + const outcome = yield* executor.execute(continueContext); + + assert.equal(outcome._tag, "completed"); + // Hit: dispatch reuses the stored thread, and no new upsert overwrites it. + assert.equal(continueDispatchInputs.length, 1); + assert.equal(continueDispatchInputs[0]?.threadId, "stored-thread-resume"); + assert.equal(continueUpsertCalls.length, 0); + }), + ); + + it.effect("reuses the same thread across a second (loop) run", () => + Effect.gen(function* () { + continueDispatchInputs.length = 0; + continueUpsertCalls.length = 0; + storedThreads.clear(); + const executor = yield* StepExecutor; + yield* seedStepStartedFor(continueContext, "event-step-started-continue-loop-1"); + + const first = yield* executor.execute(continueContext); + assert.equal(first._tag, "completed"); + const firstThread = continueDispatchInputs[0]?.threadId; + assert.isDefined(firstThread); + // The first run records the thread; the second run reads it back. + assert.equal(continueUpsertCalls.length, 1); + + const second = yield* executor.execute({ + ...continueContext, + stepRunId: "step-run-continue-loop-2" as never, + }); + assert.equal(second._tag, "completed"); + assert.equal(continueDispatchInputs.length, 2); + assert.equal(continueDispatchInputs[1]?.threadId, firstThread); + // No second upsert — the stored thread is preserved on the hit path. + assert.equal(continueUpsertCalls.length, 1); + }), + ); + + it.effect("leaves a plain (non-continueSession) step minting a fresh thread", () => + Effect.gen(function* () { + continueDispatchInputs.length = 0; + continueUpsertCalls.length = 0; + storedThreads.clear(); + const executor = yield* StepExecutor; + yield* seedStepStartedFor(plainContext, "event-step-started-plain"); + + const outcome = yield* executor.execute(plainContext); + + assert.equal(outcome._tag, "completed"); + assert.equal(continueDispatchInputs.length, 1); + // A plain step never touches the session store. + assert.equal(continueUpsertCalls.length, 0); + assert.equal(storedThreads.size, 0); + }), + ); +}); + +continueSessionLayer({ ok: false, error: "turn did not reach a terminal state before timeout" })( + "RealStepExecutor continueSession failure cleanup", + (it) => { + it.effect("still interrupts + stops the session on failure, then resumes next run", () => + Effect.gen(function* () { + continueDispatchInputs.length = 0; + continueUpsertCalls.length = 0; + continueProviderCalls.length = 0; + storedThreads.clear(); + const executor = yield* StepExecutor; + yield* seedStepStartedFor(continueContext, "event-step-started-continue-fail"); + + const outcome = yield* executor.execute(continueContext); + + assert.equal(outcome._tag, "failed"); + // The first (failed) run still minted + recorded the stable thread. + const failedThread = continueDispatchInputs[0]?.threadId; + assert.isDefined(failedThread); + assert.equal(continueUpsertCalls.length, 1); + assert.equal(continueUpsertCalls[0]?.threadId, failedThread); + // Failure cleanup MUST still run — interrupt the live turn and stop the + // session (stopSession preserves the resume cursor, so resume survives). + assert.deepEqual(continueProviderCalls, [ + `interrupt:${failedThread}:turn-stub`, + `stop:${failedThread}`, + ]); + + // A later run resumes the SAME thread (the store kept it through cleanup). + const retry = yield* executor.execute({ + ...continueContext, + stepRunId: "step-run-continue-fail-retry" as never, + }); + assert.equal(retry._tag, "failed"); + assert.equal(continueDispatchInputs[1]?.threadId, failedThread); + assert.equal(continueUpsertCalls.length, 1); + }), + ); + }, +); diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.ts b/apps/server/src/workflow/Layers/RealStepExecutor.ts new file mode 100644 index 00000000000..31bb19c3e2f --- /dev/null +++ b/apps/server/src/workflow/Layers/RealStepExecutor.ts @@ -0,0 +1,965 @@ +import { + ProviderInstanceId, + TrimmedNonEmptyString, + type ProjectId, + type StepOutcome, + type TurnId, + type WorkflowStepUsage, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { ProjectScriptTrust } from "../Services/ProjectScriptTrust.ts"; +import { + ProviderDispatchOutbox, + type ProviderDispatchTerminalResult, +} from "../Services/ProviderDispatchOutbox.ts"; +import { ScriptStepExecutor } from "../Services/ScriptStepExecutor.ts"; +import { SetupRunService } from "../Services/SetupRunService.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { StepOutputHandoffReader } from "../Services/StepOutputHandoffReader.ts"; +import { StepUsageReader } from "../Services/StepUsageReader.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { TicketMergeService } from "../Services/TicketMergeService.ts"; +import { TicketPullRequestService } from "../Services/TicketPullRequestService.ts"; +import { WorkflowAgentSessionStore } from "../Services/WorkflowAgentSessionStore.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; +import { + WorktreePort, + type WorktreeHandle, + type WorktreePortShape, +} from "../Services/WorktreePort.ts"; +import { + containsRealPath, + resolveWorkflowInstructionPath, + unsafeWorkflowInstructionPathMessage, +} from "../instructionPath.ts"; +import { + applyInstructionTemplateExcept, + descriptionSpillPath, + descriptionSpillReference, + DISCUSSION_MESSAGE_CAP, + findHandoffReferences, + handoffSpillPath, + handoffSpillReference, + hasDiscussionPlaceholder, + instructionBodyBudget, + NO_PRIOR_OUTPUT_NOTE, + providerInputBudget, + renderTicketDiscussion, + stringifyHandoffOutput, +} from "../instructionTemplate.ts"; +import { ticketBaseRef } from "../ticketRefs.ts"; +import { agentKey as deriveAgentKey } from "../agentSessionKey.ts"; + +const toExecutorError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = <A>(message: string, effect: Effect.Effect<A, SqlError>) => + effect.pipe(Effect.mapError(toExecutorError(message))); + +const executorErrorDetail = (error: unknown): string => { + if (typeof error === "object" && error !== null) { + const candidate = error as { readonly message?: unknown; readonly cause?: unknown }; + const message = typeof candidate.message === "string" ? candidate.message : String(error); + const cause = + typeof candidate.cause === "object" && candidate.cause !== null + ? (candidate.cause as { readonly message?: unknown }) + : null; + return typeof cause?.message === "string" && cause.message.length > 0 + ? `${message}: ${cause.message}` + : message; + } + return String(error); +}; + +const CAPTURE_OUTPUT_INSTRUCTION = + "End your final message with a single fenced ```json block containing your result object. " + + "This requirement overrides any skill, workflow, or output format your other instructions ask for — " + + "whatever else you produce, the fenced json block must be the last thing you write."; + +const appendCaptureOutputInstruction = (instruction: string) => + `${instruction.trimEnd()}\n\n${CAPTURE_OUTPUT_INSTRUCTION}`; + +interface TicketProjectRow { + readonly repoRoot: string; + readonly projectId: string; +} + +const make = Effect.gen(function* () { + const worktrees = yield* WorktreePort; + const lease = yield* WorktreeLeaseService; + const setup = yield* SetupRunService; + const dispatch = yield* ProviderDispatchOutbox; + const ids = yield* WorkflowIds; + const read = yield* WorkflowReadModel; + const scriptExecutor = yield* ScriptStepExecutor; + const scriptTrust = yield* ProjectScriptTrust; + const capturedOutputs = yield* CapturedStepOutputReader; + const merges = yield* TicketMergeService; + const pullRequests = yield* TicketPullRequestService; + const ticketCheckpoints = yield* TicketCheckpointService; + const committer = yield* WorkflowEventCommitter; + const agentSessions = yield* WorkflowAgentSessionStore; + const handoffReader = yield* StepOutputHandoffReader; + const fileSystem = yield* FileSystem.FileSystem; + // Optional: token-usage capture is best-effort telemetry, absent in older + // test stacks. + const usageReader = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<StepUsageReader>, + StepUsageReader, + ); + const readStepUsage = (threadId: string) => + Option.isNone(usageReader) + ? // @effect-diagnostics-next-line effectSucceedWithVoid:off — must stay `Effect<undefined>` (not `Effect<void>`) so it unifies with read()'s `WorkflowStepUsage | undefined` and feeds sumUsage + Effect.succeed<WorkflowStepUsage | undefined>(undefined) + : usageReader.value.read(threadId as never); + + const prepareWorktreeStep = ( + ctx: Parameters<StepExecutorShape["execute"]>[0], + body: (worktree: WorktreeHandle) => Effect.Effect<StepOutcome, WorkflowEventStoreError>, + options?: { + readonly preSetupGuard?: ( + worktree: WorktreeHandle, + ) => Effect.Effect<StepOutcome | null, WorkflowEventStoreError>; + readonly skipSetup?: boolean; + // When true, the project's setup script is only run if the worktree's + // project is trusted; an untrusted project's setup is SKIPPED (its + // arbitrary shell never executes) without failing the step. + readonly gateSetupOnTrust?: boolean; + }, + ) => + Effect.gen(function* () { + const worktree = yield* worktrees.ensureWorktree(ctx.ticketId); + const hasBaseline = yield* ticketCheckpoints.hasBaseline(ctx.ticketId, worktree.path); + if (!hasBaseline) { + yield* ticketCheckpoints.captureBaseline(ctx.ticketId, worktree.path); + } + + const guarded = yield* options?.preSetupGuard?.(worktree) ?? Effect.succeed(null); + if (guarded !== null) { + return guarded; + } + + let runSetupStep = options?.skipSetup !== true; + if (runSetupStep && options?.gateSetupOnTrust === true && worktree.projectId !== undefined) { + const trusted = yield* scriptTrust.isTrusted(worktree.projectId as ProjectId); + if (!trusted) { + // Untrusted project: withhold the setup script (arbitrary code) but let + // the agent step proceed. Distinct from the script-step guard, which + // blocks because the script IS the untrusted surface. + runSetupStep = false; + yield* Effect.logWarning("skipping setup for untrusted project on agent step", { + ticketId: ctx.ticketId, + projectId: worktree.projectId, + }); + } + } + if (runSetupStep) { + const setupRunId = yield* ids.eventId(); + const setupResult = yield* setup.runSetup( + ctx.ticketId, + worktree.worktreeRef, + worktree.path, + setupRunId as never, + worktree.projectId, + ); + if (setupResult.status !== "completed") { + return { _tag: "failed", error: `setup ${setupResult.status}` } satisfies StepOutcome; + } + } + + const acquired = yield* lease.acquire(worktree.worktreeRef, "step", ctx.stepRunId as string); + const releaseIfStillOwner = lease.isValid(worktree.worktreeRef, acquired.fenceToken).pipe( + Effect.flatMap((valid) => + valid ? lease.release(worktree.worktreeRef, acquired.fenceToken) : Effect.void, + ), + Effect.orElseSucceed(() => undefined), + ); + + const result = yield* Effect.gen(function* () { + const preRef = yield* ticketCheckpoints.captureStep( + ctx.ticketId, + ctx.stepRunId, + worktree.path, + "pre", + ); + const bodyExit = yield* body(worktree).pipe(Effect.exit); + const postRef = yield* ticketCheckpoints.captureStep( + ctx.ticketId, + ctx.stepRunId, + worktree.path, + "post", + ); + const eventId = yield* ids.eventId(); + const occurredAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + yield* committer.commit({ + type: "StepRefsCaptured", + eventId: eventId as never, + ticketId: ctx.ticketId, + occurredAt: occurredAt as never, + payload: { stepRunId: ctx.stepRunId, preRef, postRef }, + }); + if (Exit.isFailure(bodyExit)) { + return yield* Effect.failCause(bodyExit.cause); + } + return bodyExit.value; + }).pipe(Effect.ensuring(releaseIfStillOwner)); + + return result; + }); + + const providerServiceOption = Effect.serviceOption(ProviderService); + + const cleanupStepSession = (threadId: string, turnId: TurnId) => + Effect.gen(function* () { + const provider = yield* providerServiceOption; + if (Option.isNone(provider)) { + return; + } + yield* provider.value + .interruptTurn({ threadId: threadId as never, turnId: turnId as never }) + .pipe(Effect.catch(() => Effect.void)); + yield* provider.value + .stopSession({ threadId: threadId as never }) + .pipe(Effect.catch(() => Effect.void)); + }); + + const sumUsage = ( + total: WorkflowStepUsage | undefined, + next: WorkflowStepUsage | undefined, + ): WorkflowStepUsage | undefined => { + if (next === undefined) { + return total; + } + if (total === undefined) { + return next; + } + const add = (a: number | undefined, b: number | undefined) => + a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0); + return { + ...(add(total.inputTokens, next.inputTokens) === undefined + ? {} + : { inputTokens: add(total.inputTokens, next.inputTokens) }), + ...(add(total.cachedInputTokens, next.cachedInputTokens) === undefined + ? {} + : { cachedInputTokens: add(total.cachedInputTokens, next.cachedInputTokens) }), + ...(add(total.outputTokens, next.outputTokens) === undefined + ? {} + : { outputTokens: add(total.outputTokens, next.outputTokens) }), + ...(add(total.totalTokens, next.totalTokens) === undefined + ? {} + : { totalTokens: add(total.totalTokens, next.totalTokens) }), + }; + }; + + const verdictOf = (output: unknown): string | null => { + if (typeof output !== "object" || output === null || Array.isArray(output)) { + return null; + } + const verdict = (output as Record<string, unknown>)["verdict"]; + return typeof verdict === "string" ? verdict : null; + }; + + // Fan out `panelSize` independent turns of the same review step and take + // the strict-majority verdict. A member that fails, stalls on a question, + // or returns unusable output simply contributes no vote; without a strict + // majority the step fails (never silently picks a side). + const runReviewPanel = ( + ctx: Parameters<StepExecutorShape["execute"]>[0], + step: Extract<Parameters<StepExecutorShape["execute"]>[0]["step"], { readonly type: "agent" }>, + panelSize: number, + runTurn: ( + turnIds: { readonly dispatchId: string; readonly threadId: string }, + titleSuffix: string, + ) => Effect.Effect< + { + readonly terminal: ProviderDispatchTerminalResult; + readonly turnId: TurnId; + readonly threadId: string; + }, + WorkflowEventStoreError + >, + ) => + Effect.gen(function* () { + const memberIds = yield* Effect.forEach( + Array.from({ length: panelSize }, (_, index) => index), + () => + Effect.all({ + dispatchId: ids.eventId().pipe(Effect.map((id) => id as string)), + threadId: ids.eventId().pipe(Effect.map((id) => id as string)), + }), + ); + // Members run sequentially: they share the ticket worktree, and two + // concurrent full-access agents in one tree can corrupt each other's + // view. Review steps are read-mostly, so serial members are safe even + // if one misbehaves and writes. + const members = yield* Effect.all( + memberIds.map((turnIds, index) => + runTurn(turnIds, ` (reviewer ${index + 1}/${panelSize})`), + ), + { concurrency: 1 }, + ); + + let usage: WorkflowStepUsage | undefined; + const votes: Array<{ + readonly reviewer: number; + readonly verdict: string | null; + readonly output: unknown; + readonly error?: string; + }> = []; + for (const [index, member] of members.entries()) { + usage = sumUsage(usage, yield* readStepUsage(member.threadId)); + if (!member.terminal.ok) { + votes.push({ + reviewer: index + 1, + verdict: null, + output: null, + error: + "awaitingUser" in member.terminal + ? "reviewer asked a question" + : (member.terminal.error ?? "turn failed"), + }); + continue; + } + const output = yield* capturedOutputs.read({ + stepRunId: ctx.stepRunId, + threadId: member.threadId as never, + turnId: member.turnId, + }); + votes.push({ + reviewer: index + 1, + verdict: verdictOf(output), + output: output ?? null, + }); + } + + // A member that stalled on a question (or failed mid-turn) leaves a + // live provider session and an unconfirmed outbox row nobody is meant + // to answer — stop the session and settle every member row so restart + // recovery never re-monitors a decided panel. + for (const member of members) { + if (member.terminal.ok) { + continue; + } + // cleanupStepSession already swallows its own failures (its error channel + // is `never`), so this is a defensive best-effort guard; `Effect.ignore` + // discards any typed failure while letting genuine defects surface. + yield* cleanupStepSession(member.threadId, member.turnId).pipe(Effect.ignore); + } + yield* dispatch.confirmStep(ctx.stepRunId).pipe(Effect.catch(() => Effect.void)); + + const counts = new Map<string, number>(); + for (const vote of votes) { + if (vote.verdict !== null) { + counts.set(vote.verdict, (counts.get(vote.verdict) ?? 0) + 1); + } + } + let winner: string | null = null; + let winnerCount = 0; + for (const [verdict, count] of counts) { + if (count > winnerCount) { + winner = verdict; + winnerCount = count; + } + } + if (winner !== null && winnerCount * 2 > panelSize) { + return { + _tag: "completed", + output: { verdict: winner, votes }, + ...(usage === undefined ? {} : { usage }), + } satisfies StepOutcome; + } + return { + _tag: "failed", + error: `review panel did not reach a majority (${votes + .map((vote) => vote.verdict ?? "no vote") + .join(", ")})`, + ...(usage === undefined ? {} : { usage }), + } satisfies StepOutcome; + }); + + // Resolve a single handoff variable to its source step's captured output. + // `prev` reads the immediately-preceding step in THIS pass; `step.<key>` + // reads this pass first, then the latest completed prior pass (loop). A + // forward reference with nothing captured yet resolves to null. + const resolveHandoffSource = ( + ctx: Parameters<StepExecutorShape["execute"]>[0], + sourceStepKey: string | null, + ) => + Effect.gen(function* () { + if (sourceStepKey === null) { + return null; + } + const thisPass = yield* handoffReader.currentPassOutput( + ctx.pipelineRunId, + sourceStepKey as never, + ); + if (thisPass !== null) { + return thisPass; + } + return yield* handoffReader.latestCompletedOutput( + ctx.ticketId, + ctx.laneKey, + sourceStepKey as never, + ); + }); + + // Resolve `{{prev.output}}` / `{{step.<key>.output}}` in the assembled + // instruction. Each resolved output is inlined when the running assembled + // instruction stays under the handoff budget (the provider input cap minus + // reserved room for discussion + capture suffix); otherwise the full output + // spills to `.t3/ticket/<id>/handoff/<safeKey>.md` in the worktree and a path + // reference is substituted. Spill files live in the per-ticket scratch tree + // the merge step purges, so they never reach the branch/PR. + const resolveHandoffPlaceholders = ( + ctx: Parameters<StepExecutorShape["execute"]>[0], + worktree: WorktreeHandle, + step: Extract<Parameters<StepExecutorShape["execute"]>[0]["step"], { readonly type: "agent" }>, + baseInstruction: string, + bodyBudget: number, + ) => + Effect.gen(function* () { + const references = findHandoffReferences(baseInstruction); + if (references.length === 0) { + return baseInstruction; + } + const stepKeys = ctx.laneStepKeys as ReadonlyArray<string>; + const currentIndex = stepKeys.indexOf(step.key as string); + const precedingStepKey = currentIndex > 0 ? (stepKeys[currentIndex - 1] ?? null) : null; + + let assembled = baseInstruction; + // Tracks the projected final length as inlines accumulate, so the budget + // applies to the WHOLE assembled instruction, not each output in isolation. + let assembledLength = baseInstruction.length; + for (const reference of references) { + const sourceStepKey = + reference.kind === "prev" ? precedingStepKey : (reference.stepKey ?? null); + const output = yield* resolveHandoffSource(ctx, sourceStepKey); + let replacement: string; + if (output === null) { + replacement = NO_PRIOR_OUTPUT_NOTE; + } else { + const rendered = stringifyHandoffOutput(output); + const projected = assembledLength - reference.raw.length + rendered.length; + if (projected > bodyBudget && sourceStepKey !== null) { + const relativePath = handoffSpillPath(ctx.ticketId as string, sourceStepKey); + const absolutePath = `${worktree.path}/${relativePath}`; + const directory = absolutePath.slice(0, absolutePath.lastIndexOf("/")); + yield* fileSystem + .makeDirectory(directory, { recursive: true }) + .pipe(Effect.mapError(toExecutorError("handoff spill directory create failed"))); + yield* fileSystem + .writeFileString(absolutePath, rendered) + .pipe(Effect.mapError(toExecutorError("handoff spill write failed"))); + replacement = handoffSpillReference(relativePath); + } else { + replacement = rendered; + } + } + // Function replacer: a string replacement would interpret `$` + // sequences (e.g. `$&`) in agent output as special patterns. + assembled = assembled.replace(reference.raw, () => replacement); + assembledLength = assembledLength - reference.raw.length + replacement.length; + } + return assembled; + }); + + const executeAgentStep = ( + ctx: Parameters<StepExecutorShape["execute"]>[0], + worktree: WorktreeHandle, + step: Extract<Parameters<StepExecutorShape["execute"]>[0]["step"], { readonly type: "agent" }>, + ) => + Effect.gen(function* () { + // Budget gate: once the ticket's usage roll-up reaches its budget, no + // further provider turns start — the step blocks (not fails) so a human + // can raise the budget or move the ticket on. + const budgetDetail = yield* read.getTicketDetail(ctx.ticketId); + const tokenBudget = budgetDetail?.ticket.tokenBudget; + const usedTokens = budgetDetail?.ticket.totalTokens ?? 0; + if (typeof tokenBudget === "number" && usedTokens >= tokenBudget) { + return { + _tag: "blocked", + reason: `token budget reached (${usedTokens.toLocaleString("en-US")} of ${tokenBudget.toLocaleString("en-US")} tokens used)`, + } satisfies StepOutcome; + } + const dispatchId = yield* ids.eventId(); + const mintedThreadId = yield* ids.eventId(); + // A `continueSession` agent step resumes its own provider session across + // steps/loops by reusing a stable workflow `threadId` anchored to + // (ticket, lane, agentKey): `startSession(threadId)` replays the persisted + // resume cursor. On a miss we mint a fresh thread and record it; on a hit + // we dispatch the stored thread (and never overwrite it). Panel members + // always keep fresh ids — lint forbids continueSession + panel. + const threadId = + step.continueSession === true + ? yield* Effect.gen(function* () { + const agentKey = deriveAgentKey( + step.agent.instance as string, + step.agent.model as string, + step.agent.options, + ); + const existing = yield* agentSessions.getThreadId( + ctx.ticketId, + ctx.laneKey, + agentKey, + ); + if (existing !== null) { + return existing; + } + yield* agentSessions.upsert( + ctx.ticketId, + ctx.laneKey, + agentKey, + mintedThreadId as string, + ); + return mintedThreadId as string; + }) + : (mintedThreadId as string); + const resolvedInstruction = yield* Effect.gen(function* () { + if (typeof step.instruction === "string") { + return step.instruction; + } + + const instructionFile = step.instruction.file; + const instructionPath = resolveWorkflowInstructionPath(worktree.repoRoot, instructionFile); + if (instructionPath === null) { + return yield* new WorkflowEventStoreError({ + message: unsafeWorkflowInstructionPathMessage(instructionFile), + }); + } + + const realRepoRoot = yield* fileSystem + .realPath(worktree.repoRoot) + .pipe(Effect.mapError(toExecutorError("instruction file realpath check failed"))); + const realInstructionPath = yield* fileSystem + .realPath(instructionPath) + .pipe(Effect.mapError(toExecutorError("instruction file realpath check failed"))); + if (!containsRealPath(realRepoRoot, realInstructionPath)) { + return yield* Effect.succeed({ + _tag: "failed", + error: `Instruction file resolves outside the project root: "${instructionFile}"`, + } satisfies StepOutcome); + } + + return yield* fileSystem + .readFileString(realInstructionPath) + .pipe(Effect.mapError(toExecutorError("instruction file read failed"))); + }); + if (typeof resolvedInstruction !== "string") { + return resolvedInstruction; + } + // Attachment-count-only query capped one past the renderer's message + // budget, so long threads never decode attachment data URLs here. + const discussion = renderTicketDiscussion( + yield* read.listTicketDiscussion(ctx.ticketId, DISCUSSION_MESSAGE_CAP + 1), + ); + // Resolve the active provider's per-turn input budget (clamped to 120k). + // Absent ProviderService (some test layers) or a failed lookup → 120k. + const providerSvcOpt = yield* providerServiceOption; + const maxInputChars = Option.isSome(providerSvcOpt) + ? yield* providerSvcOpt.value + .getCapabilities(step.agent.instance as ProviderInstanceId) + .pipe( + Effect.map((c) => c.maxInputChars), + Effect.orElseSucceed(() => undefined), + ) + : undefined; + const providerBudget = providerInputBudget(maxInputChars); + + // The discussion block appended after the body (0 when inlined via the + // {{ticket.discussion}} placeholder). Reserved exactly against the budget. + const appendedDiscussionBlock = + discussion !== "" && !hasDiscussionPlaceholder(resolvedInstruction) + ? `\n\n## Ticket discussion\n\n${discussion}` + : ""; + const bodyBudget = instructionBodyBudget( + providerBudget, + appendedDiscussionBlock.length, + step.captureOutput === true, + ); + + // Substitute the short ticket fields, decide whether the {{ticket.description}} + // body inlines or spills, resolve handoff against the SKELETON (description + // still a marker), then substitute the description. Resolving handoff before + // the description is spliced in is deliberate: it stops the handoff scanner + // from matching {{prev.output}}/{{step.k.output}} text that happens to appear + // inside a ticket description (which would silently mangle the description). + const instructionWithHandoff = resolvedInstruction.includes("{{") + ? yield* Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ctx.ticketId); + const title = detail?.ticket.title ?? ""; + const rawDescription = detail?.ticket.description ?? ""; + const templatedShort = applyInstructionTemplateExcept( + resolvedInstruction, + { + title, + id: ctx.ticketId as string, + baseRef: ticketBaseRef(ctx.ticketId), + ...(hasDiscussionPlaceholder(resolvedInstruction) + ? { discussion: discussion === "" ? "(no discussion yet)" : discussion } + : {}), + }, + ["description"], + ); + const descRefs = [...templatedShort.matchAll(/\{\{\s*ticket\.description\s*\}\}/g)]; + // Sum the ACTUAL matched marker lengths (the pattern allows internal + // whitespace), so the inline projection is exact. + const matchedLen = descRefs.reduce((n, m) => n + m[0].length, 0); + const pointer = descriptionSpillReference(descriptionSpillPath(ctx.ticketId as string)); + // Spilling only helps when there is a body and the pointer is shorter + // than it (otherwise inlining produces the smaller prompt). + const canSpillDescription = + descRefs.length > 0 && + rawDescription.length > 0 && + pointer.length < rawDescription.length; + const spillDescriptionFile = Effect.gen(function* () { + const spillPath = descriptionSpillPath(ctx.ticketId as string); + const absolute = `${worktree.path}/${spillPath}`; + const dir = absolute.slice(0, absolute.lastIndexOf("/")); + yield* fileSystem + .makeDirectory(dir, { recursive: true }) + .pipe(Effect.mapError(toExecutorError("description spill dir create failed"))); + yield* fileSystem + .writeFileString(absolute, `# ${title.split("\n")[0]}\n\n${rawDescription}`) + .pipe(Effect.mapError(toExecutorError("description spill write failed"))); + }); + // Decide the description replacement: inline if it fits, otherwise spill + // it to a worktree scratch file and point the agent at it. + let descriptionReplacement = rawDescription; + if (canSpillDescription) { + const inlineProjection = + templatedShort.length - matchedLen + descRefs.length * rawDescription.length; + if (inlineProjection > bodyBudget) { + yield* spillDescriptionFile; + descriptionReplacement = pointer; + } + } + // Net length the description substitution adds to the body; reserve it + // out of the handoff budget so the final assembled body stays bounded. + const descriptionDelta = descRefs.length * descriptionReplacement.length - matchedLen; + const resolvedSkeleton = yield* resolveHandoffPlaceholders( + ctx, + worktree, + step, + templatedShort, + Math.max(0, bodyBudget - descriptionDelta), + ); + // Final-fit fallback: handoff spilling adds small pointer-reference + // overhead the up-front description projection couldn't account for. If + // the assembled body (skeleton + inlined description) would still exceed + // the body budget, spill the description now — it's the largest + // reclaimable blob. providerBudget − bodyBudget already reserves the + // appended discussion + capture suffix, so body ≤ bodyBudget keeps the + // final prompt within the provider budget. + if (canSpillDescription && descriptionReplacement === rawDescription) { + const inlinedBodyLength = resolvedSkeleton.length + descriptionDelta; + if (inlinedBodyLength > bodyBudget) { + yield* spillDescriptionFile; + descriptionReplacement = pointer; + } + } + // Splice the description in LAST — its literal {{...}} text is never + // scanned for handoff placeholders. + return resolvedSkeleton.replace( + /\{\{\s*ticket\.description\s*\}\}/g, + () => descriptionReplacement, + ); + }) + : resolvedInstruction; + // Comments always reach the next agent step: unless the instruction + // already placed the transcript via {{ticket.discussion}}, append it. + const instructionWithDiscussion = + appendedDiscussionBlock !== "" + ? `${instructionWithHandoff}${appendedDiscussionBlock}` + : instructionWithHandoff; + const instruction = + step.captureOutput === true + ? appendCaptureOutputInstruction(instructionWithDiscussion) + : instructionWithDiscussion; + if (instruction.length > providerBudget) { + yield* Effect.logWarning( + `workflow step ${step.key} prompt (${instruction.length}) exceeds provider budget (${providerBudget}) after spilling`, + ); + } + const runTurn = ( + turnIds: { readonly dispatchId: string; readonly threadId: string }, + titleSuffix: string, + ) => + Effect.gen(function* () { + const started = yield* dispatch.ensureStarted({ + dispatchId: turnIds.dispatchId as never, + ticketId: ctx.ticketId, + stepRunId: ctx.stepRunId, + threadId: turnIds.threadId as never, + providerInstance: step.agent.instance as string, + model: step.agent.model as string, + instruction, + worktreePath: worktree.path, + ...(step.agent.options === undefined ? {} : { options: step.agent.options }), + ...(worktree.projectId === undefined ? {} : { projectId: worktree.projectId }), + threadTitle: `Workflow step ${step.key}${titleSuffix} · ${ctx.ticketId}`, + }); + const terminal = yield* dispatch.awaitTerminal( + turnIds.dispatchId as never, + turnIds.threadId as never, + ); + return { terminal, turnId: started.turnId, threadId: turnIds.threadId }; + }); + + const panelSize = step.panel ?? 0; + if (panelSize >= 2 && step.captureOutput === true) { + return yield* runReviewPanel(ctx, step, panelSize, runTurn); + } + + const result = yield* runTurn( + { dispatchId: dispatchId as string, threadId: threadId as string }, + "", + ); + + if (result.terminal.ok) { + const usage = yield* readStepUsage(threadId as string); + if (step.captureOutput === true) { + const output = yield* capturedOutputs.read({ + stepRunId: ctx.stepRunId, + threadId: threadId as never, + turnId: result.turnId, + }); + if (output === undefined) { + return { + _tag: "failed", + error: "missing or invalid structured output", + ...(usage === undefined ? {} : { usage }), + } satisfies StepOutcome; + } + return { + _tag: "completed", + output, + ...(usage === undefined ? {} : { usage }), + } satisfies StepOutcome; + } + return { + _tag: "completed", + ...(usage === undefined ? {} : { usage }), + } satisfies StepOutcome; + } + if ("awaitingUser" in result.terminal) { + return { + _tag: "awaiting_user", + waitingReason: result.terminal.waitingReason, + providerThreadId: result.terminal.providerThreadId, + providerRequestId: result.terminal.providerRequestId, + providerResponseKind: result.terminal.providerResponseKind, + ...(result.terminal.providerQuestionId === undefined + ? {} + : { providerQuestionId: result.terminal.providerQuestionId }), + } satisfies StepOutcome; + } + // The turn may still be live (e.g. the terminal-wait timed out): stop + // the provider session so the agent cannot keep mutating the worktree + // while the pipeline routes on. Interrupting an already-terminal turn + // is a harmless no-op. + yield* cleanupStepSession(result.threadId, result.turnId); + const failureUsage = yield* readStepUsage(threadId as string); + return { + _tag: "failed", + error: result.terminal.error ?? "turn failed", + ...(failureUsage === undefined ? {} : { usage: failureUsage }), + } satisfies StepOutcome; + }); + + const scriptTrustGuard = (ctx: Parameters<StepExecutorShape["execute"]>[0]) => + Effect.gen(function* () { + const board = yield* read.getBoard(ctx.boardId); + if (board === null) { + return { _tag: "failed", error: "workflow board not found" } satisfies StepOutcome; + } + const trusted = yield* scriptTrust.isTrusted(board.projectId as ProjectId); + if (!trusted) { + return { + _tag: "blocked", + reason: "Project not trusted to run scripts", + } satisfies StepOutcome; + } + return null; + }); + + const execute: StepExecutorShape["execute"] = (ctx) => + Effect.gen(function* () { + const step = ctx.step; + if (step.type === "approval") { + return { _tag: "completed" } satisfies StepOutcome; + } + if (step.type === "script") { + return yield* prepareWorktreeStep( + ctx, + (worktree) => scriptExecutor.execute({ ctx, step, worktree }), + { preSetupGuard: () => scriptTrustGuard(ctx) }, + ); + } + if (step.type === "merge") { + return yield* prepareWorktreeStep( + ctx, + (worktree) => + merges.merge({ + ticketId: ctx.ticketId, + repoRoot: worktree.repoRoot, + worktreePath: worktree.path, + worktreeRef: worktree.worktreeRef, + step, + }), + // Merging needs no project dependencies installed in the worktree. + { skipSetup: true }, + ); + } + if (step.type === "pullRequest") { + // PR steps need no project dependencies installed in the worktree — + // they push/merge via gh. open and land share the same worktree prep. + return yield* prepareWorktreeStep( + ctx, + (worktree) => + step.action === "open" + ? pullRequests.open({ + ticketId: ctx.ticketId, + stepRunId: ctx.stepRunId, + repoRoot: worktree.repoRoot, + worktreePath: worktree.path, + worktreeRef: worktree.worktreeRef, + step, + }) + : pullRequests.land({ + ticketId: ctx.ticketId, + stepRunId: ctx.stepRunId, + repoRoot: worktree.repoRoot, + worktreePath: worktree.path, + worktreeRef: worktree.worktreeRef, + step, + }), + { skipSetup: true }, + ); + } + // Agent steps run the project's setup shell script via runSetup, which is + // arbitrary code — the same trust surface the script-step guard protects. + // But unlike a script step (whose whole purpose IS the untrusted script, so + // it blocks), the agent itself is not the untrusted surface. So gate ONLY + // the setup on project trust (gateSetupOnTrust): for an untrusted project + // the setup script is SKIPPED (never executed) while the agent step still + // runs. Merge/PR steps skip setup unconditionally. + return yield* prepareWorktreeStep(ctx, (worktree) => executeAgentStep(ctx, worktree, step), { + gateSetupOnTrust: true, + }); + }).pipe( + // Keep the executor total, but surface the underlying cause — a bare + // "executor error" is undiagnosable from the board. + Effect.catch((error) => + Effect.succeed<StepOutcome>({ + _tag: "failed", + error: `executor error: ${executorErrorDetail(error)}`, + }), + ), + ); + + return { execute } satisfies StepExecutorShape; +}); + +export const RealStepExecutorLive = Layer.effect(StepExecutor, make); + +export const WorktreePortLive = Layer.effect( + WorktreePort, + Effect.gen(function* () { + const git = yield* GitWorkflowService; + const sql = yield* SqlClient.SqlClient; + const fileSystem = yield* FileSystem.FileSystem; + + const canonicalizeExistingPath = (value: string) => + fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); + + const repoRootForTicket = (ticketId: string) => + wrapSql( + "ticket project lookup failed", + sql<TicketProjectRow>` + SELECT + projects.workspace_root AS "repoRoot", + projects.project_id AS "projectId" + FROM projection_ticket AS ticket + INNER JOIN projection_board AS board + ON board.board_id = ticket.board_id + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE ticket.ticket_id = ${ticketId} + LIMIT 1 + `, + ).pipe( + Effect.flatMap((rows) => { + const row = rows[0]; + return row?.repoRoot + ? Effect.succeed(row) + : Effect.fail( + new WorkflowEventStoreError({ + message: `project repo root not found for ticket ${ticketId}`, + }), + ); + }), + ); + + const ensureWorktree: WorktreePortShape["ensureWorktree"] = (ticketId) => + Effect.gen(function* () { + const project = yield* repoRootForTicket(ticketId as string); + const repoRoot = yield* canonicalizeExistingPath(project.repoRoot); + const projectId = project.projectId; + const worktreeRef = `workflow/${ticketId}`; + const refs = yield* git + .listRefs({ cwd: TrimmedNonEmptyString.make(repoRoot) }) + .pipe(Effect.mapError(toExecutorError("worktree ref lookup failed"))); + const existing = refs.refs.find((ref) => !ref.isRemote && ref.name === worktreeRef); + if (existing?.worktreePath) { + return { + repoRoot, + worktreeRef, + path: yield* canonicalizeExistingPath(existing.worktreePath), + projectId, + } satisfies WorktreeHandle; + } + + const result = yield* git + .createWorktree( + existing + ? { + cwd: TrimmedNonEmptyString.make(repoRoot), + refName: TrimmedNonEmptyString.make(worktreeRef), + path: null, + } + : { + cwd: TrimmedNonEmptyString.make(repoRoot), + refName: TrimmedNonEmptyString.make("HEAD"), + newRefName: TrimmedNonEmptyString.make(worktreeRef), + path: null, + }, + ) + .pipe(Effect.mapError(toExecutorError("worktree creation failed"))); + + return { + repoRoot, + worktreeRef: result.worktree.refName, + path: yield* canonicalizeExistingPath(result.worktree.path), + projectId, + } satisfies WorktreeHandle; + }); + + return { ensureWorktree } satisfies WorktreePortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/ScriptCancelRegistry.test.ts b/apps/server/src/workflow/Layers/ScriptCancelRegistry.test.ts new file mode 100644 index 00000000000..dd9e5ab2c67 --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptCancelRegistry.test.ts @@ -0,0 +1,43 @@ +import { assert, it } from "@effect/vitest"; +import { StepRunId, type TerminalCloseInput } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { ScriptCancelRegistryLive } from "./ScriptCancelRegistry.ts"; + +const layer = it.layer( + ScriptCancelRegistryLive.pipe( + Layer.provide( + Layer.succeed(TerminalManager, { + close: (input: TerminalCloseInput) => + Effect.sync(() => { + closed.push(`${input.threadId}:${input.terminalId ?? "*"}`); + }), + } as never), + ), + ), +); + +const closed: string[] = []; + +layer("ScriptCancelRegistryLive", (it) => { + it.effect("closes the registered script terminal and forgets it after unregister", () => + Effect.gen(function* () { + closed.length = 0; + const registry = yield* ScriptCancelRegistry; + const stepRunId = StepRunId.make("step-run-cancel"); + + yield* registry.register(stepRunId, { + scriptThreadId: "workflow-script:script-run-cancel" as never, + terminalId: "script-script-run-cancel", + }); + yield* registry.cancel(stepRunId); + yield* registry.unregister(stepRunId); + yield* registry.cancel(stepRunId); + + assert.deepEqual(closed, ["workflow-script:script-run-cancel:script-script-run-cancel"]); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/ScriptCancelRegistry.ts b/apps/server/src/workflow/Layers/ScriptCancelRegistry.ts new file mode 100644 index 00000000000..359b0df7dda --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptCancelRegistry.ts @@ -0,0 +1,44 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ScriptCancelRegistry, + type ScriptCancelHandle, + type ScriptCancelRegistryShape, +} from "../Services/ScriptCancelRegistry.ts"; + +const toCancelError = (cause: unknown) => + new WorkflowEventStoreError({ message: "script cancel failed", cause }); + +const make = Effect.gen(function* () { + const terminals = yield* TerminalManager; + const handles = yield* Ref.make(new Map<string, ScriptCancelHandle>()); + + const register: ScriptCancelRegistryShape["register"] = (stepRunId, handle) => + Ref.update(handles, (current) => new Map(current).set(stepRunId as string, handle)); + + const unregister: ScriptCancelRegistryShape["unregister"] = (stepRunId) => + Ref.update(handles, (current) => { + const next = new Map(current); + next.delete(stepRunId as string); + return next; + }); + + const cancel: ScriptCancelRegistryShape["cancel"] = (stepRunId) => + Effect.gen(function* () { + const handle = (yield* Ref.get(handles)).get(stepRunId as string); + if (!handle) { + return; + } + yield* terminals + .close({ threadId: handle.scriptThreadId, terminalId: handle.terminalId }) + .pipe(Effect.mapError(toCancelError)); + }); + + return { register, unregister, cancel } satisfies ScriptCancelRegistryShape; +}); + +export const ScriptCancelRegistryLive = Layer.effect(ScriptCancelRegistry, make); diff --git a/apps/server/src/workflow/Layers/ScriptCommandRunner.test.ts b/apps/server/src/workflow/Layers/ScriptCommandRunner.test.ts new file mode 100644 index 00000000000..ce31d94c8db --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptCommandRunner.test.ts @@ -0,0 +1,292 @@ +import { assert, it } from "@effect/vitest"; +import type { TerminalEvent, TerminalSessionSnapshot } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import { TerminalManager, type TerminalManagerShape } from "../../terminal/Services/Manager.ts"; +import { ScriptCommandRunner } from "../Services/ScriptCommandRunner.ts"; +import { ScriptCommandRunnerLive } from "./ScriptCommandRunner.ts"; + +const snapshot = (input: { + readonly threadId: string; + readonly terminalId: string; + readonly cwd: string; +}): TerminalSessionSnapshot => ({ + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + worktreePath: null, + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "script", + updatedAt: "2026-06-07T00:00:00.000Z", +}); + +const layerWithTerminal = (manager: TerminalManagerShape) => + ScriptCommandRunnerLive.pipe(Layer.provideMerge(Layer.succeed(TerminalManager, manager))); + +it.effect( + "subscribes before writing, wraps the command, and filters exit events by thread and terminal", + () => + Effect.gen(function* () { + const calls: string[] = []; + let listener: ((event: TerminalEvent) => Effect.Effect<void>) | null = null; + const layer = layerWithTerminal({ + open: (input) => + Effect.sync(() => { + calls.push(`open:${input.threadId}:${input.terminalId}:${input.cwd}`); + return snapshot(input); + }), + attachStream: () => Effect.succeed(() => undefined), + attachHistoryStream: () => Effect.succeed(() => undefined), + write: (input) => + Effect.gen(function* () { + calls.push(`write:${input.data}`); + if (listener === null) { + assert.fail("terminal listener was not installed before write"); + } + yield* listener({ + type: "exited", + threadId: "other-thread", + terminalId: input.terminalId, + exitCode: 99, + exitSignal: null, + }); + yield* listener({ + type: "exited", + threadId: input.threadId, + terminalId: "other-terminal", + exitCode: 98, + exitSignal: null, + }); + yield* listener({ + type: "exited", + threadId: input.threadId, + terminalId: input.terminalId, + exitCode: 7, + exitSignal: 15, + }); + }), + resize: () => Effect.void, + clear: () => Effect.void, + restart: (input) => Effect.succeed(snapshot(input)), + close: (input) => + Effect.sync(() => { + calls.push(`close:${input.threadId}:${input.terminalId ?? "*"}`); + }), + subscribe: (next) => + Effect.sync(() => { + calls.push("subscribe"); + listener = next; + return () => { + calls.push("unsubscribe"); + }; + }), + getSnapshot: () => Effect.succeed(null), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + + const result = yield* Effect.gen(function* () { + const runner = yield* ScriptCommandRunner; + return yield* runner.run({ + scriptThreadId: "script-thread" as never, + terminalId: "script-terminal", + cwd: "/tmp/worktree", + run: "exit 7", + timeout: Duration.seconds(1), + }); + }).pipe(Effect.provide(layer)); + + assert.deepEqual(result, { outcome: "exited", exitCode: 7, signal: 15 }); + assert.deepEqual(calls, [ + "subscribe", + "open:script-thread:script-terminal:/tmp/worktree", + "write:exit 7\nexit $?\r", + "unsubscribe", + ]); + }), +); + +it.effect("closes the terminal and resolves timeout when no terminal event arrives", () => + Effect.gen(function* () { + const calls: string[] = []; + const layer = layerWithTerminal({ + open: (input) => Effect.succeed(snapshot(input)), + attachStream: () => Effect.succeed(() => undefined), + attachHistoryStream: () => Effect.succeed(() => undefined), + write: () => Effect.void, + resize: () => Effect.void, + clear: () => Effect.void, + restart: (input) => Effect.succeed(snapshot(input)), + close: (input) => + Effect.sync(() => { + calls.push(`close:${input.threadId}:${input.terminalId ?? "*"}`); + }), + getSnapshot: () => Effect.succeed(null), + subscribe: () => Effect.succeed(() => undefined), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + + const result = yield* Effect.gen(function* () { + const runner = yield* ScriptCommandRunner; + const fiber = yield* Effect.forkChild( + runner.run({ + scriptThreadId: "timeout-thread" as never, + terminalId: "timeout-terminal", + cwd: "/tmp/worktree", + run: "sleep 10", + timeout: Duration.millis(10), + }), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(10)); + return yield* Fiber.join(fiber); + }).pipe(Effect.provide(Layer.merge(layer, TestClock.layer()))); + + assert.deepEqual(result, { outcome: "timeout", exitCode: null, signal: null }); + assert.deepEqual(calls, ["close:timeout-thread:timeout-terminal"]); + }), +); + +it.effect("treats a closed terminal event as cooperative cancellation", () => + Effect.gen(function* () { + let listener: ((event: TerminalEvent) => Effect.Effect<void>) | null = null; + const layer = layerWithTerminal({ + open: (input) => Effect.succeed(snapshot(input)), + attachStream: () => Effect.succeed(() => undefined), + attachHistoryStream: () => Effect.succeed(() => undefined), + write: (input) => + Effect.gen(function* () { + if (listener === null) { + assert.fail("terminal listener was not installed before write"); + } + yield* listener({ + type: "closed", + threadId: input.threadId, + terminalId: input.terminalId, + }); + }), + resize: () => Effect.void, + clear: () => Effect.void, + restart: (input) => Effect.succeed(snapshot(input)), + close: () => Effect.void, + getSnapshot: () => Effect.succeed(null), + subscribe: (next) => + Effect.sync(() => { + listener = next; + return () => undefined; + }), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + + const result = yield* Effect.gen(function* () { + const runner = yield* ScriptCommandRunner; + return yield* runner.run({ + scriptThreadId: "cancel-thread" as never, + terminalId: "cancel-terminal", + cwd: "/tmp/worktree", + run: "sleep 10", + timeout: Duration.seconds(1), + }); + }).pipe(Effect.provide(layer)); + + assert.deepEqual(result, { outcome: "cancelled", exitCode: null, signal: null }); + }), +); + +it.effect("fails fast with the terminal error message instead of stalling on timeout", () => + Effect.gen(function* () { + let listener: ((event: TerminalEvent) => Effect.Effect<void>) | null = null; + const layer = layerWithTerminal({ + open: (input) => Effect.succeed(snapshot(input)), + attachStream: () => Effect.succeed(() => undefined), + attachHistoryStream: () => Effect.succeed(() => undefined), + write: (input) => + Effect.gen(function* () { + if (listener === null) { + assert.fail("terminal listener was not installed before write"); + } + // An errored terminal emits `error` with NO following exited/closed. + yield* listener({ + type: "error", + threadId: input.threadId, + terminalId: input.terminalId, + message: "pty spawn failed", + }); + }), + resize: () => Effect.void, + clear: () => Effect.void, + restart: (input) => Effect.succeed(snapshot(input)), + close: () => Effect.void, + getSnapshot: () => Effect.succeed(null), + subscribe: (next) => + Effect.sync(() => { + listener = next; + return () => undefined; + }), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + + const error = yield* Effect.gen(function* () { + const runner = yield* ScriptCommandRunner; + return yield* runner.run({ + scriptThreadId: "error-thread" as never, + terminalId: "error-terminal", + cwd: "/tmp/worktree", + run: "boom", + // A long timeout: the test would hang for it without the error handling. + timeout: Duration.minutes(10), + }); + }).pipe(Effect.flip, Effect.provide(layer)); + + assert.include(error.message, "pty spawn failed"); + }), +); + +it.effect("closes the terminal when the runner fiber is interrupted", () => + Effect.gen(function* () { + const written = yield* Deferred.make<void>(); + const calls: string[] = []; + const layer = layerWithTerminal({ + open: (input) => Effect.succeed(snapshot(input)), + attachStream: () => Effect.succeed(() => undefined), + attachHistoryStream: () => Effect.succeed(() => undefined), + write: () => Deferred.succeed(written, undefined).pipe(Effect.asVoid), + resize: () => Effect.void, + clear: () => Effect.void, + restart: (input) => Effect.succeed(snapshot(input)), + close: (input) => + Effect.sync(() => { + calls.push(`close:${input.threadId}:${input.terminalId ?? "*"}`); + }), + getSnapshot: () => Effect.succeed(null), + subscribe: () => Effect.succeed(() => undefined), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + + const fiber = yield* Effect.forkChild( + Effect.gen(function* () { + const runner = yield* ScriptCommandRunner; + return yield* runner.run({ + scriptThreadId: "interrupt-thread" as never, + terminalId: "interrupt-terminal", + cwd: "/tmp/worktree", + run: "sleep 10", + timeout: Duration.seconds(10), + }); + }).pipe(Effect.provide(layer)), + ); + yield* Deferred.await(written); + + yield* Fiber.interrupt(fiber); + + assert.deepEqual(calls, ["close:interrupt-thread:interrupt-terminal"]); + }), +); diff --git a/apps/server/src/workflow/Layers/ScriptCommandRunner.ts b/apps/server/src/workflow/Layers/ScriptCommandRunner.ts new file mode 100644 index 00000000000..70e43ac9874 --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptCommandRunner.ts @@ -0,0 +1,113 @@ +import type { TerminalEvent } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ScriptCommandRunner, + type ScriptCommandResult, + type ScriptCommandRunnerShape, +} from "../Services/ScriptCommandRunner.ts"; + +const toRunnerError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const timeoutResult = { + outcome: "timeout", + exitCode: null, + signal: null, +} satisfies ScriptCommandResult; + +const cancelledResult = { + outcome: "cancelled", + exitCode: null, + signal: null, +} satisfies ScriptCommandResult; + +const wrapShellCommand = (run: string) => `${run}\nexit $?\r`; + +const matchesRun = ( + event: TerminalEvent, + input: { + readonly scriptThreadId: string; + readonly terminalId: string; + }, +) => event.threadId === input.scriptThreadId && event.terminalId === input.terminalId; + +const make = Effect.gen(function* () { + const terminals = yield* TerminalManager; + + const run: ScriptCommandRunnerShape["run"] = (input) => + Effect.gen(function* () { + const done = yield* Deferred.make<ScriptCommandResult, WorkflowEventStoreError>(); + const complete = (result: ScriptCommandResult) => + Deferred.succeed(done, result).pipe(Effect.asVoid); + // An errored terminal (PTY spawn failure, shell error) emits `error` + // without a following `exited`/`closed`, so it must settle the deferred + // too — otherwise the script step would block for the entire timeout and + // then mislabel the real fault as a timeout. Fail fast with the message. + const fail = (message: string) => + Deferred.fail(done, new WorkflowEventStoreError({ message })).pipe(Effect.asVoid); + const closeTerminal = terminals + .close({ threadId: input.scriptThreadId, terminalId: input.terminalId }) + .pipe(Effect.ignore); + + const unsubscribe = yield* terminals.subscribe((event) => { + if (!matchesRun(event, input)) { + return Effect.void; + } + if (event.type === "exited") { + return complete({ + outcome: "exited", + exitCode: event.exitCode ?? 1, + signal: event.exitSignal, + }); + } + if (event.type === "error") { + return fail(`script terminal error: ${event.message}`); + } + if (event.type === "closed") { + return complete(cancelledResult); + } + return Effect.void; + }); + + const awaitTerminal = Deferred.await(done).pipe( + Effect.timeoutOption(input.timeout), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => closeTerminal.pipe(Effect.as(timeoutResult)), + onSome: Effect.succeed, + }), + ), + ); + + return yield* Effect.gen(function* () { + yield* terminals + .open({ + threadId: input.scriptThreadId, + terminalId: input.terminalId, + cwd: input.cwd, + }) + .pipe(Effect.mapError(toRunnerError("script terminal open failed"))); + yield* terminals + .write({ + threadId: input.scriptThreadId, + terminalId: input.terminalId, + data: wrapShellCommand(input.run), + }) + .pipe(Effect.mapError(toRunnerError("script terminal write failed"))); + return yield* awaitTerminal; + }).pipe( + Effect.onInterrupt(() => closeTerminal), + Effect.ensuring(Effect.sync(unsubscribe)), + ); + }); + + return { run } satisfies ScriptCommandRunnerShape; +}); + +export const ScriptCommandRunnerLive = Layer.effect(ScriptCommandRunner, make); diff --git a/apps/server/src/workflow/Layers/ScriptStepExecutor.test.ts b/apps/server/src/workflow/Layers/ScriptStepExecutor.test.ts new file mode 100644 index 00000000000..52c7e50b724 --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptStepExecutor.test.ts @@ -0,0 +1,254 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { ScriptCommandRunner, type ScriptCommandResult } from "../Services/ScriptCommandRunner.ts"; +import { ScriptStepExecutor } from "../Services/ScriptStepExecutor.ts"; +import type { StepExecutionContext } from "../Services/StepExecutor.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { ScriptStepExecutorLive } from "./ScriptStepExecutor.ts"; + +const context: StepExecutionContext = { + ticketId: "ticket-script" as never, + boardId: "board-script" as never, + pipelineRunId: "pipeline-script" as never, + stepRunId: "step-run-script" as never, + laneEntryToken: "lane-token-script" as never, + laneKey: "lane-script" as never, + laneStepKeys: ["tests"] as never, + step: { + key: "tests" as never, + type: "script", + run: "pnpm test", + cwd: "packages/app", + }, +}; + +const layer = ( + commandResult: ScriptCommandResult, + inputs: Array<{ + readonly scriptThreadId: string; + readonly terminalId: string; + readonly cwd: string; + readonly run: string; + }>, + cancelEvents: string[] = [], +) => + ScriptStepExecutorLive.pipe( + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: (stepRunId, handle) => + Effect.sync(() => { + cancelEvents.push( + `register:${stepRunId}:${handle.scriptThreadId}:${handle.terminalId}`, + ); + }), + unregister: (stepRunId) => + Effect.sync(() => { + cancelEvents.push(`unregister:${stepRunId}`); + }), + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ScriptCommandRunner, { + run: (input) => + Effect.sync(() => { + inputs.push({ + scriptThreadId: input.scriptThreadId, + terminalId: input.terminalId, + cwd: input.cwd, + run: input.run, + }); + return commandResult; + }), + }), + ), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), + ); + +const makeWorktree = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-script-step-" }); + const cwd = path.join(worktreePath, "packages", "app"); + yield* fileSystem.makeDirectory(cwd, { recursive: true }); + return { + repoRoot: worktreePath, + worktreeRef: "workflow/ticket-script", + path: worktreePath, + cwd, + }; +}); + +const seedTicket = Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + yield* registry.register(context.boardId, { + name: "Script board", + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }); + yield* read.registerBoard({ + boardId: context.boardId, + projectId: "project-script" as never, + name: "Script board", + workflowFilePath: ".t3/boards/script.json", + workflowVersionHash: "hash-script", + maxConcurrentTickets: 1, + }); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + ${context.ticketId}, + ${context.boardId}, + 'Script ticket', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + ON CONFLICT(ticket_id) DO NOTHING + `; +}); + +it.effect("runs a script command in a contained cwd and commits start and exit events", () => { + const inputs: Array<{ + readonly scriptThreadId: string; + readonly terminalId: string; + readonly cwd: string; + readonly run: string; + }> = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const worktree = yield* makeWorktree; + const expectedCwd = yield* fileSystem.realPath(worktree.cwd); + const executor = yield* ScriptStepExecutor; + const store = yield* WorkflowEventStore; + yield* seedTicket; + + const outcome = yield* executor.execute({ + ctx: context, + step: context.step as Extract<StepExecutionContext["step"], { readonly type: "script" }>, + worktree, + }); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(inputs, [ + { + scriptThreadId: "workflow-script:scriptrun-1", + terminalId: "script-scriptrun-1", + cwd: expectedCwd, + run: "pnpm test", + }, + ]); + + const events = yield* Stream.runCollect(store.readByTicket(context.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const started = events.find((event) => event.type === "ScriptStepStarted"); + const exited = events.find((event) => event.type === "ScriptStepExited"); + assert.equal(started?.payload.scriptRunId, "scriptrun-1"); + assert.equal(started?.payload.scriptThreadId, "workflow-script:scriptrun-1"); + assert.equal(started?.payload.terminalId, "script-scriptrun-1"); + assert.equal(exited?.payload.scriptRunId, "scriptrun-1"); + assert.equal(exited?.payload.exitCode, 0); + assert.equal(exited?.payload.outcome, "exited"); + }).pipe(Effect.provide(layer({ outcome: "exited", exitCode: 0, signal: null }, inputs))); +}); + +it.effect("registers the script terminal as cancellable while the command is running", () => { + const inputs: Array<{ + readonly scriptThreadId: string; + readonly terminalId: string; + readonly cwd: string; + readonly run: string; + }> = []; + const cancelEvents: string[] = []; + return Effect.gen(function* () { + const worktree = yield* makeWorktree; + const executor = yield* ScriptStepExecutor; + yield* seedTicket; + + const outcome = yield* executor.execute({ + ctx: context, + step: context.step as Extract<StepExecutionContext["step"], { readonly type: "script" }>, + worktree, + }); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(cancelEvents, [ + "register:step-run-script:workflow-script:scriptrun-1:script-scriptrun-1", + "unregister:step-run-script", + ]); + }).pipe( + Effect.provide(layer({ outcome: "exited", exitCode: 0, signal: null }, inputs, cancelEvents)), + ); +}); + +it.effect("rejects a script cwd that escapes the worktree before running a command", () => { + const inputs: Array<{ + readonly scriptThreadId: string; + readonly terminalId: string; + readonly cwd: string; + readonly run: string; + }> = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const worktree = yield* makeWorktree; + const outside = path.join(path.dirname(worktree.path), "outside"); + yield* fileSystem.makeDirectory(outside, { recursive: true }); + const executor = yield* ScriptStepExecutor; + + const outcome = yield* executor.execute({ + ctx: { + ...context, + step: { + ...context.step, + cwd: "../outside", + } as Extract<StepExecutionContext["step"], { readonly type: "script" }>, + }, + step: { + ...context.step, + cwd: "../outside", + } as Extract<StepExecutionContext["step"], { readonly type: "script" }>, + worktree, + }); + + assert.deepEqual(outcome, { _tag: "failed", error: "script cwd escapes worktree" }); + assert.deepEqual(inputs, []); + }).pipe(Effect.provide(layer({ outcome: "exited", exitCode: 0, signal: null }, inputs))); +}); diff --git a/apps/server/src/workflow/Layers/ScriptStepExecutor.ts b/apps/server/src/workflow/Layers/ScriptStepExecutor.ts new file mode 100644 index 00000000000..3f343c0ae01 --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptStepExecutor.ts @@ -0,0 +1,148 @@ +import { ThreadId, type StepOutcome, type WorkflowEventId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { ScriptCommandRunner } from "../Services/ScriptCommandRunner.ts"; +import { + ScriptStepExecutor, + type ScriptStepExecutorShape, +} from "../Services/ScriptStepExecutor.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { type WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import type { WorktreeHandle } from "../Services/WorktreePort.ts"; + +const DEFAULT_SCRIPT_TIMEOUT = Duration.minutes(10); + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const toScriptExecutorError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const isContainedPath = ( + path: Path.Path, + input: { + readonly root: string; + readonly candidate: string; + }, +) => { + const relative = path.relative(input.root, input.candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +}; + +const mapCommandResult = ( + result: { + readonly outcome: "exited" | "timeout" | "cancelled"; + readonly exitCode: number | null; + }, + allowFailure: boolean, +): StepOutcome => { + if (result.outcome === "timeout") { + return { _tag: "failed", error: "script timed out" }; + } + if (result.outcome === "cancelled") { + // User-initiated cancellation: never auto-retried. + return { _tag: "failed", error: "script cancelled", retryable: false }; + } + if (result.exitCode === 0 || allowFailure) { + return { _tag: "completed" }; + } + return { _tag: "failed", error: `script exited with code ${result.exitCode ?? 1}` }; +}; + +const make = Effect.gen(function* () { + const cancels = yield* ScriptCancelRegistry; + const commands = yield* ScriptCommandRunner; + const committer = yield* WorkflowEventCommitter; + const fileSystem = yield* FileSystem.FileSystem; + const ids = yield* WorkflowIds; + const path = yield* Path.Path; + + const commit = ( + event: Omit<WorkflowEventInput, "eventId" | "occurredAt">, + ): Effect.Effect<void, WorkflowEventStoreError> => + Effect.gen(function* () { + const eventId = yield* ids.eventId(); + yield* committer.commit({ + ...event, + eventId: eventId as WorkflowEventId, + occurredAt: (yield* nowIso) as never, + } as WorkflowEventInput); + }); + + const resolveContainedCwd = (worktree: WorktreeHandle, cwd: string | undefined) => + Effect.gen(function* () { + const requested = cwd ?? "."; + const absolute = path.resolve(worktree.path, requested); + const worktreeRoot = yield* fileSystem + .realPath(worktree.path) + .pipe(Effect.mapError(toScriptExecutorError("script worktree realpath failed"))); + const resolved = yield* fileSystem + .realPath(absolute) + .pipe(Effect.mapError(toScriptExecutorError("script cwd realpath failed"))); + if (!isContainedPath(path, { root: worktreeRoot, candidate: resolved })) { + return { _tag: "failed", error: "script cwd escapes worktree" } as const; + } + return { _tag: "success", cwd: resolved } as const; + }).pipe(Effect.orElseSucceed(() => ({ _tag: "failed", error: "script cwd invalid" }) as const)); + + const execute: ScriptStepExecutorShape["execute"] = (input) => + Effect.gen(function* () { + const cwd = yield* resolveContainedCwd(input.worktree, input.step.cwd); + if (cwd._tag === "failed") { + return { _tag: "failed", error: cwd.error } satisfies StepOutcome; + } + + const scriptRunId = yield* ids.scriptRunId(); + const scriptThreadId = ThreadId.make(`workflow-script:${scriptRunId}`); + const terminalId = `script-${scriptRunId}`; + + yield* cancels.register(input.ctx.stepRunId, { scriptThreadId, terminalId }); + + const result = yield* Effect.gen(function* () { + yield* commit({ + type: "ScriptStepStarted", + ticketId: input.ctx.ticketId, + payload: { + scriptRunId, + stepRunId: input.ctx.stepRunId, + scriptThreadId, + terminalId, + }, + }); + + const commandResult = yield* commands.run({ + scriptThreadId, + terminalId, + cwd: cwd.cwd, + run: input.step.run, + timeout: input.step.timeout ?? DEFAULT_SCRIPT_TIMEOUT, + }); + + yield* commit({ + type: "ScriptStepExited", + ticketId: input.ctx.ticketId, + payload: { + scriptRunId, + exitCode: commandResult.exitCode, + signal: commandResult.signal, + outcome: commandResult.outcome, + }, + }); + + return commandResult; + }).pipe(Effect.ensuring(cancels.unregister(input.ctx.stepRunId))); + + return mapCommandResult(result, input.step.allowFailure ?? false); + }); + + return { execute } satisfies ScriptStepExecutorShape; +}); + +export const ScriptStepExecutorLive = Layer.effect(ScriptStepExecutor, make); diff --git a/apps/server/src/workflow/Layers/SetupRunService.test.ts b/apps/server/src/workflow/Layers/SetupRunService.test.ts new file mode 100644 index 00000000000..9dca415c3d3 --- /dev/null +++ b/apps/server/src/workflow/Layers/SetupRunService.test.ts @@ -0,0 +1,128 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { SetupRunService, SetupTerminalPort } from "../Services/SetupRunService.ts"; +import { SetupRunServiceLive, SetupTerminalPortLive } from "./SetupRunService.ts"; +import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; + +const stubTerminal = (exitCode: number) => + Layer.succeed(SetupTerminalPort, { + launch: () => Effect.succeed({ threadId: "workflow-setup:/tmp/wt-1", terminalId: "term-1" }), + awaitExit: () => Effect.succeed({ exitCode }), + }); + +const layerForExit = (exitCode: number) => + it.layer( + SetupRunServiceLive.pipe( + Layer.provideMerge(stubTerminal(exitCode)), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ); + +layerForExit(0)("SetupRunService success", (it) => { + it.effect("completes on exit 0", () => + Effect.gen(function* () { + const setup = yield* SetupRunService; + const sql = yield* SqlClient.SqlClient; + const result = yield* setup.runSetup("t-1" as never, "wt-1", "/tmp/wt-1", "setup-1" as never); + + assert.equal(result.status, "completed"); + const rows = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_setup_run WHERE ticket_id = 't-1' + `; + assert.equal(rows[0]?.status, "completed"); + }), + ); +}); + +layerForExit(1)("SetupRunService failure", (it) => { + it.effect("fails on non-zero exit", () => + Effect.gen(function* () { + const setup = yield* SetupRunService; + const result = yield* setup.runSetup("t-2" as never, "wt-2", "/tmp/wt-2", "setup-2" as never); + + assert.equal(result.status, "failed"); + assert.equal(result.exitCode, 1); + }), + ); +}); + +// --------------------------------------------------------------------------- +// SetupTerminalPortLive — subscribe-then-check race (Fix 2) +// --------------------------------------------------------------------------- + +// Test that awaitExit resolves immediately when the terminal is already exited +// at the time the listener is installed (no live event required). +const preExitedTerminalLayer = (exitCode: number) => + Layer.succeed(TerminalManager, { + open: () => Effect.die("unused"), + attachStream: () => Effect.die("unused"), + attachHistoryStream: () => Effect.die("unused"), + write: () => Effect.die("unused"), + resize: () => Effect.die("unused"), + clear: () => Effect.die("unused"), + restart: () => Effect.die("unused"), + close: () => Effect.void, + getSnapshot: () => + Effect.succeed({ + threadId: "workflow-setup:/tmp/pre-exited", + terminalId: "term-pre-exited", + cwd: "/tmp/pre-exited", + worktreePath: null, + status: "exited" as const, + pid: null, + history: "", + exitCode, + exitSignal: null, + label: "pre-exited", + updatedAt: "2026-01-01T00:00:00.000Z", + sequence: 0, + }), + subscribe: () => Effect.succeed(() => undefined), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + +const stubSetupRunner = Layer.succeed(ProjectSetupScriptRunner, { + runForThread: () => + Effect.succeed({ + status: "started", + scriptId: "script-1", + scriptName: "setup", + terminalId: "term-pre-exited", + cwd: "/tmp/pre-exited", + }), +}); + +it.layer( + SetupTerminalPortLive.pipe( + Layer.provideMerge(preExitedTerminalLayer(0)), + Layer.provideMerge(stubSetupRunner), + ), +)("SetupTerminalPortLive pre-exited terminal", (it) => { + it.effect("resolves immediately when terminal already exited before listener installed", () => + Effect.gen(function* () { + const port = yield* SetupTerminalPort; + // The terminal manager stub never fires any events — if the subscribe- + // then-check race fix is working, awaitExit must resolve via the + // getSnapshot check rather than waiting for a live event. + // We use a short timeout: without the fix this would time out (returning + // exitCode -1 via orElseSucceed), with the fix it resolves immediately. + const result = yield* port.awaitExit({ + threadId: "workflow-setup:/tmp/pre-exited", + terminalId: "term-pre-exited", + timeoutMs: 50, + }); + assert.equal( + result.exitCode, + 0, + "awaitExit should return the recorded exitCode for a pre-exited terminal", + ); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/SetupRunService.ts b/apps/server/src/workflow/Layers/SetupRunService.ts new file mode 100644 index 00000000000..3be21e86e5a --- /dev/null +++ b/apps/server/src/workflow/Layers/SetupRunService.ts @@ -0,0 +1,221 @@ +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { + ProjectSetupScriptRunner, + type ProjectSetupScriptRunnerInput, +} from "../../project/Services/ProjectSetupScriptRunner.ts"; +import { TerminalManager, type TerminalManagerShape } from "../../terminal/Services/Manager.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + SetupRunService, + SetupTerminalPort, + type SetupRunServiceShape, + type SetupTerminalPortShape, + type SetupStatus, +} from "../Services/SetupRunService.ts"; + +const SETUP_TIMEOUT_MS = 10 * 60 * 1000; + +const toSetupError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = <A>(effect: Effect.Effect<A, SqlError>) => + effect.pipe(Effect.mapError(toSetupError("setup op failed"))); + +interface SetupRunRow { + readonly status: string; + readonly exitCode: number | null; + readonly worktreeRef: string | null; +} + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const normalizeStatus = (exitCode: number): SetupStatus => + exitCode === 0 ? "completed" : exitCode === -1 ? "timed_out" : "failed"; + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const terminal = yield* SetupTerminalPort; + + const runSetup: SetupRunServiceShape["runSetup"] = ( + ticketId, + worktreeRef, + worktreePath, + setupRunId, + projectId, + ) => + Effect.gen(function* () { + const existing = yield* wrapSql(sql<SetupRunRow>` + SELECT + status, + exit_code AS "exitCode", + worktree_ref AS "worktreeRef" + FROM workflow_setup_run + WHERE ticket_id = ${ticketId} + `); + // Only skip setup when the completed run was for the SAME worktree. The + // worktree janitor can remove a worktree (and its lease) without clearing + // this row; a later re-activation recreates an empty worktree with a new + // ref, where dependencies must be installed again. Keying the skip on + // ticket_id alone would run the next step in a dependency-less checkout. + if (existing[0]?.status === "completed" && existing[0].worktreeRef === worktreeRef) { + return { status: "completed", exitCode: existing[0].exitCode }; + } + + yield* wrapSql(sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES (${setupRunId}, ${ticketId}, ${worktreeRef}, 'running', ${yield* nowIso}) + ON CONFLICT(ticket_id) DO UPDATE SET + setup_run_id = excluded.setup_run_id, + worktree_ref = excluded.worktree_ref, + status = 'running', + started_at = excluded.started_at, + finished_at = NULL, + exit_code = NULL + `); + + const { threadId: launchedThreadId, terminalId } = yield* terminal.launch({ + worktreePath, + ...(projectId === undefined ? {} : { projectId }), + }); + const exit = + terminalId === null + ? { exitCode: 0 } + : yield* terminal + .awaitExit({ threadId: launchedThreadId, terminalId, timeoutMs: SETUP_TIMEOUT_MS }) + .pipe(Effect.orElseSucceed(() => ({ exitCode: -1 }))); + const status = normalizeStatus(exit.exitCode); + + yield* wrapSql(sql` + UPDATE workflow_setup_run + SET status = ${status}, + exit_code = ${exit.exitCode}, + finished_at = ${yield* nowIso} + WHERE ticket_id = ${ticketId} + `); + + return { status, exitCode: exit.exitCode }; + }); + + return { runSetup } satisfies SetupRunServiceShape; +}); + +export const SetupRunServiceLive = Layer.effect(SetupRunService, make); + +const awaitTerminalExit = ( + terminals: TerminalManagerShape, + input: { + readonly threadId: string; + readonly terminalId: string | null; + readonly timeoutMs?: number; + }, +): Effect.Effect<{ readonly exitCode: number }, WorkflowEventStoreError> => { + const { terminalId } = input; + if (terminalId === null) { + return Effect.succeed({ exitCode: 0 }); + } + + return Effect.gen(function* () { + const done = yield* Deferred.make<{ readonly exitCode: number }>(); + // Subscribe FIRST so we don't miss an exit event that races with our check. + const unsubscribe = yield* terminals.subscribe((event) => { + if ( + event.type !== "exited" || + event.terminalId !== terminalId || + event.threadId !== input.threadId + ) { + return Effect.void; + } + return Deferred.succeed(done, { exitCode: event.exitCode ?? 1 }).pipe(Effect.asVoid); + }); + // THEN check current status: if the terminal already exited before we + // subscribed, resolve the deferred immediately with its recorded exit code. + const currentSnapshot = yield* terminals.getSnapshot({ + threadId: input.threadId, + terminalId, + }); + if (currentSnapshot !== null && currentSnapshot.status === "exited") { + yield* Deferred.succeed(done, { exitCode: currentSnapshot.exitCode ?? 1 }).pipe( + Effect.asVoid, + ); + } + const wait = Deferred.await(done); + const timed = + input.timeoutMs === undefined + ? wait + : wait.pipe( + Effect.timeoutOption(Duration.millis(input.timeoutMs)), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + // Setup timed out while the PTY is still running. Close it + // (best-effort) so the process doesn't leak past the timeout, + // then surface the timeout; `orElseSucceed` upstream maps this + // to a timed_out result. + terminals.close({ threadId: input.threadId, terminalId }).pipe( + Effect.ignore, + Effect.andThen( + Effect.fail( + new WorkflowEventStoreError({ + message: "setup terminal wait timed out", + }), + ), + ), + ), + onSome: Effect.succeed, + }), + ), + ); + // The only failure `timed` can produce is the timeout branch's own + // descriptive WorkflowEventStoreError ("setup terminal wait timed out") — + // `Deferred.await` here never fails. Wrapping it again via `toSetupError` + // would nest WorkflowEventStoreErrors and bury that message, so fail through + // directly. (PR #3032 macroscope review.) + return yield* timed.pipe(Effect.ensuring(Effect.sync(unsubscribe))); + }); +}; + +export const SetupTerminalPortLive = Layer.effect( + SetupTerminalPort, + Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner; + const terminals = yield* TerminalManager; + + return { + launch: (input) => { + const setupInput = { + threadId: input.threadId ?? `workflow-setup:${input.worktreePath}`, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + ...(input.preferredTerminalId === undefined + ? {} + : { preferredTerminalId: input.preferredTerminalId }), + } satisfies ProjectSetupScriptRunnerInput; + + return runner.runForThread(setupInput).pipe( + Effect.map((result) => + result.status === "no-script" + ? { threadId: setupInput.threadId, terminalId: null } + : { threadId: setupInput.threadId, terminalId: result.terminalId }, + ), + Effect.mapError(toSetupError("setup launch failed")), + ); + }, + awaitExit: (input) => awaitTerminalExit(terminals, input), + } satisfies SetupTerminalPortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/StepOutputHandoffReader.test.ts b/apps/server/src/workflow/Layers/StepOutputHandoffReader.test.ts new file mode 100644 index 00000000000..a9b5a8e0fb5 --- /dev/null +++ b/apps/server/src/workflow/Layers/StepOutputHandoffReader.test.ts @@ -0,0 +1,290 @@ +import { LaneKey, PipelineRunId, StepKey, TicketId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { StepOutputHandoffReader } from "../Services/StepOutputHandoffReader.ts"; +import { StepOutputHandoffReaderLive } from "./StepOutputHandoffReader.ts"; + +const readerLayer = it.layer( + StepOutputHandoffReaderLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const seedPipelineRun = (input: { + readonly pipelineRunId: PipelineRunId; + readonly ticketId: TicketId; + readonly laneKey: LaneKey; + readonly status: string; + readonly finishedAt: string | null; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_pipeline_run + (pipeline_run_id, ticket_id, lane_key, lane_entry_token, status, started_at, finished_at) + VALUES + (${String(input.pipelineRunId)}, ${String(input.ticketId)}, ${String(input.laneKey)}, + ${"tok"}, ${input.status}, ${"2020-01-01T00:00:00.000Z"}, ${input.finishedAt}) + `; + }); + +const seedStepRun = (input: { + readonly stepRunId: string; + readonly pipelineRunId: PipelineRunId; + readonly ticketId: TicketId; + readonly stepKey: StepKey; + readonly status: string; + readonly finishedAt: string | null; + readonly output: unknown; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const outputJson = + input.output === undefined + ? null + : yield* Schema.encodeUnknownEffect(Schema.UnknownFromJsonString)(input.output); + yield* sql` + INSERT INTO projection_step_run + (step_run_id, pipeline_run_id, ticket_id, step_key, step_type, status, started_at, finished_at, output_json) + VALUES + (${input.stepRunId}, ${String(input.pipelineRunId)}, ${String(input.ticketId)}, + ${String(input.stepKey)}, ${"agent"}, ${input.status}, ${"2020-01-01T00:00:00.000Z"}, + ${input.finishedAt}, ${outputJson}) + `; + }); + +readerLayer("StepOutputHandoffReader", (it) => { + it.effect("latestCompletedOutput returns the newest completed output by finished_at", () => + Effect.gen(function* () { + const reader = yield* StepOutputHandoffReader; + const ticketId = TicketId.make("ticket-1"); + const laneKey = LaneKey.make("implement"); + const stepKey = StepKey.make("review"); + const runId = PipelineRunId.make("run-1"); + + yield* seedPipelineRun({ + pipelineRunId: runId, + ticketId, + laneKey, + status: "completed", + finishedAt: "2020-01-01T00:05:00.000Z", + }); + yield* seedStepRun({ + stepRunId: "step-old", + pipelineRunId: runId, + ticketId, + stepKey, + status: "completed", + finishedAt: "2020-01-01T00:01:00.000Z", + output: { verdict: "old" }, + }); + yield* seedStepRun({ + stepRunId: "step-new", + pipelineRunId: runId, + ticketId, + stepKey, + status: "completed", + finishedAt: "2020-01-01T00:02:00.000Z", + output: { verdict: "new" }, + }); + + const output = yield* reader.latestCompletedOutput(ticketId, laneKey, stepKey); + assert.deepEqual(output, { verdict: "new" }); + }), + ); + + it.effect("latestCompletedOutput is loop-aware across pipeline runs", () => + Effect.gen(function* () { + const reader = yield* StepOutputHandoffReader; + const ticketId = TicketId.make("ticket-2"); + const laneKey = LaneKey.make("implement"); + const stepKey = StepKey.make("review"); + const firstRun = PipelineRunId.make("run-2a"); + const secondRun = PipelineRunId.make("run-2b"); + + yield* seedPipelineRun({ + pipelineRunId: firstRun, + ticketId, + laneKey, + status: "completed", + finishedAt: "2020-01-01T00:05:00.000Z", + }); + yield* seedPipelineRun({ + pipelineRunId: secondRun, + ticketId, + laneKey, + status: "completed", + finishedAt: "2020-01-01T00:10:00.000Z", + }); + yield* seedStepRun({ + stepRunId: "step-pass1", + pipelineRunId: firstRun, + ticketId, + stepKey, + status: "completed", + finishedAt: "2020-01-01T00:03:00.000Z", + output: { pass: 1 }, + }); + yield* seedStepRun({ + stepRunId: "step-pass2", + pipelineRunId: secondRun, + ticketId, + stepKey, + status: "completed", + finishedAt: "2020-01-01T00:08:00.000Z", + output: { pass: 2 }, + }); + + const output = yield* reader.latestCompletedOutput(ticketId, laneKey, stepKey); + assert.deepEqual(output, { pass: 2 }); + }), + ); + + it.effect("latestCompletedOutput ignores non-completed and other-lane rows", () => + Effect.gen(function* () { + const reader = yield* StepOutputHandoffReader; + const ticketId = TicketId.make("ticket-3"); + const laneKey = LaneKey.make("implement"); + const otherLane = LaneKey.make("review-lane"); + const stepKey = StepKey.make("review"); + + const matchRun = PipelineRunId.make("run-3-match"); + const runningRun = PipelineRunId.make("run-3-running"); + const otherLaneRun = PipelineRunId.make("run-3-otherlane"); + + yield* seedPipelineRun({ + pipelineRunId: matchRun, + ticketId, + laneKey, + status: "completed", + finishedAt: "2020-01-01T00:05:00.000Z", + }); + yield* seedPipelineRun({ + pipelineRunId: runningRun, + ticketId, + laneKey, + status: "running", + finishedAt: null, + }); + yield* seedPipelineRun({ + pipelineRunId: otherLaneRun, + ticketId, + laneKey: otherLane, + status: "completed", + finishedAt: "2020-01-01T00:20:00.000Z", + }); + + // The only completed step in the right lane. + yield* seedStepRun({ + stepRunId: "step-match", + pipelineRunId: matchRun, + ticketId, + stepKey, + status: "completed", + finishedAt: "2020-01-01T00:04:00.000Z", + output: { verdict: "match" }, + }); + // Newer finished_at but not completed → ignored. + yield* seedStepRun({ + stepRunId: "step-running", + pipelineRunId: runningRun, + ticketId, + stepKey, + status: "running", + finishedAt: "2020-01-01T00:09:00.000Z", + output: { verdict: "running" }, + }); + // Newer finished_at but a different lane → ignored. + yield* seedStepRun({ + stepRunId: "step-otherlane", + pipelineRunId: otherLaneRun, + ticketId, + stepKey, + status: "completed", + finishedAt: "2020-01-01T00:19:00.000Z", + output: { verdict: "otherlane" }, + }); + + const output = yield* reader.latestCompletedOutput(ticketId, laneKey, stepKey); + assert.deepEqual(output, { verdict: "match" }); + }), + ); + + it.effect("latestCompletedOutput returns null when there is no completed output", () => + Effect.gen(function* () { + const reader = yield* StepOutputHandoffReader; + const output = yield* reader.latestCompletedOutput( + TicketId.make("ticket-missing"), + LaneKey.make("implement"), + StepKey.make("review"), + ); + assert.isNull(output); + }), + ); + + it.effect("currentPassOutput returns this pass's output for the step key", () => + Effect.gen(function* () { + const reader = yield* StepOutputHandoffReader; + const ticketId = TicketId.make("ticket-4"); + const laneKey = LaneKey.make("implement"); + const stepKey = StepKey.make("implement"); + const thisRun = PipelineRunId.make("run-4-this"); + const otherRun = PipelineRunId.make("run-4-other"); + + yield* seedPipelineRun({ + pipelineRunId: thisRun, + ticketId, + laneKey, + status: "running", + finishedAt: null, + }); + yield* seedPipelineRun({ + pipelineRunId: otherRun, + ticketId, + laneKey, + status: "completed", + finishedAt: "2020-01-01T00:05:00.000Z", + }); + yield* seedStepRun({ + stepRunId: "step-this", + pipelineRunId: thisRun, + ticketId, + stepKey, + status: "completed", + finishedAt: "2020-01-01T00:02:00.000Z", + output: { from: "this-pass" }, + }); + // Same step key in a different pipeline run → not returned by currentPassOutput. + yield* seedStepRun({ + stepRunId: "step-other", + pipelineRunId: otherRun, + ticketId, + stepKey, + status: "completed", + finishedAt: "2020-01-01T00:04:00.000Z", + output: { from: "other-pass" }, + }); + + const output = yield* reader.currentPassOutput(thisRun, stepKey); + assert.deepEqual(output, { from: "this-pass" }); + }), + ); + + it.effect("currentPassOutput returns null when this pass has no completed step", () => + Effect.gen(function* () { + const reader = yield* StepOutputHandoffReader; + const output = yield* reader.currentPassOutput( + PipelineRunId.make("run-missing"), + StepKey.make("implement"), + ); + assert.isNull(output); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/StepOutputHandoffReader.ts b/apps/server/src/workflow/Layers/StepOutputHandoffReader.ts new file mode 100644 index 00000000000..a663a867fcb --- /dev/null +++ b/apps/server/src/workflow/Layers/StepOutputHandoffReader.ts @@ -0,0 +1,86 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + StepOutputHandoffReader, + type StepOutputHandoffReaderShape, +} from "../Services/StepOutputHandoffReader.ts"; + +const decodeOutputJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); + +const toReaderError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrap = <A>(message: string, effect: Effect.Effect<A, SqlError>) => + effect.pipe(Effect.mapError(toReaderError(message))); + +const parseOutput = (outputJson: string | null) => + outputJson === null + ? Effect.succeed(null) + : decodeOutputJson(outputJson).pipe( + Effect.mapError(toReaderError("handoff output decode failed")), + ); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const latestCompletedOutput: StepOutputHandoffReaderShape["latestCompletedOutput"] = ( + ticketId, + laneKey, + stepKey, + ) => + Effect.gen(function* () { + const rows = yield* wrap( + "StepOutputHandoffReader.latestCompletedOutput", + sql<{ readonly outputJson: string | null }>` + SELECT sr.output_json AS "outputJson" + FROM projection_step_run AS sr + JOIN projection_pipeline_run AS pr + ON pr.pipeline_run_id = sr.pipeline_run_id + WHERE sr.ticket_id = ${String(ticketId)} + AND pr.lane_key = ${String(laneKey)} + AND sr.step_key = ${String(stepKey)} + AND sr.status = 'completed' + ORDER BY sr.finished_at DESC + LIMIT 1 + `, + ); + const row = rows[0]; + if (row === undefined) { + return null; + } + return yield* parseOutput(row.outputJson); + }); + + const currentPassOutput: StepOutputHandoffReaderShape["currentPassOutput"] = ( + pipelineRunId, + stepKey, + ) => + Effect.gen(function* () { + const rows = yield* wrap( + "StepOutputHandoffReader.currentPassOutput", + sql<{ readonly outputJson: string | null }>` + SELECT output_json AS "outputJson" + FROM projection_step_run + WHERE pipeline_run_id = ${String(pipelineRunId)} + AND step_key = ${String(stepKey)} + AND status = 'completed' + ORDER BY finished_at DESC + LIMIT 1 + `, + ); + const row = rows[0]; + if (row === undefined) { + return null; + } + return yield* parseOutput(row.outputJson); + }); + + return { latestCompletedOutput, currentPassOutput } satisfies StepOutputHandoffReaderShape; +}); + +export const StepOutputHandoffReaderLive = Layer.effect(StepOutputHandoffReader, make); diff --git a/apps/server/src/workflow/Layers/StepUsageReader.test.ts b/apps/server/src/workflow/Layers/StepUsageReader.test.ts new file mode 100644 index 00000000000..f3c9375f0b9 --- /dev/null +++ b/apps/server/src/workflow/Layers/StepUsageReader.test.ts @@ -0,0 +1,94 @@ +import { assert, it } from "@effect/vitest"; +import type { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + ProjectionThreadActivityRepository, + type ProjectionThreadActivity, +} from "../../persistence/Services/ProjectionThreadActivities.ts"; +import { StepUsageReader } from "../Services/StepUsageReader.ts"; +import { StepUsageReaderLive } from "./StepUsageReader.ts"; + +const threadId = "thread-usage" as ThreadId; + +const activity = (overrides: Partial<ProjectionThreadActivity>): ProjectionThreadActivity => + ({ + activityId: "act-1" as never, + threadId, + turnId: null, + tone: "info", + kind: "context-window.updated", + summary: "Context window updated", + payload: {}, + createdAt: "2026-06-09T00:00:00.000Z", + ...overrides, + }) as ProjectionThreadActivity; + +const layerWith = (rows: ReadonlyArray<ProjectionThreadActivity>) => + StepUsageReaderLive.pipe( + Layer.provideMerge( + Layer.succeed(ProjectionThreadActivityRepository, { + upsert: () => Effect.void, + listByThreadId: () => Effect.succeed(rows), + deleteByThreadId: () => Effect.void, + }), + ), + ); + +const readUsage = (rows: ReadonlyArray<ProjectionThreadActivity>) => + Effect.gen(function* () { + const reader = yield* StepUsageReader; + return yield* reader.read(threadId); + }).pipe(Effect.provide(layerWith(rows))); + +it.effect("maps the latest context-window snapshot to workflow usage", () => + Effect.gen(function* () { + const usage = yield* readUsage([ + activity({ + activityId: "act-1" as never, + payload: { usedTokens: 100, inputTokens: 80, outputTokens: 20 }, + }), + activity({ + activityId: "act-2" as never, + payload: { + usedTokens: 500, + totalProcessedTokens: 1200, + inputTokens: 900, + cachedInputTokens: 300, + outputTokens: 250, + }, + }), + ]); + + assert.deepEqual(usage, { + inputTokens: 900, + cachedInputTokens: 300, + outputTokens: 250, + totalTokens: 1200, + }); + }), +); + +it.effect("ignores other activity kinds and malformed payloads", () => + Effect.gen(function* () { + const usage = yield* readUsage([ + activity({ activityId: "act-1" as never, payload: { usedTokens: 42, inputTokens: 30 } }), + activity({ + activityId: "act-2" as never, + kind: "tool.completed", + payload: { usedTokens: 999999 }, + }), + activity({ activityId: "act-3" as never, payload: { usedTokens: "not-a-number" } }), + ]); + + assert.deepEqual(usage, { inputTokens: 30, totalTokens: 42 }); + }), +); + +it.effect("returns undefined when no usage was emitted", () => + Effect.gen(function* () { + const usage = yield* readUsage([]); + assert.equal(usage, undefined); + }), +); diff --git a/apps/server/src/workflow/Layers/StepUsageReader.ts b/apps/server/src/workflow/Layers/StepUsageReader.ts new file mode 100644 index 00000000000..582137da007 --- /dev/null +++ b/apps/server/src/workflow/Layers/StepUsageReader.ts @@ -0,0 +1,47 @@ +import { ThreadTokenUsageSnapshot, type WorkflowStepUsage } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; +import { StepUsageReader, type StepUsageReaderShape } from "../Services/StepUsageReader.ts"; + +const decodeUsageSnapshot = Schema.decodeUnknownEffect(ThreadTokenUsageSnapshot); + +const toWorkflowUsage = (snapshot: ThreadTokenUsageSnapshot): WorkflowStepUsage | undefined => { + const usage = { + ...(snapshot.inputTokens === undefined ? {} : { inputTokens: snapshot.inputTokens }), + ...(snapshot.cachedInputTokens === undefined + ? {} + : { cachedInputTokens: snapshot.cachedInputTokens }), + ...(snapshot.outputTokens === undefined ? {} : { outputTokens: snapshot.outputTokens }), + totalTokens: snapshot.totalProcessedTokens ?? snapshot.usedTokens, + } satisfies WorkflowStepUsage; + return usage.totalTokens === 0 && usage.inputTokens === undefined ? undefined : usage; +}; + +const make = Effect.gen(function* () { + const activities = yield* ProjectionThreadActivityRepository; + + const read: StepUsageReaderShape["read"] = (threadId) => + Effect.gen(function* () { + const rows = yield* activities.listByThreadId({ threadId }); + for (let index = rows.length - 1; index >= 0; index -= 1) { + const row = rows[index]; + if (row?.kind !== "context-window.updated") { + continue; + } + const snapshot = yield* decodeUsageSnapshot(row.payload).pipe( + Effect.orElseSucceed(() => null), + ); + if (snapshot !== null) { + return toWorkflowUsage(snapshot); + } + } + return undefined; + }).pipe(Effect.orElseSucceed(() => undefined)); + + return { read } satisfies StepUsageReaderShape; +}); + +export const StepUsageReaderLive = Layer.effect(StepUsageReader, make); diff --git a/apps/server/src/workflow/Layers/StubStepExecutor.test.ts b/apps/server/src/workflow/Layers/StubStepExecutor.test.ts new file mode 100644 index 00000000000..d0786c7e2a4 --- /dev/null +++ b/apps/server/src/workflow/Layers/StubStepExecutor.test.ts @@ -0,0 +1,31 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { makeStubStepExecutor } from "./StubStepExecutor.ts"; + +const layer = it.layer(makeStubStepExecutor({ default: { _tag: "completed" } })); + +layer("StubStepExecutor", (it) => { + it.effect("returns the scripted default outcome", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + const outcome = yield* executor.execute({ + ticketId: "t-1" as never, + boardId: "b-1" as never, + pipelineRunId: "pr-1" as never, + stepRunId: "sr-1" as never, + laneEntryToken: "tok-1" as never, + laneKey: "lane-1" as never, + laneStepKeys: ["code"] as never, + step: { + key: "code" as never, + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "x", + }, + }); + assert.equal(outcome._tag, "completed"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/StubStepExecutor.ts b/apps/server/src/workflow/Layers/StubStepExecutor.ts new file mode 100644 index 00000000000..f7f1e6d0cbf --- /dev/null +++ b/apps/server/src/workflow/Layers/StubStepExecutor.ts @@ -0,0 +1,15 @@ +import type { StepOutcome } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; + +export interface StubScript { + readonly default: StepOutcome; + readonly byStepKey?: Record<string, StepOutcome>; +} + +export const makeStubStepExecutor = (script: StubScript): Layer.Layer<StepExecutor> => + Layer.succeed(StepExecutor, { + execute: (ctx) => Effect.succeed(script.byStepKey?.[ctx.step.key as string] ?? script.default), + } satisfies StepExecutorShape); diff --git a/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts b/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts new file mode 100644 index 00000000000..e7d793a4cfd --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts @@ -0,0 +1,21 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("step refs migration", (it) => { + it.effect("projection_step_run has pre/post ref columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_step_run)`; + const names = new Set(cols.map((column) => column.name)); + assert.isTrue(names.has("pre_checkpoint_ref")); + assert.isTrue(names.has("post_checkpoint_ref")); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TicketCheckpointService.test.ts b/apps/server/src/workflow/Layers/TicketCheckpointService.test.ts new file mode 100644 index 00000000000..36938c56157 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketCheckpointService.test.ts @@ -0,0 +1,99 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; + +import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; +import { ServerConfig } from "../../config.ts"; +import type { VcsError } from "@t3tools/contracts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { TicketCheckpointServiceLive } from "./TicketCheckpointService.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-ticket-checkpoint-test-", +}); +const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); + +const layer = it.layer( + TicketCheckpointServiceLive.pipe( + Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +const makeTmpDir = ( + prefix = "ticket-checkpoint-test-", +): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + filePath: string, + contents: string, +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); + +const git = ( + cwd: string, + args: ReadonlyArray<string>, +): Effect.Effect<string, VcsError, VcsProcess.VcsProcess> => + Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ + operation: "TicketCheckpointService.test.git", + command: "git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + void, + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem +> => + Effect.gen(function* () { + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + }); + +layer("TicketCheckpointService", (it) => { + it.effect("captures a baseline ref that exists", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const service = yield* TicketCheckpointService; + + const ref = yield* service.captureBaseline("t-1" as never, tmp); + const exists = yield* service.hasBaseline("t-1" as never, tmp); + + assert.equal(ref, "refs/t3/tickets/dC0x/base"); + assert.equal(exists, true); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TicketCheckpointService.ts b/apps/server/src/workflow/Layers/TicketCheckpointService.ts new file mode 100644 index 00000000000..7b5639391a8 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketCheckpointService.ts @@ -0,0 +1,61 @@ +import { CheckpointRef } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + TicketCheckpointService, + type TicketCheckpointServiceShape, +} from "../Services/TicketCheckpointService.ts"; +import { ticketBaseRef, ticketStepRef } from "../ticketRefs.ts"; + +const toCheckpointError = (cause: unknown) => + new WorkflowEventStoreError({ message: "checkpoint op failed", cause }); + +const wrap = <A, E>(effect: Effect.Effect<A, E>) => effect.pipe(Effect.mapError(toCheckpointError)); + +const make = Effect.gen(function* () { + const checkpoints = yield* CheckpointStore; + + const captureBaseline: TicketCheckpointServiceShape["captureBaseline"] = (ticketId, cwd) => + Effect.gen(function* () { + const ref = ticketBaseRef(ticketId); + yield* wrap( + checkpoints.captureCheckpoint({ + cwd, + checkpointRef: CheckpointRef.make(ref), + }), + ); + return ref; + }); + + const hasBaseline: TicketCheckpointServiceShape["hasBaseline"] = (ticketId, cwd) => + wrap( + checkpoints.hasCheckpointRef({ + cwd, + checkpointRef: CheckpointRef.make(ticketBaseRef(ticketId)), + }), + ); + + const captureStep: TicketCheckpointServiceShape["captureStep"] = ( + ticketId, + stepRunId, + cwd, + kind, + ) => + Effect.gen(function* () { + const ref = ticketStepRef(ticketId, stepRunId, kind); + yield* wrap( + checkpoints.captureCheckpoint({ + cwd, + checkpointRef: CheckpointRef.make(ref), + }), + ); + return ref; + }); + + return { captureBaseline, hasBaseline, captureStep } satisfies TicketCheckpointServiceShape; +}); + +export const TicketCheckpointServiceLive = Layer.effect(TicketCheckpointService, make); diff --git a/apps/server/src/workflow/Layers/TicketDiffQuery.test.ts b/apps/server/src/workflow/Layers/TicketDiffQuery.test.ts new file mode 100644 index 00000000000..c3e011a0048 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketDiffQuery.test.ts @@ -0,0 +1,125 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import type { VcsError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; + +import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { TicketDiffQuery } from "../Services/TicketDiffQuery.ts"; +import { TicketCheckpointServiceLive } from "./TicketCheckpointService.ts"; +import { TicketDiffQueryLive, WorktreeDiffPortLive } from "./TicketDiffQuery.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-ticket-diff-test-", +}); +const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); +const GitVcsDriverTestLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(NodeServices.layer), +); + +const layer = it.layer( + TicketDiffQueryLive.pipe( + Layer.provideMerge(WorktreeDiffPortLive), + Layer.provideMerge(TicketCheckpointServiceLive), + Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(GitVcsDriverTestLayer), + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +const makeTmpDir = ( + prefix = "ticket-diff-test-", +): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + filePath: string, + contents: string, +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); + +const git = ( + cwd: string, + args: ReadonlyArray<string>, +): Effect.Effect<string, VcsError, VcsProcess.VcsProcess> => + Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ + operation: "TicketDiffQuery.test.git", + command: "git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + void, + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem +> => + Effect.gen(function* () { + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(cwd, "README.md"), "# original\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + }); + +layer("TicketDiffQuery", (it) => { + it.effect("returns accumulated base-to-worktree diff for tracked and untracked files", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointService = yield* TicketCheckpointService; + const query = yield* TicketDiffQuery; + const ticketId = "t-1" as never; + + const baseRef = yield* checkpointService.captureBaseline(ticketId, tmp); + yield* writeTextFile(path.join(tmp, "README.md"), "# changed\n"); + yield* writeTextFile(path.join(tmp, "notes.txt"), "new note\n"); + + const diff = yield* query.getTicketDiff(ticketId, tmp, baseRef); + + assert.equal(diff.ticketId, ticketId); + assert.equal(diff.baseRef, baseRef); + assert.equal(diff.truncated, false); + assert.include(diff.patch, "diff --git"); + assert.include(diff.patch, "README.md"); + assert.include(diff.patch, "notes.txt"); + assert.deepEqual( + new Map(diff.files.map((file) => [file.path, file])), + new Map([ + ["README.md", { path: "README.md", additions: 1, deletions: 1 }], + ["notes.txt", { path: "notes.txt", additions: 1, deletions: 0 }], + ]), + ); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TicketDiffQuery.ts b/apps/server/src/workflow/Layers/TicketDiffQuery.ts new file mode 100644 index 00000000000..867a549dd84 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketDiffQuery.ts @@ -0,0 +1,99 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; +import { GitVcsDriver } from "../../vcs/GitVcsDriver.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + TicketDiffQuery, + WorktreeDiffPort, + type TicketDiffQueryShape, + type WorktreeDiffPortShape, +} from "../Services/TicketDiffQuery.ts"; + +const make = Effect.gen(function* () { + const port = yield* WorktreeDiffPort; + + const getTicketDiff: TicketDiffQueryShape["getTicketDiff"] = (ticketId, cwd, baseRef) => + Effect.gen(function* () { + const { patch, truncated } = yield* port.diffRefToWorktree({ cwd, baseRef }); + const files = parseTurnDiffFilesFromUnifiedDiff(patch); + + return { + ticketId, + baseRef, + patch, + files, + truncated, + }; + }); + + return { getTicketDiff } satisfies TicketDiffQueryShape; +}); + +export const TicketDiffQueryLive = Layer.effect(TicketDiffQuery, make); + +export const WorktreeDiffPortLive = Layer.effect( + WorktreeDiffPort, + Effect.gen(function* () { + const git = yield* GitVcsDriver; + + const diffRefToWorktree: WorktreeDiffPortShape["diffRefToWorktree"] = ({ cwd, baseRef }) => + Effect.gen(function* () { + const tracked = yield* git.execute({ + operation: "WorkflowTicketDiff.tracked", + cwd, + args: ["diff", "--patch", "--minimal", `${baseRef}^{commit}`, "--"], + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }); + const untrackedList = yield* git + .execute({ + operation: "WorkflowTicketDiff.untracked.list", + cwd, + args: ["ls-files", "--others", "--exclude-standard", "-z"], + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.orElseSucceed(() => ({ + stdout: "", + stdoutTruncated: false, + })), + ); + const untrackedPaths = untrackedList.stdout.split("\0").filter((path) => path.length > 0); + const untrackedDiffs = yield* Effect.forEach( + untrackedPaths, + (path) => + git.execute({ + operation: "WorkflowTicketDiff.untracked.diff", + cwd, + args: ["diff", "--no-index", "--patch", "--minimal", "--", "/dev/null", path], + allowNonZeroExit: true, + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }), + { concurrency: 4 }, + ); + + return { + patch: [ + tracked.stdout.trimEnd(), + ...untrackedDiffs.map((result) => result.stdout.trimEnd()), + ] + .filter((part) => part.length > 0) + .join("\n"), + truncated: + tracked.stdoutTruncated || + untrackedList.stdoutTruncated || + untrackedDiffs.some((result) => result.stdoutTruncated), + }; + }).pipe( + Effect.mapError( + (cause) => new WorkflowEventStoreError({ message: "ticket diff failed", cause }), + ), + ); + + return { diffRefToWorktree } satisfies WorktreeDiffPortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/TicketMergeService.test.ts b/apps/server/src/workflow/Layers/TicketMergeService.test.ts new file mode 100644 index 00000000000..344c4f6754c --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketMergeService.test.ts @@ -0,0 +1,293 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { MergeStep, TicketId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + MergeGitPort, + TicketMergeService, + type MergeGitResult, +} from "../Services/TicketMergeService.ts"; +import { WorkflowReadModel, type WorkflowReadModelShape } from "../Services/WorkflowReadModel.ts"; +import { TicketMergeServiceLive } from "./TicketMergeService.ts"; + +interface RecordedGitCall { + readonly cwd: string; + readonly args: ReadonlyArray<string>; +} + +interface GitScript { + readonly worktreeStatus?: string; + readonly repoStatus?: string; + readonly branch?: string; + readonly aheadCount?: string; + readonly mergeResult?: MergeGitResult; +} + +const mergeInput = (step: Partial<MergeStep> = {}) => ({ + ticketId: "ticket-merge" as TicketId, + repoRoot: "/repo", + worktreePath: "/repo-worktrees/ticket-merge", + worktreeRef: "workflow/ticket-merge", + step: { + key: "land" as never, + type: "merge" as const, + ...step, + }, +}); + +const stubReadModel = Layer.succeed(WorkflowReadModel, { + getTicketDetail: () => + Effect.succeed({ + ticket: { + ticketId: "ticket-merge", + boardId: "board-1", + title: "Fix login", + description: null, + currentLaneKey: "land", + currentLaneEntryToken: "token-1", + queuedAt: null, + status: "running", + }, + steps: [], + messages: [], + }), +} as unknown as WorkflowReadModelShape); + +const makeHarness = (script: GitScript) => { + const calls: Array<RecordedGitCall> = []; + const layer = TicketMergeServiceLive.pipe( + Layer.provideMerge( + Layer.succeed(MergeGitPort, { + run: (input) => + Effect.sync(() => { + calls.push({ cwd: input.cwd, args: input.args }); + const command = input.args[0]; + if (command === "status") { + return { + exitCode: 0, + stdout: + input.cwd === "/repo" ? (script.repoStatus ?? "") : (script.worktreeStatus ?? ""), + stderr: "", + }; + } + if (command === "rev-parse") { + return { exitCode: 0, stdout: `${script.branch ?? "main"}\n`, stderr: "" }; + } + if (command === "rev-list") { + return { exitCode: 0, stdout: `${script.aheadCount ?? "1"}\n`, stderr: "" }; + } + if (command === "merge" && input.args[1] !== "--abort") { + return script.mergeResult ?? { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }), + }), + ), + Layer.provideMerge(stubReadModel), + ); + return { calls, layer }; +}; + +describe("TicketMergeService", () => { + it.effect("merges the ticket branch into the checked-out branch", () => + Effect.gen(function* () { + const harness = makeHarness({}); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput()); + }).pipe(Effect.provide(harness.layer)); + + assert.deepEqual(outcome, { _tag: "completed" }); + const mergeCall = harness.calls.find( + (call) => call.args[0] === "merge" && call.args[1] !== "--abort", + ); + assert.deepEqual(mergeCall?.args, [ + "merge", + "--no-ff", + "--no-verify", + "-m", + "Fix login (ticket-merge)", + "workflow/ticket-merge", + ]); + assert.equal(mergeCall?.cwd, "/repo"); + }), + ); + + it.effect("snapshots dirty worktree changes before merging", () => + Effect.gen(function* () { + const harness = makeHarness({ worktreeStatus: " M src/app.ts\n" }); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput({ commitMessage: "Land it" })); + }).pipe(Effect.provide(harness.layer)); + + assert.deepEqual(outcome, { _tag: "completed" }); + const commitCall = harness.calls.find((call) => call.args[0] === "commit"); + assert.equal(commitCall?.cwd, "/repo-worktrees/ticket-merge"); + assert.deepEqual(commitCall?.args, ["commit", "--no-verify", "-m", "Land it"]); + assert.ok(harness.calls.some((call) => call.args[0] === "add")); + }), + ); + + it.effect("blocks when the repo working tree is dirty", () => + Effect.gen(function* () { + const harness = makeHarness({ repoStatus: " M README.md\n" }); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput()); + }).pipe(Effect.provide(harness.layer)); + + assert.equal(outcome._tag, "blocked"); + assert.ok(harness.calls.every((call) => call.args[0] !== "merge")); + }), + ); + + it.effect("blocks on detached HEAD or mismatched target branch", () => + Effect.gen(function* () { + const detached = makeHarness({ branch: "HEAD" }); + const detachedOutcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput()); + }).pipe(Effect.provide(detached.layer)); + assert.equal(detachedOutcome._tag, "blocked"); + + const mismatch = makeHarness({ branch: "feature/x" }); + const mismatchOutcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput({ target: "main" })); + }).pipe(Effect.provide(mismatch.layer)); + assert.equal(mismatchOutcome._tag, "blocked"); + assert.ok(mismatchOutcome._tag === "blocked" && mismatchOutcome.reason.includes("feature/x")); + }), + ); + + it.effect("completes without merging when there is nothing to merge", () => + Effect.gen(function* () { + const harness = makeHarness({ aheadCount: "0" }); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput()); + }).pipe(Effect.provide(harness.layer)); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.ok(harness.calls.every((call) => call.args[0] !== "merge")); + }), + ); + + it.effect("aborts and blocks on merge conflicts", () => + Effect.gen(function* () { + const harness = makeHarness({ + mergeResult: { + exitCode: 1, + stdout: "CONFLICT (content): Merge conflict in src/app.ts\n", + stderr: "", + }, + }); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput()); + }).pipe(Effect.provide(harness.layer)); + + assert.equal(outcome._tag, "blocked"); + assert.ok(outcome._tag === "blocked" && outcome.reason.includes("src/app.ts")); + assert.ok( + harness.calls.some((call) => call.args[0] === "merge" && call.args[1] === "--abort"), + ); + }), + ); +}); + +describe("TicketMergeService cleanup", () => { + it.effect("removes cleanup paths from the worktree before the snapshot commit", () => + Effect.gen(function* () { + const harness = makeHarness({ worktreeStatus: " M src/app.ts\n" }); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput({ cleanupPaths: ["PLAN.md", "REVIEW.md"] as never })); + }).pipe(Effect.provide(harness.layer)); + + assert.deepEqual(outcome, { _tag: "completed" }); + const cleanupCalls = harness.calls.filter( + (call) => call.args[0] === "rm" || call.args[0] === "clean", + ); + // 2 unconditional scratch-tree cleanups + 2 each for PLAN.md / REVIEW.md. + assert.equal(cleanupCalls.length, 6); + assert.ok(cleanupCalls.every((call) => call.cwd === "/repo-worktrees/ticket-merge")); + assert.ok( + cleanupCalls.some((call) => call.args[0] === "rm" && call.args.includes("PLAN.md")), + ); + assert.ok( + cleanupCalls.some((call) => call.args[0] === "clean" && call.args.includes("REVIEW.md")), + ); + const firstCommitIndex = harness.calls.findIndex((call) => call.args[0] === "commit"); + const lastCleanupIndex = harness.calls.reduce( + (latest, call, index) => + call.args[0] === "rm" || call.args[0] === "clean" ? index : latest, + -1, + ); + assert.ok(lastCleanupIndex < firstCommitIndex); + }), + ); +}); + +describe("TicketMergeService scratch cleanup", () => { + it.effect("unconditionally removes the per-ticket scratch tree before the snapshot", () => + Effect.gen(function* () { + // No cleanupPaths configured — scratch files (DESCRIPTION.md, handoff/, + // design/) must still be purged from the whole `.t3/ticket/<id>` tree. + const harness = makeHarness({ worktreeStatus: " M src/app.ts\n" }); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput()); + }).pipe(Effect.provide(harness.layer)); + + assert.deepEqual(outcome, { _tag: "completed" }); + const scratchDir = ".t3/ticket/ticket-merge"; + const rmCall = harness.calls.find( + (call) => call.args[0] === "rm" && call.args.includes(scratchDir), + ); + const cleanCall = harness.calls.find( + (call) => call.args[0] === "clean" && call.args.includes(scratchDir), + ); + assert.ok(rmCall, "expected a git rm of the scratch tree"); + assert.ok(cleanCall, "expected a git clean of the scratch tree"); + assert.equal(rmCall?.cwd, "/repo-worktrees/ticket-merge"); + // The cleanup must precede the snapshot commit so spilled files never land + // in the merged branch / PR diff. + const firstCommitIndex = harness.calls.findIndex((call) => call.args[0] === "commit"); + const scratchCleanupIndex = harness.calls.findIndex( + (call) => + (call.args[0] === "rm" || call.args[0] === "clean") && call.args.includes(scratchDir), + ); + assert.ok(scratchCleanupIndex >= 0); + assert.ok(scratchCleanupIndex < firstCommitIndex); + }), + ); +}); + +describe("TicketMergeService cleanup templating", () => { + it.effect("substitutes the ticket id into cleanup paths and removes directories", () => + Effect.gen(function* () { + const harness = makeHarness({}); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge( + mergeInput({ cleanupPaths: [".t3/ticket/{{ticket.id}}"] as never }), + ); + }).pipe(Effect.provide(harness.layer)); + + assert.deepEqual(outcome, { _tag: "completed" }); + const rmCall = harness.calls.find( + (call) => call.args[0] === "rm" && call.args.includes(".t3/ticket/ticket-merge"), + ); + assert.ok(rmCall?.args.includes(".t3/ticket/ticket-merge")); + assert.ok(rmCall?.args.includes("-r")); + const cleanCall = harness.calls.find( + (call) => call.args[0] === "clean" && call.args.includes(".t3/ticket/ticket-merge"), + ); + assert.ok(cleanCall?.args.includes(".t3/ticket/ticket-merge")); + assert.ok(cleanCall?.args.includes("-d")); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TicketMergeService.ts b/apps/server/src/workflow/Layers/TicketMergeService.ts new file mode 100644 index 00000000000..bae75e8ab20 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketMergeService.ts @@ -0,0 +1,177 @@ +import type { StepOutcome } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { GitVcsDriver } from "../../vcs/GitVcsDriver.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + MergeGitPort, + TicketMergeService, + type MergeGitPortShape, + type TicketMergeServiceShape, +} from "../Services/TicketMergeService.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { cleanupTicketScratch } from "./ticketScratchCleanup.ts"; + +const blocked = (reason: string): StepOutcome => ({ _tag: "blocked", reason }); +const completed: StepOutcome = { _tag: "completed" }; + +const firstLine = (text: string) => text.trim().split("\n")[0] ?? ""; + +// Only the path-safe ticket id is templated into cleanup paths — titles and +// other free text could smuggle path segments into a git rm. +const resolveCleanupPath = (path: string, ticketId: string): string => + path.replace(/\{\{\s*ticket\.id\s*\}\}/g, ticketId); + +const conflictSummary = (output: string) => { + const lines = output + .split("\n") + .filter((line) => line.includes("CONFLICT")) + .slice(0, 5); + return lines.join("; "); +}; + +const make = Effect.gen(function* () { + const git = yield* MergeGitPort; + const read = yield* WorkflowReadModel; + + const merge: TicketMergeServiceShape["merge"] = (input) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(input.ticketId); + const rawMessage = input.step.commitMessage?.trim(); + const message = + rawMessage !== undefined && rawMessage.length > 0 + ? rawMessage + : `${detail?.ticket.title ?? "workflow ticket"} (${input.ticketId})`; + + // The per-ticket scratch tree (`.t3/ticket/<id>`: DESCRIPTION.md, handoff/, + // design/) is pipeline scratch written by the executor — never a deliverable. + // Purge it UNCONDITIONALLY — independent of `step.cleanupPaths` — so it never + // reaches the snapshot commit, the merged branch, or the PR diff. + yield* cleanupTicketScratch(git, input.worktreePath, input.ticketId as string); + + // Working files like PLAN.md / REVIEW.md are pipeline scratch space — + // drop them before the snapshot so they never land in the target branch. + for (const rawCleanupPath of input.step.cleanupPaths ?? []) { + const cleanupPath = resolveCleanupPath(rawCleanupPath as string, input.ticketId as string); + // rm covers tracked files, clean covers untracked ones (-d so a + // per-ticket scratch directory disappears with its files). + yield* git + .run({ + cwd: input.worktreePath, + args: ["rm", "-r", "-f", "--ignore-unmatch", "--", cleanupPath], + allowNonZeroExit: true, + }) + .pipe(Effect.ignore); + yield* git + .run({ + cwd: input.worktreePath, + args: ["clean", "-f", "-d", "--", cleanupPath], + allowNonZeroExit: true, + }) + .pipe(Effect.ignore); + } + + // Snapshot any uncommitted agent work onto the ticket branch so the + // merge carries the full accumulated state, not just prior commits. + const worktreeStatus = yield* git.run({ + cwd: input.worktreePath, + args: ["status", "--porcelain"], + }); + if (worktreeStatus.stdout.trim().length > 0) { + yield* git.run({ cwd: input.worktreePath, args: ["add", "-A"] }); + yield* git.run({ + cwd: input.worktreePath, + args: ["commit", "--no-verify", "-m", message], + }); + } + + // Preconditions on the repo checkout. Anything a human can fix by + // tidying the repo is blocked (not failed) and never mutates state: + // we refuse to touch a dirty tree and never switch the user's branch. + const repoStatus = yield* git.run({ + cwd: input.repoRoot, + args: ["status", "--porcelain"], + }); + if (repoStatus.stdout.trim().length > 0) { + return blocked( + "Repo working tree has uncommitted changes; commit or stash them, then re-run the lane.", + ); + } + + const branch = (yield* git.run({ + cwd: input.repoRoot, + args: ["rev-parse", "--abbrev-ref", "HEAD"], + })).stdout.trim(); + if (branch === "HEAD") { + return blocked("Repo is on a detached HEAD; check out a branch first."); + } + if (input.step.target !== undefined && branch !== input.step.target) { + return blocked( + `Repo has "${branch}" checked out but this step merges into "${input.step.target}".`, + ); + } + + const ahead = (yield* git.run({ + cwd: input.repoRoot, + args: ["rev-list", "--count", `HEAD..${input.worktreeRef}`], + })).stdout.trim(); + if (ahead === "0") { + return completed; + } + + const result = yield* git.run({ + cwd: input.repoRoot, + args: ["merge", "--no-ff", "--no-verify", "-m", message, input.worktreeRef], + allowNonZeroExit: true, + }); + if (result.exitCode !== 0) { + yield* git + .run({ cwd: input.repoRoot, args: ["merge", "--abort"], allowNonZeroExit: true }) + .pipe(Effect.ignore); + const conflicts = conflictSummary(`${result.stdout}\n${result.stderr}`); + return blocked( + conflicts.length > 0 + ? `Merge conflict: ${conflicts}` + : `Merge failed: ${firstLine(result.stderr) || firstLine(result.stdout) || "unknown git error"}`, + ); + } + + return completed; + }); + + return { merge } satisfies TicketMergeServiceShape; +}); + +export const TicketMergeServiceLive = Layer.effect(TicketMergeService, make); + +export const MergeGitPortLive = Layer.effect( + MergeGitPort, + Effect.gen(function* () { + const git = yield* GitVcsDriver; + + const run: MergeGitPortShape["run"] = (input) => + git + .execute({ + operation: "WorkflowTicketMerge", + cwd: input.cwd, + args: [...input.args], + ...(input.allowNonZeroExit === undefined + ? {} + : { allowNonZeroExit: input.allowNonZeroExit }), + }) + .pipe( + Effect.map((result) => ({ + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + })), + Effect.mapError( + (cause) => + new WorkflowEventStoreError({ message: "workflow merge git command failed", cause }), + ), + ); + + return { run } satisfies MergeGitPortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/TicketPullRequestService.test.ts b/apps/server/src/workflow/Layers/TicketPullRequestService.test.ts new file mode 100644 index 00000000000..29a5454b944 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketPullRequestService.test.ts @@ -0,0 +1,456 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { PullRequestStep, StepRunId, TicketId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { GitHubPort, type GitHubPortShape } from "../Services/GitHubPort.ts"; +import { MergeGitPort, type MergeGitResult } from "../Services/TicketMergeService.ts"; +import { + TicketPullRequestService, + type TicketPullRequestInput, +} from "../Services/TicketPullRequestService.ts"; +import { + WorkflowEventCommitter, + type WorkflowEventCommitterShape, +} from "../Services/WorkflowEventCommitter.ts"; +import type { WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; +import { WorkflowIds, type WorkflowIdsShape } from "../Services/WorkflowIds.ts"; +import { + WorkflowReadModel, + type TicketPrStateRow, + type WorkflowReadModelShape, +} from "../Services/WorkflowReadModel.ts"; +import { TicketPullRequestServiceLive } from "./TicketPullRequestService.ts"; + +interface RecordedGitCall { + readonly cwd: string; + readonly args: ReadonlyArray<string>; +} + +interface OpenPrCall { + readonly cwd: string; + readonly branch: string; + readonly base: string; + readonly title: string; + readonly body: string; + readonly draft: boolean; +} + +interface MergePrCall { + readonly cwd: string; + readonly prNumber: number; + readonly strategy: "squash" | "merge" | "rebase"; + readonly deleteBranch: boolean; + readonly branch: string; + readonly remoteName: string; +} + +interface Harness { + readonly gitCalls: Array<RecordedGitCall>; + readonly openPrCalls: Array<OpenPrCall>; + readonly mergePrCalls: Array<MergePrCall>; + readonly committed: Array<WorkflowEventInput>; + readonly resolveRemoteCalls: { count: number }; + readonly layer: Layer.Layer<TicketPullRequestService>; +} + +interface HarnessScript { + readonly worktreeStatus?: string; + readonly preflight?: { ok: true } | { ok: false; reason: string }; + readonly defaultBranch?: string; + readonly remote?: { remoteName: string; repo: string }; + readonly openPrResult?: { number: number; url: string; adopted: boolean }; + // When set, openPr fails with a WorkflowEventStoreError carrying this message + // (used to exercise the diverged-push blocked path through a resolved layer). + readonly openPrError?: string; + // land: the stored PR state for the ticket (null → nothing to land). + readonly prState?: TicketPrStateRow | null; + // land: mergePr outcome (defaults to a successful merge). + readonly mergePrResult?: { ok: true } | { ok: false; reason: string }; +} + +const TICKET_ID = "ticket-pr" as TicketId; + +const prInput = (step: Partial<PullRequestStep> = {}): TicketPullRequestInput => ({ + ticketId: TICKET_ID, + stepRunId: "step-run-1" as StepRunId, + repoRoot: "/repo", + worktreePath: "/repo-worktrees/ticket-pr", + worktreeRef: "workflow/ticket-pr", + step: { + key: "open-pr" as never, + type: "pullRequest" as const, + action: "open" as const, + ...step, + }, +}); + +const stubReadModel = (script: HarnessScript) => + Layer.succeed(WorkflowReadModel, { + getTicketDetail: () => + Effect.succeed({ + ticket: { + ticketId: "ticket-pr", + boardId: "board-1", + title: "Fix login", + description: "Make login work again", + currentLaneKey: "open-pr", + currentLaneEntryToken: "token-1", + queuedAt: null, + status: "running", + }, + steps: [], + messages: [], + }), + getTicketPrState: () => Effect.succeed(script.prState ?? null), + } as unknown as WorkflowReadModelShape); + +const stubIds = Layer.succeed(WorkflowIds, { + eventId: () => Effect.succeed("event-1"), +} as unknown as WorkflowIdsShape); + +const makeHarness = (script: HarnessScript): Harness => { + const gitCalls: Array<RecordedGitCall> = []; + const openPrCalls: Array<OpenPrCall> = []; + const mergePrCalls: Array<MergePrCall> = []; + const committed: Array<WorkflowEventInput> = []; + const resolveRemoteCalls = { count: 0 }; + + const gitHubPort = Layer.succeed(GitHubPort, { + preflight: () => Effect.succeed(script.preflight ?? { ok: true }), + resolveRemote: () => + Effect.sync(() => { + resolveRemoteCalls.count += 1; + return script.remote ?? { remoteName: "origin", repo: "acme/widgets" }; + }), + defaultBranch: () => Effect.succeed(script.defaultBranch ?? "main"), + openPr: (input: OpenPrCall) => + Effect.suspend(() => { + openPrCalls.push({ + cwd: input.cwd, + branch: input.branch, + base: input.base, + title: input.title, + body: input.body, + draft: input.draft, + }); + if (script.openPrError !== undefined) { + return Effect.fail(new WorkflowEventStoreError({ message: script.openPrError })); + } + return Effect.succeed( + script.openPrResult ?? { + number: 42, + url: "https://github.com/acme/widgets/pull/42", + adopted: false, + }, + ); + }), + mergePr: (input: MergePrCall) => + Effect.sync(() => { + mergePrCalls.push({ + cwd: input.cwd, + prNumber: input.prNumber, + strategy: input.strategy, + deleteBranch: input.deleteBranch, + branch: input.branch, + remoteName: input.remoteName, + }); + return script.mergePrResult ?? { ok: true }; + }), + } as unknown as GitHubPortShape); + + const mergeGitPort = Layer.succeed(MergeGitPort, { + run: (input: { cwd: string; args: ReadonlyArray<string> }) => + Effect.sync(() => { + gitCalls.push({ cwd: input.cwd, args: input.args }); + const command = input.args[0]; + if (command === "status") { + return { + exitCode: 0, + stdout: script.worktreeStatus ?? "", + stderr: "", + } satisfies MergeGitResult; + } + return { exitCode: 0, stdout: "", stderr: "" } satisfies MergeGitResult; + }), + } as never); + + const committer = Layer.succeed(WorkflowEventCommitter, { + commit: (event: WorkflowEventInput) => + Effect.sync(() => { + committed.push(event); + }), + commitMany: () => Effect.void, + appendManyUnlocked: () => Effect.succeed([]), + publishTicketView: () => Effect.void, + } as WorkflowEventCommitterShape); + + const layer = TicketPullRequestServiceLive.pipe( + Layer.provideMerge(gitHubPort), + Layer.provideMerge(mergeGitPort), + Layer.provideMerge(committer), + Layer.provideMerge(stubReadModel(script)), + Layer.provideMerge(stubIds), + ); + + return { gitCalls, openPrCalls, mergePrCalls, committed, resolveRemoteCalls, layer }; +}; + +const runOpen = (harness: Harness, input: TicketPullRequestInput) => + Effect.gen(function* () { + const service = yield* TicketPullRequestService; + return yield* service.open(input); + }).pipe(Effect.provide(harness.layer)); + +const runLand = (harness: Harness, input: TicketPullRequestInput) => + Effect.gen(function* () { + const service = yield* TicketPullRequestService; + return yield* service.land(input); + }).pipe(Effect.provide(harness.layer)); + +const prStateRow = (overrides: Partial<TicketPrStateRow> = {}): TicketPrStateRow => ({ + prNumber: 42, + prUrl: "https://github.com/acme/widgets/pull/42", + branch: "workflow/ticket-pr", + remoteName: "origin", + repo: "acme/widgets", + prState: "open", + lastHeadSha: null, + lastCiState: null, + lastReviewDecision: null, + lastCommentCursor: null, + ...overrides, +}); + +describe("TicketPullRequestService open", () => { + it.effect("snapshots a dirty worktree before opening the PR", () => + Effect.gen(function* () { + const harness = makeHarness({ worktreeStatus: " M src/app.ts\n" }); + const outcome = yield* runOpen(harness, prInput()); + + assert.equal(outcome._tag, "completed"); + const statusIndex = harness.gitCalls.findIndex((c) => c.args[0] === "status"); + const addCall = harness.gitCalls.find((c) => c.args[0] === "add"); + const commitCall = harness.gitCalls.find((c) => c.args[0] === "commit"); + assert.ok(statusIndex >= 0); + assert.deepEqual(addCall?.args, ["add", "-A"]); + assert.equal(commitCall?.cwd, "/repo-worktrees/ticket-pr"); + assert.deepEqual(commitCall?.args, ["commit", "--no-verify", "-m", "Fix login (ticket-pr)"]); + }), + ); + + it.effect("purges the per-ticket scratch tree before the status read and snapshot", () => + Effect.gen(function* () { + const harness = makeHarness({ worktreeStatus: " M src/app.ts\n" }); + const outcome = yield* runOpen(harness, prInput()); + + assert.equal(outcome._tag, "completed"); + const scratchDir = ".t3/ticket/ticket-pr"; + const rmCall = harness.gitCalls.find( + (c) => c.args[0] === "rm" && c.args.includes(scratchDir), + ); + const cleanCall = harness.gitCalls.find( + (c) => c.args[0] === "clean" && c.args.includes(scratchDir), + ); + assert.ok(rmCall, "expected a git rm of the scratch tree"); + assert.ok(cleanCall, "expected a git clean of the scratch tree"); + // Cleanup must precede the status read (so the snapshot reflects post-cleanup + // reality and `git add -A` never stages pipeline scratch into the PR). + const statusIndex = harness.gitCalls.findIndex((c) => c.args[0] === "status"); + const scratchCleanupIndex = harness.gitCalls.findIndex( + (c) => (c.args[0] === "rm" || c.args[0] === "clean") && c.args.includes(scratchDir), + ); + assert.ok(scratchCleanupIndex >= 0); + assert.ok(scratchCleanupIndex < statusIndex); + }), + ); + + it.effect("opens a PR with defaults and commits TicketPrOpened", () => + Effect.gen(function* () { + const harness = makeHarness({}); + const outcome = yield* runOpen(harness, prInput()); + + assert.deepEqual(outcome, { + _tag: "completed", + output: { prNumber: 42, url: "https://github.com/acme/widgets/pull/42" }, + }); + + const call = harness.openPrCalls[0]; + assert.equal(call?.branch, "workflow/ticket-pr"); + assert.equal(call?.base, "main"); + assert.equal(call?.title, "Fix login"); + assert.equal(call?.draft, false); + assert.ok(call?.body.endsWith("t3-ticket: ticket-pr")); + + assert.equal(harness.committed.length, 1); + const event = harness.committed[0]; + assert.equal(event?.type, "TicketPrOpened"); + assert.deepEqual((event as { payload: unknown }).payload, { + stepRunId: "step-run-1", + prNumber: 42, + url: "https://github.com/acme/widgets/pull/42", + branch: "workflow/ticket-pr", + remoteName: "origin", + repo: "acme/widgets", + }); + }), + ); + + it.effect("honors explicit base, templates, and draft", () => + Effect.gen(function* () { + const harness = makeHarness({}); + const outcome = yield* runOpen( + harness, + prInput({ + base: "develop" as never, + draft: true, + titleTemplate: "{{ticket.title}} PR" as never, + bodyTemplate: "Body." as never, + }), + ); + + assert.equal(outcome._tag, "completed"); + const call = harness.openPrCalls[0]; + assert.equal(call?.base, "develop"); + assert.equal(call?.title, "Fix login PR"); + assert.equal(call?.body, "Body.\n\nt3-ticket: ticket-pr"); + assert.equal(call?.draft, true); + }), + ); + + it.effect( + "renders {{ticket.baseRef}} as the RESOLVED default branch when base is omitted (PR #3032)", + () => + Effect.gen(function* () { + const harness = makeHarness({ defaultBranch: "trunk" }); + const outcome = yield* runOpen( + harness, + prInput({ + // No explicit `base` → falls back to the repo default branch. + bodyTemplate: "Targets {{ticket.baseRef}}." as never, + }), + ); + + assert.equal(outcome._tag, "completed"); + const call = harness.openPrCalls[0]; + assert.equal(call?.base, "trunk"); + // Regression: baseRef must be the resolved default branch, NOT "" — the + // vars used to be built before `base` was resolved. + assert.equal(call?.body, "Targets trunk.\n\nt3-ticket: ticket-pr"); + }), + ); + + it.effect("commits TicketPrOpened when an existing PR is adopted", () => + Effect.gen(function* () { + const harness = makeHarness({ + openPrResult: { number: 7, url: "https://github.com/acme/widgets/pull/7", adopted: true }, + }); + const outcome = yield* runOpen(harness, prInput()); + + assert.deepEqual(outcome, { + _tag: "completed", + output: { prNumber: 7, url: "https://github.com/acme/widgets/pull/7" }, + }); + assert.equal(harness.committed.length, 1); + assert.equal(harness.committed[0]?.type, "TicketPrOpened"); + }), + ); + + it.effect("blocks and does nothing when preflight fails", () => + Effect.gen(function* () { + const harness = makeHarness({ + preflight: { ok: false, reason: "gh not authenticated; run gh auth login" }, + }); + const outcome = yield* runOpen(harness, prInput()); + + assert.deepEqual(outcome, { + _tag: "blocked", + reason: "gh not authenticated; run gh auth login", + }); + assert.equal(harness.openPrCalls.length, 0); + assert.equal(harness.committed.length, 0); + assert.equal(harness.resolveRemoteCalls.count, 0); + assert.ok(harness.gitCalls.every((c) => c.args[0] !== "commit")); + }), + ); + + it.effect("blocks on a diverged push and commits nothing", () => + Effect.gen(function* () { + const harness = makeHarness({ + openPrError: "branch diverged: remote push rejected", + }); + const outcome = yield* runOpen(harness, prInput()); + + assert.equal(outcome._tag, "blocked"); + assert.ok(outcome._tag === "blocked" && outcome.reason.startsWith("branch diverged")); + assert.equal(harness.openPrCalls.length, 1); + assert.equal(harness.committed.length, 0); + // resolveRemote runs only after the push guard clears. + assert.equal(harness.resolveRemoteCalls.count, 0); + }), + ); +}); + +describe("TicketPullRequestService land", () => { + const landInput = (step: Partial<PullRequestStep> = {}) => + prInput({ action: "land" as const, ...step }); + + it.effect("blocks and never merges when there is no recorded PR state", () => + Effect.gen(function* () { + const harness = makeHarness({ prState: null }); + const outcome = yield* runLand(harness, landInput()); + + assert.deepEqual(outcome, { _tag: "blocked", reason: "no PR to land" }); + assert.equal(harness.mergePrCalls.length, 0); + }), + ); + + it.effect("merges with default squash + deleteBranch and completes", () => + Effect.gen(function* () { + const harness = makeHarness({ prState: prStateRow() }); + const outcome = yield* runLand(harness, landInput()); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.equal(harness.mergePrCalls.length, 1); + const call = harness.mergePrCalls[0]; + assert.equal(call?.cwd, "/repo-worktrees/ticket-pr"); + assert.equal(call?.prNumber, 42); + assert.equal(call?.strategy, "squash"); + assert.equal(call?.deleteBranch, true); + assert.equal(call?.branch, "workflow/ticket-pr"); + assert.equal(call?.remoteName, "origin"); + }), + ); + + it.effect("threads through an explicit strategy and deleteBranch:false", () => + Effect.gen(function* () { + const harness = makeHarness({ prState: prStateRow() }); + const outcome = yield* runLand( + harness, + landInput({ strategy: "rebase", deleteBranch: false }), + ); + + assert.deepEqual(outcome, { _tag: "completed" }); + const call = harness.mergePrCalls[0]; + assert.equal(call?.strategy, "rebase"); + assert.equal(call?.deleteBranch, false); + }), + ); + + it.effect("blocks with the gh reason when the PR is not mergeable", () => + Effect.gen(function* () { + const harness = makeHarness({ + prState: prStateRow(), + mergePrResult: { ok: false, reason: "branch protection: review required" }, + }); + const outcome = yield* runLand(harness, landInput()); + + assert.deepEqual(outcome, { + _tag: "blocked", + reason: "branch protection: review required", + }); + assert.equal(harness.mergePrCalls.length, 1); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TicketPullRequestService.ts b/apps/server/src/workflow/Layers/TicketPullRequestService.ts new file mode 100644 index 00000000000..028c405964a --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketPullRequestService.ts @@ -0,0 +1,173 @@ +import type { StepOutcome } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { applyInstructionTemplate, type TicketTemplateVars } from "../instructionTemplate.ts"; +import { GitHubPort } from "../Services/GitHubPort.ts"; +import { MergeGitPort } from "../Services/TicketMergeService.ts"; +import { + TicketPullRequestService, + type TicketPullRequestInput, + type TicketPullRequestServiceShape, +} from "../Services/TicketPullRequestService.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import type { WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { cleanupTicketScratch } from "./ticketScratchCleanup.ts"; + +const blocked = (reason: string): StepOutcome => ({ _tag: "blocked", reason }); + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const make = Effect.gen(function* () { + const github = yield* GitHubPort; + const git = yield* MergeGitPort; + const read = yield* WorkflowReadModel; + const committer = yield* WorkflowEventCommitter; + const ids = yield* WorkflowIds; + + const open: TicketPullRequestServiceShape["open"] = (input) => + Effect.gen(function* () { + // 1. Preflight: a missing/unauthenticated gh is human-fixable, so block + // (nothing has been pushed) rather than fail. + const preflight = yield* github.preflight(input.worktreePath); + if (!preflight.ok) { + return blocked(preflight.reason); + } + + // Resolve the base branch BEFORE building the template vars: when the + // step omits `base`, it falls back to the repo default branch, and + // `{{ticket.baseRef}}` must render that resolved branch — not "" — in the + // snapshot message and PR title/body below. (PR #3032 macroscope review.) + const base = input.step.base ?? (yield* github.defaultBranch(input.worktreePath)); + + // Ticket detail backs the template vars and the snapshot-commit message. + const detail = yield* read.getTicketDetail(input.ticketId); + const ticketTitle = detail?.ticket.title ?? "workflow ticket"; + const vars: TicketTemplateVars = { + title: ticketTitle, + description: detail?.ticket.description ?? "", + id: input.ticketId as string, + baseRef: base, + discussion: "", + }; + + // 2. Snapshot any uncommitted agent work onto the ticket branch so the + // PR carries the full accumulated state (mirrors TicketMergeService). + const snapshotMessage = + input.step.titleTemplate !== undefined + ? applyInstructionTemplate(input.step.titleTemplate, vars) + : `${ticketTitle} (${input.ticketId})`; + // Purge the per-ticket scratch tree (`.t3/ticket/<id>`) BEFORE the status + // read, so the status reflects post-cleanup reality and the `git add -A` + // snapshot never stages pipeline scratch (DESCRIPTION.md, handoff/, design/) + // into the PR. (TicketMergeService already does this before its snapshot.) + yield* cleanupTicketScratch(git, input.worktreePath, input.ticketId as string); + const worktreeStatus = yield* git.run({ + cwd: input.worktreePath, + args: ["status", "--porcelain"], + }); + if (worktreeStatus.stdout.trim().length > 0) { + yield* git.run({ cwd: input.worktreePath, args: ["add", "-A"] }); + yield* git.run({ + cwd: input.worktreePath, + args: ["commit", "--no-verify", "-m", snapshotMessage], + }); + } + + // 3. Render the PR title and body, always appending the ticket trailer. + const title = + input.step.titleTemplate !== undefined + ? applyInstructionTemplate(input.step.titleTemplate, vars) + : ticketTitle; + const renderedBody = + input.step.bodyTemplate !== undefined + ? applyInstructionTemplate(input.step.bodyTemplate, vars) + : ""; + const body = `${renderedBody}${renderedBody ? "\n\n" : ""}t3-ticket: ${input.ticketId}`; + + // 5. Push + open (or adopt) the PR. A diverged remote is human-fixable; + // map it to blocked. Other failures are real infra faults — let them + // propagate on the error channel. + const result = yield* github + .openPr({ + cwd: input.worktreePath, + branch: input.worktreeRef, + base, + title, + body, + draft: input.step.draft ?? false, + }) + .pipe( + Effect.catchIf( + (error) => error.message.startsWith("branch diverged"), + (error) => Effect.succeed({ _blocked: error.message } as const), + ), + ); + if ("_blocked" in result) { + return blocked(result._blocked); + } + + // 6. Resolve the remote for the TicketPrOpened projection. Done only + // after the push guard so a resolveRemote fault can't pre-empt a block. + const remote = yield* github.resolveRemote(input.worktreePath); + + // 7. Emit TicketPrOpened. The projection upsert is idempotent, so this is + // safe whether the PR was freshly created or adopted. + const eventId = yield* ids.eventId(); + yield* committer.commit({ + type: "TicketPrOpened", + eventId, + ticketId: input.ticketId, + occurredAt: yield* nowIso, + payload: { + stepRunId: input.stepRunId, + prNumber: result.number, + url: result.url, + branch: input.worktreeRef, + remoteName: remote.remoteName, + repo: remote.repo, + }, + } as WorkflowEventInput); + + // 8. Completed. + return { + _tag: "completed", + output: { prNumber: result.number, url: result.url }, + }; + }); + + const land: TicketPullRequestServiceShape["land"] = (input) => + Effect.gen(function* () { + // 1. The PR to land is whichever one `open` recorded for this ticket. No + // recorded state means there is nothing to merge — block (human-fixable) + // rather than fail. + const state = yield* read.getTicketPrState(input.ticketId); + if (state === null) { + return blocked("no PR to land"); + } + + // 2. Merge through gh. Branch cleanup (deleteBranch) is best-effort inside + // the port; a not-mergeable PR (branch protection, failing checks, review + // required) is human-fixable, so map it to blocked. Real infra faults + // propagate on the error channel. + const result = yield* github.mergePr({ + cwd: input.worktreePath, + prNumber: state.prNumber, + strategy: input.step.strategy ?? "squash", + deleteBranch: input.step.deleteBranch ?? true, + branch: state.branch, + remoteName: state.remoteName, + }); + if (!result.ok) { + return blocked(result.reason); + } + return { _tag: "completed" }; + }); + + return { open, land } satisfies TicketPullRequestServiceShape; +}); + +export const TicketPullRequestServiceLive = Layer.effect(TicketPullRequestService, make); diff --git a/apps/server/src/workflow/Layers/TurnStateReader.test.ts b/apps/server/src/workflow/Layers/TurnStateReader.test.ts new file mode 100644 index 00000000000..fc371692fce --- /dev/null +++ b/apps/server/src/workflow/Layers/TurnStateReader.test.ts @@ -0,0 +1,102 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { TurnProjectionPort, TurnStateReader } from "../Services/TurnStateReader.ts"; +import { TurnProjectionPortLive, TurnStateReaderLive } from "./TurnStateReader.ts"; + +const stub = (state: string) => + Layer.succeed(TurnProjectionPort, { + getLatestTurnState: () => + Effect.succeed({ state, completed: state === "completed" || state === "error" }), + }); + +const mk = (state: string) => + it.layer( + TurnStateReaderLive.pipe( + Layer.provideMerge(stub(state)), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ); + +mk("completed")("TurnStateReader completed", (it) => { + it.effect("maps completed", () => + Effect.gen(function* () { + const reader = yield* TurnStateReader; + const result = yield* reader.read("thread-1" as never); + assert.equal(result._tag, "completed"); + }), + ); +}); + +mk("error")("TurnStateReader error", (it) => { + it.effect("maps error to failed", () => + Effect.gen(function* () { + const reader = yield* TurnStateReader; + const result = yield* reader.read("thread-1" as never); + assert.equal(result._tag, "failed"); + }), + ); +}); + +mk("running")("TurnStateReader running", (it) => { + it.effect("maps running", () => + Effect.gen(function* () { + const reader = yield* TurnStateReader; + const result = yield* reader.read("thread-1" as never); + assert.equal(result._tag, "running"); + }), + ); +}); + +const liveProjectionLayer = it.layer( + TurnStateReaderLive.pipe( + Layer.provideMerge(TurnProjectionPortLive), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +liveProjectionLayer("TurnStateReader live projection", (it) => { + it.effect("maps running completed and error through the live turn projection", () => + Effect.gen(function* () { + const turns = yield* ProjectionTurnRepository; + const reader = yield* TurnStateReader; + const upsert = (threadId: string, turnId: string, state: "running" | "completed" | "error") => + turns.upsertByTurnId({ + threadId: threadId as never, + turnId: turnId as never, + pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: null, + state, + requestedAt: "2026-06-07T00:00:00.000Z" as never, + startedAt: "2026-06-07T00:00:00.000Z" as never, + completedAt: state === "running" ? null : ("2026-06-07T00:00:01.000Z" as never), + checkpointTurnCount: null, + checkpointRef: null, + checkpointStatus: null, + checkpointFiles: [], + }); + + yield* upsert("thread-live-running", "turn-live-running", "running"); + yield* upsert("thread-live-completed", "turn-live-completed", "completed"); + yield* upsert("thread-live-error", "turn-live-error", "error"); + + assert.equal((yield* reader.read("thread-live-running" as never))._tag, "running"); + assert.equal((yield* reader.read("thread-live-completed" as never))._tag, "completed"); + const failed = yield* reader.read("thread-live-error" as never); + assert.equal(failed._tag, "failed"); + if (failed._tag === "failed") { + assert.equal(failed.error, "error"); + } + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TurnStateReader.ts b/apps/server/src/workflow/Layers/TurnStateReader.ts new file mode 100644 index 00000000000..1a0ad7b80af --- /dev/null +++ b/apps/server/src/workflow/Layers/TurnStateReader.ts @@ -0,0 +1,160 @@ +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { + TurnProjectionPort, + TurnStateReader, + type TurnProjectionPortShape, + type TurnState, + type TurnStateReaderShape, +} from "../Services/TurnStateReader.ts"; + +interface PendingProviderRequestRow { + readonly requestId: string; +} + +interface PendingUserInputRow { + readonly requestId: string; + readonly prompt: string | null; + readonly questionId: string | null; +} + +const toTurnState = (state: string): TurnState => { + if (state === "completed") { + return { _tag: "completed" }; + } + if (state === "error" || state === "interrupted") { + return { _tag: "failed", error: state }; + } + return { _tag: "running" }; +}; + +const make = Effect.gen(function* () { + const port = yield* TurnProjectionPort; + const sql = yield* SqlClient.SqlClient; + + const pendingProviderRequest = (threadId: ThreadId) => + sql<PendingProviderRequestRow>` + SELECT request_id AS "requestId" + FROM projection_pending_approvals + WHERE thread_id = ${threadId} + AND status = 'pending' + ORDER BY created_at ASC + LIMIT 1 + `.pipe( + Effect.map((rows) => rows[0] ?? null), + Effect.orElseSucceed(() => null), + ); + + const pendingUserInputRequest = (threadId: ThreadId) => + sql<PendingUserInputRow>` + WITH latest_user_input_states AS ( + SELECT + latest.request_id AS "requestId", + latest.question_id AS "questionId", + latest.prompt, + latest.kind, + latest.detail + FROM ( + SELECT + json_extract(activity.payload_json, '$.requestId') AS request_id, + json_extract(activity.payload_json, '$.questions[0].id') AS question_id, + json_extract(activity.payload_json, '$.questions[0].question') AS prompt, + activity.kind, + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) AS detail, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS row_number + FROM projection_thread_activities AS activity + WHERE activity.thread_id = ${threadId} + AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL + AND activity.kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ) AS latest + WHERE latest.row_number = 1 + ) + SELECT "requestId" + , "questionId" + , prompt + FROM latest_user_input_states + WHERE kind = 'user-input.requested' + OR ( + kind = 'provider.user-input.respond.failed' + AND detail NOT LIKE '%stale pending user-input request%' + AND detail NOT LIKE '%unknown pending user-input request%' + AND detail NOT LIKE '%unknown pending user input request%' + AND detail NOT LIKE '%unknown pending codex user input request%' + ) + ORDER BY "requestId" ASC + LIMIT 1 + `.pipe( + Effect.map((rows) => rows[0] ?? null), + Effect.orElseSucceed(() => null), + ); + + const read: TurnStateReaderShape["read"] = (threadId) => + Effect.gen(function* () { + const { state } = yield* port.getLatestTurnState(threadId); + const turnState = toTurnState(state); + if (turnState._tag !== "running") { + return turnState; + } + + const pending = yield* pendingProviderRequest(threadId); + if (pending) { + return { + _tag: "awaiting_user", + waitingReason: "Provider is waiting for user input", + providerThreadId: threadId, + providerRequestId: ApprovalRequestId.make(pending.requestId), + providerResponseKind: "request", + } satisfies TurnState; + } + const pendingUserInput = yield* pendingUserInputRequest(threadId); + if (pendingUserInput) { + return { + _tag: "awaiting_user", + waitingReason: pendingUserInput.prompt ?? "Provider is waiting for user input", + providerThreadId: threadId, + providerRequestId: ApprovalRequestId.make(pendingUserInput.requestId), + providerResponseKind: "user-input", + ...(pendingUserInput.questionId === null + ? {} + : { providerQuestionId: pendingUserInput.questionId }), + } satisfies TurnState; + } + return turnState; + }); + + return { read } satisfies TurnStateReaderShape; +}); + +export const TurnStateReaderLive = Layer.effect(TurnStateReader, make); + +export const TurnProjectionPortLive = Layer.effect( + TurnProjectionPort, + Effect.gen(function* () { + const turns = yield* ProjectionTurnRepository; + + const getLatestTurnState: TurnProjectionPortShape["getLatestTurnState"] = (threadId) => + turns.listByThreadId({ threadId }).pipe( + Effect.map((rows) => rows.at(-1)), + Effect.map((turn) => ({ + state: turn?.state ?? "pending", + // Mirrors toTurnState: interrupted turns are terminal too. + completed: + turn?.state === "completed" || turn?.state === "error" || turn?.state === "interrupted", + })), + Effect.orElseSucceed(() => ({ state: "pending", completed: false })), + ); + + return { getLatestTurnState } satisfies TurnProjectionPortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/WorkSourceConnectionStore.test.ts b/apps/server/src/workflow/Layers/WorkSourceConnectionStore.test.ts new file mode 100644 index 00000000000..1c006191f59 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkSourceConnectionStore.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import { WorkSourceConnectionStore } from "../Services/WorkSourceConnectionStore.ts"; +import { WorkSourceConnectionStoreLive } from "./WorkSourceConnectionStore.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; + +// --------------------------------------------------------------------------- +// Stub ServerSecretStore backed by an in-memory Map +// --------------------------------------------------------------------------- +const makeInMemorySecretStore = () => { + const store = new Map<string, Uint8Array>(); + const layer = Layer.succeed(ServerSecretStore.ServerSecretStore, { + get: (name) => Effect.succeed(store.get(name) ?? null), + set: (name, value) => + Effect.sync(() => { + store.set(name, value); + }), + create: (name, value) => + Effect.sync(() => { + store.set(name, value); + }), + getOrCreateRandom: (_name, _bytes) => Effect.die("not needed in test"), + remove: (name) => + Effect.sync(() => { + store.delete(name); + }), + } satisfies ServerSecretStore.ServerSecretStoreShape); + return { layer, store }; +}; + +// --------------------------------------------------------------------------- +// Test layer +// --------------------------------------------------------------------------- +const buildTestLayer = () => { + const { layer: secretStoreLayer, store: secretStore } = makeInMemorySecretStore(); + + const layer = WorkSourceConnectionStoreLive.pipe( + Layer.provide(DeterministicWorkflowIds), + Layer.provide(secretStoreLayer), + Layer.provide(MigrationsLive), + Layer.provide(SqlitePersistenceMemory), + ); + return { layer, secretStore }; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe("WorkSourceConnectionStore", () => { + it.effect("create inserts a row and stores the token in the secret store", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + + const view = yield* store.create({ + provider: "github", + displayName: "My GitHub", + token: "ghp_test1234", + }); + + expect(view.provider).toBe("github"); + expect(view.displayName).toBe("My GitHub"); + expect(typeof view.connectionRef).toBe("string"); + expect(view.connectionRef.length).toBeGreaterThan(0); + + // Token must be retrievable + const token = yield* store.getToken(view.connectionRef, "github"); + expect(token).toBe("ghp_test1234"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect( + "getToken fails with WorkSourceAuthError when expectedProvider does not match the row", + () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const view = yield* store.create({ + provider: "github", + displayName: "GH conn", + token: "ghp_bound", + }); + + // Wrong provider for this connectionRef → must NOT return the github token. + const error = yield* store.getToken(view.connectionRef, "asana").pipe(Effect.flip); + expect((error as { _tag: string })._tag).toBe("WorkSourceAuthError"); + + // Correct provider → token returned. + const token = yield* store.getToken(view.connectionRef, "github"); + expect(token).toBe("ghp_bound"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("list returns all views without the token", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + + yield* store.create({ provider: "github", displayName: "GH 1", token: "tok-1" }); + yield* store.create({ provider: "asana", displayName: "Asana 1", token: "tok-2" }); + + const connections = yield* store.list(); + expect(connections).toHaveLength(2); + expect(connections.map((c) => c.provider).sort()).toEqual(["asana", "github"]); + // No token field in view + for (const conn of connections) { + expect((conn as Record<string, unknown>)["token"]).toBeUndefined(); + } + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("getToken returns the stored PAT as a string", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const view = yield* store.create({ + provider: "asana", + displayName: "Asana connection", + token: "asana-pat-abc", + }); + + const token = yield* store.getToken(view.connectionRef, "asana"); + expect(token).toBe("asana-pat-abc"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("getToken fails with WorkSourceAuthError for unknown connectionRef", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const result = yield* Effect.exit(store.getToken("nonexistent-ref", "github")); + expect(result._tag).toBe("Failure"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("getToken fails gracefully when the row exists but its secret is missing", () => { + // INSERT-before-secret create ordering can leave a row whose secret was + // never stored (or was removed out of band). getToken must degrade to a + // typed WorkSourceAuthError rather than crashing. + const { layer, secretStore } = buildTestLayer(); + return Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const view = yield* store.create({ + provider: "github", + displayName: "Orphaned secret", + token: "soon-to-vanish", + }); + + // Simulate the row-exists-but-secret-missing state by deleting the secret + // directly from the backing map (the row remains in SQLite). + secretStore.delete(`work-source-token:${view.connectionRef}`); + + // getToken fails in the typed error channel (not a defect) with WorkSourceAuthError. + const error = yield* store.getToken(view.connectionRef, "github").pipe(Effect.flip); + expect((error as { _tag: string })._tag).toBe("WorkSourceAuthError"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("remove deletes the row and the secret", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + + const view = yield* store.create({ + provider: "github", + displayName: "To be deleted", + token: "delete-me-token", + }); + + // Exists before remove + const before = yield* store.list(); + expect(before.some((c) => c.connectionRef === view.connectionRef)).toBe(true); + + yield* store.remove(view.connectionRef); + + // Gone after remove + const after = yield* store.list(); + expect(after.some((c) => c.connectionRef === view.connectionRef)).toBe(false); + + // Token is also gone + const tokenResult = yield* Effect.exit(store.getToken(view.connectionRef, "github")); + expect(tokenResult._tag).toBe("Failure"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("persists authMode + baseUrl and exposes them via list + getConnectionAuth", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + + const view = yield* store.create({ + provider: "github", + displayName: "GH with base url", + token: "ghp_x", + authMode: "bearer", + baseUrl: "https://example.test", + email: "me@example.test", + }); + + // View carries the non-secret fields + expect(view.authMode).toBe("bearer"); + expect(view.baseUrl).toBe("https://example.test"); + + // getConnectionAuth returns the full bundle (token + non-secret fields) + const auth = yield* store.getConnectionAuth(view.connectionRef, "github"); + expect(auth.token).toBe("ghp_x"); + expect(auth.authMode).toBe("bearer"); + expect(auth.baseUrl).toBe("https://example.test"); + expect(auth.email).toBe("me@example.test"); + + // list()/toView path surfaces the same non-secret fields + const views = yield* store.list(); + const persisted = views.find((v) => v.connectionRef === view.connectionRef); + expect(persisted?.authMode).toBe("bearer"); + expect(persisted?.baseUrl).toBe("https://example.test"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("defaults authMode to 'pat' and base_url/email to null when omitted", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const view = yield* store.create({ + provider: "asana", + displayName: "Plain asana", + token: "tok", + }); + expect(view.authMode).toBe("pat"); + expect(view.baseUrl).toBeNull(); + + const auth = yield* store.getConnectionAuth(view.connectionRef, "asana"); + expect(auth.authMode).toBe("pat"); + expect(auth.baseUrl).toBeNull(); + expect(auth.email).toBeNull(); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("getConnectionAuth is provider-bound (wrong provider → WorkSourceAuthError)", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const view = yield* store.create({ + provider: "github", + displayName: "Bound", + token: "ghp_bound", + }); + const error = yield* store.getConnectionAuth(view.connectionRef, "asana").pipe(Effect.flip); + expect((error as { _tag: string })._tag).toBe("WorkSourceAuthError"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("getConnectionAuth fails gracefully when the row exists but its secret is missing", () => { + // Mirrors the getToken orphaned-secret case: a row whose secret was never + // stored (or removed out of band) must degrade to a typed + // WorkSourceAuthError rather than crashing. + const { layer, secretStore } = buildTestLayer(); + return Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const view = yield* store.create({ + provider: "github", + displayName: "Orphaned secret (auth)", + token: "soon-to-vanish", + }); + + // Simulate the row-exists-but-secret-missing state. + secretStore.delete(`work-source-token:${view.connectionRef}`); + + const error = yield* store.getConnectionAuth(view.connectionRef, "github").pipe(Effect.flip); + expect((error as { _tag: string })._tag).toBe("WorkSourceAuthError"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("create rejects a Jira connection without a base URL", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const error = yield* store + .create({ provider: "jira", displayName: "Jira", token: "t", authMode: "bearer" }) + .pipe(Effect.flip); + expect((error as { _tag: string })._tag).toBe("WorkSourceConnectionStoreError"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("create rejects a Jira Cloud (basic) connection without an email", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const error = yield* store + .create({ + provider: "jira", + displayName: "Jira Cloud", + token: "t", + authMode: "basic", + baseUrl: "https://acme.atlassian.net", + }) + .pipe(Effect.flip); + expect((error as { _tag: string })._tag).toBe("WorkSourceConnectionStoreError"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("create accepts a valid Jira Cloud connection", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const view = yield* store.create({ + provider: "jira", + displayName: "Jira Cloud", + token: "t", + authMode: "basic", + baseUrl: "https://acme.atlassian.net", + email: "me@acme.test", + }); + expect(view.provider).toBe("jira"); + expect(view.authMode).toBe("basic"); + const auth = yield* store.getConnectionAuth(view.connectionRef, "jira"); + expect(auth.email).toBe("me@acme.test"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("create rejects a Jira connection whose base URL host is blocked (SSRF)", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const error = yield* store + .create({ + provider: "jira", + displayName: "Jira internal", + token: "t", + authMode: "bearer", + baseUrl: "http://169.254.169.254/", + }) + .pipe(Effect.flip); + expect((error as { _tag: string })._tag).toBe("WorkSourceConnectionStoreError"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); +}); + +// Suppress unused import warning for FileSystem / Path (used indirectly via SqlitePersistenceMemory) +void FileSystem; +void Path; diff --git a/apps/server/src/workflow/Layers/WorkSourceConnectionStore.ts b/apps/server/src/workflow/Layers/WorkSourceConnectionStore.ts new file mode 100644 index 00000000000..33e4330337b --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkSourceConnectionStore.ts @@ -0,0 +1,247 @@ +/** + * WorkSourceConnectionStore — Layer implementation. + * + * Persists connection metadata to the `work_source_connection` SQLite table + * and stores the PAT bytes in `ServerSecretStore` under + * `work-source-token:<connectionRef>`. + * + * getToken: SELECT row → secrets.get(token_secret_name) → TextDecoder. + * Missing row or missing secret → WorkSourceAuthError. + * + * create: generate connectionRef via WorkflowIds.eventId() (produces a + * prefixed uuid, e.g. "evt-<uuid>"), derive token_secret_name, store + * secret bytes, INSERT row, return view (no token). + * + * list: SELECT all rows, map to WorkSourceConnectionView (no token). + * + * remove: secrets.remove(token_secret_name) + DELETE row. + * v1 does NOT check for boards still referencing the connectionRef — + * a dangling ref will cause WorkSourceAuthError at sync time, which + * the syncer handles gracefully (exponential backoff per source). + */ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import type { WorkSourceConnectionView } from "@t3tools/contracts/workSource"; +import type { WorkSourceProviderName } from "@t3tools/contracts/workSource"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import { isBlockedHost } from "../blockedHost.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkSourceAuthError } from "../Services/WorkSourceProvider.ts"; +import { + WorkSourceConnectionStore, + WorkSourceConnectionStoreError, + type WorkSourceConnectionStoreShape, +} from "../Services/WorkSourceConnectionStore.ts"; + +interface ConnectionRow { + readonly connection_ref: string; + readonly provider: string; + readonly display_name: string; + readonly auth_mode: string; + readonly token_secret_name: string; + readonly base_url: string | null; + readonly auth_email: string | null; + readonly created_at: string; +} + +const toWorkSourceConnectionStoreError = (message: string) => (cause: unknown) => + new WorkSourceConnectionStoreError({ message, cause }); + +const toView = (row: ConnectionRow): WorkSourceConnectionView => ({ + connectionRef: row.connection_ref as never, + provider: row.provider as WorkSourceProviderName, + displayName: row.display_name as never, + authMode: row.auth_mode as "pat" | "basic" | "bearer", + baseUrl: row.base_url, +}); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const ids = yield* WorkflowIds; + + // Shared provider-bound lookup: resolves BOTH the connection row AND its + // decoded token, or fails with WorkSourceAuthError. Provider-bound — only + // matches when the ref AND the provider agree, so a source can never use + // another provider's credential. Both public methods build on this. + const fetchRowAndToken = Effect.fn("WorkSourceConnectionStore.fetchRowAndToken")(function* ( + connectionRef: string, + expectedProvider: WorkSourceProviderName, + ) { + const rows = yield* sql<ConnectionRow>` + SELECT connection_ref, provider, display_name, auth_mode, token_secret_name, base_url, auth_email, created_at + FROM work_source_connection + WHERE connection_ref = ${connectionRef} AND provider = ${expectedProvider} + `.pipe( + Effect.mapError((cause) => new WorkSourceAuthError({ connectionRef, cause } as never)), + ); + + const row = rows[0]; + if (row === undefined) { + return yield* new WorkSourceAuthError({ connectionRef }); + } + + const bytes = yield* secretStore + .get(row.token_secret_name) + .pipe(Effect.mapError((cause) => new WorkSourceAuthError({ connectionRef, cause } as never))); + + if (bytes === null) { + return yield* new WorkSourceAuthError({ connectionRef }); + } + + return { row, token: new TextDecoder().decode(bytes) }; + }); + + const getToken: WorkSourceConnectionStoreShape["getToken"] = Effect.fn( + "WorkSourceConnectionStore.getToken", + )(function* (connectionRef, expectedProvider) { + const { token } = yield* fetchRowAndToken(connectionRef, expectedProvider); + return token; + }); + + const getConnectionAuth: WorkSourceConnectionStoreShape["getConnectionAuth"] = Effect.fn( + "WorkSourceConnectionStore.getConnectionAuth", + )(function* (connectionRef, expectedProvider) { + const { row, token } = yield* fetchRowAndToken(connectionRef, expectedProvider); + return { + token, + authMode: row.auth_mode as "pat" | "basic" | "bearer", + baseUrl: row.base_url, + email: row.auth_email, + }; + }); + + const create: WorkSourceConnectionStoreShape["create"] = Effect.fn( + "WorkSourceConnectionStore.create", + )(function* (input) { + if (input.provider === "jira") { + const base = input.baseUrl?.trim(); + if (!base) { + return yield* new WorkSourceConnectionStoreError({ + message: "Jira connections require a base URL", + }); + } + const parsed = yield* Effect.try({ + try: () => { + const url = new URL(base); + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("scheme"); + return url; + }, + catch: () => + new WorkSourceConnectionStoreError({ + message: "Jira base URL must be a valid http(s) URL", + }), + }); + if (isBlockedHost(parsed.hostname)) { + return yield* new WorkSourceConnectionStoreError({ + message: "Jira base URL host is not allowed", + }); + } + if (input.authMode === "basic" && !input.email?.trim()) { + return yield* new WorkSourceConnectionStoreError({ + message: "Jira Cloud (Basic auth) connections require an email", + }); + } + } + + const connectionRef = yield* ids.eventId().pipe(Effect.map((id) => `conn-${id}`)); + const tokenSecretName = `work-source-token:${connectionRef}`; + const now = yield* DateTime.now; + const createdAt = DateTime.formatIso(now); + + const authMode = input.authMode ?? "pat"; + + // INSERT the row BEFORE storing the secret. If the INSERT fails we leave + // no orphaned, unreachable secret behind. The reverse failure mode (row + // exists, secret missing) is graceful: getToken fails with + // WorkSourceAuthError and remove can still clean up the row. + yield* sql` + INSERT INTO work_source_connection ( + connection_ref, + provider, + display_name, + auth_mode, + token_secret_name, + base_url, + auth_email, + created_at + ) VALUES ( + ${connectionRef}, + ${input.provider}, + ${input.displayName}, + ${authMode}, + ${tokenSecretName}, + ${input.baseUrl ?? null}, + ${input.email ?? null}, + ${createdAt} + ) + `.pipe( + Effect.mapError(toWorkSourceConnectionStoreError("Failed to insert work source connection")), + ); + + yield* secretStore + .set(tokenSecretName, new TextEncoder().encode(input.token)) + .pipe(Effect.mapError(toWorkSourceConnectionStoreError("Failed to store connection token"))); + + return { + connectionRef: connectionRef as never, + provider: input.provider, + displayName: input.displayName as never, + authMode, + baseUrl: input.baseUrl ?? null, + } satisfies WorkSourceConnectionView; + }); + + const list: WorkSourceConnectionStoreShape["list"] = () => + sql<ConnectionRow>` + SELECT connection_ref, provider, display_name, auth_mode, token_secret_name, base_url, auth_email, created_at + FROM work_source_connection + ORDER BY created_at ASC + `.pipe( + Effect.map((rows) => rows.map(toView)), + Effect.mapError(toWorkSourceConnectionStoreError("Failed to list work source connections")), + Effect.withSpan("WorkSourceConnectionStore.list"), + ); + + const remove: WorkSourceConnectionStoreShape["remove"] = Effect.fn( + "WorkSourceConnectionStore.remove", + )(function* (connectionRef) { + const rows = yield* sql<{ readonly token_secret_name: string }>` + SELECT token_secret_name FROM work_source_connection WHERE connection_ref = ${connectionRef} + `.pipe( + Effect.mapError(toWorkSourceConnectionStoreError("Failed to look up connection for removal")), + ); + + const row = rows[0]; + if (row !== undefined) { + yield* secretStore + .remove(row.token_secret_name) + .pipe( + Effect.mapError( + toWorkSourceConnectionStoreError("Failed to remove connection token secret"), + ), + ); + } + + yield* sql` + DELETE FROM work_source_connection WHERE connection_ref = ${connectionRef} + `.pipe( + Effect.mapError( + toWorkSourceConnectionStoreError("Failed to delete work source connection row"), + ), + ); + }); + + return { + getToken, + getConnectionAuth, + create, + list, + remove, + } satisfies WorkSourceConnectionStoreShape; +}); + +export const WorkSourceConnectionStoreLive = Layer.effect(WorkSourceConnectionStore, make); diff --git a/apps/server/src/workflow/Layers/WorkSourceProviderRegistry.test.ts b/apps/server/src/workflow/Layers/WorkSourceProviderRegistry.test.ts new file mode 100644 index 00000000000..5ba2f4dce61 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkSourceProviderRegistry.test.ts @@ -0,0 +1,68 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { + AsanaProvider, + GithubIssuesProvider, + JiraProvider, + WorkSourceProviderRegistry, + type WorkSourceProvider, +} from "../Services/WorkSourceProvider.ts"; +import { WorkSourceProviderRegistryLive } from "./WorkSourceProviderRegistry.ts"; + +const makeStub = (name: "github" | "asana" | "jira"): WorkSourceProvider => ({ + provider: name, + selectorSchema: Schema.Unknown, + listPage: () => Effect.succeed({ items: [] }), + getItem: () => Effect.succeed(null), + viewer: () => Effect.succeed(null), + toImportableView: () => ({ displayRef: "", container: "" }), +}); + +const githubStubLayer = Layer.succeed(GithubIssuesProvider, makeStub("github")); +const asanaStubLayer = Layer.succeed(AsanaProvider, makeStub("asana")); +const jiraStubLayer = Layer.succeed(JiraProvider, makeStub("jira")); + +const testLayer = WorkSourceProviderRegistryLive.pipe( + Layer.provide(Layer.mergeAll(githubStubLayer, asanaStubLayer, jiraStubLayer)), +); + +const layer = it.layer(testLayer); + +layer("WorkSourceProviderRegistry", (it) => { + it.effect("get('github') returns the github provider", () => + Effect.gen(function* () { + const registry = yield* WorkSourceProviderRegistry; + const provider = registry.get("github"); + assert.equal(provider.provider, "github"); + }), + ); + + it.effect("get('asana') returns the asana provider", () => + Effect.gen(function* () { + const registry = yield* WorkSourceProviderRegistry; + const provider = registry.get("asana"); + assert.equal(provider.provider, "asana"); + }), + ); + + it.effect("get('jira') returns the jira provider", () => + Effect.gen(function* () { + const registry = yield* WorkSourceProviderRegistry; + const provider = registry.get("jira"); + assert.equal(provider.provider, "jira"); + }), + ); + + it.effect("Fix L8: get(<unknown provider>) throws instead of misrouting to asana", () => + Effect.gen(function* () { + const registry = yield* WorkSourceProviderRegistry; + // Simulate a future provider literal added to the contract union but not + // wired into the registry. The exhaustive switch must FAIL FAST rather + // than silently dispatch to the asana provider. + assert.throws(() => registry.get("linear" as never), /Unknown work-source provider: linear/u); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkSourceProviderRegistry.ts b/apps/server/src/workflow/Layers/WorkSourceProviderRegistry.ts new file mode 100644 index 00000000000..b65d5ed4107 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkSourceProviderRegistry.ts @@ -0,0 +1,41 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { WorkSourceProviderName } from "@t3tools/contracts/workSource"; + +import { + AsanaProvider, + GithubIssuesProvider, + JiraProvider, + WorkSourceProviderRegistry, + type WorkSourceProviderRegistryShape, +} from "../Services/WorkSourceProvider.ts"; + +const make = Effect.gen(function* () { + const github = yield* GithubIssuesProvider; + const asana = yield* AsanaProvider; + const jira = yield* JiraProvider; + + return { + // Exhaustive dispatch: a `default` that returns asana would silently + // misroute any future provider literal (e.g. 'linear') added to + // WorkSourceProviderName but not wired here. Fail fast instead so the gap + // is loud at runtime, and let the `never` assignment make it a compile + // error too once the union grows. + get: (provider: WorkSourceProviderName) => { + switch (provider) { + case "github": + return github; + case "asana": + return asana; + case "jira": + return jira; + default: { + const unknown: never = provider; + throw new Error(`Unknown work-source provider: ${String(unknown)}`); + } + } + }, + } satisfies WorkSourceProviderRegistryShape; +}); + +export const WorkSourceProviderRegistryLive = Layer.effect(WorkSourceProviderRegistry, make); diff --git a/apps/server/src/workflow/Layers/WorkflowAgentSessionStore.test.ts b/apps/server/src/workflow/Layers/WorkflowAgentSessionStore.test.ts new file mode 100644 index 00000000000..ad2a87840ff --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowAgentSessionStore.test.ts @@ -0,0 +1,145 @@ +import { BoardId, LaneKey, TicketId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowAgentSessionStore } from "../Services/WorkflowAgentSessionStore.ts"; +import { WorkflowAgentSessionStoreLive } from "./WorkflowAgentSessionStore.ts"; + +const storeLayer = it.layer( + WorkflowAgentSessionStoreLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const seedTicket = (ticketId: TicketId, boardId: BoardId) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = DateTime.formatIso(yield* DateTime.now); + yield* sql` + INSERT INTO projection_ticket + (ticket_id, board_id, title, current_lane_key, status, created_at, updated_at) + VALUES + (${String(ticketId)}, ${String(boardId)}, ${"t"}, ${"backlog"}, ${"open"}, ${now}, ${now}) + `; + }); + +storeLayer("WorkflowAgentSessionStore", (it) => { + it.effect("upsert then getThreadId returns the stored thread id", () => + Effect.gen(function* () { + const store = yield* WorkflowAgentSessionStore; + const ticketId = TicketId.make("ticket-1"); + const laneKey = LaneKey.make("implement"); + + yield* store.upsert(ticketId, laneKey, "agent-a", "thread-1"); + const threadId = yield* store.getThreadId(ticketId, laneKey, "agent-a"); + assert.equal(threadId, "thread-1"); + }), + ); + + it.effect("missing key returns null", () => + Effect.gen(function* () { + const store = yield* WorkflowAgentSessionStore; + const threadId = yield* store.getThreadId( + TicketId.make("ticket-missing"), + LaneKey.make("implement"), + "agent-a", + ); + assert.isNull(threadId); + }), + ); + + it.effect("re-upsert updates last_used_at and keeps the original thread id", () => + Effect.gen(function* () { + const store = yield* WorkflowAgentSessionStore; + const ticketId = TicketId.make("ticket-2"); + const laneKey = LaneKey.make("implement"); + + yield* store.upsert(ticketId, laneKey, "agent-a", "thread-1"); + const before = yield* store.listByTicket(ticketId); + assert.equal(before.length, 1); + const firstUsedAt = before[0]!.lastUsedAt; + + // A second upsert with a different thread id must NOT overwrite thread_id + // (resume must keep reusing the same stable thread); it bumps last_used_at. + yield* store.upsert(ticketId, laneKey, "agent-a", "thread-IGNORED"); + + const threadId = yield* store.getThreadId(ticketId, laneKey, "agent-a"); + assert.equal(threadId, "thread-1"); + + const after = yield* store.listByTicket(ticketId); + assert.equal(after.length, 1); + assert.isTrue(after[0]!.lastUsedAt >= firstUsedAt); + }), + ); + + it.effect("two agent keys in one (ticket, lane) coexist", () => + Effect.gen(function* () { + const store = yield* WorkflowAgentSessionStore; + const ticketId = TicketId.make("ticket-3"); + const laneKey = LaneKey.make("implement"); + + yield* store.upsert(ticketId, laneKey, "agent-a", "thread-a"); + yield* store.upsert(ticketId, laneKey, "agent-b", "thread-b"); + + assert.equal(yield* store.getThreadId(ticketId, laneKey, "agent-a"), "thread-a"); + assert.equal(yield* store.getThreadId(ticketId, laneKey, "agent-b"), "thread-b"); + + const rows = yield* store.listByTicket(ticketId); + assert.equal(rows.length, 2); + }), + ); + + it.effect("listByTicket and deleteByTicket scope to the ticket", () => + Effect.gen(function* () { + const store = yield* WorkflowAgentSessionStore; + const ticketId = TicketId.make("ticket-4"); + const otherTicketId = TicketId.make("ticket-5"); + const laneKey = LaneKey.make("implement"); + + yield* store.upsert(ticketId, laneKey, "agent-a", "thread-a"); + yield* store.upsert(ticketId, LaneKey.make("review"), "agent-b", "thread-b"); + yield* store.upsert(otherTicketId, laneKey, "agent-c", "thread-c"); + + const listed = yield* store.listByTicket(ticketId); + assert.deepEqual(listed.map((r) => r.threadId).sort(), ["thread-a", "thread-b"]); + + yield* store.deleteByTicket(ticketId); + assert.deepEqual(yield* store.listByTicket(ticketId), []); + // The other ticket's rows are untouched. + assert.equal((yield* store.listByTicket(otherTicketId)).length, 1); + }), + ); + + it.effect("listByBoard and deleteByBoard join through projection_ticket", () => + Effect.gen(function* () { + const store = yield* WorkflowAgentSessionStore; + const boardId = BoardId.make("board-1"); + const otherBoardId = BoardId.make("board-2"); + const ticketA = TicketId.make("ticket-board-a"); + const ticketB = TicketId.make("ticket-board-b"); + const ticketOther = TicketId.make("ticket-board-other"); + + yield* seedTicket(ticketA, boardId); + yield* seedTicket(ticketB, boardId); + yield* seedTicket(ticketOther, otherBoardId); + + yield* store.upsert(ticketA, LaneKey.make("implement"), "agent-a", "thread-a"); + yield* store.upsert(ticketB, LaneKey.make("implement"), "agent-b", "thread-b"); + yield* store.upsert(ticketOther, LaneKey.make("implement"), "agent-c", "thread-c"); + + const listed = yield* store.listByBoard(boardId); + assert.deepEqual(listed.map((r) => r.threadId).sort(), ["thread-a", "thread-b"]); + + yield* store.deleteByBoard(boardId); + assert.deepEqual(yield* store.listByBoard(boardId), []); + // Rows reachable only from the other board are untouched. + assert.equal((yield* store.listByBoard(otherBoardId)).length, 1); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowAgentSessionStore.ts b/apps/server/src/workflow/Layers/WorkflowAgentSessionStore.ts new file mode 100644 index 00000000000..dfade440daf --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowAgentSessionStore.ts @@ -0,0 +1,143 @@ +import type { BoardId, LaneKey, TicketId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowAgentSessionStore, + type WorkflowAgentSessionRow, + type WorkflowAgentSessionStoreShape, +} from "../Services/WorkflowAgentSessionStore.ts"; + +const toStoreError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrap = <A>(message: string, effect: Effect.Effect<A, SqlError>) => + effect.pipe(Effect.mapError(toStoreError(message))); + +interface RawRow { + readonly ticketId: string; + readonly laneKey: string; + readonly agentKey: string; + readonly threadId: string; + readonly createdAt: string; + readonly lastUsedAt: string; +} + +const decodeRow = (row: RawRow): WorkflowAgentSessionRow => ({ + ticketId: row.ticketId as TicketId, + laneKey: row.laneKey as LaneKey, + agentKey: row.agentKey, + threadId: row.threadId, + createdAt: row.createdAt, + lastUsedAt: row.lastUsedAt, +}); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsert: WorkflowAgentSessionStoreShape["upsert"] = ( + ticketId, + laneKey, + agentKey, + threadId, + ) => + Effect.gen(function* () { + const now = DateTime.formatIso(yield* DateTime.now); + yield* wrap( + "WorkflowAgentSessionStore.upsert", + sql` + INSERT INTO workflow_agent_session + (ticket_id, lane_key, agent_key, thread_id, created_at, last_used_at) + VALUES + (${String(ticketId)}, ${String(laneKey)}, ${agentKey}, ${threadId}, ${now}, ${now}) + ON CONFLICT (ticket_id, lane_key, agent_key) + DO UPDATE SET last_used_at = ${now} + `, + ); + }); + + const getThreadId: WorkflowAgentSessionStoreShape["getThreadId"] = ( + ticketId, + laneKey, + agentKey, + ) => + wrap( + "WorkflowAgentSessionStore.getThreadId", + sql<{ readonly threadId: string }>` + SELECT thread_id AS "threadId" + FROM workflow_agent_session + WHERE ticket_id = ${String(ticketId)} + AND lane_key = ${String(laneKey)} + AND agent_key = ${agentKey} + LIMIT 1 + `, + ).pipe(Effect.map((rows) => rows[0]?.threadId ?? null)); + + const listByTicket: WorkflowAgentSessionStoreShape["listByTicket"] = (ticketId) => + wrap( + "WorkflowAgentSessionStore.listByTicket", + sql<RawRow>` + SELECT + ticket_id AS "ticketId", + lane_key AS "laneKey", + agent_key AS "agentKey", + thread_id AS "threadId", + created_at AS "createdAt", + last_used_at AS "lastUsedAt" + FROM workflow_agent_session + WHERE ticket_id = ${String(ticketId)} + `, + ).pipe(Effect.map((rows) => rows.map(decodeRow))); + + const deleteByTicket: WorkflowAgentSessionStoreShape["deleteByTicket"] = (ticketId) => + wrap( + "WorkflowAgentSessionStore.deleteByTicket", + sql` + DELETE FROM workflow_agent_session + WHERE ticket_id = ${String(ticketId)} + `, + ).pipe(Effect.asVoid); + + const listByBoard: WorkflowAgentSessionStoreShape["listByBoard"] = (boardId) => + wrap( + "WorkflowAgentSessionStore.listByBoard", + sql<RawRow>` + SELECT + s.ticket_id AS "ticketId", + s.lane_key AS "laneKey", + s.agent_key AS "agentKey", + s.thread_id AS "threadId", + s.created_at AS "createdAt", + s.last_used_at AS "lastUsedAt" + FROM workflow_agent_session AS s + JOIN projection_ticket AS t ON t.ticket_id = s.ticket_id + WHERE t.board_id = ${String(boardId)} + `, + ).pipe(Effect.map((rows) => rows.map(decodeRow))); + + const deleteByBoard: WorkflowAgentSessionStoreShape["deleteByBoard"] = (boardId) => + wrap( + "WorkflowAgentSessionStore.deleteByBoard", + sql` + DELETE FROM workflow_agent_session + WHERE ticket_id IN ( + SELECT ticket_id FROM projection_ticket WHERE board_id = ${String(boardId)} + ) + `, + ).pipe(Effect.asVoid); + + return { + upsert, + getThreadId, + listByTicket, + deleteByTicket, + listByBoard, + deleteByBoard, + } satisfies WorkflowAgentSessionStoreShape; +}); + +export const WorkflowAgentSessionStoreLive = Layer.effect(WorkflowAgentSessionStore, make); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts b/apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts new file mode 100644 index 00000000000..afe383eeeb9 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts @@ -0,0 +1,68 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowBoardEvents } from "../Services/WorkflowBoardEvents.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardEventsLive } from "./WorkflowBoardEvents.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; + +const layer = it.layer( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(WorkflowBoardEventsLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowBoardEvents", (it) => { + it.effect("publishes a ticket delta after the committer projects a ticket event", () => + Effect.gen(function* () { + const events = yield* WorkflowBoardEvents; + const committer = yield* WorkflowEventCommitter; + const registry = yield* BoardRegistry; + yield* registry.register("b-1" as never, { + name: "Board events", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }], + }); + const deltasFiber = yield* events + .stream("b-1" as never) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped); + yield* Effect.yieldNow; + + yield* committer.commit({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-1" as never, + title: "Board delta" as never, + laneKey: "backlog" as never, + }, + }); + + const deltas = Array.from(yield* Fiber.join(deltasFiber)); + assert.equal(deltas[0]?.ticketId, "t-1"); + assert.equal(deltas[0]?.boardId, "b-1"); + assert.equal(deltas[0]?.title, "Board delta"); + assert.equal(deltas[0]?.currentLaneKey, "backlog"); + assert.equal(deltas[0]?.status, "idle"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardEvents.ts b/apps/server/src/workflow/Layers/WorkflowBoardEvents.ts new file mode 100644 index 00000000000..4cee100de64 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardEvents.ts @@ -0,0 +1,35 @@ +import type { BoardTicketView } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; + +import { + WorkflowBoardEvents, + type WorkflowBoardEventsShape, +} from "../Services/WorkflowBoardEvents.ts"; + +const make = Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded<BoardTicketView>(); + + const publish: WorkflowBoardEventsShape["publish"] = (ticket) => + PubSub.publish(pubsub, ticket).pipe(Effect.asVoid); + const stream: WorkflowBoardEventsShape["stream"] = (boardId) => + Stream.fromPubSub(pubsub).pipe(Stream.filter((ticket) => ticket.boardId === boardId)); + // Subscribe synchronously in the calling fiber (the subscription is registered + // with the PubSub the instant this returns), then expose it as a stream. A + // snapshot read after awaiting this cannot lose a publish into the read→subscribe + // gap that `Stream.fromPubSub` (lazy subscribe) leaves open. + const subscribe: WorkflowBoardEventsShape["subscribe"] = (boardId) => + PubSub.subscribe(pubsub).pipe( + Effect.map((subscription) => + Stream.fromSubscription(subscription).pipe( + Stream.filter((ticket) => ticket.boardId === boardId), + ), + ), + ); + + return { publish, stream, subscribe } satisfies WorkflowBoardEventsShape; +}); + +export const WorkflowBoardEventsLive = Layer.effect(WorkflowBoardEvents, make); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.test.ts b/apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.test.ts new file mode 100644 index 00000000000..b06bcbbcdd6 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.test.ts @@ -0,0 +1,433 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayBoardTicketState } from "@t3tools/contracts/relay"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowBoardNotificationDispatcher } from "../Services/WorkflowBoardNotificationDispatcher.ts"; +import { WorkflowBoardNotificationRelay } from "../Services/WorkflowBoardNotificationRelay.ts"; +import { + WorkflowReadModel, + type TicketDetail, + type TicketRow, +} from "../Services/WorkflowReadModel.ts"; +import { makeWorkflowBoardNotificationDispatcherLive } from "./WorkflowBoardNotificationDispatcher.ts"; + +const ENV_ID = "env-1" as EnvironmentId; + +interface PublishCall { + readonly environmentId: EnvironmentId; + readonly boardId: string; + readonly ticketId: string; + readonly state: RelayBoardTicketState; +} + +// Mutable per-test recorder for the stub relay. Reset in each test setup. +interface RelayRecorder { + calls: Array<PublishCall>; + failQueue: Array<"ok" | "fail">; +} + +const makeRecorder = (failQueue: ReadonlyArray<"ok" | "fail"> = []): RelayRecorder => ({ + calls: [], + failQueue: [...failQueue], +}); + +const stubRelayLayer = (recorder: RelayRecorder) => + Layer.succeed(WorkflowBoardNotificationRelay, { + publishTicket: (input) => + Effect.suspend(() => { + recorder.calls.push(input); + const outcome = recorder.failQueue.length === 0 ? "ok" : recorder.failQueue.shift()!; + return outcome === "fail" + ? Effect.fail(new WorkflowEventStoreError({ message: "stub relay failure" })) + : Effect.void; + }), + } satisfies WorkflowBoardNotificationRelay["Service"]); + +const makeTicketRow = (over: Partial<TicketRow> = {}): TicketRow => ({ + ticketId: "ticket-1", + boardId: "board-1", + title: "Fix the thing", + description: null, + currentLaneKey: "review", + currentLaneEntryToken: null, + status: "waiting_on_user", + queuedAt: null, + totalTokens: null, + totalDurationMs: null, + attentionKind: "waiting_for_input", + attentionReason: "please review", + ...over, +}); + +const detail = (ticket: TicketRow): TicketDetail => ({ ticket, steps: [], messages: [] }); + +// Stub read model: only getTicketDetail is exercised by the dispatcher. +const stubReadModelLayer = (byTicket: Record<string, TicketDetail | null>) => + Layer.succeed(WorkflowReadModel, { + getTicketDetail: (ticketId: string) => Effect.succeed(byTicket[ticketId] ?? null), + } as unknown as WorkflowReadModel["Service"]) as Layer.Layer<WorkflowReadModel>; + +const serverEnvironmentLayer = Layer.succeed(ServerEnvironment, { + getEnvironmentId: Effect.succeed(ENV_ID), + getDescriptor: Effect.die("unsupported descriptor read"), +} as unknown as ServerEnvironment["Service"]) as Layer.Layer<ServerEnvironment>; + +const insertOutboxRow = (over: { + readonly outboxId: string; + readonly ticketId: string; + readonly boardId: string; + readonly sequence: number; + readonly status: string; + readonly attentionKind?: string | null; + readonly attentionReason?: string | null; + readonly deliveryState?: string; + readonly attemptCount?: number; + readonly createdAt?: string; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO workflow_notification_outbox ( + outbox_id, ticket_id, board_id, sequence, status, + attention_kind, attention_reason, delivery_state, attempt_count, created_at + ) VALUES ( + ${over.outboxId}, ${over.ticketId}, ${over.boardId}, ${over.sequence}, ${over.status}, + ${over.attentionKind ?? null}, ${over.attentionReason ?? null}, + ${over.deliveryState ?? "pending"}, ${over.attemptCount ?? 0}, + ${over.createdAt ?? "2026-06-12T00:00:00.000Z"} + ) + `; + }); + +const readOutbox = (outboxId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ + readonly delivery_state: string; + readonly attempt_count: number; + }>` + SELECT delivery_state AS "delivery_state", attempt_count AS "attempt_count" + FROM workflow_notification_outbox WHERE outbox_id = ${outboxId} + `; + return rows[0]!; + }); + +const buildLayer = (recorder: RelayRecorder, byTicket: Record<string, TicketDetail | null>) => + makeWorkflowBoardNotificationDispatcherLive({ sweepIntervalMs: 60_000 }).pipe( + Layer.provideMerge(stubRelayLayer(recorder)), + Layer.provideMerge(stubReadModelLayer(byTicket)), + Layer.provideMerge(serverEnvironmentLayer), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +describe.sequential("WorkflowBoardNotificationDispatcher", () => { + it.effect("publishes a pending needs-you row and marks it sent", () => { + const recorder = makeRecorder(); + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-1", + ticketId: "ticket-1", + boardId: "board-1", + sequence: 7, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "please review", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + const result = yield* dispatcher.sweep(); + + assert.strictEqual(result.claimed, 1); + assert.strictEqual(result.sent, 1); + assert.strictEqual(result.superseded, 0); + assert.strictEqual(result.failed, 0); + assert.strictEqual(recorder.calls.length, 1); + const call = recorder.calls[0]!; + assert.strictEqual(call.boardId, "board-1"); + assert.strictEqual(call.ticketId, "ticket-1"); + assert.strictEqual(call.state.attentionKind, "waiting_for_input"); + assert.strictEqual(call.state.title, "Fix the thing"); + assert.strictEqual(call.state.body, "please review"); + assert.strictEqual(call.state.deepLink, "/tickets/env-1/board-1/ticket-1"); + assert.strictEqual(call.state.transitionId, "7"); + + const row = yield* readOutbox("ob-1"); + assert.strictEqual(row.delivery_state, "sent"); + }).pipe( + Effect.provide( + buildLayer(recorder, { + "ticket-1": detail(makeTicketRow({ status: "waiting_on_user" })), + }), + ), + ); + }); + + it.effect("supersedes a row whose ticket has left needs-you", () => { + const recorder = makeRecorder(); + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-2", + ticketId: "ticket-2", + boardId: "board-1", + sequence: 8, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "stale", + }); + yield* insertOutboxRow({ + outboxId: "ob-3", + ticketId: "ticket-3", + boardId: "board-1", + sequence: 9, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "gone", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + const result = yield* dispatcher.sweep(); + + assert.strictEqual(result.superseded, 2); + assert.strictEqual(result.sent, 0); + assert.strictEqual(recorder.calls.length, 0); + assert.strictEqual((yield* readOutbox("ob-2")).delivery_state, "superseded"); + assert.strictEqual((yield* readOutbox("ob-3")).delivery_state, "superseded"); + }).pipe( + Effect.provide( + buildLayer(recorder, { + // ticket-2 left needs-you (now running); ticket-3 detail missing (null). + "ticket-2": detail(makeTicketRow({ ticketId: "ticket-2", status: "running" })), + "ticket-3": null, + }), + ), + ); + }); + + it.effect("retries on relay failure then gives up at the attempt ceiling", () => { + // Five consecutive failures across five sweeps → row ends 'failed'. + const recorder = makeRecorder(["fail", "fail", "fail", "fail", "fail"]); + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-4", + ticketId: "ticket-4", + boardId: "board-1", + sequence: 10, + status: "blocked", + attentionKind: "blocked", + attentionReason: "needs help", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + + // Sweeps 1-4: stays pending, attempt_count climbs. + for (let i = 1; i <= 4; i++) { + const r = yield* dispatcher.sweep(); + assert.strictEqual(r.failed, 1, `sweep ${i} failed count`); + const row = yield* readOutbox("ob-4"); + assert.strictEqual(row.delivery_state, "pending", `sweep ${i} state`); + assert.strictEqual(row.attempt_count, i, `sweep ${i} attempts`); + } + + // Sweep 5: 5th attempt hits the ceiling → 'failed'. + const r5 = yield* dispatcher.sweep(); + assert.strictEqual(r5.failed, 1); + const after = yield* readOutbox("ob-4"); + assert.strictEqual(after.delivery_state, "failed"); + assert.strictEqual(after.attempt_count, 5); + assert.strictEqual(recorder.calls.length, 5); + + // Sweep 6: failed rows are not re-selected → no new publish. + const r6 = yield* dispatcher.sweep(); + assert.strictEqual(r6.claimed, 0); + assert.strictEqual(recorder.calls.length, 5); + }).pipe( + Effect.provide( + buildLayer(recorder, { + "ticket-4": detail( + makeTicketRow({ + ticketId: "ticket-4", + status: "blocked", + attentionKind: "blocked", + attentionReason: "needs help", + }), + ), + }), + ), + ); + }); + + it.effect("drains a pre-existing pending row (startup drain)", () => { + const recorder = makeRecorder(); + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-5", + ticketId: "ticket-5", + boardId: "board-1", + sequence: 11, + status: "waiting_on_user", + attentionKind: "waiting_for_approval", + attentionReason: "approve me", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + const result = yield* dispatcher.sweep(); + assert.strictEqual(result.sent, 1); + assert.strictEqual(recorder.calls[0]!.state.attentionKind, "waiting_for_approval"); + assert.strictEqual((yield* readOutbox("ob-5")).delivery_state, "sent"); + }).pipe( + Effect.provide( + buildLayer(recorder, { + "ticket-5": detail( + makeTicketRow({ + ticketId: "ticket-5", + status: "waiting_on_user", + attentionKind: "waiting_for_approval", + attentionReason: "approve me", + }), + ), + }), + ), + ); + }); + + it.effect("falls back to a non-empty title when the ticket title is blank", () => { + const recorder = makeRecorder(); + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-8", + ticketId: "ticket-8", + boardId: "board-1", + sequence: 14, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "please review", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + const result = yield* dispatcher.sweep(); + + assert.strictEqual(result.sent, 1); + assert.strictEqual(recorder.calls.length, 1); + const title = recorder.calls[0]!.state.title; + // The relay decodes title as TrimmedNonEmptyString; a whitespace title + // must be replaced with the non-empty fallback before publish. + assert.isTrue(title.trim().length > 0, "blank title falls back to non-empty title"); + assert.strictEqual(title, "Ticket needs your attention"); + }).pipe( + Effect.provide( + buildLayer(recorder, { + "ticket-8": detail(makeTicketRow({ ticketId: "ticket-8", title: " " })), + }), + ), + ); + }); + + it.effect("redacts and caps the body, and falls back when reason is empty", () => { + const recorder = makeRecorder(); + const secret = "ghp_" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const longReason = `token leak ${secret} ` + "x".repeat(400); + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-6", + ticketId: "ticket-6", + boardId: "board-1", + sequence: 12, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: longReason, + }); + yield* insertOutboxRow({ + outboxId: "ob-7", + ticketId: "ticket-7", + boardId: "board-1", + sequence: 13, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + yield* dispatcher.sweep(); + + const byTicket = Object.fromEntries(recorder.calls.map((c) => [c.ticketId, c])); + const redactedBody = byTicket["ticket-6"]!.state.body; + assert.isFalse(redactedBody.includes(secret), "raw secret must not appear"); + assert.isAtMost(redactedBody.length, 240, "body capped to MAX_NOTIFICATION_BODY"); + + const fallbackBody = byTicket["ticket-7"]!.state.body; + assert.isTrue(fallbackBody.trim().length > 0, "empty reason falls back to non-empty body"); + }).pipe( + Effect.provide( + buildLayer(recorder, { + "ticket-6": detail(makeTicketRow({ ticketId: "ticket-6", attentionReason: longReason })), + "ticket-7": detail(makeTicketRow({ ticketId: "ticket-7", attentionReason: "" })), + }), + ), + ); + }); + + it.effect( + "does not resurrect a row the committer superseded during a failed publish (M11)", + () => { + // Regression: the dispatcher SELECTs a pending row, the relay publish fails, + // and concurrently the committer supersedes the row (a newer needs-you + // transition committed for the same ticket). The retry re-mark must be a + // no-op once the row has left 'pending', or the superseded transition gets + // resurrected and re-delivered as a stale push. The relay stub below + // supersedes the row mid-publish (standing in for the committer's UPDATE + // landing during the publish round-trip), then fails — exercising the retry + // path against an already-superseded row. + const supersedingRelayLayer = Layer.effect( + WorkflowBoardNotificationRelay, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return { + publishTicket: () => + Effect.gen(function* () { + // SqlError here would be an infra failure, not part of the relay + // contract (WorkflowEventStoreError only) — orDie keeps the failure + // channel aligned with the publishTicket signature. + yield* sql`UPDATE workflow_notification_outbox SET delivery_state = 'superseded' WHERE outbox_id = ${"ob-m11"}`.pipe( + Effect.orDie, + ); + return yield* new WorkflowEventStoreError({ message: "stub relay failure" }); + }), + } satisfies WorkflowBoardNotificationRelay["Service"]; + }), + ); + + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-m11", + ticketId: "ticket-m11", + boardId: "board-1", + sequence: 5, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "please review", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + const result = yield* dispatcher.sweep(); + assert.strictEqual(result.failed, 1); + + // The row must stay 'superseded' — the guarded retry re-mark must NOT + // flip it back to 'pending' (which would re-deliver the stale transition). + const row = yield* readOutbox("ob-m11"); + assert.strictEqual(row.delivery_state, "superseded"); + }).pipe( + Effect.provide( + makeWorkflowBoardNotificationDispatcherLive({ sweepIntervalMs: 60_000 }).pipe( + Layer.provideMerge(supersedingRelayLayer), + Layer.provideMerge( + stubReadModelLayer({ + "ticket-m11": detail(makeTicketRow({ ticketId: "ticket-m11" })), + }), + ), + Layer.provideMerge(serverEnvironmentLayer), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ), + ); + }, + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.ts b/apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.ts new file mode 100644 index 00000000000..ffa9f9c1177 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.ts @@ -0,0 +1,270 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayBoardTicketState } from "@t3tools/contracts/relay"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import { + WorkflowBoardNotificationDispatcher, + type WorkflowBoardNotificationDispatcherShape, + type WorkflowBoardNotificationSweepResult, +} from "../Services/WorkflowBoardNotificationDispatcher.ts"; +import { WorkflowBoardNotificationRelay } from "../Services/WorkflowBoardNotificationRelay.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { redactSensitiveText, truncateKeepingHead } from "../redactSensitiveText.ts"; + +const DEFAULT_SWEEP_INTERVAL_MS = 5_000; +const DEFAULT_MAX_PER_SWEEP = 20; +const MAX_ATTEMPTS = 5; +// Push-notification body cap. Push payloads are tiny; 240 chars is a generous +// single-screen preview that still leaves room for the truncation marker. +const MAX_NOTIFICATION_BODY = 240; +const DEFAULT_BODY = "Needs your attention"; + +// Statuses that mean the ticket still wants a human. Anything else (running, +// idle, done, failed, or a vanished ticket) means it self-resolved before we +// notified — supersede the row so we don't buzz. +const NEEDS_YOU_STATUSES = new Set(["waiting_on_user", "blocked"]); + +const VALID_ATTENTION_KINDS = new Set<RelayBoardTicketState["attentionKind"]>([ + "waiting_for_approval", + "waiting_for_input", + "blocked", +]); + +const normalizeAttentionKind = (raw: string | null): RelayBoardTicketState["attentionKind"] => + raw !== null && VALID_ATTENTION_KINDS.has(raw as RelayBoardTicketState["attentionKind"]) + ? (raw as RelayBoardTicketState["attentionKind"]) + : "waiting_for_input"; + +interface OutboxRow { + readonly outboxId: string; + readonly ticketId: string; + readonly boardId: string; + readonly sequence: number; + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + readonly attemptCount: number; +} + +export interface WorkflowBoardNotificationDispatcherLiveOptions { + readonly sweepIntervalMs?: number; + readonly maxPerSweep?: number; +} + +const makeWorkflowBoardNotificationDispatcher = ( + options?: WorkflowBoardNotificationDispatcherLiveOptions, +) => + Effect.gen(function* () { + const relay = yield* WorkflowBoardNotificationRelay; + const readModel = yield* WorkflowReadModel; + const serverEnvironment = yield* ServerEnvironment; + const sql = yield* SqlClient.SqlClient; + + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + const maxPerSweep = Math.max(1, Math.floor(options?.maxPerSweep ?? DEFAULT_MAX_PER_SWEEP)); + + const buildBody = (reason: string | null): string => { + // Keep the START of the reason for a notification preview — the meaningful + // content (e.g. "Approve deploy to prod?") leads; trailing log noise is + // what should be dropped on overflow. + const redacted = truncateKeepingHead( + redactSensitiveText(reason ?? ""), + MAX_NOTIFICATION_BODY, + ); + return redacted.trim().length === 0 ? DEFAULT_BODY : redacted; + }; + + const markState = (outboxId: string, deliveryState: string, attemptCount?: number) => + attemptCount === undefined + ? sql`UPDATE workflow_notification_outbox SET delivery_state = ${deliveryState} WHERE outbox_id = ${outboxId}` + : sql`UPDATE workflow_notification_outbox SET delivery_state = ${deliveryState}, attempt_count = ${attemptCount} WHERE outbox_id = ${outboxId}`; + + // Conditional re-mark for the retry path. The committer (WorkflowEventCommitter) + // can concurrently flip this row to 'superseded' when a newer needs-you + // transition for the same ticket commits between our SELECT and this write. + // An unconditional `markState(..., 'pending', ...)` would resurrect a + // superseded row and re-deliver a stale older transition on the next sweep. + // Guarding on `delivery_state = 'pending'` makes the re-mark a no-op once the + // row has left 'pending', preventing the lost-update. Terminal re-marks + // ('sent'/'failed'/'superseded') don't need this guard: writing a terminal + // state over a superseded row is harmless since neither is re-swept. + const rescheduleRetry = (outboxId: string, attemptCount: number) => + sql`UPDATE workflow_notification_outbox SET delivery_state = 'pending', attempt_count = ${attemptCount} WHERE outbox_id = ${outboxId} AND delivery_state = 'pending'`; + + // Process a single row. Returns the outcome category for the sweep summary. + // Per-row errors are caught here so one bad row can't abort the sweep. + const processRow = ( + row: OutboxRow, + envId: EnvironmentId, + ): Effect.Effect<"sent" | "superseded" | "failed"> => + Effect.gen(function* () { + const detail = yield* readModel.getTicketDetail(row.ticketId as never); + + // Relevance recheck: ticket self-resolved → supersede, don't buzz. + if (detail === null || !NEEDS_YOU_STATUSES.has(detail.ticket.status)) { + yield* markState(row.outboxId, "superseded"); + return "superseded" as const; + } + + // The relay decodes title as TrimmedNonEmptyString; a blank/whitespace + // ticket title would be rejected → retries → lost notification. Fall + // back to a non-empty default. + const safeTitle = + detail.ticket.title.trim().length > 0 + ? detail.ticket.title + : "Ticket needs your attention"; + + const state: RelayBoardTicketState = { + environmentId: envId, + boardId: row.boardId, + ticketId: row.ticketId, + attentionKind: normalizeAttentionKind(row.attentionKind), + title: safeTitle, + body: buildBody(row.attentionReason), + // Canonical push deep-link format: `/tickets/{env}/{board}/{ticket}`. + // This is the ONLY consumer of this field — it flows relay → APNs → + // mobile (`normalizeTicketDeepLink` in + // apps/mobile/src/features/agent-awareness/notificationPayload.ts), which + // rejects query-string forms (`?`/`#`). The web in-app `/{env}/board?...` + // route is a separate concern and never reads this field. Keep this path + // shape in sync with mobile's `encodeTicketDeepLink`. + deepLink: `/tickets/${encodeURIComponent(envId)}/${encodeURIComponent( + row.boardId, + )}/${encodeURIComponent(row.ticketId)}`, + transitionId: String(row.sequence), + }; + + const published = yield* relay + .publishTicket({ + environmentId: envId, + boardId: row.boardId, + ticketId: row.ticketId, + state, + }) + .pipe(Effect.result); + + if (Result.isSuccess(published)) { + yield* markState(row.outboxId, "sent"); + return "sent" as const; + } + + const nextAttempt = row.attemptCount + 1; + if (nextAttempt >= MAX_ATTEMPTS) { + yield* Effect.logError("workflow.board-notification.give-up", { + outboxId: row.outboxId, + ticketId: row.ticketId, + sequence: row.sequence, + attemptCount: nextAttempt, + error: published.failure, + }); + yield* markState(row.outboxId, "failed", nextAttempt); + return "failed" as const; + } + yield* rescheduleRetry(row.outboxId, nextAttempt); + return "failed" as const; + }).pipe( + Effect.catchCause((cause) => + // Re-raise defects (programming bugs) so the sweep-level catchDefect + // guard surfaces them; only swallow expected/transient failures as a + // per-row "failed" so one bad row can't abort the whole sweep. + // Re-dying with the squashed cause keeps the error channel `never`. + Cause.hasDies(cause) || Cause.hasInterrupts(cause) + ? Effect.die(Cause.squash(cause)) + : Effect.logWarning("workflow.board-notification.row-failed", { + outboxId: row.outboxId, + ticketId: row.ticketId, + cause, + }).pipe(Effect.as("failed" as const)), + ), + ); + + const sweep: WorkflowBoardNotificationDispatcherShape["sweep"] = () => + Effect.gen(function* () { + const rows = yield* sql<OutboxRow>` + SELECT + outbox_id AS "outboxId", + ticket_id AS "ticketId", + board_id AS "boardId", + sequence, + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason", + attempt_count AS "attemptCount" + FROM workflow_notification_outbox + WHERE delivery_state = 'pending' + ORDER BY created_at ASC + LIMIT ${maxPerSweep} + `.pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.board-notification.select-failed", { cause }).pipe( + Effect.as([] as ReadonlyArray<OutboxRow>), + ), + ), + ); + + let claimed = 0; + let sent = 0; + let superseded = 0; + let failed = 0; + + if (rows.length === 0) { + return { claimed, sent, superseded, failed }; + } + + // Resolve the environment id once per sweep. + const envId = yield* serverEnvironment.getEnvironmentId; + + for (const row of rows) { + claimed += 1; + const outcome = yield* processRow(row, envId); + if (outcome === "sent") sent += 1; + else if (outcome === "superseded") superseded += 1; + else if (outcome === "failed") failed += 1; + } + + if (claimed > 0) { + yield* Effect.logInfo("workflow.board-notification.sweep-complete", { + claimed, + sent, + superseded, + failed, + }); + } + + return { claimed, sent, superseded, failed } satisfies WorkflowBoardNotificationSweepResult; + }); + + const start: WorkflowBoardNotificationDispatcherShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + sweep().pipe( + Effect.catchDefect((defect: unknown) => + Effect.logWarning("workflow.board-notification.sweep-defect", { defect }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), + ), + ); + + yield* Effect.logInfo("workflow.board-notification.started", { sweepIntervalMs }); + }); + + return { sweep, start } satisfies WorkflowBoardNotificationDispatcherShape; + }); + +export const makeWorkflowBoardNotificationDispatcherLive = ( + options?: WorkflowBoardNotificationDispatcherLiveOptions, +) => + Layer.effect( + WorkflowBoardNotificationDispatcher, + makeWorkflowBoardNotificationDispatcher(options), + ); + +export const WorkflowBoardNotificationDispatcherLive = + makeWorkflowBoardNotificationDispatcherLive(); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.test.ts b/apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.test.ts new file mode 100644 index 00000000000..fcc433358d8 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.test.ts @@ -0,0 +1,366 @@ +// @effect-diagnostics globalFetch:off - test harness installs a stable fetch dispatcher to defeat FetchHttpClient.Fetch memoization across cases. +// @effect-diagnostics nodeBuiltinImport:off - test seeds a deterministic Ed25519 key pair so the published proof JWT can be verified. +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import type { RelayBoardTicketState } from "@t3tools/contracts/relay"; +import { RELAY_BOARD_TICKET_PUBLISH_TYP } from "@t3tools/contracts/relay"; +import { normalizeRelayIssuer, verifyRelayJwt } from "@t3tools/shared/relayJwt"; +import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import { + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, +} from "../../cloud/config.ts"; +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import { WorkflowBoardNotificationRelay } from "../Services/WorkflowBoardNotificationRelay.ts"; +import { WorkflowBoardNotificationRelayLive } from "./WorkflowBoardNotificationRelay.ts"; + +const environmentId = "env-1" as EnvironmentId; +const boardId = "board-1"; +const ticketId = "ticket-1"; + +const state: RelayBoardTicketState = { + environmentId, + boardId, + ticketId, + attentionKind: "waiting_for_approval", + title: "Needs approval", + body: "The agent is waiting for your approval.", + deepLink: "/boards/env-1/board-1/ticket-1", + transitionId: "transition-1", +}; + +// Deterministic environment signing key pair. Seeding it into the secret store +// under the same name getOrCreateEnvironmentKeyPairFromSecretStore reads means the +// layer signs with this private key, so the test can verify the proof with the +// matching public key. +const ENVIRONMENT_KEY_PAIR_SECRET = "cloud-link-ed25519-key-pair"; +const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); +const testIssuer = "https://issuer.example.test"; + +const descriptor = { + environmentId, + label: "Test Desktop", + platform: { + os: "darwin", + arch: "arm64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +} satisfies ExecutionEnvironmentDescriptor; + +const encodeSecret = (value: string): Uint8Array => new TextEncoder().encode(value); + +function makeMemorySecretStore() { + const values = new Map<string, Uint8Array>(); + const store = { + get: ((name) => + Effect.sync( + () => values.get(name) ?? null, + )) satisfies ServerSecretStore.ServerSecretStoreShape["get"], + set: ((name, value) => + Effect.sync(() => { + values.set(name, Uint8Array.from(value)); + })) satisfies ServerSecretStore.ServerSecretStoreShape["set"], + create: ((name, value) => + Effect.sync(() => { + values.set(name, Uint8Array.from(value)); + })) satisfies ServerSecretStore.ServerSecretStoreShape["create"], + getOrCreateRandom: ((name, bytes) => + Effect.sync(() => { + const existing = values.get(name); + if (existing) { + return existing; + } + const generated = new Uint8Array(bytes); + values.set(name, generated); + return generated; + })) satisfies ServerSecretStore.ServerSecretStoreShape["getOrCreateRandom"], + remove: ((name) => + Effect.sync(() => { + values.delete(name); + })) satisfies ServerSecretStore.ServerSecretStoreShape["remove"], + } satisfies ServerSecretStore.ServerSecretStoreShape; + return { + store, + setString: (name: string, value: string) => store.set(name, encodeSecret(value)), + }; +} + +// effect's FetchHttpClient.Fetch is a Context.Reference whose default +// (`globalThis.fetch`) is read and memoized on first use, which would otherwise +// pin every test in this process to whichever fetch stub ran first. We install a +// single stable dispatcher into `globalThis.fetch` that delegates to a mutable +// per-test handler, so each test's override stays live regardless of memoization. +type FetchFn = typeof globalThis.fetch; +const realFetch = globalThis.fetch; +let currentFetch: FetchFn = realFetch; +globalThis.fetch = ((...args: Parameters<FetchFn>) => currentFetch(...args)) as unknown as FetchFn; + +function useFetch(handler: FetchFn): Effect.Effect<void, never, Scope.Scope> { + return Effect.acquireRelease( + Effect.sync(() => { + currentFetch = handler; + }), + () => + Effect.sync(() => { + currentFetch = realFetch; + }), + ).pipe(Effect.asVoid); +} + +function makeLayer(secrets: ReturnType<typeof makeMemorySecretStore>) { + return WorkflowBoardNotificationRelayLive.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), + Layer.succeed(ServerEnvironment, { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); +} + +describe.sequential("WorkflowBoardNotificationRelay", () => { + it.effect("signs a board-ticket proof and publishes it to the relay", () => + Effect.scoped( + Effect.gen(function* () { + // The plain fetch callback (not an Effect) records the captured request + // by resolving a native promise; the test bridges it back into Effect + // via Effect.promise — no manual Effect runtime runner needed. + type SeenRequest = { readonly url: URL; readonly body: unknown }; + let resolveSeen: (value: SeenRequest) => void = () => {}; + const requestSeen = new Promise<SeenRequest>((resolve) => { + resolveSeen = resolve; + }); + const secrets = makeMemorySecretStore(); + + yield* useFetch((( + input: Parameters<typeof fetch>[0], + init?: Parameters<typeof fetch>[1], + ) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + const readBody = async (): Promise<unknown> => { + if (input instanceof Request) { + const text = await input.clone().text(); + return text ? JSON.parse(text) : null; + } + const rawBody = init?.body; + if (typeof rawBody === "string") { + return JSON.parse(rawBody); + } + if (rawBody instanceof Uint8Array || rawBody instanceof ArrayBuffer) { + const text = new TextDecoder().decode(rawBody); + return text ? JSON.parse(text) : null; + } + return null; + }; + void readBody().then((body) => { + resolveSeen({ url, body }); + }); + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch); + + yield* secrets.setString( + ENVIRONMENT_KEY_PAIR_SECRET, + // @effect-diagnostics-next-line preferSchemaOverJson:off - mirrors the on-disk JSON envelope getOrCreateEnvironmentKeyPairFromSecretStore decodes. + JSON.stringify({ privateKey: keyPair.privateKey, publicKey: keyPair.publicKey }), + ); + + yield* Effect.gen(function* () { + yield* secrets.setString(RELAY_URL_SECRET, "https://transport.example.test"); + yield* secrets.setString(RELAY_ISSUER_SECRET, testIssuer); + yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); + + const relay = yield* WorkflowBoardNotificationRelay; + yield* relay.publishTicket({ environmentId, boardId, ticketId, state }); + + // The wait guard must be a NATIVE timer: under `it.effect` (kept for the + // TestClock-anchored JWT iat/exp below) `Effect.timeout` is TestClock-bound + // and would never elapse, so a missing request would hang to Vitest's outer + // timeout instead of failing here. `setTimeout` runs on the real event loop. + const seen = yield* Effect.promise(() => + Promise.race([ + requestSeen, + new Promise<never>((_, reject) => + // A deliberate native timer: Effect.sleep is TestClock-bound under + // it.effect and would never fire, so the guard must use the real loop. + // @effect-diagnostics-next-line globalTimers:off + setTimeout( + () => reject(new Error("timed out waiting for the relay request (2s)")), + 2000, + ), + ), + ]), + ); + expect(seen.url.origin).toBe("https://transport.example.test"); + expect(seen.url.pathname).toBe( + `/v1/environments/${environmentId}/tickets/${ticketId}/board-activity`, + ); + const body = seen.body as { readonly state: unknown; readonly proof: unknown }; + expect(body.state).toMatchObject({ + ticketId, + boardId, + attentionKind: "waiting_for_approval", + }); + expect(typeof body.proof).toBe("string"); + expect((body.proof as string).length).toBeGreaterThan(0); + + // Decode and verify the proof JWT the way the relay side (Task 10) will, + // asserting every signed claim — not just that a non-empty proof exists. + // it.effect runs under a TestClock anchored at epoch 0, so the proof's + // iat/exp are 0/300. Verify at a point inside that window. + const verified = yield* verifyRelayJwt({ + publicKey: keyPair.publicKey, + token: body.proof as string, + typ: RELAY_BOARD_TICKET_PUBLISH_TYP, + issuer: `t3-env:${environmentId}`, + audience: normalizeRelayIssuer(testIssuer), + nowEpochSeconds: 150, + }); + expect(verified.iss).toBe(`t3-env:${environmentId}`); + expect(verified.sub).toBe(environmentId); + expect(verified.aud).toBe(normalizeRelayIssuer(testIssuer)); + expect((verified as { environmentId?: unknown }).environmentId).toBe(environmentId); + expect((verified as { boardId?: unknown }).boardId).toBe(boardId); + expect((verified as { ticketId?: unknown }).ticketId).toBe(ticketId); + expect((verified as { state?: unknown }).state).toEqual(state); + expect(typeof verified.iat).toBe("number"); + expect(typeof verified.exp).toBe("number"); + expect((verified.exp as number) > (verified.iat as number)).toBe(true); + }).pipe(Effect.provide(makeLayer(secrets))); + }), + ), + ); + + it.effect("is a no-op success when relay config is missing", () => + Effect.scoped( + Effect.gen(function* () { + const secrets = makeMemorySecretStore(); + let fetchCalls = 0; + + yield* useFetch((() => { + fetchCalls += 1; + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch); + + yield* Effect.gen(function* () { + const relay = yield* WorkflowBoardNotificationRelay; + yield* relay.publishTicket({ environmentId, boardId, ticketId, state }); + }).pipe(Effect.provide(makeLayer(secrets))); + + expect(fetchCalls).toBe(0); + }), + ), + ); + + it.effect("fails (not standby success) when reading relay config secrets errors", () => + Effect.scoped( + Effect.gen(function* () { + // Seed the env key pair so the layer build succeeds, but make get() for + // the relay config secrets FAIL — a real secret-store read error must + // propagate as a failure, not be swallowed into a standby no-op success + // (which would let the dispatcher mark the row sent and drop the buzz). + const backing = makeMemorySecretStore(); + yield* backing.setString( + ENVIRONMENT_KEY_PAIR_SECRET, + // @effect-diagnostics-next-line preferSchemaOverJson:off - mirrors the on-disk JSON envelope getOrCreateEnvironmentKeyPairFromSecretStore decodes. + JSON.stringify({ privateKey: keyPair.privateKey, publicKey: keyPair.publicKey }), + ); + const RELAY_CONFIG_SECRETS = new Set([ + RELAY_URL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + ]); + let fetchCalls = 0; + yield* useFetch((() => { + fetchCalls += 1; + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch); + + const failingStore: ServerSecretStore.ServerSecretStoreShape = { + ...backing.store, + get: ((name) => + RELAY_CONFIG_SECRETS.has(name) + ? Effect.fail( + new ServerSecretStore.SecretStoreError({ + message: `boom reading ${name}`, + }), + ) + : backing.store.get(name)) satisfies ServerSecretStore.ServerSecretStoreShape["get"], + }; + const failingLayer = WorkflowBoardNotificationRelayLive.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, failingStore), + Layer.succeed(ServerEnvironment, { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); + + const exit = yield* Effect.gen(function* () { + const relay = yield* WorkflowBoardNotificationRelay; + return yield* relay + .publishTicket({ environmentId, boardId, ticketId, state }) + .pipe(Effect.exit); + }).pipe(Effect.provide(failingLayer)); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect((error as { _tag?: string })._tag).toBe("WorkflowEventStoreError"); + } + expect(fetchCalls).toBe(0); + }), + ), + ); + + it.effect("fails with WorkflowEventStoreError when the relay HTTP call fails", () => + Effect.scoped( + Effect.gen(function* () { + const secrets = makeMemorySecretStore(); + + yield* useFetch((() => + Promise.reject(new Error("upstream boom"))) as unknown as typeof fetch); + + const exit = yield* Effect.gen(function* () { + yield* secrets.setString(RELAY_URL_SECRET, "https://transport.example.test"); + yield* secrets.setString(RELAY_ISSUER_SECRET, "https://issuer.example.test"); + yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); + + const relay = yield* WorkflowBoardNotificationRelay; + return yield* relay + .publishTicket({ environmentId, boardId, ticketId, state }) + .pipe(Effect.exit); + }).pipe(Effect.provide(makeLayer(secrets))); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect((error as { _tag?: string })._tag).toBe("WorkflowEventStoreError"); + } + }), + ), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.ts b/apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.ts new file mode 100644 index 00000000000..f9424ef65bd --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.ts @@ -0,0 +1,165 @@ +import { + RelayApi, + RELAY_BOARD_TICKET_PUBLISH_TYP, + type RelayBoardTicketPublishProofPayload, + type RelayBoardTicketState, +} from "@t3tools/contracts/relay"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { signRelayJwt, normalizeRelayIssuer } from "@t3tools/shared/relayJwt"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../../cloud/environmentKeys.ts"; +import { + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, +} from "../../cloud/config.ts"; +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowBoardNotificationRelay, + type WorkflowBoardNotificationRelayShape, +} from "../Services/WorkflowBoardNotificationRelay.ts"; + +function relayEnvironmentClient(token: string) { + return HttpClient.mapRequest(HttpClientRequest.setHeader("authorization", `Bearer ${token}`)); +} + +const make = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverEnvironment = yield* ServerEnvironment; + const crypto = yield* Crypto.Crypto; + const environmentKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); + + const readSecretString = (name: string) => + secrets.get(name).pipe(Effect.map((bytes) => (bytes ? new TextDecoder().decode(bytes) : null))); + + const readRelayConfig = Effect.gen(function* () { + const [url, issuer, environmentCredential] = yield* Effect.all([ + readSecretString(RELAY_URL_SECRET), + readSecretString(RELAY_ISSUER_SECRET), + readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + return url && environmentCredential + ? { url, issuer: issuer ?? url, environmentCredential } + : null; + }); + + const makeRelayClient = (relayConfig: { + readonly url: string; + readonly environmentCredential: string; + }) => + HttpApiClient.make(RelayApi, { + baseUrl: relayConfig.url, + transformClient: relayEnvironmentClient(relayConfig.environmentCredential), + }).pipe(Effect.provide(FetchHttpClient.layer)); + + const makePublishProof = (input: { + readonly relayIssuer: string; + readonly environmentId: EnvironmentId; + readonly boardId: string; + readonly ticketId: string; + readonly state: RelayBoardTicketState; + readonly jti: string; + }) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { minutes: 5 }); + const payload = { + iss: `t3-env:${input.environmentId}`, + aud: normalizeRelayIssuer(input.relayIssuer), + sub: input.environmentId, + jti: input.jti, + iat: Math.floor(now.epochMilliseconds / 1_000), + exp: Math.floor(expiresAt.epochMilliseconds / 1_000), + environmentId: input.environmentId, + boardId: input.boardId, + ticketId: input.ticketId, + state: input.state, + } satisfies RelayBoardTicketPublishProofPayload; + return yield* signRelayJwt({ + privateKey: environmentKeyPair.privateKey, + typ: RELAY_BOARD_TICKET_PUBLISH_TYP, + payload, + }); + }); + + const publishTicket: WorkflowBoardNotificationRelayShape["publishTicket"] = (input) => + Effect.gen(function* () { + // Absent config legitimately returns null (standby no-op). A real + // secret-store READ error propagates here and is wrapped by the outer + // catchCause into a WorkflowEventStoreError, so the dispatcher retries + // instead of marking the row sent and silently dropping the notification. + const relayConfig = yield* readRelayConfig; + if (!relayConfig) { + yield* Effect.logDebug("board ticket notification standby; T3 Connect config missing", { + boardId: input.boardId, + ticketId: input.ticketId, + }); + return; + } + + const relayClient = yield* makeRelayClient(relayConfig); + const proof = yield* makePublishProof({ + relayIssuer: relayConfig.issuer, + environmentId: input.environmentId, + boardId: input.boardId, + ticketId: input.ticketId, + state: input.state, + jti: yield* crypto.randomUUIDv4, + }); + + yield* Effect.logInfo("publishing board ticket attention", { + environmentId: input.environmentId, + boardId: input.boardId, + ticketId: input.ticketId, + attentionKind: input.state.attentionKind, + }); + + const response = yield* relayClient.server.publishBoardTicket({ + params: { + environmentId: input.environmentId, + ticketId: input.ticketId, + }, + payload: { + state: input.state, + proof, + }, + }); + + yield* Effect.logInfo("board ticket attention publish completed", { + environmentId: input.environmentId, + boardId: input.boardId, + ticketId: input.ticketId, + ok: response.ok, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.fail( + new WorkflowEventStoreError({ + message: `board ticket relay publish failed for ticket ${input.ticketId}`, + cause: Cause.squash(cause), + }), + ), + ), + Effect.withSpan("WorkflowBoardNotificationRelay.publishTicket"), + ); + + return { + publishTicket, + } satisfies WorkflowBoardNotificationRelayShape; +}); + +export const WorkflowBoardNotificationRelayLive = Layer.effect( + WorkflowBoardNotificationRelay, + make, +); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardSaveLocks.ts b/apps/server/src/workflow/Layers/WorkflowBoardSaveLocks.ts new file mode 100644 index 00000000000..15276e3df54 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardSaveLocks.ts @@ -0,0 +1,59 @@ +import type { BoardId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import { + WorkflowBoardSaveLocks, + type WorkflowBoardSaveLocksShape, +} from "../Services/WorkflowBoardSaveLocks.ts"; + +export const makeWorkflowBoardSaveLocks = Effect.gen(function* () { + const saveSemaphores = yield* SynchronizedRef.make<Map<string, Semaphore.Semaphore>>(new Map()); + + const semaphoreFor = (boardId: BoardId) => + SynchronizedRef.modifyEffect(saveSemaphores, (current) => { + const key = boardId as string; + const existing = current.get(key); + if (existing) { + return Effect.succeed([existing, current] as const); + } + + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(key, semaphore); + return [semaphore, next] as const; + }), + ); + }); + + const withSaveLock: WorkflowBoardSaveLocksShape["withSaveLock"] = (boardId, effect) => + Effect.gen(function* () { + const semaphore = yield* semaphoreFor(boardId); + return yield* semaphore.withPermits(1)(effect); + }); + + // Drop a board's cached semaphore so a deleted board doesn't leak its entry. + // Any in-flight withSaveLock already captured its semaphore reference, so it + // completes safely; a later withSaveLock for the same id just creates a fresh + // one (there are no legitimate concurrent saves on a deleted board). + const evict: NonNullable<WorkflowBoardSaveLocksShape["evict"]> = (boardId) => + SynchronizedRef.update(saveSemaphores, (current) => { + const key = boardId as string; + if (!current.has(key)) { + return current; + } + const next = new Map(current); + next.delete(key); + return next; + }); + + return { withSaveLock, evict } satisfies WorkflowBoardSaveLocksShape; +}); + +export const WorkflowBoardSaveLocksLive = Layer.effect( + WorkflowBoardSaveLocks, + makeWorkflowBoardSaveLocks, +); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardVersionStore.test.ts b/apps/server/src/workflow/Layers/WorkflowBoardVersionStore.test.ts new file mode 100644 index 00000000000..877166d2e6b --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardVersionStore.test.ts @@ -0,0 +1,88 @@ +import { BoardId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowBoardVersionStore } from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowBoardVersionStoreLive } from "./WorkflowBoardVersionStore.ts"; + +const storeLayer = it.layer( + WorkflowBoardVersionStoreLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +storeLayer("WorkflowBoardVersionStore", (it) => { + it.effect("dedups only consecutive hashes and keeps A-B-A versions distinct", () => + Effect.gen(function* () { + const store = yield* WorkflowBoardVersionStore; + const boardId = BoardId.make("board-history"); + const otherBoardId = BoardId.make("board-history-other"); + + yield* store.record({ + boardId, + versionHash: "hash-a", + contentJson: '{"name":"A"}\n', + source: "create", + }); + yield* store.record({ + boardId, + versionHash: "hash-a", + contentJson: '{"name":"A"}\n', + source: "save", + }); + yield* store.record({ + boardId, + versionHash: "hash-b", + contentJson: '{"name":"B"}\n', + source: "save", + }); + yield* store.record({ + boardId: otherBoardId, + versionHash: "hash-other", + contentJson: '{"name":"other"}\n', + source: "create", + }); + yield* store.record({ + boardId, + versionHash: "hash-a", + contentJson: '{"name":"A"}\n', + source: "revert", + }); + + const versions = yield* store.list(boardId); + assert.equal(versions.length, 3); + assert.deepEqual( + versions.map((version) => version.versionHash), + ["hash-a", "hash-b", "hash-a"], + ); + assert.deepEqual( + versions.map((version) => version.source), + ["revert", "save", "create"], + ); + assert.deepEqual(new Set(versions.map((version) => version.versionId)).size, versions.length); + assert.isTrue(versions.every((version) => version.createdAt.length > 0)); + + const newest = versions[0]; + assert.isDefined(newest); + const loaded = yield* store.get(boardId, newest.versionId); + assert.deepEqual(loaded, { + versionId: newest.versionId, + versionHash: "hash-a", + contentJson: '{"name":"A"}\n', + source: "revert", + createdAt: newest.createdAt, + }); + + const wrongBoard = yield* store.get(otherBoardId, newest.versionId); + assert.isNull(wrongBoard); + + yield* store.deleteForBoard(boardId); + assert.deepEqual(yield* store.list(boardId), []); + assert.equal((yield* store.list(otherBoardId)).length, 1); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardVersionStore.ts b/apps/server/src/workflow/Layers/WorkflowBoardVersionStore.ts new file mode 100644 index 00000000000..8d1f2e67e9c --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardVersionStore.ts @@ -0,0 +1,100 @@ +import type { BoardId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowBoardVersionStore, + type WorkflowBoardVersionRow, + type WorkflowBoardVersionStoreShape, + type WorkflowBoardVersionSummaryRow, +} from "../Services/WorkflowBoardVersionStore.ts"; + +const toStoreError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrap = <A>(message: string, effect: Effect.Effect<A, SqlError>) => + effect.pipe(Effect.mapError(toStoreError(message))); + +const boardIdValue = (boardId: BoardId) => String(boardId); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const record: WorkflowBoardVersionStoreShape["record"] = (input) => + Effect.gen(function* () { + const boardId = boardIdValue(input.boardId); + const newest = yield* wrap( + "WorkflowBoardVersionStore.record:readNewest", + sql<{ readonly versionHash: string }>` + SELECT version_hash AS "versionHash" + FROM workflow_board_version + WHERE board_id = ${boardId} + ORDER BY version_id DESC + LIMIT 1 + `, + ); + if (newest[0]?.versionHash === input.versionHash) { + return; + } + + const createdAt = DateTime.formatIso(yield* DateTime.now); + yield* wrap( + "WorkflowBoardVersionStore.record:insert", + sql` + INSERT INTO workflow_board_version + (board_id, version_hash, content_json, source, created_at) + VALUES + (${boardId}, ${input.versionHash}, ${input.contentJson}, ${input.source}, ${createdAt}) + `, + ); + }); + + const list: WorkflowBoardVersionStoreShape["list"] = (boardId) => + wrap( + "WorkflowBoardVersionStore.list", + sql<WorkflowBoardVersionSummaryRow>` + SELECT + version_id AS "versionId", + version_hash AS "versionHash", + source, + created_at AS "createdAt" + FROM workflow_board_version + WHERE board_id = ${boardIdValue(boardId)} + ORDER BY version_id DESC + `, + ); + + const get: WorkflowBoardVersionStoreShape["get"] = (boardId, versionId) => + wrap( + "WorkflowBoardVersionStore.get", + sql<WorkflowBoardVersionRow>` + SELECT + version_id AS "versionId", + version_hash AS "versionHash", + content_json AS "contentJson", + source, + created_at AS "createdAt" + FROM workflow_board_version + WHERE board_id = ${boardIdValue(boardId)} + AND version_id = ${versionId} + LIMIT 1 + `, + ).pipe(Effect.map((rows) => rows[0] ?? null)); + + const deleteForBoard: WorkflowBoardVersionStoreShape["deleteForBoard"] = (boardId) => + wrap( + "WorkflowBoardVersionStore.deleteForBoard", + sql` + DELETE FROM workflow_board_version + WHERE board_id = ${boardIdValue(boardId)} + `, + ).pipe(Effect.asVoid); + + return { record, list, get, deleteForBoard } satisfies WorkflowBoardVersionStoreShape; +}); + +export const WorkflowBoardVersionStoreLive = Layer.effect(WorkflowBoardVersionStore, make); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts new file mode 100644 index 00000000000..5499015f7d1 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts @@ -0,0 +1,701 @@ +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProviderSessionNotFoundError } from "../../provider/Errors.ts"; +import { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const definition = { + name: "limited", + settings: { maxConcurrentTickets: 1 }, + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +let activeExecutions = 0; +let maxActiveExecutions = 0; + +const countingExecutor = Layer.succeed(StepExecutor, { + execute: () => + Effect.gen(function* () { + activeExecutions += 1; + maxActiveExecutions = Math.max(maxActiveExecutions, activeExecutions); + yield* Effect.sleep("20 millis"); + activeExecutions -= 1; + return { _tag: "completed" as const }; + }), +} satisfies StepExecutorShape); + +const layer = it.layer( + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(countingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowEngine concurrency", (it) => { + it.effect("caps simultaneously running tickets per board", () => + Effect.gen(function* () { + activeExecutions = 0; + maxActiveExecutions = 0; + + const registry = yield* BoardRegistry; + yield* registry.register("b-limit" as never, definition); + const engine = yield* WorkflowEngine; + + yield* Effect.all( + [ + engine.createTicket({ + boardId: "b-limit" as never, + title: "First", + initialLane: "impl" as never, + }), + engine.createTicket({ + boardId: "b-limit" as never, + title: "Second", + initialLane: "impl" as never, + }), + ], + { concurrency: "unbounded" }, + ); + + assert.equal(maxActiveExecutions, 1); + }), + ); + + it.effect("applies a raised maxConcurrentTickets without a server restart", () => + Effect.gen(function* () { + activeExecutions = 0; + maxActiveExecutions = 0; + + const registry = yield* BoardRegistry; + yield* registry.register("b-resize" as never, definition); + const engine = yield* WorkflowEngine; + + yield* Effect.all( + [ + engine.createTicket({ + boardId: "b-resize" as never, + title: "First", + initialLane: "impl" as never, + }), + engine.createTicket({ + boardId: "b-resize" as never, + title: "Second", + initialLane: "impl" as never, + }), + ], + { concurrency: "unbounded" }, + ); + assert.equal(maxActiveExecutions, 1); + + // Saving the definition with a higher limit must take effect for the + // very next pipeline — not only after a restart. + yield* registry.register("b-resize" as never, { + ...definition, + settings: { maxConcurrentTickets: 2 }, + }); + activeExecutions = 0; + maxActiveExecutions = 0; + + yield* Effect.all( + [ + engine.createTicket({ + boardId: "b-resize" as never, + title: "Third", + initialLane: "impl" as never, + }), + engine.createTicket({ + boardId: "b-resize" as never, + title: "Fourth", + initialLane: "impl" as never, + }), + ], + { concurrency: "unbounded" }, + ); + assert.equal(maxActiveExecutions, 2); + }), + ); + + it.effect("rejects createTicket that races after a board delete under the save lock", () => + Effect.gen(function* () { + const boardId = "b-delete-race" as never; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const eventStore = yield* WorkflowEventStore; + const saveLocks = yield* WorkflowBoardSaveLocks; + const sql = yield* SqlClient.SqlClient; + const deleteReady = yield* Deferred.make<void>(); + const releaseDelete = yield* Deferred.make<void>(); + + yield* registry.register(boardId, { + name: "delete-race", + lanes: [{ key: "todo", name: "Todo", entry: "manual" }], + }); + + const deleteFiber = yield* saveLocks + .withSaveLock( + boardId, + Effect.gen(function* () { + yield* registry.unregister(boardId); + yield* eventStore.deleteForBoard(boardId); + yield* Deferred.succeed(deleteReady, undefined); + yield* Deferred.await(releaseDelete); + }), + ) + .pipe(Effect.forkChild); + + yield* Deferred.await(deleteReady); + const createFiber = yield* engine + .createTicket({ + boardId, + title: "Should not survive", + initialLane: "todo" as never, + }) + .pipe(Effect.exit, Effect.forkChild); + + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseDelete, undefined); + yield* Fiber.join(deleteFiber); + + const createResult = yield* Fiber.join(createFiber); + assert.isTrue(Exit.isFailure(createResult)); + + const counts = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE board_id = ${boardId} + UNION ALL + SELECT 'workflow_events' AS tableName, COUNT(*) AS count + FROM workflow_events + WHERE json_extract(payload_json, '$.boardId') = ${boardId} + `; + + assert.deepEqual( + counts.map((row) => [row.tableName, row.count]), + [ + ["projection_ticket", 0], + ["workflow_events", 0], + ], + ); + }), + ); + + it.effect("does not orphan ticket messages when answerTicketStep races board delete", () => + Effect.gen(function* () { + const boardId = "b-answer-delete-race" as never; + const ticketId = "ticket-answer-delete-race" as never; + const stepRunId = "step-answer-delete-race" as never; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const eventStore = yield* WorkflowEventStore; + const saveLocks = yield* WorkflowBoardSaveLocks; + const sql = yield* SqlClient.SqlClient; + const deleteReady = yield* Deferred.make<void>(); + const releaseDelete = yield* Deferred.make<void>(); + + yield* registry.register(boardId, definition); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + ${ticketId}, + ${boardId}, + 'Delete race', + 'impl', + 'waiting_on_user', + '2026-06-08T00:00:00.000Z', + '2026-06-08T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + waiting_reason, + provider_response_kind, + started_at + ) + VALUES ( + ${stepRunId}, + 'pipeline-answer-delete-race', + ${ticketId}, + 'code', + 'agent', + 'awaiting_user', + 'Need answer', + 'user-input', + '2026-06-08T00:00:00.000Z' + ) + `; + + const deleteFiber = yield* saveLocks + .withSaveLock( + boardId, + Effect.gen(function* () { + yield* registry.unregister(boardId); + yield* eventStore.deleteForBoard(boardId); + yield* sql`DELETE FROM projection_ticket WHERE board_id = ${boardId}`; + yield* Deferred.succeed(deleteReady, undefined); + yield* Deferred.await(releaseDelete); + }), + ) + .pipe(Effect.forkChild); + + yield* Deferred.await(deleteReady); + const answerFiber = yield* engine + .answerTicketStep({ + stepRunId, + text: "Use the sandbox endpoint.", + attachments: [], + }) + .pipe(Effect.exit, Effect.forkChild); + + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseDelete, undefined); + yield* Fiber.join(deleteFiber); + + const answerResult = yield* Fiber.join(answerFiber); + assert.isTrue(Exit.isSuccess(answerResult)); + + const counts = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE board_id = ${boardId} + UNION ALL + SELECT 'projection_ticket_message' AS tableName, COUNT(*) AS count + FROM projection_ticket_message + WHERE ticket_id = ${ticketId} + UNION ALL + SELECT 'workflow_events' AS tableName, COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = ${ticketId} + `; + + assert.deepEqual( + counts.map((row) => [row.tableName, row.count]), + [ + ["projection_ticket", 0], + ["projection_ticket_message", 0], + ["workflow_events", 0], + ], + ); + }), + ); +}); + +it.effect("cancelBoardPipelines interrupts and stops active provider turns for board tickets", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make<ReadonlyArray<unknown>>([]); + const testLayer = WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(countingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: (input) => + Ref.update(providerCalls, (calls) => [...calls, { kind: "interrupt", input }]), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: (input) => + Ref.update(providerCalls, (calls) => [...calls, { kind: "stop", input }]), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + } satisfies ProviderServiceShape), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES + ('ticket-active-provider', 'board-provider-cancel', 'Active provider', 'impl', 'running', ${now}, ${now}), + ('ticket-other-provider', 'board-other-provider', 'Other provider', 'impl', 'running', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + turn_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES + ('dispatch-active-provider', 'ticket-active-provider', 'step-active-provider', 'thread-active-provider', 'turn-active-provider', 'codex', 'gpt-5.5', 'cancel me', '/tmp/active-provider', 'started', ${now}, ${now}), + ('dispatch-other-provider', 'ticket-other-provider', 'step-other-provider', 'thread-other-provider', 'turn-other-provider', 'codex', 'gpt-5.5', 'keep me', '/tmp/other-provider', 'started', ${now}, ${now}), + ('dispatch-pending-provider', 'ticket-active-provider', 'step-pending-provider', 'thread-pending-provider', NULL, 'codex', 'gpt-5.5', 'not started', '/tmp/pending-provider', 'pending', ${now}, NULL) + `; + + yield* engine + .cancelBoardPipelines("board-provider-cancel" as never) + .pipe(Effect.timeout("1 second")); + + assert.deepEqual(yield* Ref.get(providerCalls), [ + { + kind: "interrupt", + input: { + threadId: "thread-active-provider", + turnId: "turn-active-provider", + }, + }, + { + kind: "stop", + input: { + threadId: "thread-active-provider", + }, + }, + { + kind: "stop", + input: { + threadId: "thread-pending-provider", + }, + }, + ]); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("cancelTicketPipelines interrupts and stops active provider turns for one ticket", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make<ReadonlyArray<unknown>>([]); + const testLayer = WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(countingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: (input) => + Ref.update(providerCalls, (calls) => [...calls, { kind: "interrupt", input }]), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: (input) => + Ref.update(providerCalls, (calls) => [...calls, { kind: "stop", input }]), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + } satisfies ProviderServiceShape), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES + ('ticket-provider-delete-one', 'board-provider-delete-one', 'Delete provider', 'impl', 'running', ${now}, ${now}), + ('ticket-provider-keep-one', 'board-provider-delete-one', 'Keep provider', 'impl', 'running', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + turn_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES + ('dispatch-provider-delete-one', 'ticket-provider-delete-one', 'step-provider-delete-one', 'thread-provider-delete-one', 'turn-provider-delete-one', 'codex', 'gpt-5.5', 'cancel me', '/tmp/delete-one', 'started', ${now}, ${now}), + ('dispatch-provider-keep-one', 'ticket-provider-keep-one', 'step-provider-keep-one', 'thread-provider-keep-one', 'turn-provider-keep-one', 'codex', 'gpt-5.5', 'keep me', '/tmp/keep-one', 'started', ${now}, ${now}), + ('dispatch-provider-pending-one', 'ticket-provider-delete-one', 'step-provider-pending-one', 'thread-provider-pending-one', NULL, 'codex', 'gpt-5.5', 'not started', '/tmp/pending-one', 'pending', ${now}, NULL) + `; + + yield* engine + .cancelTicketPipelines("ticket-provider-delete-one" as never) + .pipe(Effect.timeout("1 second")); + + assert.deepEqual(yield* Ref.get(providerCalls), [ + { + kind: "interrupt", + input: { + threadId: "thread-provider-delete-one", + turnId: "turn-provider-delete-one", + }, + }, + { + kind: "stop", + input: { + threadId: "thread-provider-delete-one", + }, + }, + { + kind: "stop", + input: { + threadId: "thread-provider-pending-one", + }, + }, + ]); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("cancelBoardPipelines treats already-stopped provider sessions as cleanup success", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make<ReadonlyArray<unknown>>([]); + const testLayer = WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(countingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: (input) => + Ref.update(providerCalls, (calls) => [...calls, { kind: "interrupt", input }]).pipe( + Effect.andThen( + Effect.fail(new ProviderSessionNotFoundError({ threadId: input.threadId })), + ), + ), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: (input) => + Ref.update(providerCalls, (calls) => [...calls, { kind: "stop", input }]).pipe( + Effect.andThen( + Effect.fail(new ProviderSessionNotFoundError({ threadId: input.threadId })), + ), + ), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + } satisfies ProviderServiceShape), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-stale-provider', + 'board-stale-provider', + 'Stale provider', + 'impl', + 'running', + ${now}, + ${now} + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + turn_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES ( + 'dispatch-stale-provider', + 'ticket-stale-provider', + 'step-stale-provider', + 'thread-stale-provider', + 'turn-stale-provider', + 'codex', + 'gpt-5.5', + 'already gone', + '/tmp/stale-provider', + 'started', + ${now}, + ${now} + ) + `; + + yield* engine + .cancelBoardPipelines("board-stale-provider" as never) + .pipe(Effect.timeout("1 second")); + + assert.deepEqual(yield* Ref.get(providerCalls), [ + { + kind: "interrupt", + input: { + threadId: "thread-stale-provider", + turnId: "turn-stale-provider", + }, + }, + { + kind: "stop", + input: { + threadId: "thread-stale-provider", + }, + }, + ]); + }).pipe(Effect.provide(testLayer)); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.dependencies.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.dependencies.test.ts new file mode 100644 index 00000000000..6e5461ce4c9 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.dependencies.test.ts @@ -0,0 +1,212 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const succeedingExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.succeed({ _tag: "completed" as const }), +} satisfies StepExecutorShape); + +const layer = it.layer( + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(succeedingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const dependencyDefinition = { + name: "deps", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const awaitTicketWhere = (ticketId: string, predicate: (detail: TicketDetail | null) => boolean) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + for (let attempt = 0; attempt < 100; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId as never); + if (predicate(detail)) { + return detail; + } + yield* Effect.promise<void>(() => new Promise((resolve) => setTimeout(resolve, 10))); + yield* Effect.yieldNow; + } + return yield* read.getTicketDetail(ticketId as never); + }); + +layer("WorkflowEngine ticket dependencies", (it) => { + it.effect("queues a dependent in an auto lane and releases it when the dependency lands", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-deps" as never, dependencyDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const blocker = yield* engine.createTicket({ + boardId: "b-deps" as never, + title: "Blocker", + initialLane: "backlog" as never, + }); + const dependent = yield* engine.createTicket({ + boardId: "b-deps" as never, + title: "Dependent", + initialLane: "work" as never, + dependsOn: [blocker], + }); + + const queued = yield* read.getTicketDetail(dependent); + assert.equal(queued?.ticket.status, "queued"); + assert.isNotNull(queued?.ticket.queuedAt); + assert.deepEqual(queued?.ticket.dependsOn, [blocker as string]); + assert.equal(queued?.ticket.unresolvedDependencyCount, 1); + + // Manual run is refused while the dependency is open. + const refusal = yield* engine.runLane(dependent).pipe(Effect.flip); + assert.include(refusal.message, "waiting on 1 unresolved dependency"); + + // No pipeline may have started for the dependent yet. + const eventsBefore = yield* Stream.runCollect(store.readByTicket(dependent)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isFalse(eventsBefore.some((event) => event.type === "PipelineStarted")); + + // Landing the blocker in the terminal lane auto-releases the dependent. + yield* engine.moveTicket(blocker, "done" as never); + const released = yield* awaitTicketWhere( + dependent as string, + (detail) => detail?.ticket.currentLaneKey === "done", + ); + assert.equal(released?.ticket.currentLaneKey, "done"); + assert.equal(released?.ticket.unresolvedDependencyCount, 0); + + const eventsAfter = yield* Stream.runCollect(store.readByTicket(dependent)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isTrue(eventsAfter.some((event) => event.type === "TicketAdmitted")); + assert.isTrue(eventsAfter.some((event) => event.type === "PipelineStarted")); + }), + ); + + it.effect("releases a queued dependent when an edit clears its last dependency", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-deps-edit" as never, dependencyDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const blocker = yield* engine.createTicket({ + boardId: "b-deps-edit" as never, + title: "Blocker", + initialLane: "backlog" as never, + }); + const dependent = yield* engine.createTicket({ + boardId: "b-deps-edit" as never, + title: "Dependent", + initialLane: "work" as never, + dependsOn: [blocker], + }); + const queued = yield* read.getTicketDetail(dependent); + assert.equal(queued?.ticket.status, "queued"); + + yield* engine.editTicket({ ticketId: dependent, dependsOn: [] }); + + const released = yield* awaitTicketWhere( + dependent as string, + (detail) => detail?.ticket.currentLaneKey === "done", + ); + assert.equal(released?.ticket.currentLaneKey, "done"); + }), + ); + + it.effect("rejects circular and invalid dependencies", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-deps-cycle" as never, dependencyDefinition); + const engine = yield* WorkflowEngine; + + const first = yield* engine.createTicket({ + boardId: "b-deps-cycle" as never, + title: "First", + initialLane: "backlog" as never, + }); + const second = yield* engine.createTicket({ + boardId: "b-deps-cycle" as never, + title: "Second", + initialLane: "backlog" as never, + }); + + yield* engine.editTicket({ ticketId: first, dependsOn: [second] }); + const cycle = yield* engine + .editTicket({ ticketId: second, dependsOn: [first] }) + .pipe(Effect.flip); + assert.include(cycle.message, "circular"); + + const selfDependency = yield* engine + .editTicket({ ticketId: first, dependsOn: [first] }) + .pipe(Effect.flip); + assert.include(selfDependency.message, "depend on itself"); + + const missing = yield* engine + .createTicket({ + boardId: "b-deps-cycle" as never, + title: "Broken", + initialLane: "backlog" as never, + dependsOn: ["ticket-i-do-not-exist" as never], + }) + .pipe(Effect.flip); + assert.include(missing.message, "was not found"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.externalEvents.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.externalEvents.test.ts new file mode 100644 index 00000000000..5469606a1f2 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.externalEvents.test.ts @@ -0,0 +1,479 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const succeedingExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.succeed({ _tag: "completed" as const }), +} satisfies StepExecutorShape); + +const layer = it.layer( + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(succeedingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const eventDefinition = { + name: "events", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + onEvent: [ + { + name: "ci.passed", + when: { "==": [{ var: "event.payload.status" }, "green"] }, + to: "done", + }, + { name: "ci.failed", to: "work" }, + ], + }, + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "review" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +layer("WorkflowEngine external events", (it) => { + it.effect("moves a ticket when name and predicate match and records the decision", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-events" as never, eventDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-events" as never, + title: "Ship it", + initialLane: "review" as never, + }); + + // Wrong name: no-op. + const wrongName = yield* engine.ingestExternalEvent({ + boardId: "b-events" as never, + name: "deploy.finished", + ticketId, + payload: { status: "green" }, + }); + assert.equal(wrongName.outcome, "noop"); + + // Matching name but failing predicate: no-op. + const failingPredicate = yield* engine.ingestExternalEvent({ + boardId: "b-events" as never, + name: "ci.passed", + ticketId, + payload: { status: "red" }, + }); + assert.equal(failingPredicate.outcome, "noop"); + + const moved = yield* engine.ingestExternalEvent({ + boardId: "b-events" as never, + name: "ci.passed", + ticketId, + payload: { status: "green" }, + }); + assert.equal(moved.outcome, "moved"); + assert.equal(moved.toLane, "done"); + + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.currentLaneKey, "done"); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const decision = events.find((event) => event.type === "TicketRouteDecided"); + assert.isDefined(decision); + if (decision?.type === "TicketRouteDecided") { + assert.equal(decision.payload.source, "external_event"); + assert.equal(decision.payload.toLane, "done"); + } + const externalMove = events.find( + (event) => + event.type === "TicketMovedToLane" && + event.payload.reason === "external" && + event.payload.toLane === ("done" as string), + ); + assert.isDefined(externalMove); + + const decisions = yield* read.listTicketRouteDecisions(ticketId); + const externalDecision = decisions.find((row) => row.source === "external_event"); + assert.equal(externalDecision?.eventName, "ci.passed"); + assert.equal(externalDecision?.toLane, "done"); + }), + ); + + it.effect("an event into an auto lane starts the pipeline", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-events-auto" as never, eventDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-events-auto" as never, + title: "Send back", + initialLane: "review" as never, + }); + + const moved = yield* engine.ingestExternalEvent({ + boardId: "b-events-auto" as never, + name: "ci.failed", + ticketId, + payload: null, + }); + assert.equal(moved.outcome, "moved"); + assert.equal(moved.toLane, "work"); + + // The auto pipeline runs and routes onward to review. + for (let attempt = 0; attempt < 100; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId); + if (detail?.ticket.currentLaneKey === "review") { + return; + } + yield* Effect.promise<void>(() => new Promise((resolve) => setTimeout(resolve, 10))); + } + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.currentLaneKey, "review"); + }), + ); + + it.effect("rejects events for tickets on other boards", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-events-a" as never, eventDefinition); + yield* registry.register("b-events-b" as never, eventDefinition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-events-a" as never, + title: "Mine", + initialLane: "review" as never, + }); + + const refused = yield* engine + .ingestExternalEvent({ + boardId: "b-events-b" as never, + name: "ci.passed", + ticketId, + payload: { status: "green" }, + }) + .pipe(Effect.flip); + assert.include(refused.message, "not found"); + }), + ); +}); + +// Board definitions for pr.* predicate context tests. +const prCiGateDefinition = { + name: "pr-ci-gate", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "manual", + onEvent: [ + { + name: "pr.approved", + when: { "==": [{ var: "pr.ciState" }, "success"] }, + to: "land", + }, + ], + }, + { key: "land", name: "Land", entry: "manual", terminal: true }, + ], +}; + +const prReviewGateDefinition = { + name: "pr-review-gate", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "manual", + onEvent: [ + { + name: "ci.passed", + when: { "==": [{ var: "pr.reviewDecision" }, "approved"] }, + to: "land", + }, + ], + }, + { key: "land", name: "Land", entry: "manual", terminal: true }, + ], +}; + +layer("WorkflowEngine pr.* predicate context", (it) => { + it.effect("pr.approved with pr.ciState=success moves the ticket; with pending stays noop", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pr-ci-success" as never, prCiGateDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pr-ci-success" as never, + title: "My PR ticket", + initialLane: "implement" as never, + }); + + // Seed workflow_pr_state with last_ci_state='success' + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, + pr_state, last_ci_state, last_review_decision, updated_at + ) VALUES ( + ${ticketId}, 42, 'https://github.com/o/r/pull/42', + 'workflow/my-ticket', 'origin', 'o/r', + 'open', 'success', 'none', '2026-06-12T00:00:00.000Z' + ) + `; + + // Ingest pr.approved with ci passing → should move + const moved = yield* engine.ingestExternalEvent({ + boardId: "b-pr-ci-success" as never, + name: "pr.approved", + ticketId, + payload: null, + }); + assert.equal(moved.outcome, "moved"); + assert.equal(moved.toLane, "land"); + + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.currentLaneKey, "land"); + }), + ); + + it.effect("pr.approved with pr.ciState=pending stays noop", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pr-ci-pending" as never, prCiGateDefinition); + const engine = yield* WorkflowEngine; + const sql = yield* SqlClient.SqlClient; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pr-ci-pending" as never, + title: "Pending CI ticket", + initialLane: "implement" as never, + }); + + // Seed workflow_pr_state with last_ci_state='pending' + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, + pr_state, last_ci_state, last_review_decision, updated_at + ) VALUES ( + ${ticketId}, 43, 'https://github.com/o/r/pull/43', + 'workflow/pending-ticket', 'origin', 'o/r', + 'open', 'pending', 'none', '2026-06-12T00:00:00.000Z' + ) + `; + + const noop = yield* engine.ingestExternalEvent({ + boardId: "b-pr-ci-pending" as never, + name: "pr.approved", + ticketId, + payload: null, + }); + assert.equal(noop.outcome, "noop"); + }), + ); + + it.effect("pr.approved with no workflow_pr_state row stays noop", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pr-ci-norow" as never, prCiGateDefinition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pr-ci-norow" as never, + title: "No PR row ticket", + initialLane: "implement" as never, + }); + + // No workflow_pr_state row → pr.ciState is null → predicate fails + const noop = yield* engine.ingestExternalEvent({ + boardId: "b-pr-ci-norow" as never, + name: "pr.approved", + ticketId, + payload: null, + }); + assert.equal(noop.outcome, "noop"); + }), + ); + + it.effect( + "ci.passed with pr.reviewDecision=approved moves the ticket; with none stays noop", + () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pr-review-approved" as never, prReviewGateDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pr-review-approved" as never, + title: "Approved review ticket", + initialLane: "implement" as never, + }); + + // Seed workflow_pr_state with last_review_decision='approved' + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, + pr_state, last_ci_state, last_review_decision, updated_at + ) VALUES ( + ${ticketId}, 44, 'https://github.com/o/r/pull/44', + 'workflow/review-ticket', 'origin', 'o/r', + 'open', 'success', 'approved', '2026-06-12T00:00:00.000Z' + ) + `; + + // Ingest ci.passed with review approved → should move + const moved = yield* engine.ingestExternalEvent({ + boardId: "b-pr-review-approved" as never, + name: "ci.passed", + ticketId, + payload: null, + }); + assert.equal(moved.outcome, "moved"); + assert.equal(moved.toLane, "land"); + + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.currentLaneKey, "land"); + }), + ); + + it.effect("ci.passed with pr.reviewDecision=none stays noop", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pr-review-none" as never, prReviewGateDefinition); + const engine = yield* WorkflowEngine; + const sql = yield* SqlClient.SqlClient; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pr-review-none" as never, + title: "None review ticket", + initialLane: "implement" as never, + }); + + // Seed workflow_pr_state with last_review_decision='none' + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, + pr_state, last_ci_state, last_review_decision, updated_at + ) VALUES ( + ${ticketId}, 45, 'https://github.com/o/r/pull/45', + 'workflow/none-review-ticket', 'origin', 'o/r', + 'open', 'pending', 'none', '2026-06-12T00:00:00.000Z' + ) + `; + + const noop = yield* engine.ingestExternalEvent({ + boardId: "b-pr-review-none" as never, + name: "ci.passed", + ticketId, + payload: null, + }); + assert.equal(noop.outcome, "noop"); + }), + ); + + it.effect("flows pr context (ciState + reviewDecision) through to predicate evaluation", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pr-context-flow" as never, prCiGateDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pr-context-flow" as never, + title: "Context flow ticket", + initialLane: "implement" as never, + }); + + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, + pr_state, last_ci_state, last_review_decision, updated_at + ) VALUES ( + ${ticketId}, 46, 'https://github.com/o/r/pull/46', + 'workflow/context-flow', 'origin', 'o/r', + 'open', 'success', 'approved', '2026-06-12T00:00:00.000Z' + ) + `; + + // Sanity-check the seeded state the engine will read. + const prStateRow = yield* read.getTicketPrState(ticketId); + assert.equal(prStateRow?.lastCiState, "success"); + assert.equal(prStateRow?.lastReviewDecision, "approved"); + + // The engine reads workflow_pr_state once and exposes pr.ciState / + // pr.reviewDecision to the onEvent.when predicate. The single-read property + // (engine reads pr state once before resolveTarget; revalidate reuses that + // snapshot instead of re-reading) is enforced structurally in + // WorkflowEngine.ts — see the comment at the getTicketPrState call site. + const moved = yield* engine.ingestExternalEvent({ + boardId: "b-pr-context-flow" as never, + name: "pr.approved", + ticketId, + payload: null, + }); + assert.equal(moved.outcome, "moved"); + assert.equal(moved.toLane, "land"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts new file mode 100644 index 00000000000..325707ca06d --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts @@ -0,0 +1,2347 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { + ProviderResponsePort, + type ProviderResponseInput, +} from "../Services/ProviderResponsePort.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { ProviderDispatchOutboxLive } from "./ProviderDispatchOutbox.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; +import { makeStubStepExecutor } from "./StubStepExecutor.ts"; + +const definition = { + name: "wf", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const baseLayer = ( + executor: Layer.Layer<StepExecutor>, + boardRegistry: Layer.Layer<BoardRegistry> = BoardRegistryLive, +) => + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(executor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(boardRegistry), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +const awaitLane = (ticketId: string, laneKey: string) => + awaitTicketWhere(ticketId, (detail) => detail?.ticket.currentLaneKey === laneKey); + +const awaitStatus = (ticketId: string, status: string) => + awaitTicketWhere(ticketId, (detail) => detail?.ticket.status === status); + +const awaitTicketWhere = (ticketId: string, predicate: (detail: TicketDetail | null) => boolean) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + for (let attempt = 0; attempt < 50; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId as never); + if (predicate(detail)) { + return detail; + } + yield* Effect.promise<void>(() => new Promise((resolve) => setTimeout(resolve, 10))); + yield* Effect.yieldNow; + } + return yield* read.getTicketDetail(ticketId as never); + }); + +const awaitDeferredWithinYields = (deferred: Deferred.Deferred<void>, label: string) => + Effect.gen(function* () { + const fiber = yield* Effect.forkChild(Deferred.await(deferred)); + for (let attempt = 0; attempt < 50; attempt += 1) { + const exit = yield* Effect.sync(() => fiber.pollUnsafe()); + if (exit !== undefined) { + return yield* Fiber.join(fiber); + } + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(fiber); + assert.fail(`Timed out waiting for ${label}`); + }); + +const successLayer = it.layer(baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } }))); + +successLayer("WorkflowEngine integration", (it) => { + it.effect("auto lane runs the pipeline and routes to done", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-1" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-1" as never, + title: "Export", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "done"); + assert.equal(detail?.ticket.currentLaneKey, "done"); + assert.equal( + detail?.steps.some((step) => step.status === "completed"), + true, + ); + }), + ); + + it.effect("edits ticket title and description metadata", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-edit" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-edit" as never, + title: "Original title", + description: "Original description", + initialLane: "backlog" as never, + }); + + yield* engine.editTicket({ + ticketId, + title: " Updated title ", + description: "", + }); + + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.title, "Updated title"); + assert.equal(detail?.ticket.description, ""); + }), + ); +}); + +const failLayer = it.layer( + baseLayer(makeStubStepExecutor({ default: { _tag: "failed", error: "boom" } })), +); + +failLayer("WorkflowEngine integration failure path", (it) => { + it.effect("failed step routes to the failure lane", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-fail" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-fail" as never, + title: "Fix", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal( + detail?.steps.some((step) => step.status === "failed"), + true, + ); + }), + ); +}); + +const stepOnDefinition = { + name: "step-on-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "first", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "first", + on: { success: "needs" }, + }, + { + key: "second", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "second", + }, + ], + on: { success: "done" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const transitionDefinition = { + name: "transition-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "review", + captureOutput: true, + }, + ], + transitions: [ + { when: { "==": [{ var: "steps.review.output.verdict" }, "pass"] }, to: "done" }, + { when: { "==": [{ var: "steps.review.output.verdict" }, "block"] }, to: "needs" }, + ], + on: { success: "done" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const recoveredCaptureReadErrorDefinition = { + name: "recovered-capture-read-error-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "review", + captureOutput: true, + }, + ], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const noRouteFailureDefinition = { + name: "no-route-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "fail", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "fail", + }, + ], + }, + ], +}; + +const routeDecisionLayer = it.layer( + baseLayer( + makeStubStepExecutor({ + default: { _tag: "completed" }, + byStepKey: { + review: { _tag: "completed", output: { verdict: "block" } }, + fail: { _tag: "failed", error: "boom" }, + }, + }), + ), +); + +const providerContinuationLayer = it.layer( + baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } })).pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: () => Effect.succeed({ verdict: "block" }), + }), + ), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.die("unused provider turn start"), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const recoveredCaptureReadErrorLayer = it.layer( + baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } })).pipe( + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: () => + Effect.fail(new WorkflowEventStoreError({ message: "simulated repository failure" })), + }), + ), + ), +); + +routeDecisionLayer("WorkflowEngine smart route decisions", (it) => { + it.effect("step on success short-circuits remaining steps and emits route audit", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-step-on" as never, stepOnDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-step-on" as never, + title: "Step route", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.deepEqual( + detail?.steps.map((step) => step.stepKey), + ["first"], + ); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const routeIndex = events.findIndex((event) => event.type === "TicketRouteDecided"); + const moveIndex = events.findIndex( + (event) => event.type === "TicketMovedToLane" && event.payload.reason === "routed", + ); + const audit = events.find((event) => event.type === "TicketRouteDecided"); + assert.isTrue(routeIndex >= 0); + assert.equal(moveIndex, routeIndex + 1); + assert.equal(audit?.type, "TicketRouteDecided"); + if (audit?.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(audit.payload.source, "step_on"); + assert.equal(audit.payload.toLane, "needs"); + }), + ); + + it.effect("lane transitions first-match before lane on fallback", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-transition" as never, transitionDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-transition" as never, + title: "Transition route", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const audit = events.find((event) => event.type === "TicketRouteDecided"); + assert.equal(audit?.type, "TicketRouteDecided"); + if (audit?.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(audit.payload.source, "lane_transition"); + assert.equal(audit.payload.matchedTransitionIndex, 1); + assert.deepEqual((audit.payload.contextSnapshot as any).steps.review.output, { + verdict: "block", + }); + }), + ); + + it.effect("lane on fallback still emits route audit", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-lane-on-audit" as never, definition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-lane-on-audit" as never, + title: "Lane route", + initialLane: "impl" as never, + }); + + yield* awaitLane(ticketId as string, "done"); + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const audit = events.find((event) => event.type === "TicketRouteDecided"); + assert.equal(audit?.type, "TicketRouteDecided"); + if (audit?.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(audit.payload.source, "lane_on"); + assert.equal(audit.payload.toLane, "done"); + }), + ); + + it.effect("failure with no route keeps TicketBlocked and emits no route audit", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-no-route" as never, noRouteFailureDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-no-route" as never, + title: "No route", + initialLane: "impl" as never, + }); + + const detail = yield* awaitStatus(ticketId as string, "blocked"); + assert.equal(detail?.ticket.status, "blocked"); + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isFalse(events.some((event) => event.type === "TicketRouteDecided")); + assert.isTrue( + events.some( + (event) => + event.type === "TicketBlocked" && + event.payload.reason === "pipeline failure with no route", + ), + ); + }), + ); + + it.effect("recovered step on success short-circuits remaining steps", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-recovered-step-on" as never, stepOnDefinition); + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const store = yield* WorkflowEventStore; + + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-recovered-ticket" as never, + ticketId: "ticket-recovered-step-on" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-recovered-step-on" as never, + title: "Recovered step", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-recovered-move-in" as never, + ticketId: "ticket-recovered-step-on" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-recovered-step-on" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-recovered-pipeline" as never, + ticketId: "ticket-recovered-step-on" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-recovered-step-on" as never, + laneKey: "impl" as never, + laneEntryToken: "tok-recovered-step-on" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-recovered-step" as never, + ticketId: "ticket-recovered-step-on" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-recovered-step-on" as never, + stepRunId: "step-recovered-step-on" as never, + stepKey: "first" as never, + stepType: "agent", + }, + } as never); + + yield* engine.completeRecoveredStep("step-recovered-step-on" as never, { + _tag: "completed", + }); + + const detail = yield* awaitLane("ticket-recovered-step-on", "needs"); + assert.deepEqual( + detail?.steps.map((step) => step.stepKey), + ["first"], + ); + const events = yield* Stream.runCollect( + store.readByTicket("ticket-recovered-step-on" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + const audit = events.find((event) => event.type === "TicketRouteDecided"); + assert.equal(audit?.type, "TicketRouteDecided"); + if (audit?.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(audit.payload.source, "step_on"); + assert.equal(audit.payload.toLane, "needs"); + }), + ); + + it.effect("stale recovered completion emits no route audit after token supersede", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-stale-token" as never, stepOnDefinition); + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const store = yield* WorkflowEventStore; + + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-stale-token-ticket" as never, + ticketId: "ticket-stale-token" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-stale-token" as never, + title: "Stale token", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-stale-token-move-in" as never, + ticketId: "ticket-stale-token" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-stale-token-old" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-stale-token-pipeline" as never, + ticketId: "ticket-stale-token" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-stale-token" as never, + laneKey: "impl" as never, + laneEntryToken: "tok-stale-token-old" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-stale-token-step" as never, + ticketId: "ticket-stale-token" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-stale-token" as never, + stepRunId: "step-stale-token" as never, + stepKey: "first" as never, + stepType: "agent", + }, + } as never); + + yield* engine.moveTicket("ticket-stale-token" as never, "needs" as never); + yield* engine.completeRecoveredStep("step-stale-token" as never, { + _tag: "completed", + }); + + const detail = yield* awaitLane("ticket-stale-token", "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + + const events = yield* Stream.runCollect( + store.readByTicket("ticket-stale-token" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + assert.isFalse(events.some((event) => event.type === "TicketRouteDecided")); + assert.isFalse( + events.some( + (event) => event.type === "TicketMovedToLane" && event.payload.reason === "routed", + ), + ); + }), + ); +}); + +recoveredCaptureReadErrorLayer("WorkflowEngine recovered capture output failures", (it) => { + it.effect("terminalizes recovered captureOutput steps when structured output lookup fails", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register( + "b-recovered-capture-read-error" as never, + recoveredCaptureReadErrorDefinition, + ); + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const store = yield* WorkflowEventStore; + + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-recovered-capture-read-error-ticket" as never, + ticketId: "ticket-recovered-capture-read-error" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-recovered-capture-read-error" as never, + title: "Recovered capture read error", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-recovered-capture-read-error-move-in" as never, + ticketId: "ticket-recovered-capture-read-error" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-recovered-capture-read-error" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-recovered-capture-read-error-pipeline" as never, + ticketId: "ticket-recovered-capture-read-error" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-recovered-capture-read-error" as never, + laneKey: "impl" as never, + laneEntryToken: "tok-recovered-capture-read-error" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-recovered-capture-read-error-step" as never, + ticketId: "ticket-recovered-capture-read-error" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-recovered-capture-read-error" as never, + stepRunId: "step-recovered-capture-read-error" as never, + stepKey: "review" as never, + stepType: "agent", + }, + } as never); + + const exit = yield* engine + .completeRecoveredStep( + "step-recovered-capture-read-error" as never, + { _tag: "completed" }, + { + threadId: "thread-recovered-capture-read-error" as never, + turnId: "turn-recovered-capture-read-error" as never, + }, + ) + .pipe(Effect.exit); + + assert.isTrue(Exit.isSuccess(exit)); + + const detail = yield* awaitLane("ticket-recovered-capture-read-error", "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(detail?.steps.find((step) => step.stepKey === "review")?.status, "failed"); + + const events = yield* Stream.runCollect( + store.readByTicket("ticket-recovered-capture-read-error" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + assert.isTrue( + events.some( + (event) => + event.type === "StepFailed" && + event.payload.stepRunId === "step-recovered-capture-read-error" && + event.payload.error === "structured output lookup failed", + ), + ); + assert.isTrue( + events.some( + (event) => event.type === "PipelineCompleted" && event.payload.result === "failure", + ), + ); + assert.isTrue( + events.some( + (event) => + event.type === "TicketRouteDecided" && + event.payload.source === "lane_on" && + event.payload.toLane === "needs", + ), + ); + }), + ); +}); + +providerContinuationLayer("WorkflowEngine provider continuation routing", (it) => { + it.effect("routes recovered provider approval continuation with captured output", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-provider-continuation" as never, transitionDefinition); + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const providerOutput = 'Review complete.\n```json\n{"verdict":"block"}\n```'; + + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-provider-ticket" as never, + ticketId: "ticket-provider-continuation" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-provider-continuation" as never, + title: "Provider continuation", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-provider-move" as never, + ticketId: "ticket-provider-continuation" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-provider-continuation" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-provider-pipeline" as never, + ticketId: "ticket-provider-continuation" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-provider-continuation" as never, + laneKey: "impl" as never, + laneEntryToken: "tok-provider-continuation" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-provider-step" as never, + ticketId: "ticket-provider-continuation" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-provider-continuation" as never, + stepRunId: "step-provider-continuation" as never, + stepKey: "review" as never, + stepType: "agent", + }, + } as never); + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-provider-await" as never, + ticketId: "ticket-provider-continuation" as never, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + stepRunId: "step-provider-continuation" as never, + waitingReason: "Provider needs approval", + providerThreadId: "thread-provider-continuation" as never, + providerRequestId: "request-provider-continuation" as never, + providerResponseKind: "request", + }, + } as never); + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-provider-continuation', + 'ticket-provider-continuation', + 'step-provider-continuation', + 'thread-provider-continuation', + 'codex', + 'gpt-5.5', + 'Review the test result', + '/tmp/wt-provider-continuation', + 'started', + 'turn-provider-continuation', + '2026-06-07T00:00:05.000Z', + '2026-06-07T00:00:05.000Z' + ) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'assistant-provider-continuation', + 'thread-provider-continuation', + 'turn-provider-continuation', + 'assistant', + ${providerOutput}, + NULL, + 0, + '2026-06-07T00:00:06.000Z', + '2026-06-07T00:00:06.000Z' + ) + `; + 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-provider-continuation', + 'turn-provider-continuation', + NULL, + NULL, + NULL, + 'assistant-provider-continuation', + 'completed', + '2026-06-07T00:00:05.000Z', + '2026-06-07T00:00:05.000Z', + '2026-06-07T00:00:06.000Z', + NULL, + NULL, + NULL, + '[]' + ) + `; + + yield* engine.resolveApproval("step-provider-continuation" as never, true); + + const detail = yield* awaitLane("ticket-provider-continuation", "needs"); + assert.deepEqual(detail?.steps.find((step) => step.stepKey === "review")?.output, { + verdict: "block", + }); + assert.equal( + (yield* read.getTicketDetail("ticket-provider-continuation" as never))?.ticket + .currentLaneKey, + "needs", + ); + }), + ); +}); + +const blockedDefinition = { + name: "blocked-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done", failure: "needs", blocked: "trust" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "trust", name: "Trust", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const blockedLayer = it.layer( + baseLayer( + makeStubStepExecutor({ + default: { _tag: "blocked", reason: "Project not trusted to run scripts" } as never, + }), + ), +); + +blockedLayer("WorkflowEngine integration blocked path", (it) => { + it.effect("blocked step routes through the lane blocked target and records its reason", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-blocked" as never, blockedDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-blocked" as never, + title: "Trust", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "trust"); + assert.equal(detail?.ticket.currentLaneKey, "trust"); + assert.equal(detail?.steps[0]?.status, "blocked"); + assert.equal(detail?.steps[0]?.blockedReason, "Project not trusted to run scripts"); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isTrue( + events.some( + (event) => + event.type === "StepBlocked" && + event.payload.reason === "Project not trusted to run scripts", + ), + ); + assert.isTrue( + events.some( + (event) => event.type === "PipelineCompleted" && event.payload.result === "blocked", + ), + ); + }), + ); +}); + +const explodingExecutor = Layer.succeed(StepExecutor, { + execute: () => + Effect.fail(new WorkflowEventStoreError({ message: "executor exploded" })) as never, +} satisfies StepExecutorShape); + +const explodingLayer = it.layer(baseLayer(explodingExecutor)); + +explodingLayer("WorkflowEngine pipeline error handling", (it) => { + it.effect("records a failed step and routes when the executor effect fails", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-explodes" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-explodes" as never, + title: "Explode", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(detail?.steps[0]?.status, "failed"); + }), + ); +}); + +let capturedLaneKey: string | undefined; +let capturedLaneStepKeys: ReadonlyArray<string> | undefined; + +const capturingLaneContextExecutor = Layer.succeed(StepExecutor, { + execute: (ctx) => + Effect.sync(() => { + capturedLaneKey = ctx.laneKey as string; + capturedLaneStepKeys = ctx.laneStepKeys as ReadonlyArray<string>; + return { _tag: "completed" as const }; + }), +} satisfies StepExecutorShape); + +const capturingLaneContextLayer = it.layer(baseLayer(capturingLaneContextExecutor)); + +capturingLaneContextLayer("WorkflowEngine step context lane wiring", (it) => { + it.effect("populates ctx.laneKey and ctx.laneStepKeys from the running lane", () => + Effect.gen(function* () { + capturedLaneKey = undefined; + capturedLaneStepKeys = undefined; + const registry = yield* BoardRegistry; + yield* registry.register("b-lane-ctx" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-lane-ctx" as never, + title: "Lane context", + initialLane: "impl" as never, + }); + + yield* awaitLane(ticketId as string, "done"); + assert.equal(capturedLaneKey, "impl"); + assert.deepEqual(capturedLaneStepKeys, ["code"]); + }), + ); +}); + +const failingDefinitionRegistry = Layer.succeed(BoardRegistry, { + register: () => Effect.succeed(definition as never), + unregister: () => Effect.void, + getLane: (_boardId, laneKey) => + Effect.succeed((definition.lanes.find((lane) => lane.key === laneKey) ?? null) as never), + getDefinition: () => Effect.die("definition unavailable"), + listDefinitions: () => Effect.succeed([]), +}); + +const pipelineFailureLayer = it.layer( + baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } }), failingDefinitionRegistry), +); + +pipelineFailureLayer("WorkflowEngine orchestration error handling", (it) => { + it.effect("blocks and logs when pipeline orchestration fails before the first step", () => { + const messages: string[] = []; + const logger = Logger.make(({ message }) => { + messages.push(String(message)); + }); + + return Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pipeline-fails" as never, + title: "Pipeline fails", + initialLane: "impl" as never, + }); + + const detail = yield* awaitStatus(ticketId as string, "blocked"); + assert.equal(detail?.ticket.status, "blocked"); + assert.equal(detail?.steps.length, 0); + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const blocked = events.find((event) => event.type === "TicketBlocked"); + assert.include(blocked?.payload.reason ?? "", "definition unavailable"); + assert.isTrue( + messages.some((message) => message.includes("workflow pipeline orchestration failed")), + ); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); +}); + +const approvalDefinition = { + name: "approval-wf", + lanes: [ + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [{ key: "ok", type: "approval", prompt: "Approve?" }], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +successLayer("WorkflowEngine approval gate", (it) => { + it.effect("parks on approval then routes on approve", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-approval" as never, approvalDefinition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-approval" as never, + title: "Approve me", + initialLane: "review" as never, + }); + + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + assert.equal(waitingDetail?.ticket.status, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + yield* engine.resolveApproval(stepRunId as never, true); + const doneDetail = yield* awaitLane(ticketId as string, "done"); + assert.equal(doneDetail?.ticket.currentLaneKey, "done"); + }), + ); + + it.effect("moveTicket fails for an unknown ticket instead of silently succeeding", () => + Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const exit = yield* engine + .moveTicket("ticket-does-not-exist" as never, "needs" as never) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : null; + assert.instanceOf(error, WorkflowEventStoreError); + assert.match((error as WorkflowEventStoreError).message, /ticket-does-not-exist not found/); + }), + ); + + it.effect("answerTicketStep fails for an unknown stepRunId instead of silently succeeding", () => + Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const exit = yield* engine + .answerTicketStep({ + stepRunId: "step-run-does-not-exist" as never, + text: "an answer that should not be dropped", + attachments: [], + }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : null; + assert.instanceOf(error, WorkflowEventStoreError); + assert.match((error as WorkflowEventStoreError).message, /step-run-does-not-exist not found/); + }), + ); +}); + +const awaitingUserDefinition = { + name: "awaiting-user-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "question", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "ask", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +it.effect("answerTicketStep posts both messages, delivers text, and resumes the parked turn", () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make<ReadonlyArray<ProviderResponseInput>>([]); + const answerLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Which API should I use?", + providerThreadId: "thread-ticket-answer" as never, + providerRequestId: "request-ticket-answer" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-api-choice", + } as never, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-answer" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-answer" as never, + title: "Answer me", + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + assert.deepEqual( + waitingDetail?.messages.map((message) => [message.author, message.body]), + [["agent", "Which API should I use?"]], + ); + + yield* engine.answerTicketStep({ + stepRunId: stepRunId as never, + text: "Use the sandbox endpoint.", + attachments: [], + }); + + const doneDetail = yield* awaitLane(ticketId as string, "done"); + const calls = yield* Ref.get(providerResponses); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.responseKind, "user-input"); + assert.equal( + (calls[0] as { readonly questionId?: string } | undefined)?.questionId, + "question-api-choice", + ); + assert.equal(calls[0]?.text, "Use the sandbox endpoint."); + assert.deepEqual( + (yield* read.getTicketDetail(ticketId))?.messages.map((message) => [ + message.author, + message.body, + ]), + [ + ["agent", "Which API should I use?"], + ["user", "Use the sandbox endpoint."], + ], + ); + assert.equal(doneDetail?.ticket.currentLaneKey, "done"); + }).pipe(Effect.provide(answerLayer)); + }), +); + +it.effect( + "answerTicketStep rejects stale provider user-input waits until a live request is visible", + () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make<ReadonlyArray<ProviderResponseInput>>([]); + const providerWaitState = yield* Ref.make<"stale" | "live">("stale"); + const staleGuardLayer = baseLayer( + makeStubStepExecutor({ default: { _tag: "completed" } }), + ).pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.die("unused provider turn start"), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: (threadId) => + Ref.get(providerResponses).pipe( + Effect.zip(Ref.get(providerWaitState)), + Effect.map(([responses, state]) => { + if (responses.length > 0) { + return { _tag: "completed" as const }; + } + if (state === "live") { + return { + _tag: "awaiting_user" as const, + waitingReason: "Live provider question", + providerThreadId: threadId, + providerRequestId: "request-live-answer" as never, + providerResponseKind: "user-input" as const, + providerQuestionId: "question-live-answer", + }; + } + return { _tag: "running" as const }; + }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + yield* registry.register("b-stale-answer" as never, awaitingUserDefinition); + + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-stale-answer-created" as never, + ticketId: "ticket-stale-answer" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-stale-answer" as never, + title: "Stale answer", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-stale-answer-moved" as never, + ticketId: "ticket-stale-answer" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "token-stale-answer" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-stale-answer-pipeline" as never, + ticketId: "ticket-stale-answer" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-stale-answer" as never, + laneKey: "impl" as never, + laneEntryToken: "token-stale-answer" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-stale-answer-step" as never, + ticketId: "ticket-stale-answer" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-stale-answer" as never, + stepRunId: "step-stale-answer" as never, + stepKey: "question" as never, + stepType: "agent", + }, + } as never); + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-stale-answer-await" as never, + ticketId: "ticket-stale-answer" as never, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + stepRunId: "step-stale-answer" as never, + waitingReason: "Stale provider question", + providerThreadId: "thread-stale-answer" as never, + providerRequestId: "request-stale-answer" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-stale-answer", + }, + } as never); + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-stale-answer', + 'ticket-stale-answer', + 'step-stale-answer', + 'thread-stale-answer', + 'codex', + 'gpt-5.5', + 'ask', + '/tmp/stale-answer', + 'started', + 'turn-stale-answer', + '2026-06-07T00:00:04.000Z', + '2026-06-07T00:00:04.000Z' + ) + `; + + const staleExit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: "step-stale-answer" as never, + text: "Use the stale answer.", + }), + ); + assert.isTrue(Exit.isFailure(staleExit)); + if (Exit.isFailure(staleExit)) { + assert.include(String(staleExit.cause), "retry"); + } + assert.deepEqual(yield* Ref.get(providerResponses), []); + + const detailAfterStaleAnswer = yield* read.getTicketDetail("ticket-stale-answer" as never); + assert.equal(detailAfterStaleAnswer?.ticket.status, "waiting_on_user"); + assert.equal(detailAfterStaleAnswer?.steps[0]?.status, "awaiting_user"); + assert.isFalse( + detailAfterStaleAnswer?.messages.some((message) => message.author === "user") ?? false, + ); + const resolvedBeforeLive = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = 'ticket-stale-answer' + AND event_type = 'StepUserResolved' + `; + assert.equal(resolvedBeforeLive[0]?.count, 0); + + yield* Ref.set(providerWaitState, "live"); + yield* sql` + UPDATE workflow_dispatch_outbox + SET turn_id = 'turn-live-answer' + WHERE dispatch_id = 'dispatch-stale-answer' + `; + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-live-answer-await" as never, + ticketId: "ticket-stale-answer" as never, + occurredAt: "2026-06-07T00:00:05.000Z" as never, + payload: { + stepRunId: "step-stale-answer" as never, + waitingReason: "Live provider question", + providerThreadId: "thread-stale-answer" as never, + providerRequestId: "request-live-answer" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-live-answer", + }, + } as never); + + yield* engine.answerTicketStep({ + stepRunId: "step-stale-answer" as never, + text: "Use the live answer.", + }); + + assert.deepEqual( + (yield* Ref.get(providerResponses)).map((response) => ({ + requestId: response.requestId as string, + questionId: response.questionId, + text: response.text, + })), + [ + { + requestId: "request-live-answer", + questionId: "question-live-answer", + text: "Use the live answer.", + }, + ], + ); + }).pipe(Effect.provide(staleGuardLayer)); + }), +); + +it.effect("truncates over-long provider prompts before posting agent ticket messages", () => + Effect.gen(function* () { + const longPrompt = `${"x".repeat(8_010)} tail`; + const promptLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: longPrompt, + providerThreadId: "thread-ticket-long-prompt" as never, + providerRequestId: "request-ticket-long-prompt" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-ticket-long-prompt", + } as never, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: () => Effect.void, + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-long-prompt" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-long-prompt" as never, + title: "Long prompt", + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const body = waitingDetail?.messages[0]?.body ?? ""; + + assert.equal(body.length, 8_000); + assert.isTrue(body.endsWith("...")); + assert.isFalse(body.includes(" tail")); + }).pipe(Effect.provide(promptLayer)); + }), +); + +it.effect("answerTicketStep rejects attachment-only answers and keeps the step awaiting", () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make<ReadonlyArray<ProviderResponseInput>>([]); + const imageOnlyLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Attach a screenshot.", + providerThreadId: "thread-ticket-image-only" as never, + providerRequestId: "request-ticket-image-only" as never, + providerResponseKind: "user-input", + }, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-image-only" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-image-only" as never, + title: "Need screenshot", + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + // Provider responses are text-only: an attachment-only reply could never + // resume the parked turn, so it must fail before posting any message. + const exit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: stepRunId as never, + attachments: [ + { + kind: "image", + id: "image-only", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 1200, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + }), + ); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.include(String(exit.cause), "requires text"); + } + + const detail = yield* read.getTicketDetail(ticketId); + const calls = yield* Ref.get(providerResponses); + assert.equal(calls.length, 0); + assert.equal(detail?.ticket.status, "waiting_on_user"); + assert.equal(detail?.steps[0]?.status, "awaiting_user"); + assert.deepEqual( + detail?.messages.map((message) => [message.author, message.body]), + [["agent", "Attach a screenshot."]], + ); + }).pipe(Effect.provide(imageOnlyLayer)); + }), +); + +it.effect("answerTicketStep rejects non-awaiting steps without posting a user message", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-answer-completed" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-answer-completed" as never, + title: "Already answered", + initialLane: "impl" as never, + }); + const doneDetail = yield* awaitLane(ticketId as string, "done"); + const stepRunId = doneDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + const exit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: stepRunId as never, + text: "This should not be posted.", + }), + ); + assert.isTrue(Exit.isFailure(exit)); + + const detail = yield* read.getTicketDetail(ticketId); + assert.deepEqual(detail?.messages, []); + }).pipe(Effect.provide(baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } })))), +); + +it.effect( + "answerTicketStep rejects provider approval requests without posting a user message", + () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make<ReadonlyArray<ProviderResponseInput>>([]); + const requestLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Approve this command?", + providerThreadId: "thread-ticket-request" as never, + providerRequestId: "request-ticket-request" as never, + providerResponseKind: "request", + }, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-answer-request" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-answer-request" as never, + title: "Approve me", + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + const exit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: stepRunId as never, + text: "This should not be posted.", + }), + ); + assert.isTrue(Exit.isFailure(exit)); + + const detail = yield* read.getTicketDetail(ticketId); + assert.deepEqual(detail?.messages, []); + assert.deepEqual(yield* Ref.get(providerResponses), []); + }).pipe(Effect.provide(requestLayer)); + }), +); + +it.effect("answerTicketStep rejects over-limit reply bodies and attachments", () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make<ReadonlyArray<ProviderResponseInput>>([]); + const limitLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Provide details.", + providerThreadId: "thread-ticket-limits" as never, + providerRequestId: "request-ticket-limits" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-ticket-limits", + } as never, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + const image = (id: string, dataUrl = "data:image/png;base64,AAAA") => ({ + kind: "image" as const, + id, + name: `${id}.png`, + mimeType: "image/png" as const, + sizeBytes: dataUrl.length, + dataUrl, + }); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-limits" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const assertRejected = Effect.fn("assertRejected")(function* ( + title: string, + input: { + readonly text?: string; + readonly attachments?: ReadonlyArray<ReturnType<typeof image>>; + }, + ) { + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-limits" as never, + title, + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + const exit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: stepRunId as never, + ...input, + }), + ); + assert.isTrue(Exit.isFailure(exit)); + + const detail = yield* read.getTicketDetail(ticketId); + assert.deepEqual( + detail?.messages.map((message) => [message.author, message.body]), + [["agent", "Provide details."]], + ); + }); + + yield* assertRejected("Too many attachments", { + text: "See attached.", + attachments: Array.from({ length: 7 }, (_, index) => image(`image-${index}`)), + }); + yield* assertRejected("Too much image data", { + text: "See attached.", + attachments: [image("huge", `data:image/png;base64,${"A".repeat(10 * 1024 * 1024)}`)], + }); + yield* assertRejected("Too much text", { + text: "x".repeat(8001), + }); + + assert.deepEqual(yield* Ref.get(providerResponses), []); + }).pipe(Effect.provide(limitLayer)); + }), +); + +it.effect("answerTicketStep rejects non-image attachments before storing messages", () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make<ReadonlyArray<ProviderResponseInput>>([]); + const attachmentKindLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Attach an image.", + providerThreadId: "thread-ticket-attachment-kind" as never, + providerRequestId: "request-ticket-attachment-kind" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-ticket-attachment-kind", + } as never, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-attachment-kind" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const assertRejectedAttachment = Effect.fn("assertRejectedAttachment")(function* ( + title: string, + attachment: NonNullable< + Parameters<typeof engine.answerTicketStep>[0]["attachments"] + >[number], + ) { + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-attachment-kind" as never, + title, + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + const exit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: stepRunId as never, + text: "See attached.", + attachments: [attachment], + }), + ); + assert.isTrue(Exit.isFailure(exit)); + + const detail = yield* read.getTicketDetail(ticketId); + assert.deepEqual( + detail?.messages.map((message) => [message.author, message.body]), + [["agent", "Attach an image."]], + ); + }); + + yield* assertRejectedAttachment("Reject video", { + kind: "video", + id: "video-attachment", + name: "clip.mp4", + mimeType: "video/mp4", + sizeBytes: 1200, + ref: "ticket-media/video-attachment", + }); + yield* assertRejectedAttachment("Reject file", { + kind: "file", + id: "file-attachment", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 1200, + ref: "ticket-media/file-attachment", + }); + + assert.deepEqual(yield* Ref.get(providerResponses), []); + }).pipe(Effect.provide(attachmentKindLayer)); + }), +); + +it.effect("answerTicketStep rejects SVG image data URLs before storing messages", () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make<ReadonlyArray<ProviderResponseInput>>([]); + const svgLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Attach a raster image.", + providerThreadId: "thread-ticket-svg" as never, + providerRequestId: "request-ticket-svg" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-ticket-svg", + } as never, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-svg" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-svg" as never, + title: "Reject SVG", + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + const exit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: stepRunId as never, + text: "See attached.", + attachments: [ + { + kind: "image", + id: "svg-attachment", + name: "payload.svg", + mimeType: "image/svg+xml", + sizeBytes: 1200, + dataUrl: "data:image/svg+xml;base64,PHN2Zy8+", + } as never, + ], + }), + ); + assert.isTrue(Exit.isFailure(exit)); + + const detail = yield* read.getTicketDetail(ticketId); + assert.deepEqual( + detail?.messages.map((message) => [message.author, message.body]), + [["agent", "Attach a raster image."]], + ); + assert.deepEqual(yield* Ref.get(providerResponses), []); + }).pipe(Effect.provide(svgLayer)); + }), +); + +let supersedeStarted: Deferred.Deferred<void> | undefined; +let supersedeInterrupted: Deferred.Deferred<void> | undefined; +let supersedeRelease: Deferred.Deferred<void> | undefined; +let routedAutoStarted: Deferred.Deferred<void> | undefined; +let routedAutoRelease: Deferred.Deferred<void> | undefined; +let routedAutoCompletions = 0; + +const blockingSuccessExecutor = Layer.effect( + StepExecutor, + Effect.gen(function* () { + const started = yield* Deferred.make<void>(); + const interrupted = yield* Deferred.make<void>(); + const release = yield* Deferred.make<void>(); + supersedeStarted = started; + supersedeInterrupted = interrupted; + supersedeRelease = release; + + return StepExecutor.of({ + execute: () => + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { _tag: "completed" as const }; + }).pipe( + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined).pipe(Effect.ignore)), + ), + } satisfies StepExecutorShape); + }), +); + +const supersedeLayer = it.layer(baseLayer(blockingSuccessExecutor)); + +supersedeLayer("WorkflowEngine manual move supersede", (it) => { + it.effect("manual move prevents a stale pipeline from routing the ticket", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-supersede" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-supersede" as never, + title: "Hold position", + initialLane: "impl" as never, + }); + yield* Effect.yieldNow; + assert.exists(supersedeStarted); + assert.exists(supersedeRelease); + yield* awaitDeferredWithinYields(supersedeStarted, "supersede start"); + yield* engine.moveTicket(ticketId, "needs" as never); + yield* Deferred.succeed(supersedeRelease, undefined); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + }), + ); + + it.effect("manual move interrupts the stale running pipeline", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-hard-supersede" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-hard-supersede" as never, + title: "Interrupt stale work", + initialLane: "impl" as never, + }); + yield* Effect.yieldNow; + assert.exists(supersedeStarted); + assert.exists(supersedeInterrupted); + yield* awaitDeferredWithinYields(supersedeStarted, "hard supersede start"); + + yield* engine.moveTicket(ticketId, "needs" as never); + + yield* awaitDeferredWithinYields(supersedeInterrupted, "hard supersede interrupt"); + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + }), + ); +}); + +const routedAutoDefinition = { + name: "routed-auto-wf", + lanes: [ + { + key: "route", + name: "Route", + entry: "auto", + pipeline: [ + { + key: "route-step", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "route", + }, + ], + on: { success: "routed" }, + }, + { + key: "routed", + name: "Routed", + entry: "auto", + pipeline: [ + { + key: "routed-step", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "routed work", + }, + ], + on: { success: "done" }, + }, + { key: "manual", name: "Manual", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const routedAutoBlockingExecutor = Layer.effect( + StepExecutor, + Effect.gen(function* () { + const started = yield* Deferred.make<void>(); + const interrupted = yield* Deferred.make<void>(); + const release = yield* Deferred.make<void>(); + routedAutoStarted = started; + routedAutoRelease = release; + routedAutoCompletions = 0; + + return StepExecutor.of({ + execute: (ctx) => { + if (ctx.step.key !== "routed-step") { + return Effect.succeed({ _tag: "completed" as const }); + } + + return Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + routedAutoCompletions += 1; + return { _tag: "completed" as const }; + }).pipe( + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined).pipe(Effect.ignore)), + ); + }, + } satisfies StepExecutorShape); + }), +); + +const routedAutoSupersedeLayer = it.layer(baseLayer(routedAutoBlockingExecutor)); + +routedAutoSupersedeLayer("WorkflowEngine routed auto lane supersede", (it) => { + it.effect("starts the routed auto pipeline and lets a manual move interrupt it", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-routed-auto-supersede" as never, routedAutoDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-routed-auto-supersede" as never, + title: "Interrupt routed lane", + initialLane: "route" as never, + }); + assert.exists(routedAutoStarted); + assert.exists(routedAutoRelease); + yield* awaitDeferredWithinYields(routedAutoStarted, "routed auto start"); + + const moveFiber = yield* Effect.forkChild(engine.moveTicket(ticketId, "manual" as never)); + for (let attempt = 0; attempt < 20; attempt += 1) { + const exit = yield* Effect.sync(() => moveFiber.pollUnsafe()); + if (exit !== undefined) { + break; + } + yield* Effect.yieldNow; + } + const moveExitBeforeRelease = yield* Effect.sync(() => moveFiber.pollUnsafe()); + if (moveExitBeforeRelease === undefined) { + yield* Deferred.succeed(routedAutoRelease, undefined); + yield* Effect.yieldNow; + } + + assert.exists( + moveExitBeforeRelease, + "manual move should complete while the routed auto lane is still blocked", + ); + yield* Deferred.succeed(routedAutoRelease, undefined); + for (let attempt = 0; attempt < 20; attempt += 1) { + yield* Effect.yieldNow; + } + + const detail = yield* awaitLane(ticketId as string, "manual"); + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.equal(detail?.ticket.currentLaneKey, "manual"); + assert.isTrue( + events.some( + (event) => + event.type === "TicketMovedToLane" && + event.payload.toLane === "routed" && + event.payload.reason === "routed", + ), + ); + assert.equal(routedAutoCompletions, 0); + assert.isFalse( + events.some( + (event) => event.type === "StepCompleted" && event.payload.stepRunId === "steprun-2", + ), + ); + }), + ); +}); + +// ── Defensive missing-target-lane routing (self-improve E6) ───────────────── +// A routed move may resolve to a lane key that is absent from the current board +// def (e.g. the lane was removed via the editor between route evaluation and the +// commit). The engine must NOT commit a TicketMovedToLane into a phantom lane. +// +// We model this with a NON-LINTING stub registry whose `impl` lane routes +// `on.success → "ghost"`, where "ghost" is not among the def's lanes — so +// `getLane(boardId, "ghost")` returns null at commit time. +const missingLaneDefinition = { + name: "wf-missing-lane", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + // Routes to a lane that does NOT exist in `lanes`. + on: { success: "ghost", failure: "ghost" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +} as const; + +// A stub BoardRegistry that does NOT lint (so the dangling lane ref is allowed) +// and resolves lanes only against the def's own `lanes` array — so "ghost" → null. +const missingLaneRegistryLayer = Layer.effect( + BoardRegistry, + Effect.sync(() => { + const def = missingLaneDefinition as never; + const lanes = missingLaneDefinition.lanes; + return { + register: () => Effect.succeed(def), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(def), + listDefinitions: () => Effect.succeed([{ boardId: "b-missing" as never, definition: def }]), + getLane: (_boardId, laneKey) => + Effect.succeed((lanes.find((lane) => lane.key === (laneKey as string)) ?? null) as never), + }; + }), +); + +const missingLaneLayer = it.layer( + baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } }), missingLaneRegistryLayer), +); + +missingLaneLayer("WorkflowEngine missing-target-lane routing", (it) => { + it.effect( + "routed move to a missing lane blocks the ticket for attention (no phantom-lane move)", + () => + Effect.gen(function* () { + const engine = yield* WorkflowEngine; + + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-missing" as never, + title: "Phantom route", + initialLane: "impl" as never, + }); + + // The guard emits TicketBlocked right after the pipeline completes and the + // routed target resolves to the missing lane. Wait on the projected status + // (deterministic) then inspect the event log. + const detail = yield* awaitStatus(ticketId as string, "blocked"); + assert.equal(detail?.ticket.status, "blocked"); + // Still in its old lane (not silently parked in a phantom lane). + assert.equal(detail?.ticket.currentLaneKey, "impl"); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + // We never commit a move/queue into the phantom "ghost" lane. + assert.isFalse( + events.some( + (event) => + (event.type === "TicketMovedToLane" || event.type === "TicketQueued") && + (event.payload as { readonly toLane?: string; readonly lane?: string }).toLane === + "ghost", + ), + ); + // The block carries a clear reason naming the missing lane. + const blocked = events.find((event) => event.type === "TicketBlocked"); + assert.isDefined(blocked); + assert.include((blocked?.payload as { readonly reason: string }).reason, "ghost"); + assert.include( + (blocked?.payload as { readonly reason: string }).reason, + "no longer exists", + ); + }), + ); +}); + +it.effect("editTicketMessage edits a free-standing user comment and rejects everything else", () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make<ReadonlyArray<ProviderResponseInput>>([]); + const editLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Which API should I use?", + providerThreadId: "thread-ticket-edit" as never, + providerRequestId: "request-ticket-edit" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-edit", + } as never, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-edit" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-edit" as never, + title: "Edit me", + initialLane: "impl" as never, + }); + // Parking on the awaiting-user step posts an agent-authored message. + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + const agentMessageId = waitingDetail?.messages.find((m) => m.author === "agent")?.messageId; + assert.isString(agentMessageId); + + // Post a free-standing user comment (no stepRunId). + yield* engine.postTicketMessage({ + ticketId, + text: "original comment", + attachments: [], + }); + + // Answer the awaiting step — this posts a user message that carries a stepRunId. + yield* engine.answerTicketStep({ + stepRunId: stepRunId as never, + text: "Use the sandbox endpoint.", + attachments: [], + }); + yield* awaitLane(ticketId as string, "done"); + + const detailAfter = yield* read.getTicketDetail(ticketId); + const freeStanding = detailAfter?.messages.find( + (m) => m.author === "user" && m.stepRunId == null, + ); + const stepBound = detailAfter?.messages.find( + (m) => m.author === "user" && m.stepRunId != null, + ); + assert.isString(freeStanding?.messageId); + assert.isString(stepBound?.messageId); + + // Editing your own free-standing comment succeeds and stamps editedAt. + yield* engine.editTicketMessage({ + ticketId, + messageId: freeStanding?.messageId as never, + body: "edited comment", + }); + const detailEdited = yield* read.getTicketDetail(ticketId); + const editedMessage = detailEdited?.messages.find( + (m) => m.messageId === freeStanding?.messageId, + ); + assert.equal(editedMessage?.body, "edited comment"); + assert.isNotNull(editedMessage?.editedAt); + + // Unknown messageId → message not found. + const unknownExit = yield* engine + .editTicketMessage({ + ticketId, + messageId: "message-does-not-exist" as never, + body: "nope", + }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(unknownExit)); + const unknownError = Exit.isFailure(unknownExit) ? Cause.squash(unknownExit.cause) : null; + assert.instanceOf(unknownError, WorkflowEventStoreError); + assert.match((unknownError as WorkflowEventStoreError).message, /message not found/); + + // Agent-authored message → cannot be edited. + const agentExit = yield* engine + .editTicketMessage({ + ticketId, + messageId: agentMessageId as never, + body: "rewriting the agent", + }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(agentExit)); + const agentError = Exit.isFailure(agentExit) ? Cause.squash(agentExit.cause) : null; + assert.match( + (agentError as WorkflowEventStoreError).message, + /only your own comments can be edited/, + ); + + // User message bound to a step run → cannot be edited. + const stepExit = yield* engine + .editTicketMessage({ + ticketId, + messageId: stepBound?.messageId as never, + body: "rewriting the answer", + }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(stepExit)); + const stepError = Exit.isFailure(stepExit) ? Cause.squash(stepExit.cause) : null; + assert.match( + (stepError as WorkflowEventStoreError).message, + /only your own comments can be edited/, + ); + + // Empty body → rejected by validateTicketMessageInput. + const emptyExit = yield* engine + .editTicketMessage({ + ticketId, + messageId: freeStanding?.messageId as never, + body: " ", + }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(emptyExit)); + const emptyError = Exit.isFailure(emptyExit) ? Cause.squash(emptyExit.cause) : null; + assert.match( + (emptyError as WorkflowEventStoreError).message, + /requires text or an attachment/, + ); + + // Non-existent ticket → ticket not found. + const ghostExit = yield* engine + .editTicketMessage({ + ticketId: "ticket-does-not-exist" as never, + messageId: freeStanding?.messageId as never, + body: "into the void", + }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(ghostExit)); + const ghostError = Exit.isFailure(ghostExit) ? Cause.squash(ghostExit.cause) : null; + assert.match((ghostError as WorkflowEventStoreError).message, /ticket not found/); + }).pipe(Effect.provide(editLayer)); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.lifecycle.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.lifecycle.test.ts new file mode 100644 index 00000000000..7d0c9df4c03 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.lifecycle.test.ts @@ -0,0 +1,243 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowTerminalRetentionSweeper } from "../Services/WorkflowTerminalRetentionSweeper.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { makeStubStepExecutor } from "./StubStepExecutor.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; +import { makeWorkflowTerminalRetentionSweeperLive } from "./WorkflowTerminalRetentionSweeper.ts"; + +// A board that threads every seam of the lifecycle in one definition: +// triage (manual entry) -- ticket is created here +// impl (auto entry) -- its pipeline auto-runs to completion, then the +// lane `on.success` routes onward to `review` +// review (manual entry) -- an inbound external event (ci.passed/green) +// routes the ticket to the terminal `done` lane +// done (terminal) -- carries a retention TTL so the terminal-retention +// sweep retires it +const lifecycleDefinition = { + name: "lifecycle", + lanes: [ + { key: "triage", name: "Triage", entry: "manual" }, + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "review", failure: "needs" }, + }, + { + key: "review", + name: "Review", + entry: "manual", + onEvent: [ + { + name: "ci.passed", + when: { "==": [{ var: "event.payload.status" }, "green"] }, + to: "done", + }, + ], + }, + { key: "needs", name: "Needs", entry: "manual" }, + { + key: "done", + name: "Done", + entry: "manual", + terminal: true, + retention: "1 day", + }, + // Negative-control lane: terminal but NO retention, so the sweep must never + // pick tickets here (the sweeper only targets terminal lanes with a defined + // retention — WorkflowTerminalRetentionSweeper.ts ~L173-186). + { key: "archived", name: "Archived", entry: "manual", terminal: true }, + ], +}; + +// Engine stack identical in spirit to WorkflowEngine.concurrency.test.ts, with +// the terminal-retention sweeper layered on top. The sweeper depends only on +// services already present in the engine stack (WorkflowEngine, BoardRegistry, +// WorkflowEventStore, WorkflowReadModel, WorkflowBoardSaveLocks, SqlClient), so +// it composes cleanly without mocking any seam between create -> run -> route -> +// terminal -> retention. +// +// `nowMs` is pinned to a real 2026 instant. Under it.effect the TestClock is +// anchored at epoch 0, so the engine stamps `terminal_at` at ~1970; pinning the +// sweep clock to 2026 makes the terminal ticket older than its 1-day retention +// without needing TestClock.adjust (the sweeper reads `nowMs`, not the clock). +const lifecycleLayer = it.layer( + makeWorkflowTerminalRetentionSweeperLive({ + sweepIntervalMs: 60_000, + nowMs: Effect.succeed(Date.parse("2026-06-08T00:00:00.000Z")), + }).pipe( + Layer.provideMerge(WorkflowEngineLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(makeStubStepExecutor({ default: { _tag: "completed" } })), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const awaitLane = (ticketId: string, laneKey: string) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + let detail: TicketDetail | null = null; + for (let attempt = 0; attempt < 100; attempt += 1) { + detail = yield* read.getTicketDetail(ticketId as never); + if (detail?.ticket.currentLaneKey === laneKey) { + return detail; + } + yield* Effect.promise<void>(() => new Promise((resolve) => setTimeout(resolve, 10))); + yield* Effect.yieldNow; + } + return detail; + }); + +// NOTE: this is an ENGINE lifecycle integration test. The agent step's EXECUTION +// is stubbed (makeStubStepExecutor always returns `completed`), so it covers the +// engine's create -> route -> terminal -> retention seams threaded together, NOT +// the real step-executor / provider-dispatch path (covered by RealStepExecutor / +// ProviderDispatchOutbox tests). The value here is that the seams compose. +lifecycleLayer("WorkflowEngine lifecycle integration (stub step executor)", (it) => { + it.effect( + "threads create -> auto-run -> external-event route -> terminal -> retention sweep (with negative controls)", + () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + const sweeper = yield* WorkflowTerminalRetentionSweeper; + + // 1. create a board + yield* registry.register("b-lifecycle" as never, lifecycleDefinition); + + // 2. create a ticket on it (manual entry lane) + const ticketId = yield* engine.createTicket({ + boardId: "b-lifecycle" as never, + title: "Ship the thing", + initialLane: "triage" as never, + }); + assert.equal((yield* read.getTicketDetail(ticketId))?.ticket.currentLaneKey, "triage"); + + // 2b. NEGATIVE CONTROLS — these must SURVIVE the sweep, proving the sweep + // deletes BECAUSE a ticket is terminal+expired+in-a-retention-lane, not + // indiscriminately. Without these, the test would pass even if the + // sweeper deleted every ticket it saw. + // - controlPending: never leaves the non-terminal triage lane. + // - controlArchived: terminal, but in the no-retention `archived` lane. + const controlPending = yield* engine.createTicket({ + boardId: "b-lifecycle" as never, + title: "Still in triage", + initialLane: "triage" as never, + }); + const controlArchived = yield* engine.createTicket({ + boardId: "b-lifecycle" as never, + title: "Archived, no retention", + initialLane: "triage" as never, + }); + yield* engine.moveTicket(controlArchived as never, "archived" as never); + assert.equal( + (yield* read.getTicketDetail(controlArchived))?.ticket.currentLaneKey, + "archived", + ); + + // 3. move it into the auto lane; its pipeline auto-runs to completion and + // the lane on.success routes it onward to `review`. + yield* engine.moveTicket(ticketId as never, "impl" as never); + const reviewDetail = yield* awaitLane(ticketId as string, "review"); + assert.equal(reviewDetail?.ticket.currentLaneKey, "review"); + assert.equal( + reviewDetail?.steps.some( + (step) => step.stepKey === "code" && step.status === "completed", + ), + true, + ); + + // 4. an inbound external/webhook event routes the ticket to the terminal lane. + const moved = yield* engine.ingestExternalEvent({ + boardId: "b-lifecycle" as never, + name: "ci.passed", + ticketId, + payload: { status: "green" }, + }); + assert.equal(moved.outcome, "moved"); + assert.equal(moved.toLane, "done"); + + // 5. ticket reaches a TERMINAL lane. + const doneDetail = yield* awaitLane(ticketId as string, "done"); + assert.equal(doneDetail?.ticket.currentLaneKey, "done"); + + // The route to the terminal lane was recorded as an external-event decision. + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isTrue( + events.some( + (event) => + event.type === "TicketRouteDecided" && + event.payload.source === "external_event" && + event.payload.toLane === ("done" as string), + ), + ); + + // 6. the terminal-retention sweep removes the terminal ticket. + // (nowMs is pinned past the 1-day retention vs the epoch-0 terminal_at.) + const result = yield* sweeper.sweep(); + assert.equal(result.candidateCount, 1); + assert.equal(result.deletedCount, 1); + assert.equal(result.failedCount, 0); + + // The terminal+expired ticket and its detail are gone after the sweep. + assert.equal(yield* read.getTicketDetail(ticketId), null); + + // The negative controls SURVIVE — the sweep was selective, not a blanket + // delete: non-terminal ticket stays, terminal-but-no-retention ticket stays. + assert.equal( + (yield* read.getTicketDetail(controlPending))?.ticket.currentLaneKey, + "triage", + ); + assert.equal( + (yield* read.getTicketDetail(controlArchived))?.ticket.currentLaneKey, + "archived", + ); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.retry.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.retry.test.ts new file mode 100644 index 00000000000..495a8548838 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.retry.test.ts @@ -0,0 +1,614 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import type { StepOutcome } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +interface RecordedCall { + readonly stepKey: string; + readonly model: string | null; + readonly instance: string | null; + readonly optionIds: ReadonlyArray<string>; +} + +interface ScriptedExecutor { + readonly calls: Array<RecordedCall>; + readonly layer: Layer.Layer<StepExecutor>; +} + +const makeScriptedExecutor = (outcomeForCall: (call: number) => StepOutcome): ScriptedExecutor => { + const calls: Array<RecordedCall> = []; + const layer = Layer.succeed(StepExecutor, { + execute: (ctx) => + Effect.sync(() => { + const step = ctx.step; + calls.push({ + stepKey: step.key as string, + model: step.type === "agent" ? (step.agent.model as string) : null, + instance: step.type === "agent" ? (step.agent.instance as string) : null, + optionIds: + step.type === "agent" ? (step.agent.options ?? []).map((o) => o.id as string) : [], + }); + return outcomeForCall(calls.length); + }), + } satisfies StepExecutorShape); + return { calls, layer }; +}; + +const baseLayer = (executor: Layer.Layer<StepExecutor>) => + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(executor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +const awaitTicketWhere = (ticketId: string, predicate: (detail: TicketDetail | null) => boolean) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + for (let attempt = 0; attempt < 100; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId as never); + if (predicate(detail)) { + return detail; + } + yield* Effect.promise<void>(() => new Promise((resolve) => setTimeout(resolve, 10))); + yield* Effect.yieldNow; + } + return yield* read.getTicketDetail(ticketId as never); + }); + +const awaitLane = (ticketId: string, laneKey: string) => + awaitTicketWhere(ticketId, (detail) => detail?.ticket.currentLaneKey === laneKey); + +const retryDefinition = (retry: unknown) => ({ + name: "retry-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + retry, + }, + ], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}); + +const flakyExecutor = makeScriptedExecutor((call) => + call < 3 ? { _tag: "failed", error: `boom ${call}` } : { _tag: "completed" }, +); + +const flakyLayer = it.layer(baseLayer(flakyExecutor.layer)); + +flakyLayer("retry with escalation succeeds on a later attempt", (it) => { + it.effect("re-runs failed agent steps with the escalated selection", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register( + "b-retry" as never, + retryDefinition({ + maxAttempts: 3, + escalate: { model: "opus", options: [{ id: "effort", value: "high" }] }, + }) as never, + ); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-retry" as never, + title: "Flaky work", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "done"); + assert.equal(detail?.ticket.currentLaneKey, "done"); + + assert.equal(flakyExecutor.calls.length, 3); + assert.deepEqual( + flakyExecutor.calls.map((call) => call.model), + ["sonnet", "opus", "opus"], + ); + assert.deepEqual(flakyExecutor.calls[1]?.optionIds, ["effort"]); + + const codeRuns = (detail?.steps ?? []).filter((step) => step.stepKey === "code"); + assert.equal(codeRuns.length, 3); + assert.deepEqual( + codeRuns.map((step) => step.attempt), + [1, 2, 3], + ); + assert.deepEqual( + codeRuns.map((step) => step.status), + ["failed", "failed", "completed"], + ); + }), + ); +}); + +const alwaysFailExecutor = makeScriptedExecutor((call) => ({ + _tag: "failed", + error: `boom ${call}`, +})); + +const exhaustedLayer = it.layer(baseLayer(alwaysFailExecutor.layer)); + +exhaustedLayer("retry exhaustion routes the final failure", (it) => { + it.effect("stops after maxAttempts and routes to the failure lane", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-exhaust" as never, retryDefinition({ maxAttempts: 2 }) as never); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-exhaust" as never, + title: "Hopeless work", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(alwaysFailExecutor.calls.length, 2); + assert.deepEqual( + (detail?.steps ?? []).map((step) => step.status), + ["failed", "failed"], + ); + }), + ); +}); + +const blockedExecutor = makeScriptedExecutor(() => ({ _tag: "blocked", reason: "no trust" })); + +const blockedLayer = it.layer(baseLayer(blockedExecutor.layer)); + +blockedLayer("blocked outcomes never retry", (it) => { + it.effect("runs the step exactly once", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register( + "b-blocked" as never, + { + ...retryDefinition({ maxAttempts: 3 }), + lanes: retryDefinition({ maxAttempts: 3 }).lanes.map((lane) => + lane.key === "impl" ? { ...lane, on: { ...lane.on, blocked: "needs" } } : lane, + ), + } as never, + ); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-blocked" as never, + title: "Blocked work", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(blockedExecutor.calls.length, 1); + }), + ); +}); + +const awaitingExecutor = makeScriptedExecutor(() => ({ + _tag: "awaiting_user", + waitingReason: "Need a decision", +})); + +const rejectionLayer = it.layer(baseLayer(awaitingExecutor.layer)); + +rejectionLayer("user rejections never retry", (it) => { + it.effect("a rejected awaiting-user step fails without another attempt", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-reject" as never, retryDefinition({ maxAttempts: 3 }) as never); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-reject" as never, + title: "Risky work", + initialLane: "impl" as never, + }); + + const waiting = yield* awaitTicketWhere( + ticketId as string, + (detail) => detail?.ticket.status === "waiting_on_user", + ); + const stepRunId = waiting?.steps[0]?.stepRunId; + assert.ok(stepRunId !== undefined); + + yield* engine.resolveApproval(stepRunId as never, false); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(awaitingExecutor.calls.length, 1); + assert.deepEqual( + (detail?.steps ?? []).map((step) => step.status), + ["failed"], + ); + }), + ); +}); + +const cancelledExecutor = makeScriptedExecutor((call) => ({ + _tag: "failed", + error: `cancelled ${call}`, + retryable: false, +})); + +const cancelledLayer = it.layer(baseLayer(cancelledExecutor.layer)); + +cancelledLayer("non-retryable failures never retry", (it) => { + it.effect("a cancelled step fails without another attempt", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register( + "b-cancelled" as never, + retryDefinition({ maxAttempts: 3 }) as never, + ); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-cancelled" as never, + title: "Cancelled work", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(cancelledExecutor.calls.length, 1); + }), + ); +}); + +const recoveryExecutor = makeScriptedExecutor(() => ({ _tag: "completed" })); + +const recoveryLayer = it.layer(baseLayer(recoveryExecutor.layer)); + +recoveryLayer("recovered failed attempts resume the retry loop", (it) => { + it.effect("a failed attempt recovered after restart consumes its remaining attempts", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register( + "b-recover" as never, + retryDefinition({ maxAttempts: 2, escalate: { model: "opus" } }) as never, + ); + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + + const seed = (event: Record<string, unknown>, eventId: string) => + committer.commit({ + ...event, + eventId, + occurredAt: "1969-12-31T00:00:00.000Z", + } as never); + + yield* seed( + { + type: "TicketCreated", + ticketId: "t-recover", + payload: { boardId: "b-recover", title: "Restarted work", laneKey: "impl" }, + }, + "evt-rec-created", + ); + yield* seed( + { + type: "TicketMovedToLane", + ticketId: "t-recover", + payload: { toLane: "impl", laneEntryToken: "tok-rec", reason: "initial" }, + }, + "evt-rec-moved", + ); + yield* seed( + { + type: "PipelineStarted", + ticketId: "t-recover", + payload: { pipelineRunId: "pipe-rec", laneKey: "impl", laneEntryToken: "tok-rec" }, + }, + "evt-rec-pipe", + ); + yield* seed( + { + type: "StepStarted", + ticketId: "t-recover", + payload: { + pipelineRunId: "pipe-rec", + stepRunId: "step-rec-1", + stepKey: "code", + stepType: "agent", + attempt: 1, + }, + }, + "evt-rec-step", + ); + + yield* engine.completeRecoveredStep("step-rec-1" as never, { + _tag: "failed", + error: "interrupted", + }); + + const detail = yield* awaitLane("t-recover", "done"); + assert.equal(detail?.ticket.currentLaneKey, "done"); + // The recovered failure consumed attempt 1; the engine ran attempt 2 + // with the escalated selection and routed the success. + assert.equal(recoveryExecutor.calls.length, 1); + assert.equal(recoveryExecutor.calls[0]?.model, "opus"); + const codeRuns = (detail?.steps ?? []).filter((step) => step.stepKey === "code"); + assert.deepEqual( + codeRuns.map((step) => [step.attempt, step.status]), + [ + [1, "failed"], + [2, "completed"], + ], + ); + }), + ); +}); + +const recoveredCancelExecutor = makeScriptedExecutor(() => ({ _tag: "completed" })); + +const recoveredCancelLayer = it.layer(baseLayer(recoveredCancelExecutor.layer)); + +recoveredCancelLayer("recovered non-retryable failures never retry", (it) => { + it.effect("a recovered cancellation routes the failure without new attempts", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register( + "b-recover-cancel" as never, + retryDefinition({ maxAttempts: 3 }) as never, + ); + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + + const seed = (event: Record<string, unknown>, eventId: string) => + committer.commit({ + ...event, + eventId, + occurredAt: "1969-12-31T00:00:00.000Z", + } as never); + + yield* seed( + { + type: "TicketCreated", + ticketId: "t-recover-cancel", + payload: { boardId: "b-recover-cancel", title: "Cancelled work", laneKey: "impl" }, + }, + "evt-rc-created", + ); + yield* seed( + { + type: "TicketMovedToLane", + ticketId: "t-recover-cancel", + payload: { toLane: "impl", laneEntryToken: "tok-rc", reason: "initial" }, + }, + "evt-rc-moved", + ); + yield* seed( + { + type: "PipelineStarted", + ticketId: "t-recover-cancel", + payload: { pipelineRunId: "pipe-rc", laneKey: "impl", laneEntryToken: "tok-rc" }, + }, + "evt-rc-pipe", + ); + yield* seed( + { + type: "StepStarted", + ticketId: "t-recover-cancel", + payload: { + pipelineRunId: "pipe-rc", + stepRunId: "step-rc-1", + stepKey: "code", + stepType: "script", + attempt: 1, + }, + }, + "evt-rc-step", + ); + + yield* engine.completeRecoveredStep("step-rc-1" as never, { + _tag: "failed", + error: "script cancelled", + retryable: false, + }); + + const detail = yield* awaitLane("t-recover-cancel", "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(recoveredCancelExecutor.calls.length, 0); + }), + ); +}); + +const loopDefinition = { + name: "loop-wf", + lanes: [ + { + key: "implementation", + name: "Implementation", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "implement", + }, + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "review", + captureOutput: true, + }, + ], + transitions: [ + { + when: { + and: [ + { "==": [{ var: "steps.review.output.verdict" }, "revise"] }, + { "<": [{ var: "lane.runCount" }, 3] }, + ], + }, + to: "implementation", + }, + { + when: { "==": [{ var: "steps.review.output.verdict" }, "revise"] }, + to: "manual_review", + }, + { + when: { "==": [{ var: "steps.review.output.verdict" }, "approve"] }, + to: "owner_review", + }, + ], + on: { success: "owner_review", failure: "needs", blocked: "needs" }, + }, + { key: "owner_review", name: "Owner Review", entry: "manual" }, + { key: "manual_review", name: "Manual Review", entry: "manual" }, + { key: "needs", name: "Needs", entry: "manual" }, + ], +}; + +const reviewLoopExecutor = makeScriptedExecutor((call) => { + // Calls alternate implement/review per lane run: 1=impl 2=review(revise) + // 3=impl 4=review(approve). + if (call % 2 === 1) { + return { _tag: "completed" }; + } + return { _tag: "completed", output: { verdict: call < 4 ? "revise" : "approve" } }; +}); + +const reviewLoopLayer = it.layer(baseLayer(reviewLoopExecutor.layer)); + +reviewLoopLayer("lane.runCount bounds the review loop", (it) => { + it.effect("revise re-enters the lane and approve routes onward", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-loop" as never, loopDefinition as never); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-loop" as never, + title: "Loop work", + initialLane: "implementation" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "owner_review"); + assert.equal(detail?.ticket.currentLaneKey, "owner_review"); + // Two full lane runs: implement+review, then implement+review again. + assert.equal(reviewLoopExecutor.calls.length, 4); + const reviewRuns = (detail?.steps ?? []).filter((step) => step.stepKey === "review"); + assert.equal(reviewRuns.length, 2); + }), + ); +}); + +const exhaustedLoopExecutor = makeScriptedExecutor((call) => + call % 2 === 1 ? { _tag: "completed" } : { _tag: "completed", output: { verdict: "revise" } }, +); + +const exhaustedLoopLayer = it.layer(baseLayer(exhaustedLoopExecutor.layer)); + +exhaustedLoopLayer("review loop budget exhausts to manual review", (it) => { + it.effect("a persistently revised ticket escalates after three lane runs", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-loop-exhaust" as never, loopDefinition as never); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-loop-exhaust" as never, + title: "Stubborn work", + initialLane: "implementation" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "manual_review"); + assert.equal(detail?.ticket.currentLaneKey, "manual_review"); + // Three lane runs of implement+review before escalation. + assert.equal(exhaustedLoopExecutor.calls.length, 6); + + // A manual move back into the lane is a human intervention: the loop + // budget resets and the ticket gets three fresh passes. + yield* engine.moveTicket(ticketId, "implementation" as never); + const second = yield* awaitTicketWhere( + ticketId as string, + (current) => + current?.ticket.currentLaneKey === "manual_review" && (current.steps?.length ?? 0) >= 12, + ); + assert.equal(second?.ticket.currentLaneKey, "manual_review"); + assert.equal(exhaustedLoopExecutor.calls.length, 12); + }), + ); +}); + +const commentExecutor = makeScriptedExecutor(() => ({ _tag: "completed" })); +const commentLayer = it.layer(baseLayer(commentExecutor.layer)); + +commentLayer("postTicketMessage", (it) => { + it.effect("posts a user comment without an awaiting step", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-comment" as never, retryDefinition({ maxAttempts: 2 }) as never); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-comment" as never, + title: "Comment target", + initialLane: "needs" as never, + }); + + yield* engine.postTicketMessage({ ticketId, text: "Note to self: check auth flow." }); + + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.messages.length, 1); + assert.equal(detail?.messages[0]?.author, "user"); + assert.equal(detail?.messages[0]?.body, "Note to self: check auth flow."); + assert.equal(detail?.messages[0]?.stepRunId, null); + + const empty = yield* Effect.exit(engine.postTicketMessage({ ticketId, text: " " })); + assert.equal(empty._tag, "Failure"); + + const missing = yield* Effect.exit( + engine.postTicketMessage({ ticketId: "nope" as never, text: "hello" }), + ); + assert.equal(missing._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.sessionTeardown.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.sessionTeardown.test.ts new file mode 100644 index 00000000000..60c8b2ee4d6 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.sessionTeardown.test.ts @@ -0,0 +1,215 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowAgentSessionStore } from "../Services/WorkflowAgentSessionStore.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +// A step that never resolves so an admitted ticket keeps a running pipeline; the +// teardown tests never start a pipeline (terminal/manual lanes), so this is just +// a placeholder so the engine layer resolves a StepExecutor. +const blockingExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.never, +} satisfies StepExecutorShape); + +// Records every stopSession call so the tests can prove best-effort provider +// teardown ran for the ticket's stored agent threads when it lands in a terminal +// lane (or its board is deleted). +const makeRecordingProvider = (calls: Ref.Ref<ReadonlyArray<string>>) => + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.die("unused"), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: (input) => Ref.update(calls, (threads) => [...threads, input.threadId as string]), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + } satisfies ProviderServiceShape); + +const makeLayer = (calls: Ref.Ref<ReadonlyArray<string>>) => + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(blockingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(makeRecordingProvider(calls)), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +// inbox is a manual non-terminal start lane; done is the terminal lane the +// teardown must fire on. +const definition = { + name: "session teardown", + lanes: [ + { key: "inbox", name: "Inbox", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const inLockAndTx = <A, E>(boardId: string, body: Effect.Effect<A, E, SqlClient.SqlClient>) => + Effect.gen(function* () { + const saveLocks = yield* WorkflowBoardSaveLocks; + const sql = yield* SqlClient.SqlClient; + return yield* saveLocks.withSaveLock(boardId as never, sql.withTransaction(body)); + }); + +it.effect( + "terminal entry via the normal (manual) move tears down the ticket's stored agent sessions", + () => + Effect.gen(function* () { + const calls = yield* Ref.make<ReadonlyArray<string>>([]); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-teardown-normal" as never, definition); + const engine = yield* WorkflowEngine; + const sessions = yield* WorkflowAgentSessionStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-teardown-normal" as never, + title: "Has a session", + initialLane: "inbox" as never, + }); + + // Record a stored per-agent session as if a prior `continueSession` step + // had resumed against a stable thread. + yield* sessions.upsert(ticketId, "inbox" as never, "agent-a", "thread-teardown-1"); + assert.equal((yield* sessions.listByTicket(ticketId)).length, 1); + + yield* engine.moveTicket(ticketId, "done" as never); + + // deleteByTicket: the stored row is gone after landing in the terminal lane. + assert.equal((yield* sessions.listByTicket(ticketId)).length, 0); + // best-effort stopSession: the thread was stopped before deletion. + assert.deepEqual(yield* Ref.get(calls), ["thread-teardown-1"]); + }).pipe(Effect.provide(makeLayer(calls))); + }), +); + +it.effect( + "closeTicketFromSourceUnlocked into a terminal lane deletes stored sessions in-tx but DEFERS the live stopSession (never runs it inside the chunk transaction)", + () => + Effect.gen(function* () { + const calls = yield* Ref.make<ReadonlyArray<string>>([]); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-teardown-close" as never, definition); + const engine = yield* WorkflowEngine; + const sessions = yield* WorkflowAgentSessionStore; + + const created = yield* inLockAndTx( + "b-teardown-close", + engine.createTicketAndEnterUnlocked({ + boardId: "b-teardown-close" as never, + title: "Close me", + destinationLane: "inbox" as never, + }), + ); + + yield* sessions.upsert(created.ticketId, "inbox" as never, "agent-a", "thread-teardown-2"); + assert.equal((yield* sessions.listByTicket(created.ticketId)).length, 1); + + // The source committer SNAPSHOTS the stored threads in-tx (before the + // close deletes the rows) so it can stop them after the chunk commits — + // `provider.stopSession` is a non-rollbackable live side effect that must + // not run inside the chunk transaction. Mirror that here. + const snapshot = yield* inLockAndTx( + "b-teardown-close", + Effect.gen(function* () { + const threads = yield* engine.terminalAgentSessionThreadsForTicket(created.ticketId); + yield* engine.closeTicketFromSourceUnlocked(created.ticketId, "done" as never); + return threads; + }), + ); + + // tx-safe deleteByTicket ran in-band inside the chunk transaction: the row + // is gone. + assert.equal((yield* sessions.listByTicket(created.ticketId)).length, 0); + // The snapshot captured the thread before deletion so the live stop can be + // deferred to the committer's post-commit phase. + assert.deepEqual(snapshot, ["thread-teardown-2"]); + // CRITICAL: the live stopSession was NOT invoked on the in-tx path — + // deferred to post-commit, unlike the public-move path which stops in-band. + assert.deepEqual(yield* Ref.get(calls), []); + + // Post-commit (outside any chunk tx): the committer replays the snapshot + // through stopAgentSessionsForTicket, which is when the live stop fires. + yield* engine.stopAgentSessionsForTicket(snapshot); + assert.deepEqual(yield* Ref.get(calls), ["thread-teardown-2"]); + }).pipe(Effect.provide(makeLayer(calls))); + }), +); + +it.effect("a non-terminal move never tears down stored agent sessions", () => + Effect.gen(function* () { + const calls = yield* Ref.make<ReadonlyArray<string>>([]); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + // Two non-terminal lanes so the move below stays out of a terminal lane. + yield* registry.register("b-teardown-noop" as never, { + name: "no teardown", + lanes: [ + { key: "inbox", name: "Inbox", entry: "manual" }, + { key: "review", name: "Review", entry: "manual" }, + ], + }); + const engine = yield* WorkflowEngine; + const sessions = yield* WorkflowAgentSessionStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-teardown-noop" as never, + title: "Stays open", + initialLane: "inbox" as never, + }); + yield* sessions.upsert(ticketId, "inbox" as never, "agent-a", "thread-noop"); + + yield* engine.moveTicket(ticketId, "review" as never); + + // The session row survives a non-terminal move and no stopSession fired. + assert.equal((yield* sessions.listByTicket(ticketId)).length, 1); + assert.deepEqual(yield* Ref.get(calls), []); + }).pipe(Effect.provide(makeLayer(calls))); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts new file mode 100644 index 00000000000..06b84387f2c --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts @@ -0,0 +1,21 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("ticket token migration", (it) => { + it.effect("projection_ticket has current_lane_entry_token", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_ticket) + `; + assert.isTrue(columns.some((column) => column.name === "current_lane_entry_token")); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.ts b/apps/server/src/workflow/Layers/WorkflowEngine.ts new file mode 100644 index 00000000000..15a6932183f --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.ts @@ -0,0 +1,2812 @@ +import type { + BoardId, + LaneEntryToken, + LaneKey, + MessageId, + PipelineRunId, + StepKey, + StepOutcome, + StepRunId, + TicketAttachment, + ThreadId, + TicketId, + TurnId, + WorkflowEventId, + WorkflowLane, + WorkflowStep, + WorkflowStepUsage, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { WorkflowEventStoreError, WorkflowEventStoreErrorCode } from "../Services/Errors.ts"; +import { PredicateEvaluator } from "../Services/PredicateEvaluator.ts"; +import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; +import { ProviderResponsePort } from "../Services/ProviderResponsePort.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { StepUsageReader } from "../Services/StepUsageReader.ts"; +import { TurnStateReader, type TurnState } from "../Services/TurnStateReader.ts"; +import { WorkflowAgentSessionStore } from "../Services/WorkflowAgentSessionStore.ts"; +import { + WorkflowEngine, + type RecoveredStepResult, + type WorkflowEngineShape, +} from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { + WorkflowEventStore, + type PersistedWorkflowEvent, + type WorkflowEventInput, +} from "../Services/WorkflowEventStore.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { + WorkflowRoutingContextBuilder, + type WorkflowRoutingContext, +} from "../Services/WorkflowRoutingContextBuilder.ts"; +import { MAX_TICKET_MESSAGE_BODY_LENGTH, truncateTicketMessageBody } from "../ticketMessageBody.ts"; + +type PipelineResult = "success" | "failure" | "blocked"; +type StepResult = "completed" | "failed" | "blocked"; +type RouteSource = "step_on" | "lane_transition" | "lane_on"; +type MoveReason = "manual" | "routed" | "initial" | "external"; + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); +const formatError = (error: unknown) => (error instanceof Error ? error.message : String(error)); +const toEngineSqlError = (cause: unknown) => + new WorkflowEventStoreError({ message: "workflow engine sql failed", cause }); +const wrapSql = <A>(effect: Effect.Effect<A, SqlError>) => + effect.pipe(Effect.mapError(toEngineSqlError)); + +const alreadyStoppedProviderErrorTags = new Set([ + "ProviderSessionNotFoundError", + "ProviderAdapterSessionNotFoundError", + "ProviderAdapterSessionClosedError", +]); + +const providerErrorTag = (cause: unknown) => { + if (typeof cause !== "object" || cause === null || !("_tag" in cause)) { + return null; + } + const tag = (cause as { readonly _tag?: unknown })._tag; + return typeof tag === "string" ? tag : null; +}; + +const isAlreadyStoppedProviderError = (cause: unknown) => { + const tag = providerErrorTag(cause); + if (tag !== null && alreadyStoppedProviderErrorTags.has(tag)) { + return true; + } + if (!(cause instanceof Error)) { + return false; + } + return /(?:no active (?:provider )?(?:session|turn)|unknown provider thread|unknown .* adapter thread|adapter thread is closed)/i.test( + cause.message, + ); +}; + +const providerCleanupAttempt = <A, E>( + effect: Effect.Effect<A, E>, + message: string, +): Effect.Effect<WorkflowEventStoreError | null> => + effect.pipe( + Effect.as(null), + Effect.catch((cause) => + isAlreadyStoppedProviderError(cause) + ? Effect.succeed(null) + : Effect.succeed(new WorkflowEventStoreError({ message, cause })), + ), + ); + +const stepCompletedPayload = ( + stepRunId: StepRunId, + output?: unknown, + usage?: WorkflowStepUsage, +) => ({ + stepRunId, + ...(output === undefined ? {} : { output }), + ...(usage === undefined ? {} : { usage }), +}); + +const stepFailedPayload = ( + stepRunId: StepRunId, + error: string, + usage?: WorkflowStepUsage, + retryable?: boolean, +) => ({ + stepRunId, + error, + ...(retryable === undefined ? {} : { retryable }), + ...(usage === undefined ? {} : { usage }), +}); + +const MAX_TICKET_ANSWER_BODY_LENGTH = MAX_TICKET_MESSAGE_BODY_LENGTH; +const MAX_TICKET_ANSWER_ATTACHMENT_COUNT = 6; +const MAX_TICKET_ANSWER_ATTACHMENT_BYTES = 10 * 1024 * 1024; +const SAFE_TICKET_IMAGE_MIME_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +]); +const SAFE_TICKET_IMAGE_DATA_URL = /^data:image\/(?:png|jpeg|gif|webp);base64,/i; + +type PendingWait = Extract<PersistedWorkflowEvent, { readonly type: "StepAwaitingUser" }>; +type StepStarted = Extract<PersistedWorkflowEvent, { readonly type: "StepStarted" }>; +type PipelineStarted = Extract<PersistedWorkflowEvent, { readonly type: "PipelineStarted" }>; +type TicketCreated = Extract<PersistedWorkflowEvent, { readonly type: "TicketCreated" }>; +type UnstampedWorkflowEventInput = WorkflowEventInput extends infer Event + ? Event extends WorkflowEventInput + ? Omit<Event, "eventId" | "occurredAt"> + : never + : never; + +interface ActivePipeline { + readonly fiber: Fiber.Fiber<void, never>; + readonly laneEntryToken: LaneEntryToken; +} + +interface StepTicketRow { + readonly ticketId: TicketId; +} + +interface StepAwaitingStateRow { + readonly status: string; + readonly providerResponseKind: "request" | "user-input" | null; +} + +interface PipelineRunForTokenRow { + readonly pipelineRunId: PipelineRunId; +} + +interface ActiveProviderTurnRow { + readonly threadId: ThreadId; + readonly turnId: TurnId | null; +} + +interface RouteDecision { + readonly toLane: LaneKey; + readonly source: RouteSource; + readonly matchedTransitionIndex?: number; +} + +interface CaptureTurn { + readonly threadId: ThreadId; + readonly turnId: TurnId; +} + +interface PipelineStartAction { + readonly ticketId: TicketId; + readonly boardId: BoardId; + readonly lane: WorkflowLane; + readonly laneEntryToken: LaneEntryToken; +} + +interface RoutedEnterLaneOptions { + readonly routeDecision: RouteDecision; + readonly contextSnapshot: WorkflowRoutingContext; + readonly expectedToken: LaneEntryToken; + readonly pipelineRunId: PipelineRunId; + readonly fromLane: WorkflowLane; +} + +interface ExternalEnterLaneOptions { + // The lane the matcher was evaluated against — a concurrent move makes the + // decision stale and the external move becomes a no-op. + readonly expectedFromLane: LaneKey; + readonly routeEvent: UnstampedWorkflowEventInput; + // Re-runs matcher resolution under the admission lock: a board save between + // evaluation and commit may have removed the matcher or the target lane. + readonly revalidate: Effect.Effect<boolean, WorkflowEventStoreError>; +} + +const pipelineResultForStep = (result: StepResult): PipelineResult => { + if (result === "completed") { + return "success"; + } + return result === "blocked" ? "blocked" : "failure"; +}; + +const routingKeyForResult = (result: PipelineResult): "success" | "failure" | "blocked" => + result === "failure" ? "failure" : result; + +const stepRouteDecision = (step: WorkflowStep, result: PipelineResult): RouteDecision | null => { + const target = step.on?.[routingKeyForResult(result)]; + return target ? { toLane: target, source: "step_on" } : null; +}; + +interface StepRunOutcome { + readonly result: StepResult; + // User rejections (approval reject / awaiting-user reject) and explicit + // cancellations must never be retried — the user already said no. + readonly noRetry: boolean; +} + +// Defensive clamp so a hand-edited workflow file cannot retry unboundedly; +// the linter enforces 2..5 at save time. +const MAX_RETRY_ATTEMPTS = 5; + +const retryAttemptsForStep = (step: WorkflowStep): number => { + const retryPolicy = step.type === "agent" || step.type === "script" ? step.retry : undefined; + if (retryPolicy === undefined) { + return 1; + } + return Math.min(Math.max(1, retryPolicy.maxAttempts), MAX_RETRY_ATTEMPTS); +}; + +const stepForAttempt = (step: WorkflowStep, attempt: number): WorkflowStep => { + if (attempt === 1 || step.type !== "agent" || step.retry?.escalate === undefined) { + return step; + } + const escalate = step.retry.escalate; + return { + ...step, + agent: { + ...step.agent, + ...(escalate.instance === undefined ? {} : { instance: escalate.instance }), + ...(escalate.model === undefined ? {} : { model: escalate.model }), + ...(escalate.options === undefined ? {} : { options: escalate.options }), + }, + }; +}; + +const make = Effect.gen(function* () { + const approvals = yield* ApprovalGate; + const scriptCancels = yield* ScriptCancelRegistry; + const committer = yield* WorkflowEventCommitter; + const executor = yield* StepExecutor; + const ids = yield* WorkflowIds; + const predicates = yield* PredicateEvaluator; + const read = yield* WorkflowReadModel; + const registry = yield* BoardRegistry; + const routingContextBuilder = yield* WorkflowRoutingContextBuilder; + const sql = yield* SqlClient.SqlClient; + const boardSemaphores = yield* SynchronizedRef.make< + Map<string, { readonly semaphore: Semaphore.Semaphore; readonly permits: number }> + >(new Map()); + const admissionSemaphores = yield* SynchronizedRef.make<Map<string, Semaphore.Semaphore>>( + new Map(), + ); + const runningPipelines = yield* SynchronizedRef.make<Map<string, ActivePipeline>>(new Map()); + // One recovery continuation per step run per process: the dispatch monitors + // and the stranded-pipeline sweep can race to recover the same step. + const recoveredStepClaims = yield* SynchronizedRef.make<Set<string>>(new Set()); + + const getOptionalServices = Effect.context<never>().pipe( + Effect.map((context) => ({ + providerResponses: Context.getOption( + context as Context.Context<ProviderResponsePort>, + ProviderResponsePort, + ), + providerDispatches: Context.getOption( + context as Context.Context<ProviderDispatchOutbox>, + ProviderDispatchOutbox, + ), + providerService: Context.getOption( + context as Context.Context<ProviderService>, + ProviderService, + ), + turnStateReader: Context.getOption( + context as Context.Context<TurnStateReader>, + TurnStateReader, + ), + capturedOutputs: Context.getOption( + context as Context.Context<CapturedStepOutputReader>, + CapturedStepOutputReader, + ), + usageReader: Context.getOption(context as Context.Context<StepUsageReader>, StepUsageReader), + store: Context.getOption(context as Context.Context<WorkflowEventStore>, WorkflowEventStore), + agentSessions: Context.getOption( + context as Context.Context<WorkflowAgentSessionStore>, + WorkflowAgentSessionStore, + ), + })), + ); + + // Best-effort live stop of a set of stored agent-session threads. `stopSession` + // is a NON-rollbackable live side effect (it kills the provider session AND + // does a `directory.upsert` SQL write), so it MUST run OUTSIDE any transaction. + // The public move path calls this in-band (no chunk tx is open). The unlocked + // source-close/create path snapshots the threads in-tx and defers this to the + // committer's post-commit phase (see `stopAgentSessionsForTicket`). Best-effort: + // a missing provider or a stop error is swallowed. + const stopAgentSessionThreads = (threadIds: ReadonlyArray<string>) => + Effect.gen(function* () { + if (threadIds.length === 0) { + return; + } + const { providerService } = yield* getOptionalServices; + if (Option.isNone(providerService)) { + return; + } + const provider = providerService.value; + yield* Effect.forEach( + threadIds, + (threadId) => + providerCleanupAttempt( + provider.stopSession({ threadId: threadId as ThreadId }), + "workflow agent session stop failed", + ), + { discard: true }, + ); + }); + + // A terminal lane is the ticket's resting place: its per-agent sessions can + // never be resumed again, so drop the stored rows (tx-safe SQL) and — on the + // public path — stop their live provider sessions. This MUST NOT fail the lane + // transition — a missing store, a provider error, or a delete failure is + // swallowed. + // + // `stopProviderSessions` gates the live `stopSession` calls. The public move + // runs them IN-BAND (true) because no chunk transaction is open. The unlocked + // source committer path passes `false`: only the tx-safe `deleteByTicket` runs + // here (inside the chunk tx), and the committer collects the threads BEFORE the + // close and stops them in its POST-COMMIT phase, so the non-rollbackable live + // stop never runs inside the chunk transaction (mirrors how `boardDeletion` + // lists-before / deletes-in-tx / stops-after-commit). + const tearDownTicketAgentSessions = (ticketId: TicketId, stopProviderSessions: boolean) => + Effect.gen(function* () { + const { agentSessions } = yield* getOptionalServices; + if (Option.isNone(agentSessions)) { + return; + } + const sessions = agentSessions.value; + if (stopProviderSessions) { + const rows = yield* sessions.listByTicket(ticketId).pipe(Effect.orElseSucceed(() => [])); + yield* stopAgentSessionThreads(rows.map((row) => row.threadId)); + } + yield* sessions.deleteByTicket(ticketId).pipe(Effect.catch(() => Effect.void)); + }); + + const ticketIdForStepRun = (stepRunId: StepRunId) => + wrapSql(sql<StepTicketRow>` + SELECT ticket_id AS "ticketId" + FROM projection_step_run + WHERE step_run_id = ${stepRunId} + UNION ALL + SELECT ticket_id AS "ticketId" + FROM workflow_events + WHERE event_type = 'StepAwaitingUser' + AND json_extract(payload_json, '$.stepRunId') = ${stepRunId} + LIMIT 1 + `).pipe(Effect.map((rows) => rows[0]?.ticketId ?? null)); + + const awaitingStateForStepRun = (stepRunId: StepRunId) => + wrapSql(sql<StepAwaitingStateRow>` + SELECT + status, + provider_response_kind AS "providerResponseKind" + FROM projection_step_run + WHERE step_run_id = ${stepRunId} + LIMIT 1 + `).pipe(Effect.map((rows) => rows[0] ?? null)); + + const readStoredEventsForStep = (stepRunId: StepRunId) => + Effect.gen(function* () { + const { store } = yield* getOptionalServices; + if (Option.isNone(store)) { + return null; + } + + const ticketId = yield* ticketIdForStepRun(stepRunId); + if (ticketId === null) { + return null; + } + + return yield* Stream.runCollect(store.value.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + }); + + const pendingWaitInEvents = ( + events: ReadonlyArray<PersistedWorkflowEvent>, + stepRunId: StepRunId, + ) => { + let pending: PendingWait | null = null; + for (const event of events) { + if (event.type === "StepAwaitingUser" && event.payload.stepRunId === stepRunId) { + pending = event; + continue; + } + if (event.type === "StepUserResolved" && event.payload.stepRunId === stepRunId) { + pending = null; + } + } + return pending; + }; + + const isLiveProviderUserInputWait = (pending: PendingWait, state: TurnState) => { + if ( + state._tag !== "awaiting_user" || + state.providerResponseKind !== "user-input" || + pending.payload.providerResponseKind !== "user-input" || + pending.payload.providerThreadId === undefined || + pending.payload.providerRequestId === undefined + ) { + return false; + } + + return ( + String(state.providerThreadId) === String(pending.payload.providerThreadId) && + String(state.providerRequestId) === String(pending.payload.providerRequestId) && + (state.providerQuestionId ?? null) === (pending.payload.providerQuestionId ?? null) + ); + }; + + const ensureLiveProviderUserInputWait = (pending: PendingWait | null) => + Effect.gen(function* () { + if ( + pending?.payload.providerResponseKind !== "user-input" || + pending.payload.providerThreadId === undefined || + pending.payload.providerRequestId === undefined + ) { + return; + } + + const { providerDispatches, turnStateReader } = yield* getOptionalServices; + if (Option.isNone(turnStateReader)) { + if (Option.isSome(providerDispatches)) { + return yield* new WorkflowEventStoreError({ + message: + "provider user-input request is not live yet; retry after recovery refreshes it", + }); + } + return; + } + + const state = yield* turnStateReader.value.read(pending.payload.providerThreadId); + if (isLiveProviderUserInputWait(pending, state)) { + return; + } + + return yield* new WorkflowEventStoreError({ + message: "provider user-input request is not live yet; retry after recovery refreshes it", + }); + }); + + const hasTerminalStepEvent = ( + events: ReadonlyArray<PersistedWorkflowEvent>, + stepRunId: StepRunId, + ) => + events.some( + (event) => + (event.type === "StepCompleted" || + event.type === "StepFailed" || + event.type === "StepBlocked") && + event.payload.stepRunId === stepRunId, + ); + + const hasPipelineCompletedEvent = ( + events: ReadonlyArray<PersistedWorkflowEvent>, + pipelineRunId: PipelineRunId, + ) => + events.some( + (event) => + event.type === "PipelineCompleted" && event.payload.pipelineRunId === pipelineRunId, + ); + + const pendingWaitFor = (stepRunId: StepRunId) => + Effect.gen(function* () { + const events = yield* readStoredEventsForStep(stepRunId); + if (events === null) { + return null; + } + return pendingWaitInEvents(events, stepRunId); + }); + + const ticketAnswerAttachmentBytes = (attachments: ReadonlyArray<TicketAttachment>) => + attachments.reduce((total, attachment) => { + if (attachment.kind !== "image") { + return total; + } + return total + new TextEncoder().encode(attachment.dataUrl).byteLength; + }, 0); + + const semaphoreFor = (boardId: BoardId, permits: number) => + SynchronizedRef.modifyEffect(boardSemaphores, (current) => { + const key = boardId as string; + const existing = current.get(key); + if (existing && existing.permits === permits) { + return Effect.succeed([existing.semaphore, current] as const); + } + + // Effect semaphores are not resizable, so a changed maxConcurrentTickets + // swaps in a fresh semaphore. In-flight holders drain on the old + // semaphore and are invisible to the new one, so total concurrency can + // transiently exceed the new limit (whether raised or lowered) until + // they finish — bounded by the previously running pipelines and + // self-correcting, which is accepted here. + return Semaphore.make(permits).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(key, { semaphore, permits }); + return [semaphore, next] as const; + }), + ); + }); + + const admissionSemaphoreFor = (boardId: BoardId) => + SynchronizedRef.modifyEffect(admissionSemaphores, (current) => { + const key = boardId as string; + const existing = current.get(key); + if (existing) { + return Effect.succeed([existing, current] as const); + } + + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(key, semaphore); + return [semaphore, next] as const; + }), + ); + }); + + const withAdmissionLock = <A, E, R>( + boardId: BoardId, + body: Effect.Effect<A, E, R>, + ): Effect.Effect<A, E, R> => + Effect.gen(function* () { + const semaphore = yield* admissionSemaphoreFor(boardId); + return yield* semaphore.withPermits(1)(body); + }); + + // Public exposure of the per-board admission semaphore (the WIP read-decide + // serializer). Reuses the SAME `admissionSemaphores` instance via + // `withAdmissionLock` — there is no second semaphore map. The source committer + // MUST wrap its chunk in this (OUTER) -> the board save lock (INNER) -> the + // transaction, matching the public enterLane lock order (admission->save), so + // its sync admits serialize against concurrent user moves and cannot violate a + // WIP limit. The unlocked enterLane cores assume this is already held. + const withBoardAdmissionLock: WorkflowEngineShape["withBoardAdmissionLock"] = (boardId, effect) => + withAdmissionLock(boardId, effect); + + const commit = ( + event: UnstampedWorkflowEventInput, + ): Effect.Effect<void, WorkflowEventStoreError> => + Effect.gen(function* () { + const eventId = yield* ids.eventId(); + yield* committer.commit({ + ...event, + eventId: eventId as WorkflowEventId, + occurredAt: (yield* nowIso) as never, + } as WorkflowEventInput); + }); + + const commitMany = ( + events: ReadonlyArray<UnstampedWorkflowEventInput>, + ): Effect.Effect<void, WorkflowEventStoreError> => + Effect.gen(function* () { + const stamped: Array<WorkflowEventInput> = []; + for (const event of events) { + const eventId = yield* ids.eventId(); + stamped.push({ + ...event, + eventId: eventId as WorkflowEventId, + occurredAt: (yield* nowIso) as never, + } as WorkflowEventInput); + } + yield* committer.commitMany(stamped); + }); + + const userInputPromptMessageEvent = ( + ticketId: TicketId, + stepRunId: StepRunId, + body: string, + ): Effect.Effect<UnstampedWorkflowEventInput, never> => + Effect.gen(function* () { + const messageId = yield* ids.messageId(); + const createdAt = yield* nowIso; + return { + type: "TicketMessagePosted", + ticketId, + payload: { + messageId: messageId as MessageId, + stepRunId, + author: "agent", + body: truncateTicketMessageBody(body), + attachments: [], + createdAt: createdAt as never, + }, + } satisfies UnstampedWorkflowEventInput; + }); + + const awaitingUserEvents = ( + ticketId: TicketId, + event: Extract<UnstampedWorkflowEventInput, { readonly type: "StepAwaitingUser" }>, + ): Effect.Effect<ReadonlyArray<UnstampedWorkflowEventInput>, never> => + Effect.gen(function* () { + if (event.payload.providerResponseKind !== "user-input") { + return [event]; + } + const message = yield* userInputPromptMessageEvent( + ticketId, + event.payload.stepRunId, + event.payload.waitingReason, + ); + return [event, message]; + }); + + const currentToken = (ticketId: TicketId) => + read + .getTicketDetail(ticketId) + .pipe(Effect.map((detail) => detail?.ticket.currentLaneEntryToken ?? null)); + + const evaluateTransition = (rule: unknown, context: WorkflowRoutingContext) => + predicates.evaluate(rule, context).pipe( + Effect.mapError( + (cause) => + new WorkflowEventStoreError({ + message: "workflow route predicate evaluation failed", + cause, + }), + ), + ); + + const laneTransitionDecision = ( + lane: WorkflowLane, + context: WorkflowRoutingContext, + ): Effect.Effect<RouteDecision | null, WorkflowEventStoreError> => + Effect.gen(function* () { + const transitions = lane.transitions ?? []; + for (const [index, transition] of transitions.entries()) { + const evaluation = yield* evaluateTransition(transition.when, context); + if (evaluation.result) { + return { + toLane: transition.to, + source: "lane_transition", + matchedTransitionIndex: index, + } satisfies RouteDecision; + } + } + return null; + }); + + const laneOnDecision = (lane: WorkflowLane, result: PipelineResult): RouteDecision | null => { + const target = lane.on?.[routingKeyForResult(result)]; + return target ? { toLane: target, source: "lane_on" } : null; + }; + + const routeDecisionEvent = ( + ticketId: TicketId, + pipelineRunId: PipelineRunId, + lane: WorkflowLane, + decision: RouteDecision, + contextSnapshot: WorkflowRoutingContext, + ): UnstampedWorkflowEventInput => + ({ + type: "TicketRouteDecided", + ticketId, + payload: { + pipelineRunId, + fromLane: lane.key, + toLane: decision.toLane, + source: decision.source, + ...(decision.matchedTransitionIndex === undefined + ? {} + : { matchedTransitionIndex: decision.matchedTransitionIndex }), + contextSnapshot, + }, + }) as UnstampedWorkflowEventInput; + + const clearRunningPipeline = (ticketId: TicketId, laneEntryToken: LaneEntryToken) => + SynchronizedRef.update(runningPipelines, (current) => { + const key = ticketId as string; + const active = current.get(key); + if (!active || active.laneEntryToken !== laneEntryToken) { + return current; + } + + const next = new Map(current); + next.delete(key); + return next; + }); + + const interruptRunningPipeline = (ticketId: TicketId) => + Effect.gen(function* () { + const active = yield* SynchronizedRef.modify(runningPipelines, (current) => { + const key = ticketId as string; + const existing = current.get(key) ?? null; + if (!existing) { + return [null, current] as const; + } + + const next = new Map(current); + next.delete(key); + return [existing, next] as const; + }); + if (active) { + yield* Fiber.interrupt(active.fiber).pipe(Effect.ignore); + } + }); + + const readStepUsage = ( + threadId: ThreadId | undefined, + ): Effect.Effect<WorkflowStepUsage | undefined> => + Effect.gen(function* () { + if (threadId === undefined) { + return undefined; + } + const { usageReader } = yield* getOptionalServices; + if (Option.isNone(usageReader)) { + return undefined; + } + return yield* usageReader.value.read(threadId); + }); + + const awaitProviderTerminalForStep = ( + stepRunId: StepRunId, + threadId: ThreadId, + step?: WorkflowStep, + ): Effect.Effect<RecoveredStepResult, WorkflowEventStoreError> => + Effect.gen(function* () { + const { providerDispatches } = yield* getOptionalServices; + if (Option.isNone(providerDispatches)) { + return { _tag: "completed" } satisfies RecoveredStepResult; + } + + const result = yield* providerDispatches.value.awaitStepTerminal(stepRunId, threadId); + const usage = yield* readStepUsage(threadId); + if (result.ok) { + const completed = yield* completedResultForStep(stepRunId, step); + return usage === undefined || completed._tag === "blocked" + ? completed + : { ...completed, usage }; + } + if ("awaitingUser" in result) { + return { + _tag: "failed", + error: "provider requested additional user input", + ...(usage === undefined ? {} : { usage }), + } satisfies RecoveredStepResult; + } + return { + _tag: "failed", + error: result.error ?? "turn failed", + ...(usage === undefined ? {} : { usage }), + } satisfies RecoveredStepResult; + }); + + const completedResultForStep = ( + stepRunId: StepRunId, + step: WorkflowStep | undefined, + output?: unknown, + captureTurn?: CaptureTurn, + ): Effect.Effect<RecoveredStepResult, WorkflowEventStoreError> => + Effect.gen(function* () { + if (output !== undefined) { + return { _tag: "completed", output } satisfies RecoveredStepResult; + } + if (step?.type !== "agent" || step.captureOutput !== true) { + return { _tag: "completed" } satisfies RecoveredStepResult; + } + + const { capturedOutputs } = yield* getOptionalServices; + if (Option.isNone(capturedOutputs)) { + return { + _tag: "failed", + error: "missing or invalid structured output", + } satisfies RecoveredStepResult; + } + let turn = captureTurn; + if (turn === undefined) { + const { providerDispatches } = yield* getOptionalServices; + if (Option.isSome(providerDispatches)) { + turn = (yield* providerDispatches.value.getDispatchForStep(stepRunId)) ?? undefined; + } + } + if (turn === undefined) { + return { + _tag: "failed", + error: "missing or invalid structured output", + } satisfies RecoveredStepResult; + } + + return yield* capturedOutputs.value.read({ stepRunId, ...turn }).pipe( + Effect.map((captured) => { + if (captured === undefined) { + return { + _tag: "failed", + error: "missing or invalid structured output", + } satisfies RecoveredStepResult; + } + return { _tag: "completed", output: captured } satisfies RecoveredStepResult; + }), + Effect.orElseSucceed( + () => + ({ + _tag: "failed", + error: "structured output lookup failed", + }) satisfies RecoveredStepResult, + ), + ); + }); + + const runStep = ( + ticketId: TicketId, + boardId: BoardId, + pipelineRunId: PipelineRunId, + step: WorkflowStep, + laneEntryToken: LaneEntryToken, + laneKey: LaneKey, + laneStepKeys: ReadonlyArray<StepKey>, + attempt: number, + ): Effect.Effect<StepRunOutcome, WorkflowEventStoreError> => + Effect.gen(function* () { + const stepRunId = yield* ids.stepRunId(); + yield* commit({ + type: "StepStarted", + ticketId, + payload: { pipelineRunId, stepRunId, stepKey: step.key, stepType: step.type, attempt }, + }); + + if (step.type === "approval") { + yield* commit({ + type: "StepAwaitingUser", + ticketId, + payload: { stepRunId, waitingReason: step.prompt ?? "Approval required" }, + }); + const approved = yield* approvals.await(stepRunId); + yield* commit({ type: "StepUserResolved", ticketId, payload: { stepRunId } }); + if (!approved) { + yield* commit({ + type: "StepFailed", + ticketId, + payload: stepFailedPayload(stepRunId, "rejected", undefined, false), + }); + return { result: "failed", noRetry: true }; + } + yield* commit({ + type: "StepCompleted", + ticketId, + payload: stepCompletedPayload(stepRunId), + }); + return { result: "completed", noRetry: false }; + } + + const outcome = yield* ( + executor.execute({ + ticketId, + boardId, + pipelineRunId, + stepRunId, + laneEntryToken, + laneKey, + laneStepKeys, + step, + }) as Effect.Effect<StepOutcome, WorkflowEventStoreError> + ).pipe( + Effect.catch((error) => + Effect.succeed<StepOutcome>({ _tag: "failed", error: formatError(error) }), + ), + ); + if (outcome._tag === "awaiting_user") { + const awaitingEvent = { + type: "StepAwaitingUser", + ticketId, + payload: { + stepRunId, + waitingReason: outcome.waitingReason, + ...(outcome.providerThreadId === undefined + ? {} + : { providerThreadId: outcome.providerThreadId }), + ...(outcome.providerRequestId === undefined + ? {} + : { providerRequestId: outcome.providerRequestId }), + ...(outcome.providerResponseKind === undefined + ? {} + : { providerResponseKind: outcome.providerResponseKind }), + ...(outcome.providerQuestionId === undefined + ? {} + : { providerQuestionId: outcome.providerQuestionId }), + }, + } satisfies UnstampedWorkflowEventInput; + yield* commitMany(yield* awaitingUserEvents(ticketId, awaitingEvent)); + const approved = yield* approvals.await(stepRunId); + yield* commit({ type: "StepUserResolved", ticketId, payload: { stepRunId } }); + if (!approved) { + yield* commit({ + type: "StepFailed", + ticketId, + payload: stepFailedPayload(stepRunId, "rejected", undefined, false), + }); + return { result: "failed", noRetry: true }; + } + if (outcome.providerThreadId !== undefined) { + const terminalResult = yield* awaitProviderTerminalForStep( + stepRunId, + outcome.providerThreadId, + step, + ); + if (terminalResult._tag === "failed") { + yield* commit({ + type: "StepFailed", + ticketId, + payload: stepFailedPayload(stepRunId, terminalResult.error, terminalResult.usage), + }); + return { result: "failed", noRetry: false }; + } + if (terminalResult._tag === "blocked") { + yield* commit({ + type: "StepBlocked", + ticketId, + payload: { stepRunId, reason: terminalResult.reason }, + }); + return { result: "blocked", noRetry: false }; + } + yield* commit({ + type: "StepCompleted", + ticketId, + payload: stepCompletedPayload(stepRunId, terminalResult.output, terminalResult.usage), + }); + return { result: "completed", noRetry: false }; + } + yield* commit({ + type: "StepCompleted", + ticketId, + payload: stepCompletedPayload(stepRunId), + }); + return { result: "completed", noRetry: false }; + } + if (outcome._tag === "failed") { + yield* commit({ + type: "StepFailed", + ticketId, + payload: stepFailedPayload( + stepRunId, + outcome.error, + outcome.usage, + outcome.retryable === false ? false : undefined, + ), + }); + return { result: "failed", noRetry: outcome.retryable === false }; + } + if (outcome._tag === "blocked") { + yield* commit({ + type: "StepBlocked", + ticketId, + payload: { stepRunId, reason: outcome.reason }, + }); + return { result: "blocked", noRetry: false }; + } + + yield* commit({ + type: "StepCompleted", + ticketId, + payload: stepCompletedPayload(stepRunId, outcome.output, outcome.usage), + }); + return { result: "completed", noRetry: false }; + }); + + const runPipeline = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + ): Effect.Effect<void> => + Effect.gen(function* () { + const definition = yield* registry.getDefinition(boardId); + const permits = Math.max(1, definition?.settings?.maxConcurrentTickets ?? 3); + const semaphore = yield* semaphoreFor(boardId, permits); + yield* semaphore.withPermits(1)(runPipelineBody(ticketId, boardId, lane, laneEntryToken)); + }).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const reason = `pipeline error: ${Cause.pretty(cause)}`; + return Effect.logWarning("workflow pipeline orchestration failed", { + boardId, + laneEntryToken, + laneKey: lane.key, + reason, + ticketId, + }).pipe( + Effect.flatMap(() => + commit({ + type: "TicketBlocked", + ticketId, + payload: { reason }, + }), + ), + Effect.catch(() => Effect.void), + ); + }), + ); + + const completePipelineFrom = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + pipelineRunId: PipelineRunId, + steps: ReadonlyArray<WorkflowStep>, + startIndex: number, + initialResult: PipelineResult, + initialRouteDecision?: RouteDecision, + ): Effect.Effect<void, WorkflowEventStoreError> => + Effect.gen(function* () { + let result: PipelineResult = initialResult; + let routeDecision: RouteDecision | null = initialRouteDecision ?? null; + const laneStepKeys = steps.map((s) => s.key); + + if (routeDecision === null) { + for (const step of steps.slice(startIndex)) { + if (result !== "success") { + break; + } + const maxAttempts = retryAttemptsForStep(step); + let attempt = 1; + let stepOutcome = yield* runStep( + ticketId, + boardId, + pipelineRunId, + step, + laneEntryToken, + lane.key, + laneStepKeys, + attempt, + ); + while (stepOutcome.result === "failed" && !stepOutcome.noRetry && attempt < maxAttempts) { + attempt += 1; + stepOutcome = yield* runStep( + ticketId, + boardId, + pipelineRunId, + stepForAttempt(step, attempt), + laneEntryToken, + lane.key, + laneStepKeys, + attempt, + ); + } + result = pipelineResultForStep(stepOutcome.result); + routeDecision = stepRouteDecision(step, result); + if (routeDecision !== null || result !== "success") { + break; + } + } + } + + const contextSnapshot = yield* routingContextBuilder.build({ + ticketId, + pipelineRunId, + result, + }); + if (routeDecision === null) { + routeDecision = + (yield* laneTransitionDecision(lane, contextSnapshot)) ?? laneOnDecision(lane, result); + } + + yield* commit({ + type: "PipelineCompleted", + ticketId, + payload: { pipelineRunId, result }, + }); + + if (routeDecision !== null) { + yield* enterLane(ticketId, boardId, routeDecision.toLane, "routed", { + routeDecision, + contextSnapshot, + expectedToken: laneEntryToken, + pipelineRunId, + fromLane: lane, + }); + return; + } + + if (result !== "success") { + yield* Effect.uninterruptible( + Effect.gen(function* () { + const token = yield* currentToken(ticketId); + if (token !== laneEntryToken) { + return; + } + yield* commit({ + type: "TicketBlocked", + ticketId, + payload: { reason: `pipeline ${result} with no route` }, + }); + }), + ); + } + }); + + const runPipelineBody = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + ): Effect.Effect<void, WorkflowEventStoreError> => + Effect.gen(function* () { + const steps = lane.pipeline ?? []; + if (steps.length === 0) { + return; + } + + const pipelineRunId = yield* ids.pipelineRunId(); + yield* commit({ + type: "PipelineStarted", + ticketId, + payload: { pipelineRunId, laneKey: lane.key, laneEntryToken }, + }); + + yield* completePipelineFrom( + ticketId, + boardId, + lane, + laneEntryToken, + pipelineRunId, + steps, + 0, + "success", + ); + }); + + // The ticket's current lane/token as stored in the projection. A pipeline + // start may have been SNAPSHOTTED (e.g. by recoverBoardWip) before a + // concurrent user/source move changed the ticket's lane entry token; this is + // the authority for "is this start still current?". + const ticketLaneTokenRow = (ticketId: TicketId) => + wrapSql(sql<{ readonly currentLaneKey: string; readonly currentLaneEntryToken: string | null }>` + SELECT + current_lane_key AS "currentLaneKey", + current_lane_entry_token AS "currentLaneEntryToken" + FROM projection_ticket + WHERE ticket_id = ${ticketId} + LIMIT 1 + `).pipe(Effect.map((rows) => rows[0] ?? null)); + + const startPipeline = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + ) => + Effect.gen(function* () { + const fiber = yield* SynchronizedRef.modifyEffect(runningPipelines, (current) => + Effect.gen(function* () { + const key = ticketId as string; + const active = current.get(key); + if (active?.laneEntryToken === laneEntryToken) { + return [null, current] as const; + } + + // Stale-start guard: re-read the ticket and require its current lane + // entry token AND lane key still match the start this call is for. A + // snapshot-then-start path (recoverBoardWip) can race a user/source + // move that re-tokened or re-laned the ticket between the snapshot and + // here; starting then would run a pipeline for a lane the ticket has + // already left (and the manual move could not interrupt it because it + // was not yet in runningPipelines). This read runs INSIDE the + // runningPipelines modify (and the caller holds the admission lock), + // so a stale start is prevented atomically with the map insert. + const row = yield* ticketLaneTokenRow(ticketId); + if ( + row === null || + row.currentLaneEntryToken !== (laneEntryToken as string) || + row.currentLaneKey !== (lane.key as string) + ) { + return [null, current] as const; + } + + return yield* runPipeline(ticketId, boardId, lane, laneEntryToken).pipe( + Effect.ensuring(clearRunningPipeline(ticketId, laneEntryToken)), + Effect.forkDetach({ startImmediately: false, uninterruptible: false }), + Effect.map((fiber) => { + const next = new Map(current); + next.set(key, { fiber, laneEntryToken }); + return [fiber, next] as const; + }), + ); + }), + ); + if (fiber !== null) { + yield* Effect.yieldNow; + } + }); + + const runPipelineStarts = (starts: ReadonlyArray<PipelineStartAction>) => + Effect.forEach( + starts, + (start) => startPipeline(start.ticketId, start.boardId, start.lane, start.laneEntryToken), + { discard: true }, + ); + + const collectStartAction = ( + starts: Array<PipelineStartAction>, + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane | null, + laneEntryToken: LaneEntryToken, + ) => { + if (lane?.entry === "auto") { + starts.push({ ticketId, boardId, lane, laneEntryToken }); + } + }; + + // How a lane-entry body persists its events. The locked emitter (default) + // re-acquires the board save lock + opens a transaction per emission through + // commit/commitMany, and publishes live ticket views — used by every existing + // caller. The unlocked emitter (committer-driven Task 9 path) appends+projects + // through the committer's appendManyUnlocked, which ASSUMES the caller already + // holds the board save lock + an open transaction and does NOT publish; the + // committer publishes after releasing the lock. + type EmitEvents = ( + events: ReadonlyArray<UnstampedWorkflowEventInput>, + ) => Effect.Effect<void, WorkflowEventStoreError>; + + const lockedEmit: EmitEvents = (events) => + events.length === 0 + ? Effect.void + : events.length === 1 + ? commit(events[0] as UnstampedWorkflowEventInput) + : commitMany(events); + + const stampEvent = (event: UnstampedWorkflowEventInput) => + Effect.gen(function* () { + const eventId = yield* ids.eventId(); + return { + ...event, + eventId: eventId as WorkflowEventId, + occurredAt: (yield* nowIso) as never, + } as WorkflowEventInput; + }); + + // Append+project through the caller's already-held board save lock + open + // transaction. Asserts (via the committer's contract) that the caller opened + // the lock + tx — it never acquires either itself. + const unlockedEmit: EmitEvents = (events) => + Effect.gen(function* () { + if (events.length === 0) { + return; + } + const stamped: Array<WorkflowEventInput> = []; + for (const event of events) { + stamped.push(yield* stampEvent(event)); + } + yield* committer.appendManyUnlocked(stamped); + }); + + // Sweeps queued tickets into a lane up to its WIP limit. Runs under either + // emitter — the locked public path (default `lockedEmit`) or the unlocked + // source-committer path (caller passes `unlockedEmit`) — so it carries no + // "Locked" suffix; the caller owns the serialization (admission lock). + const admitNext = ( + boardId: BoardId, + laneKey: LaneKey, + emit: EmitEvents = lockedEmit, + ): Effect.Effect<ReadonlyArray<PipelineStartAction>, WorkflowEventStoreError> => + Effect.gen(function* () { + const lane = yield* registry.getLane(boardId, laneKey); + const limit = lane?.wipLimit; + if (lane === null || limit === undefined) { + return []; + } + + const starts: Array<PipelineStartAction> = []; + while ((yield* read.countAdmittedInLane(boardId, laneKey)) < limit) { + const queued = yield* read.oldestQueuedForLane(boardId, laneKey); + if (queued === null) { + break; + } + + const laneEntryToken = yield* ids.token(); + const queuedTicketId = queued.ticketId as TicketId; + yield* emit([ + { + type: "TicketAdmitted", + ticketId: queuedTicketId, + payload: { lane: laneKey, laneEntryToken }, + }, + ]); + collectStartAction(starts, queuedTicketId, boardId, lane, laneEntryToken); + } + + return starts; + }); + + interface EnterLaneCoreOptions { + readonly routedOptions?: RoutedEnterLaneOptions | undefined; + readonly externalOptions?: ExternalEnterLaneOptions | undefined; + // Persists the lane-entry events. Defaults to the locked emitter; the + // committer-driven unlocked path passes unlockedEmit. + readonly emit?: EmitEvents | undefined; + // Serializes the WIP read-decide body. The board SAVE lock does NOT + // serialize the WIP decision: it is taken only transiently at commit time + // (after the admit/queue decision), so concurrent paths can both read + // occupancy and both admit. The ADMISSION lock is what serializes the + // read-decide. The public path therefore wraps the body in the board + // admission lock. The unlocked path (the source committer) passes + // `Effect.uninterruptible` here ONLY because it MUST already hold the + // admission lock for the whole chunk via `withBoardAdmissionLock` (OUTER) -> + // save lock (INNER) -> transaction. Taking the admission lock again here, + // under the save lock, would invert that admission->save order and deadlock. + readonly serialize?: + | (<A, E, R>(body: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>) + | undefined; + // Runs the manual/external supersession (interrupt pipeline + cancel turns + + // tombstone dispatches). Injected so the unlocked path reuses the identical + // side effect. + readonly supersedeRunningWork: Effect.Effect<void, WorkflowEventStoreError>; + // When a terminal lane is entered, whether to call `provider.stopSession` for + // the ticket's stored agent threads IN-BAND. Defaults to `true` for the public + // move (no chunk tx is open). The unlocked source-committer callers pass + // `false`: `stopSession` is a non-rollbackable live side effect that must not + // run inside the chunk transaction, so only the tx-safe `deleteByTicket` runs + // here and the committer defers the live stop to its post-commit phase. + readonly stopProviderSessionsOnTeardown?: boolean | undefined; + } + + // The in-lock / in-tx body of a lane entry: revalidation, WIP/admission/queue + // decision, emit, and prior-lane sweep. Returns the pipeline starts to run + // AFTER the lock (and, for the unlocked path, after the caller's transaction) + // plus the outcome. Assumes the ticket already exists. Used by the public + // enterLane (locked emit + admission lock) and by the committer-facing unlocked + // ops (unlocked emit; they take no admission lock HERE only because the source + // committer must already hold it via `withBoardAdmissionLock` — the save lock + // alone does NOT serialize the WIP read-decide). + const enterLaneCore = ( + ticketId: TicketId, + boardId: BoardId, + toLane: LaneKey, + reason: MoveReason, + options: EnterLaneCoreOptions, + ): Effect.Effect< + { + readonly starts: ReadonlyArray<PipelineStartAction>; + readonly acted: "moved" | "queued" | "none"; + }, + WorkflowEventStoreError + > => { + const { routedOptions, externalOptions, supersedeRunningWork } = options; + const emit = options.emit ?? lockedEmit; + const serialize = + options.serialize ?? + (<A, E, R>(body: Effect.Effect<A, E, R>) => + withAdmissionLock(boardId, Effect.uninterruptible(body))); + return serialize( + Effect.gen(function* () { + const none = { + starts: [] as Array<PipelineStartAction>, + acted: "none" as "moved" | "queued" | "none", + }; + const detail = yield* read.getTicketDetail(ticketId); + const priorLane = detail?.ticket.currentLaneKey as LaneKey | undefined; + const priorWasAdmitted = detail !== null && detail.ticket.currentLaneEntryToken !== null; + if (reason === "routed") { + if ( + routedOptions === undefined || + detail?.ticket.currentLaneEntryToken !== routedOptions.expectedToken + ) { + return none; + } + } + if (reason === "external") { + if ( + externalOptions === undefined || + detail?.ticket.currentLaneKey !== (externalOptions.expectedFromLane as string) + ) { + return none; + } + // A board save may have removed the matcher or its target lane + // between evaluation and this commit — re-resolve before acting. + if (!(yield* externalOptions.revalidate)) { + return none; + } + // Only a confirmed-fresh event may kill the ticket's running + // work; stale events must no-op without side effects. + yield* supersedeRunningWork; + } + const routeEvent = + reason === "routed" && routedOptions !== undefined + ? routeDecisionEvent( + ticketId, + routedOptions.pipelineRunId, + routedOptions.fromLane, + routedOptions.routeDecision, + routedOptions.contextSnapshot, + ) + : reason === "external" && externalOptions !== undefined + ? externalOptions.routeEvent + : null; + const targetLane = yield* registry.getLane(boardId, toLane); + // Defense-in-depth: a routed move may resolve to a lane key that no + // longer exists in the current board def (e.g. the lane was removed via + // the normal editor between route evaluation and this commit). Committing + // a TicketMovedToLane into a non-existent lane would strand the ticket in + // a phantom lane. Instead of moving, surface the ticket for human + // attention via TicketBlocked: its pipeline is already done, so a silent + // no-op would leave it admitted in its old lane with no signal. Block it + // so attention_kind='blocked' fires through the existing path. We never + // commit a move/queue into the phantom lane. + if (reason === "routed" && targetLane === null) { + yield* Effect.logWarning( + "workflow routed move targets a lane missing from the current board def — blocking ticket", + { boardId, ticketId, toLane }, + ); + yield* emit([ + { + type: "TicketBlocked", + ticketId, + payload: { + reason: `routed to lane '${toLane}' which no longer exists in the board definition`, + }, + } as UnstampedWorkflowEventInput, + ]); + return none; + } + const limit = targetLane?.wipLimit; + const admittedCount = + limit === undefined ? 0 : yield* read.countAdmittedInLane(boardId, toLane); + const selfInTarget = priorWasAdmitted && priorLane === toLane ? 1 : 0; + const starts: Array<PipelineStartAction> = []; + + // A ticket waiting on dependencies never starts an auto lane's + // pipeline — queue it; resolution of the last dependency + // releases it through the admission sweep. + const unresolvedDeps = detail?.ticket.unresolvedDependencyCount ?? 0; + const dependencyGated = targetLane?.entry === "auto" && unresolvedDeps > 0; + + let acted: "moved" | "queued" = "moved"; + if ((limit !== undefined && admittedCount - selfInTarget >= limit) || dependencyGated) { + acted = "queued"; + const queueEvent = { + type: "TicketQueued", + ticketId, + payload: { lane: toLane }, + } as UnstampedWorkflowEventInput; + yield* emit(routeEvent === null ? [queueEvent] : [routeEvent, queueEvent]); + } else { + const laneEntryToken = yield* ids.token(); + const moveEvent = { + type: "TicketMovedToLane", + ticketId, + payload: { toLane, laneEntryToken, reason }, + } as UnstampedWorkflowEventInput; + yield* emit(routeEvent === null ? [moveEvent] : [routeEvent, moveEvent]); + collectStartAction(starts, ticketId, boardId, targetLane, laneEntryToken); + } + + if (priorWasAdmitted && priorLane !== undefined && priorLane !== toLane) { + starts.push(...(yield* admitNext(boardId, priorLane, emit))); + } + + // Landing in a terminal lane is the end of the ticket's agent work: + // tear down its stored per-agent sessions so resumable threads are not + // left dangling. Shared across all enterLaneCore callers (enterLane, + // closeTicketFromSourceUnlocked, createTicketAndEnterUnlocked). Queued + // tickets have not actually entered the lane yet, so only on a move. + // `deleteByTicket` (SQL) always runs in-band here (tx-safe). The live + // `provider.stopSession` runs in-band ONLY on the public path; the + // unlocked source-committer callers pass `false` and defer it to their + // post-commit phase so it never runs inside the chunk transaction. + if (acted === "moved" && targetLane?.terminal === true) { + yield* tearDownTicketAgentSessions( + ticketId, + options.stopProviderSessionsOnTeardown ?? true, + ); + } + + return { starts, acted }; + }), + ); + }; + + const enterLane = ( + ticketId: TicketId, + boardId: BoardId, + toLane: LaneKey, + reason: MoveReason, + routedOptions?: RoutedEnterLaneOptions, + externalOptions?: ExternalEnterLaneOptions, + ): Effect.Effect<"moved" | "queued" | "none", WorkflowEventStoreError> => + Effect.gen(function* () { + // A manual move supersedes whatever the ticket was doing: stop live + // provider turns so a stale agent cannot keep mutating the worktree + // underneath the next lane's steps (e.g. a merge), and tombstone the + // outbox rows so restart recovery never re-dispatches the stale work. + // External events do the same, but only inside the admission lock once + // the stale-lane guard has confirmed the event still applies. + const supersedeRunningWork = Effect.gen(function* () { + yield* interruptRunningPipeline(ticketId); + yield* cancelActiveProviderTurnsForTicket(ticketId).pipe(Effect.catch(() => Effect.void)); + yield* abandonTicketDispatches(ticketId).pipe(Effect.catch(() => Effect.void)); + }); + if (reason === "manual") { + yield* supersedeRunningWork; + } + + const lockResult = yield* enterLaneCore(ticketId, boardId, toLane, reason, { + routedOptions, + externalOptions, + supersedeRunningWork, + }); + + yield* runPipelineStarts(lockResult.starts); + + const movedLane = yield* registry.getLane(boardId, toLane); + if (movedLane?.terminal === true) { + // Resolution releases queued dependents; failure here must never undo + // the move itself. + yield* releaseDependents(ticketId).pipe(Effect.catch(() => Effect.void)); + } + + return lockResult.acted; + }); + + const moveToLane = ( + ticketId: TicketId, + boardId: BoardId, + toLane: LaneKey, + reason: MoveReason, + ): Effect.Effect<void, WorkflowEventStoreError> => + enterLane(ticketId, boardId, toLane, reason).pipe(Effect.asVoid); + + // Budgets are advisory caps — clamp junk client input instead of failing. + const normalizeTokenBudget = (value: number | null | undefined): number | null | undefined => { + if (value === undefined || value === null) { + return value; + } + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return Math.floor(value); + }; + + const validateDependsOn = ( + boardId: BoardId, + ticketId: TicketId | null, + dependsOn: ReadonlyArray<TicketId>, + ): Effect.Effect<ReadonlyArray<TicketId>, WorkflowEventStoreError> => + Effect.gen(function* () { + const unique = [...new Set(dependsOn)]; + if (ticketId !== null && unique.some((dep) => dep === ticketId)) { + return yield* new WorkflowEventStoreError({ + message: "a ticket cannot depend on itself", + }); + } + for (const dep of unique) { + const depDetail = yield* read.getTicketDetail(dep); + if (depDetail === null) { + return yield* new WorkflowEventStoreError({ + message: `dependency ticket ${dep} was not found`, + }); + } + if (depDetail.ticket.boardId !== (boardId as string)) { + return yield* new WorkflowEventStoreError({ + message: "dependencies must be tickets on the same board", + }); + } + } + if (ticketId !== null) { + // Walk the existing edges from each new dependency; reaching the + // ticket itself would close a cycle and deadlock both tickets. The + // budget exists only to bound pathological graphs — exhausting it + // with work remaining fails closed rather than letting a deep cycle + // slip through. + const seen = new Set<string>(); + const stack: string[] = [...unique]; + while (stack.length > 0) { + if (seen.size > 500) { + return yield* new WorkflowEventStoreError({ + message: "dependency graph is too deep to validate", + }); + } + const current = stack.pop(); + if (current === undefined) { + break; + } + if (current === (ticketId as string)) { + return yield* new WorkflowEventStoreError({ + message: "circular ticket dependencies are not allowed", + }); + } + if (seen.has(current)) { + continue; + } + seen.add(current); + const currentDetail = yield* read.getTicketDetail(current as TicketId); + stack.push(...(currentDetail?.ticket.dependsOn ?? [])); + } + } + return unique; + }); + + const releaseDependents = ( + resolvedTicketId: TicketId, + ): Effect.Effect<void, WorkflowEventStoreError> => + Effect.gen(function* () { + const dependents = yield* read.listReleasableDependents(resolvedTicketId); + for (const dependent of dependents) { + yield* releaseTicketIfEligible(dependent.ticketId as TicketId); + } + }); + + // Admit a queued ticket whose dependencies are all resolved. Used when a + // dependency edit removes the last blocker and by restart recovery — + // unlimited lanes are never swept by admitNext, so they need a + // direct admit. + const releaseTicketIfEligible = ( + ticketId: TicketId, + ): Effect.Effect<void, WorkflowEventStoreError> => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ticketId); + if ( + detail === null || + detail.ticket.queuedAt === null || + (detail.ticket.unresolvedDependencyCount ?? 0) > 0 + ) { + return; + } + const boardId = detail.ticket.boardId as BoardId; + const laneKey = detail.ticket.currentLaneKey as LaneKey; + const lane = yield* registry.getLane(boardId, laneKey); + if (lane === null) { + return; + } + const starts = yield* withAdmissionLock( + boardId, + Effect.uninterruptible( + Effect.gen(function* () { + if (lane.wipLimit !== undefined) { + return yield* admitNext(boardId, laneKey); + } + const lockedDetail = yield* read.getTicketDetail(ticketId); + if ( + lockedDetail === null || + lockedDetail.ticket.queuedAt === null || + (lockedDetail.ticket.unresolvedDependencyCount ?? 0) > 0 + ) { + return []; + } + const laneEntryToken = yield* ids.token(); + yield* commit({ + type: "TicketAdmitted", + ticketId, + payload: { lane: laneKey, laneEntryToken }, + }); + const released: Array<PipelineStartAction> = []; + collectStartAction(released, ticketId, boardId, lane, laneEntryToken); + return released; + }), + ), + ); + yield* runPipelineStarts(starts); + }); + + const createTicket: WorkflowEngineShape["createTicket"] = (input) => + Effect.gen(function* () { + const dependsOn = + input.dependsOn === undefined || input.dependsOn.length === 0 + ? [] + : yield* validateDependsOn(input.boardId, null, input.dependsOn); + const ticketId = yield* ids.ticketId(); + const tokenBudget = normalizeTokenBudget(input.tokenBudget); + yield* commit({ + type: "TicketCreated", + ticketId, + payload: { + boardId: input.boardId, + title: input.title, + laneKey: input.initialLane, + description: input.description, + ...(tokenBudget === undefined || tokenBudget === null ? {} : { tokenBudget }), + }, + } as UnstampedWorkflowEventInput); + if (dependsOn.length > 0) { + yield* commit({ + type: "TicketDependenciesSet", + ticketId, + payload: { dependsOn }, + }); + } + yield* moveToLane(ticketId, input.boardId, input.initialLane, "initial"); + return ticketId; + }); + + const editTicket: WorkflowEngineShape["editTicket"] = (input) => + Effect.gen(function* () { + const title = input.title === undefined ? undefined : input.title.trim(); + if (title !== undefined && title.length === 0) { + return yield* new WorkflowEventStoreError({ message: "ticket title cannot be empty" }); + } + if (input.dependsOn !== undefined) { + const detail = yield* read.getTicketDetail(input.ticketId); + if (detail === null) { + return yield* new WorkflowEventStoreError({ message: "ticket not found" }); + } + const boardId = detail.ticket.boardId as BoardId; + // Validate and commit under the board's admission lock so two + // concurrent edits cannot both validate against the old graph and + // commit edges that only together form a cycle. + yield* withAdmissionLock( + boardId, + Effect.gen(function* () { + const dependsOn = yield* validateDependsOn( + boardId, + input.ticketId, + input.dependsOn ?? [], + ); + yield* commit({ + type: "TicketDependenciesSet", + ticketId: input.ticketId, + payload: { dependsOn }, + }); + }), + ); + // Removing the last blocker must release the ticket right away — + // there is no terminal move to trigger it otherwise. + yield* releaseTicketIfEligible(input.ticketId).pipe(Effect.catch(() => Effect.void)); + } + const tokenBudget = normalizeTokenBudget(input.tokenBudget); + if (title === undefined && input.description === undefined && tokenBudget === undefined) { + return; + } + yield* commit({ + type: "TicketEdited", + ticketId: input.ticketId, + payload: { + ...(title === undefined ? {} : { title: title as never }), + ...(input.description === undefined ? {} : { description: input.description }), + ...(tokenBudget === undefined ? {} : { tokenBudget }), + }, + }); + }); + + const validateTicketMessageInput = ( + input: { + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray<TicketAttachment> | undefined; + }, + subject: "message" | "answer", + ): Effect.Effect< + { readonly text: string; readonly attachments: ReadonlyArray<TicketAttachment> }, + WorkflowEventStoreError + > => + Effect.gen(function* () { + const text = input.text?.trim() ?? ""; + const attachments: ReadonlyArray<TicketAttachment> = input.attachments ?? []; + if (text.length === 0 && attachments.length === 0) { + return yield* new WorkflowEventStoreError({ + message: `ticket ${subject} requires text or an attachment`, + }); + } + if (text.length > MAX_TICKET_ANSWER_BODY_LENGTH) { + return yield* new WorkflowEventStoreError({ + message: `ticket ${subject} body exceeds ${MAX_TICKET_ANSWER_BODY_LENGTH} characters`, + }); + } + if (attachments.length > MAX_TICKET_ANSWER_ATTACHMENT_COUNT) { + return yield* new WorkflowEventStoreError({ + message: `ticket ${subject} supports at most ${MAX_TICKET_ANSWER_ATTACHMENT_COUNT} attachments`, + }); + } + if (attachments.some((attachment) => attachment.kind !== "image")) { + return yield* new WorkflowEventStoreError({ + message: `ticket ${subject} attachments must be images`, + }); + } + if ( + attachments.some( + (attachment) => + attachment.kind === "image" && + (!SAFE_TICKET_IMAGE_MIME_TYPES.has(attachment.mimeType) || + !SAFE_TICKET_IMAGE_DATA_URL.test(attachment.dataUrl)), + ) + ) { + return yield* new WorkflowEventStoreError({ + message: `ticket ${subject} image attachments must use png, jpeg, gif, or webp data URLs`, + }); + } + if (ticketAnswerAttachmentBytes(attachments) > MAX_TICKET_ANSWER_ATTACHMENT_BYTES) { + return yield* new WorkflowEventStoreError({ + message: `ticket ${subject} attachments exceed the 10 MiB encoded limit`, + }); + } + return { text, attachments }; + }); + + const postTicketMessage: WorkflowEngineShape["postTicketMessage"] = (input) => + Effect.gen(function* () { + const { text, attachments } = yield* validateTicketMessageInput(input, "message"); + const detail = yield* read.getTicketDetail(input.ticketId); + if (!detail) { + return yield* new WorkflowEventStoreError({ message: "ticket not found" }); + } + const messageId = yield* ids.messageId(); + yield* commit({ + type: "TicketMessagePosted", + ticketId: input.ticketId, + payload: { + messageId, + author: "user", + body: text, + attachments, + createdAt: (yield* nowIso) as never, + }, + }); + }); + + const editTicketMessage: WorkflowEngineShape["editTicketMessage"] = (input) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(input.ticketId); + if (!detail) { + return yield* new WorkflowEventStoreError({ message: "ticket not found" }); + } + const { text } = yield* validateTicketMessageInput({ text: input.body }, "message"); + const target = detail.messages.find((m) => m.messageId === input.messageId); + if (!target) { + return yield* new WorkflowEventStoreError({ message: "message not found" }); + } + // Only a user's own free-standing comment is editable: agent messages and + // user answers bound to a step run (stepRunId set) carry provider-side + // state we must not retroactively rewrite. + if (target.author !== "user" || target.stepRunId != null) { + return yield* new WorkflowEventStoreError({ + message: "only your own comments can be edited", + }); + } + yield* commit({ + type: "TicketMessageEdited", + ticketId: input.ticketId, + payload: { + messageId: input.messageId, + body: text, + editedAt: (yield* nowIso) as never, + }, + }); + }); + + const answerTicketStep: WorkflowEngineShape["answerTicketStep"] = (input) => + Effect.gen(function* () { + const { text, attachments } = yield* validateTicketMessageInput(input, "answer"); + // Provider responses are text-only, so an attachment-only answer could + // never resume the awaiting step — reject before committing anything. + if (text.length === 0) { + return yield* new WorkflowEventStoreError({ + message: "answering an awaiting step requires text — add a note alongside attachments", + }); + } + + const ticketId = yield* ticketIdForStepRun(input.stepRunId); + if (ticketId === null) { + // Fail (don't silently succeed): a stale/unknown stepRunId means the + // answer would be dropped, and the client must learn its answer never + // landed instead of seeing a void "success". + return yield* new WorkflowEventStoreError({ + message: `step run ${input.stepRunId} not found`, + }); + } + const awaitingState = yield* awaitingStateForStepRun(input.stepRunId); + const pending = yield* pendingWaitFor(input.stepRunId); + const responseKind = + awaitingState === null + ? pending?.payload.providerResponseKind + : awaitingState.status === "awaiting_user" + ? awaitingState.providerResponseKind + : null; + if (responseKind !== "user-input") { + return yield* new WorkflowEventStoreError({ + message: "ticket answer requires an awaiting user-input step", + }); + } + yield* ensureLiveProviderUserInputWait(pending); + + const messageId = yield* ids.messageId(); + yield* commit({ + type: "TicketMessagePosted", + ticketId, + payload: { + messageId, + stepRunId: input.stepRunId, + author: "user", + body: text, + attachments, + createdAt: (yield* nowIso) as never, + }, + }); + + const { providerResponses } = yield* getOptionalServices; + if ( + pending?.payload.providerThreadId && + pending.payload.providerRequestId && + pending.payload.providerResponseKind === "user-input" && + Option.isSome(providerResponses) + ) { + yield* providerResponses.value.respond({ + threadId: pending.payload.providerThreadId, + requestId: pending.payload.providerRequestId, + responseKind: pending.payload.providerResponseKind, + approved: true, + ...(pending.payload.providerQuestionId === undefined + ? {} + : { questionId: pending.payload.providerQuestionId }), + text, + }); + } + + if (pending?.payload.providerResponseKind !== "user-input") { + return; + } + const resumedLiveWaiter = yield* approvals.resolve(input.stepRunId, true); + if (!resumedLiveWaiter) { + yield* continueRecoveredApproval(pending, true); + } + }); + + const moveTicket: WorkflowEngineShape["moveTicket"] = (ticketId, toLane) => + Effect.gen(function* () { + const currentDetail = yield* read.getTicketDetail(ticketId); + if (!currentDetail) { + // Fail (don't silently succeed): a deleted/unknown ticket id must + // surface to the caller, not look like a successful manual move. + return yield* new WorkflowEventStoreError({ + message: `ticket ${ticketId} not found`, + }); + } + yield* moveToLane(ticketId, currentDetail.ticket.boardId as BoardId, toLane, "manual"); + }); + + // --------------------------------------------------------------------------- + // Committer-facing UNLOCKED engine ops (Task 9 work-source syncer). EVERY one + // of these ASSUMES the caller already holds the board save lock for the + // ticket's board AND is inside an open `sql.withTransaction`, AND — for any op + // that makes a WIP admit/queue decision — already holds the board ADMISSION + // lock via `withBoardAdmissionLock` (OUTER) wrapping the save lock (INNER) + // wrapping the transaction. They never acquire the save lock, never open a + // transaction, and never take the admission lock themselves: the save lock is + // taken only transiently at commit time and does NOT serialize the WIP + // read-decide, so the committer must hold the admission lock to be WIP-safe + // against concurrent public enterLane moves. Calling the public + // commit/commitMany/enterLane/moveTicket from here would deadlock the + // non-reentrant save lock or nest the transaction. Pipeline starts are forked + // detached (non-blocking) so they merely queue behind the save lock the + // caller still holds and run once it is released. + // --------------------------------------------------------------------------- + + // Post-tx provider cancellation for a source-closed ticket. Does ONLY the live + // side effects — interrupt the running pipeline fiber and cancel the provider + // turns — and performs NO DB writes (the in-tx close already tombstoned the + // dispatch outbox rows). Idempotent: interrupting an already-cleared fiber or + // cancelling an absent/stopped session is a no-op. The `turns` snapshot is + // captured by the committer INSIDE the chunk tx (before the tombstone hid the + // pending/started rows) and replayed here after the tx commits. + const supersedeProviderWorkForTicket: WorkflowEngineShape["supersedeProviderWorkForTicket"] = ( + ticketId, + turns, + ) => + Effect.gen(function* () { + yield* interruptRunningPipeline(ticketId); + yield* cancelProviderTurns(turns).pipe(Effect.catch(() => Effect.void)); + }); + + const cancellableProviderTurnsForTicket: WorkflowEngineShape["cancellableProviderTurnsForTicket"] = + (ticketId) => + cancellableProviderDispatchesForTicket(ticketId).pipe( + Effect.map((rows) => rows.map((row) => ({ threadId: row.threadId, turnId: row.turnId }))), + ); + + // Snapshot the ticket's stored per-agent session thread ids. The source + // committer captures this INSIDE the chunk tx, BEFORE + // `closeTicketFromSourceUnlocked`'s terminal teardown deletes the rows, then + // replays it through `stopAgentSessionsForTicket` AFTER the tx commits — so the + // non-rollbackable live `provider.stopSession` never runs inside the chunk + // transaction (mirrors the turn-snapshot pattern above). + const terminalAgentSessionThreadsForTicket: WorkflowEngineShape["terminalAgentSessionThreadsForTicket"] = + (ticketId) => + Effect.gen(function* () { + const { agentSessions } = yield* getOptionalServices; + if (Option.isNone(agentSessions)) { + return []; + } + const rows = yield* agentSessions.value + .listByTicket(ticketId) + .pipe(Effect.orElseSucceed(() => [])); + return rows.map((row) => row.threadId); + }); + + // POST-TX best-effort stop of the agent-session threads snapshotted by + // `terminalAgentSessionThreadsForTicket`. The in-tx teardown already deleted the + // rows; this only fires the live `provider.stopSession` (which kills the session + // and does its own SQL write), so it MUST run after the chunk transaction + // commits. Best-effort: errors are swallowed. + const stopAgentSessionsForTicket: WorkflowEngineShape["stopAgentSessionsForTicket"] = ( + threadIds, + ) => stopAgentSessionThreads(threadIds); + + const createTicketAndEnterUnlocked: WorkflowEngineShape["createTicketAndEnterUnlocked"] = ( + input, + ) => + Effect.gen(function* () { + const ticketId = yield* ids.ticketId(); + yield* unlockedEmit([ + { + type: "TicketCreated", + ticketId, + payload: { + boardId: input.boardId, + title: input.title, + laneKey: input.destinationLane, + ...(input.description === undefined ? {} : { description: input.description }), + }, + } as UnstampedWorkflowEventInput, + ]); + // Pipeline starts are intentionally DROPPED here: starting a pipeline + // commits through the locked path, which would open a transaction while + // the caller's chunk transaction is still open (the SQLite connection has + // a single global transaction). The committer (Task 9) is responsible for + // triggering auto-lane pipeline starts (e.g. recoverBoardWip) AFTER it + // closes the chunk transaction and releases the save lock. + const { acted } = yield* enterLaneCore( + ticketId, + input.boardId, + input.destinationLane, + "initial", + { + emit: unlockedEmit, + serialize: Effect.uninterruptible, + supersedeRunningWork: Effect.void, + // In-tx: only the tx-safe deleteByTicket runs here; never call the live + // provider.stopSession inside the chunk transaction. + stopProviderSessionsOnTeardown: false, + }, + ); + return { ticketId, outcome: acted }; + }); + + const closeTicketFromSourceUnlocked: WorkflowEngineShape["closeTicketFromSourceUnlocked"] = ( + ticketId, + closedLane, + ) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ticketId); + if (detail === null) { + return; + } + const boardId = detail.ticket.boardId as BoardId; + const fromLane = detail.ticket.currentLaneKey as LaneKey; + const routeEvent = { + type: "TicketRouteDecided", + ticketId, + payload: { + fromLane, + toLane: closedLane, + source: "work_source", + // The event schema requires contextSnapshot; a work-source close has + // no pipeline/event context, so record an empty snapshot. + contextSnapshot: null, + }, + } as UnstampedWorkflowEventInput; + // Reuse the EXTERNAL move path so the close lands via the same stale-lane + // guard, but the supersession here is DB-ONLY: it tombstones the ticket's + // dispatch outbox rows (tx-safe — rolls back with the chunk if a later + // delta fails). It does NOT interrupt the running pipeline fiber or call + // provider interruptTurn/stopSession, because those are live side effects + // that cannot be rolled back and must not run inside the chunk + // transaction. The committer drives the fiber-interrupt + provider-cancel + // AFTER the chunk commits, via supersedeProviderWorkForTicket. revalidate + // succeeds unconditionally — the work source is the authority on closing, + // there is no stale-matcher concern. Pipeline starts the close might admit + // in the prior lane are dropped for the same single-transaction reason as + // createTicketAndEnterUnlocked (closed lanes are terminal in practice; the + // committer sweeps starts after the chunk). + yield* enterLaneCore(ticketId, boardId, closedLane, "external", { + emit: unlockedEmit, + serialize: Effect.uninterruptible, + supersedeRunningWork: abandonTicketDispatches(ticketId).pipe( + Effect.catch(() => Effect.void), + ), + externalOptions: { + expectedFromLane: fromLane, + routeEvent, + revalidate: Effect.succeed(true), + }, + // A source's closedLane is lint-required to be terminal, so this reliably + // hits the teardown branch INSIDE the committer's chunk transaction. Only + // the tx-safe deleteByTicket may run here; `provider.stopSession` is a + // non-rollbackable live side effect. The committer snapshots the threads + // via `terminalAgentSessionThreadsForTicket` BEFORE this close and stops + // them in its post-commit phase (alongside supersedeProviderWorkForTicket). + stopProviderSessionsOnTeardown: false, + }); + }); + + const reopenTicketFromSourceUnlocked: WorkflowEngineShape["reopenTicketFromSourceUnlocked"] = ( + ticketId, + destinationLane, + ) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ticketId); + if (detail === null) { + return; + } + const boardId = detail.ticket.boardId as BoardId; + const fromLane = detail.ticket.currentLaneKey as LaneKey; + // Already where we'd route it (e.g. a redundant reopen) → nothing to do. + if ((fromLane as string) === (destinationLane as string)) { + return; + } + const routeEvent = { + type: "TicketRouteDecided", + ticketId, + payload: { + fromLane, + toLane: destinationLane, + source: "work_source", + contextSnapshot: null, + }, + } as UnstampedWorkflowEventInput; + // Mirror closeTicketFromSourceUnlocked but route back to the destination + // lane. No provider supersession (a reopen revives work, it does not cancel + // it); the work source is authoritative so revalidate succeeds. Any auto- + // lane pipeline start the destination admits is dropped (single-tx) and the + // committer's post-commit recoverBoardWip sweep starts it. + yield* enterLaneCore(ticketId, boardId, destinationLane, "external", { + emit: unlockedEmit, + serialize: Effect.uninterruptible, + supersedeRunningWork: Effect.void, + externalOptions: { + expectedFromLane: fromLane, + routeEvent, + revalidate: Effect.succeed(true), + }, + // A reopen target is not a terminal lane, so the teardown branch is not + // expected to fire — but this is an in-tx caller, so never run the live + // provider.stopSession in-band regardless (only the tx-safe deleteByTicket + // may run inside the chunk transaction). + stopProviderSessionsOnTeardown: false, + }); + }); + + const editTicketFieldsUnlocked: WorkflowEngineShape["editTicketFieldsUnlocked"] = ( + ticketId, + fields, + ) => + Effect.gen(function* () { + // Mirror the locked editTicket: a whitespace-only TITLE is dropped rather + // than written, so the projection never overwrites the stored title with + // an empty string. (editTicket errors; here we silently OMIT the field — + // the syncer must not blank a title and has no caller to surface an error + // to.) + // DESCRIPTION is treated differently: an empty-string description is a + // VALID CLEAR (source-owned descriptions are authoritative), so when a + // description is PROVIDED — including "" — it is emitted and written. Only + // `undefined` (not provided) leaves the description unchanged. The guard + // below therefore checks `=== undefined` (not falsiness) for description. + const trimmed = fields.title === undefined ? undefined : fields.title.trim(); + const title = trimmed !== undefined && trimmed.length === 0 ? undefined : trimmed; + if (title === undefined && fields.description === undefined) { + return; + } + yield* unlockedEmit([ + { + type: "TicketEdited", + ticketId, + payload: { + ...(title === undefined ? {} : { title: title as never }), + ...(fields.description === undefined ? {} : { description: fields.description }), + }, + } as UnstampedWorkflowEventInput, + ]); + }); + + const hasPipelineStartedForToken = (ticketId: TicketId, laneEntryToken: LaneEntryToken) => + wrapSql(sql<PipelineRunForTokenRow>` + SELECT pipeline_run_id AS "pipelineRunId" + FROM projection_pipeline_run + WHERE ticket_id = ${ticketId} + AND lane_entry_token = ${laneEntryToken} + LIMIT 1 + `).pipe(Effect.map((rows) => rows.length > 0)); + + const cancellableProviderDispatchesForBoard = (boardId: BoardId) => + wrapSql(sql<ActiveProviderTurnRow>` + SELECT DISTINCT + outbox.thread_id AS "threadId", + outbox.turn_id AS "turnId" + FROM workflow_dispatch_outbox AS outbox + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = outbox.ticket_id + WHERE ticket.board_id = ${boardId} + AND outbox.status IN ('pending', 'started') + ORDER BY outbox.thread_id ASC, outbox.turn_id ASC + `); + + const cancellableProviderDispatchesForTicket = (ticketId: TicketId) => + wrapSql(sql<ActiveProviderTurnRow>` + SELECT DISTINCT + thread_id AS "threadId", + turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE ticket_id = ${ticketId} + AND status IN ('pending', 'started') + ORDER BY thread_id ASC, turn_id ASC + `); + + const cancelProviderTurns = (turns: ReadonlyArray<ActiveProviderTurnRow>) => + Effect.gen(function* () { + const { providerService } = yield* getOptionalServices; + if (Option.isNone(providerService)) { + return; + } + yield* Effect.forEach( + turns, + (turn) => + Effect.gen(function* () { + const interruptError = + turn.turnId === null + ? null + : yield* providerCleanupAttempt( + providerService.value.interruptTurn({ + threadId: turn.threadId, + turnId: turn.turnId, + }), + "workflow provider turn interrupt failed", + ); + + const stopError = yield* providerCleanupAttempt( + providerService.value.stopSession({ threadId: turn.threadId }), + "workflow provider session stop failed", + ); + + const cleanupError = interruptError ?? stopError; + if (cleanupError !== null) { + return yield* cleanupError; + } + }), + { discard: true }, + ); + }); + + const abandonTicketDispatches = (ticketId: TicketId) => + Effect.gen(function* () { + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE ticket_id = ${ticketId} + AND status IN ('pending', 'started') + `); + }); + + const cancelActiveProviderTurns = (boardId: BoardId) => + Effect.gen(function* () { + const turns = yield* cancellableProviderDispatchesForBoard(boardId); + yield* cancelProviderTurns(turns); + }); + + const cancelActiveProviderTurnsForTicket = (ticketId: TicketId) => + Effect.gen(function* () { + const turns = yield* cancellableProviderDispatchesForTicket(ticketId); + yield* cancelProviderTurns(turns); + }); + + const recoverBoardWip: WorkflowEngineShape["recoverBoardWip"] = (boardId) => + Effect.gen(function* () { + const definition = yield* registry.getDefinition(boardId); + if (definition === null) { + return; + } + + for (const lane of definition.lanes) { + yield* withAdmissionLock(boardId, Effect.uninterruptible(admitNext(boardId, lane.key))); + } + + const tickets = yield* read.listTickets(boardId); + // admitNext only sweeps WIP-limited lanes; a crash between a + // dependency landing and its dependents being released would otherwise + // strand queued tickets in unlimited auto lanes forever. + for (const ticket of tickets) { + if (ticket.queuedAt === null || (ticket.unresolvedDependencyCount ?? 0) > 0) { + continue; + } + const lane = yield* registry.getLane(boardId, ticket.currentLaneKey as LaneKey); + if (lane?.entry !== "auto" || lane.wipLimit !== undefined) { + continue; + } + yield* releaseTicketIfEligible(ticket.ticketId as TicketId).pipe( + Effect.catch(() => Effect.void), + ); + } + for (const ticket of tickets) { + if (ticket.currentLaneEntryToken === null) { + continue; + } + const lane = yield* registry.getLane(boardId, ticket.currentLaneKey as LaneKey); + if (lane?.entry !== "auto") { + continue; + } + const laneEntryToken = ticket.currentLaneEntryToken as LaneEntryToken; + const hasStarted = yield* hasPipelineStartedForToken( + ticket.ticketId as TicketId, + laneEntryToken, + ); + if (!hasStarted) { + yield* startPipeline(ticket.ticketId as TicketId, boardId, lane, laneEntryToken); + } + } + }); + + const ingestExternalEvent: WorkflowEngineShape["ingestExternalEvent"] = (input) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(input.ticketId); + if (detail === null || detail.ticket.boardId !== (input.boardId as string)) { + return yield* new WorkflowEventStoreError({ + message: "ticket not found on this board", + code: WorkflowEventStoreErrorCode.ticketNotOnBoard, + }); + } + const fromLaneKey = detail.ticket.currentLaneKey as LaneKey; + // Read once; revalidate reuses this snapshot — do not re-read. + // resolveTarget closes over the eventContext built below, so the lock-guarded + // revalidate inside enterLane re-runs the same matcher against the same pr + // context without a second DB read (design finding #1: single read prevents + // desync between the initial evaluation and the revalidate recheck). + const prState = yield* read.getTicketPrState(input.ticketId); + const eventContext = { + event: { name: input.name, payload: input.payload ?? null }, + pr: { + ciState: prState?.lastCiState ?? null, + reviewDecision: prState?.lastReviewDecision ?? null, + }, + }; + const resolveTarget = Effect.gen(function* () { + const lane = yield* registry.getLane(input.boardId, fromLaneKey); + for (const matcher of lane?.onEvent ?? []) { + if ((matcher.name as string) !== input.name) { + continue; + } + if (matcher.when !== undefined) { + const evaluation = yield* predicates.evaluate(matcher.when, eventContext).pipe( + Effect.mapError( + (cause) => + new WorkflowEventStoreError({ + message: "external event predicate evaluation failed", + cause, + }), + ), + ); + if (!evaluation.result) { + continue; + } + } + return matcher.to; + } + return null; + }); + const target = yield* resolveTarget; + if (target === null) { + return { outcome: "noop" as const }; + } + + const routeEvent = { + type: "TicketRouteDecided", + ticketId: input.ticketId, + payload: { + fromLane: fromLaneKey, + toLane: target, + source: "external_event", + contextSnapshot: eventContext, + }, + } as UnstampedWorkflowEventInput; + const acted = yield* enterLane(input.ticketId, input.boardId, target, "external", undefined, { + expectedFromLane: fromLaneKey, + routeEvent, + revalidate: Effect.gen(function* () { + if ((yield* resolveTarget) !== target) { + return false; + } + return (yield* registry.getLane(input.boardId, target)) !== null; + }), + }); + if (acted === "none") { + return { outcome: "noop" as const }; + } + return { outcome: acted, toLane: target as string }; + }); + + const runLane: WorkflowEngineShape["runLane"] = (ticketId) => + Effect.gen(function* () { + const currentDetail = yield* read.getTicketDetail(ticketId); + if (!currentDetail) { + return; + } + + const unresolvedDeps = currentDetail.ticket.unresolvedDependencyCount ?? 0; + if (unresolvedDeps > 0) { + return yield* new WorkflowEventStoreError({ + message: `ticket is waiting on ${unresolvedDeps} unresolved dependenc${ + unresolvedDeps === 1 ? "y" : "ies" + }`, + }); + } + const lane = yield* registry.getLane( + currentDetail.ticket.boardId as BoardId, + currentDetail.ticket.currentLaneKey as LaneKey, + ); + const token = yield* currentToken(ticketId); + if (lane && token) { + yield* startPipeline( + ticketId, + currentDetail.ticket.boardId as BoardId, + lane, + token as LaneEntryToken, + ); + } + }); + + const recoveredStepContext = ( + events: ReadonlyArray<PersistedWorkflowEvent>, + stepRunId: StepRunId, + ) => { + let stepStarted: StepStarted | null = null; + let pipelineStarted: PipelineStarted | null = null; + let ticketCreated: TicketCreated | null = null; + + for (const event of events) { + if (event.type === "StepStarted" && event.payload.stepRunId === stepRunId) { + stepStarted = event; + } + } + if (!stepStarted) { + return null; + } + + for (const event of events) { + if (event.type === "TicketCreated" && event.ticketId === stepStarted.ticketId) { + ticketCreated = event; + } + if ( + event.type === "PipelineStarted" && + event.payload.pipelineRunId === stepStarted.payload.pipelineRunId + ) { + pipelineStarted = event; + } + } + if (!pipelineStarted || !ticketCreated) { + return null; + } + + return { stepStarted, pipelineStarted, ticketCreated }; + }; + + const completeRecoveredStepUnlocked = ( + stepRunId: StepRunId, + result: RecoveredStepResult, + captureTurn: { readonly threadId: ThreadId; readonly turnId: TurnId } | undefined, + options?: { readonly allowRetry?: boolean }, + ): Effect.Effect<void, WorkflowEventStoreError> => + Effect.gen(function* () { + const events = yield* readStoredEventsForStep(stepRunId); + if (events === null) { + return; + } + + const recovered = recoveredStepContext(events, stepRunId); + if ( + !recovered || + hasPipelineCompletedEvent(events, recovered.pipelineStarted.payload.pipelineRunId) + ) { + return; + } + + const boardId = recovered.ticketCreated.payload.boardId; + const laneEntryToken = recovered.pipelineStarted.payload.laneEntryToken; + const pipelineRunId = recovered.pipelineStarted.payload.pipelineRunId; + // The board definition may have changed across the restart: a missing + // lane or step must still resolve the pipeline run, or it pins the + // ticket's WIP slot forever. + const supersedePipeline = commitMany([ + { + type: "PipelineCompleted", + ticketId: recovered.stepStarted.ticketId, + payload: { pipelineRunId, result: "superseded" }, + }, + // Surface the dead end instead of leaving the ticket "running"; the + // user re-routes it manually once the board matches reality again. + { + type: "TicketBlocked", + ticketId: recovered.stepStarted.ticketId, + payload: { reason: "board definition changed while this step was recovering" }, + }, + ] as ReadonlyArray<UnstampedWorkflowEventInput>); + const lane = yield* registry.getLane(boardId, recovered.pipelineStarted.payload.laneKey); + if (!lane) { + yield* supersedePipeline; + return; + } + + const steps = lane.pipeline ?? []; + const currentStepIndex = steps.findIndex( + (step) => step.key === recovered.stepStarted.payload.stepKey, + ); + if (currentStepIndex < 0) { + yield* supersedePipeline; + return; + } + + const recoveredStep = steps[currentStepIndex]; + let terminalResult = + result._tag === "completed" + ? yield* completedResultForStep(stepRunId, recoveredStep, result.output, captureTurn) + : result; + if ( + terminalResult._tag !== "blocked" && + terminalResult.usage === undefined && + captureTurn !== undefined + ) { + const usage = yield* readStepUsage(captureTurn.threadId); + if (usage !== undefined) { + terminalResult = { ...terminalResult, usage }; + } + } + + if (!hasTerminalStepEvent(events, stepRunId)) { + if (terminalResult._tag === "completed") { + yield* commit({ + type: "StepCompleted", + ticketId: recovered.stepStarted.ticketId, + payload: stepCompletedPayload(stepRunId, terminalResult.output, terminalResult.usage), + }); + } else if (terminalResult._tag === "failed") { + yield* commit({ + type: "StepFailed", + ticketId: recovered.stepStarted.ticketId, + payload: stepFailedPayload( + stepRunId, + terminalResult.error, + terminalResult.usage, + terminalResult.retryable === false ? false : undefined, + ), + }); + } else { + yield* commit({ + type: "StepBlocked", + ticketId: recovered.stepStarted.ticketId, + payload: { stepRunId, reason: terminalResult.reason }, + }); + } + } + + // Never continue a pipeline the ticket has already left: a manual move + // or re-route invalidated this lane entry token, so running more steps + // or routing from here would act on stale state. The terminal step + // event above is still recorded; the pipeline run closes superseded. + if ((yield* currentToken(recovered.stepStarted.ticketId)) !== laneEntryToken) { + yield* commit({ + type: "PipelineCompleted", + ticketId: recovered.stepStarted.ticketId, + payload: { pipelineRunId, result: "superseded" }, + }); + return; + } + + let finalResult: StepResult = + terminalResult._tag === "completed" + ? "completed" + : terminalResult._tag === "blocked" + ? "blocked" + : "failed"; + + // Resume the retry loop across restarts: a failed attempt recovered + // mid-policy keeps consuming its remaining attempts (with escalation), + // unless the failure was a user rejection/cancellation. + if ( + finalResult === "failed" && + (terminalResult._tag !== "failed" || terminalResult.retryable !== false) && + recoveredStep !== undefined && + options?.allowRetry !== false + ) { + const maxAttempts = retryAttemptsForStep(recoveredStep); + let attempt = recovered.stepStarted.payload.attempt ?? 1; + let outcome: StepRunOutcome = { result: "failed", noRetry: false }; + while (outcome.result === "failed" && !outcome.noRetry && attempt < maxAttempts) { + attempt += 1; + outcome = yield* runStep( + recovered.stepStarted.ticketId, + boardId, + recovered.pipelineStarted.payload.pipelineRunId, + stepForAttempt(recoveredStep, attempt), + laneEntryToken, + lane.key, + steps.map((s) => s.key), + attempt, + ); + } + if (attempt > (recovered.stepStarted.payload.attempt ?? 1)) { + finalResult = outcome.result; + } + } + + const recoveredResult: PipelineResult = pipelineResultForStep(finalResult); + const initialRouteDecision = recoveredStep + ? stepRouteDecision(recoveredStep, recoveredResult) + : null; + + yield* completePipelineFrom( + recovered.stepStarted.ticketId, + boardId, + lane, + laneEntryToken, + recovered.pipelineStarted.payload.pipelineRunId, + steps, + initialRouteDecision === null && finalResult === "completed" + ? currentStepIndex + 1 + : steps.length, + recoveredResult, + initialRouteDecision ?? undefined, + ); + }); + + const completeRecoveredStep: WorkflowEngineShape["completeRecoveredStep"] = ( + stepRunId, + result, + captureTurn, + ) => + Effect.gen(function* () { + const claimed = yield* SynchronizedRef.modify(recoveredStepClaims, (current) => { + const key = stepRunId as string; + if (current.has(key)) { + return [false, current] as const; + } + const next = new Set(current); + next.add(key); + return [true, next] as const; + }); + if (!claimed) { + return; + } + yield* completeRecoveredStepUnlocked(stepRunId, result, captureTurn).pipe( + // Release the claim on failure so a later monitor/sweep can finish + // what this continuation could not. + Effect.onError(() => + SynchronizedRef.update(recoveredStepClaims, (current) => { + const next = new Set(current); + next.delete(stepRunId as string); + return next; + }), + ), + ); + }); + + const continueRecoveredApproval = (pending: PendingWait, approved: boolean) => + Effect.gen(function* () { + const events = yield* readStoredEventsForStep(pending.payload.stepRunId); + if (events === null || !pendingWaitInEvents(events, pending.payload.stepRunId)) { + return; + } + + const recovered = recoveredStepContext(events, pending.payload.stepRunId); + if (!recovered) { + return; + } + + yield* commit({ + type: "StepUserResolved", + ticketId: pending.ticketId, + payload: { stepRunId: pending.payload.stepRunId }, + }); + if (!approved) { + yield* completeRecoveredStepUnlocked( + pending.payload.stepRunId, + { + _tag: "failed", + error: "rejected", + }, + undefined, + { allowRetry: false }, + ); + return; + } + + const terminalResult = + pending.payload.providerThreadId === undefined + ? ({ _tag: "completed" } satisfies RecoveredStepResult) + : yield* awaitProviderTerminalForStep( + pending.payload.stepRunId, + pending.payload.providerThreadId, + ); + yield* completeRecoveredStepUnlocked(pending.payload.stepRunId, terminalResult, undefined); + }); + + const cancelStep: WorkflowEngineShape["cancelStep"] = (stepRunId) => + scriptCancels.cancel(stepRunId); + + const cancelBoardPipelines: WorkflowEngineShape["cancelBoardPipelines"] = (boardId) => + Effect.gen(function* () { + const tickets = yield* read.listTickets(boardId); + yield* Effect.forEach( + tickets, + (ticket) => interruptRunningPipeline(ticket.ticketId as TicketId), + { discard: true }, + ); + yield* cancelActiveProviderTurns(boardId); + }); + + const cancelTicketPipelines: WorkflowEngineShape["cancelTicketPipelines"] = (ticketId) => + Effect.gen(function* () { + yield* interruptRunningPipeline(ticketId); + yield* cancelActiveProviderTurnsForTicket(ticketId); + }); + + const resolveApproval: WorkflowEngineShape["resolveApproval"] = (stepRunId, approved) => + Effect.gen(function* () { + const resolve = Effect.gen(function* () { + const pending = yield* pendingWaitFor(stepRunId); + const { providerResponses } = yield* getOptionalServices; + if (pending?.payload.providerResponseKind === "user-input") { + return yield* new WorkflowEventStoreError({ + message: "provider user-input waits must be answered with answerTicketStep", + }); + } + if ( + pending?.payload.providerThreadId && + pending.payload.providerRequestId && + pending.payload.providerResponseKind && + Option.isSome(providerResponses) + ) { + yield* providerResponses.value.respond({ + threadId: pending.payload.providerThreadId, + requestId: pending.payload.providerRequestId, + responseKind: pending.payload.providerResponseKind, + approved, + }); + } + + const resumedLiveWaiter = yield* approvals.resolve(stepRunId, approved); + if (!resumedLiveWaiter && pending) { + yield* continueRecoveredApproval(pending, approved); + } + }); + // Resolution serializes through the inner recovery path's own locking + // (continueRecoveredApproval -> commit/completeRecoveredStepUnlocked); + // no board-level lock is taken here, so the prior boardId lookup was a + // dead query that only ever branched into identical effects. + yield* resolve; + }); + + return { + createTicket, + editTicket, + moveTicket, + createTicketAndEnterUnlocked, + closeTicketFromSourceUnlocked, + reopenTicketFromSourceUnlocked, + cancellableProviderTurnsForTicket, + supersedeProviderWorkForTicket, + terminalAgentSessionThreadsForTicket, + stopAgentSessionsForTicket, + editTicketFieldsUnlocked, + withBoardAdmissionLock, + runLane, + ingestExternalEvent, + resolveApproval, + answerTicketStep, + postTicketMessage, + editTicketMessage, + cancelStep, + cancelBoardPipelines, + cancelTicketPipelines, + recoverBoardWip, + completeRecoveredStep, + } satisfies WorkflowEngineShape; +}); + +export const WorkflowEngineLayer = Layer.effect(WorkflowEngine, make); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.wip.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.wip.test.ts new file mode 100644 index 00000000000..9204f918620 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.wip.test.ts @@ -0,0 +1,557 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { + WorkflowEventCommitter, + type WorkflowEventCommitterShape, +} from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const failedExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.succeed({ _tag: "failed" as const, error: "hold slot" }), +} satisfies StepExecutorShape); + +let selfRouteExecutionCount = 0; +const selfRouteExecutor = Layer.succeed(StepExecutor, { + execute: () => + Effect.sync(() => { + selfRouteExecutionCount += 1; + if (selfRouteExecutionCount === 1) { + return { _tag: "failed" as const, error: "retry in same lane" }; + } + return { _tag: "blocked" as const, reason: "stop after retry" }; + }), +} satisfies StepExecutorShape); + +const workflowLayer = (executor: Layer.Layer<StepExecutor>) => + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(executor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +const layer = it.layer(workflowLayer(failedExecutor)); + +const selfRouteLayer = it.layer(workflowLayer(selfRouteExecutor)); + +const wipDefinition = { + name: "wip", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + wipLimit: 1, + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const selfRouteDefinition = { + name: "self route", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + wipLimit: 1, + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { failure: "impl" }, + }, + ], +}; + +const manualCapacityDefinition = { + name: "manual capacity", + lanes: [{ key: "impl", name: "Impl", entry: "manual", wipLimit: 2 }], +}; + +const routedQueueDefinition = { + name: "routed queue", + lanes: [ + { + key: "source", + name: "Source", + entry: "manual", + wipLimit: 1, + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "target" }, + }, + { key: "target", name: "Target", entry: "manual", wipLimit: 1 }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const concurrentExitDefinition = { + name: "concurrent exit", + lanes: [ + { key: "source", name: "Source", entry: "manual", wipLimit: 1 }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const awaitTicketWhere = (ticketId: string, predicate: (detail: TicketDetail | null) => boolean) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + for (let attempt = 0; attempt < 50; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId as never); + if (predicate(detail)) { + return detail; + } + yield* Effect.promise<void>(() => new Promise((resolve) => setTimeout(resolve, 10))); + yield* Effect.yieldNow; + } + return yield* read.getTicketDetail(ticketId as never); + }); + +const seedAdmittedTicket = ( + committer: WorkflowEventCommitterShape, + boardId: string, + ticketId: string, + token: string, + offset: number, +) => + Effect.gen(function* () { + yield* committer.commit({ + type: "TicketCreated", + eventId: `evt-${ticketId}-created` as never, + ticketId: ticketId as never, + occurredAt: `2026-06-07T00:10:${offset.toString().padStart(2, "0")}.000Z` as never, + payload: { + boardId: boardId as never, + title: ticketId, + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: `evt-${ticketId}-admitted` as never, + ticketId: ticketId as never, + occurredAt: `2026-06-07T00:11:${offset.toString().padStart(2, "0")}.000Z` as never, + payload: { + toLane: "impl" as never, + laneEntryToken: token as never, + reason: "initial", + }, + } as never); + }); + +selfRouteLayer("WorkflowEngine same-lane WIP enforcement", (it) => { + it.effect("re-admits an admitted auto ticket routed back into its own full lane", () => + Effect.gen(function* () { + selfRouteExecutionCount = 0; + const registry = yield* BoardRegistry; + yield* registry.register("b-self-route" as never, selfRouteDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-self-route" as never, + title: "Retry", + initialLane: "impl" as never, + }); + const detail = yield* awaitTicketWhere( + ticketId as string, + (detail) => detail?.ticket.status === "blocked" && selfRouteExecutionCount === 2, + ); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const starts = events.filter((event) => event.type === "PipelineStarted"); + const moves = events.filter((event) => event.type === "TicketMovedToLane"); + assert.equal(selfRouteExecutionCount, 2); + assert.equal(starts.length, 2); + assert.equal(moves.length, 2); + assert.equal(moves[1]?.type, "TicketMovedToLane"); + if (moves[0]?.type !== "TicketMovedToLane" || moves[1]?.type !== "TicketMovedToLane") { + assert.fail("expected initial and routed lane moves"); + } + assert.equal(moves[1].payload.reason, "routed"); + assert.notEqual(moves[1].payload.laneEntryToken, moves[0].payload.laneEntryToken); + assert.equal(detail?.ticket.currentLaneKey, "impl"); + assert.equal(detail?.ticket.currentLaneEntryToken, moves[1].payload.laneEntryToken); + assert.isFalse(events.some((event) => event.type === "TicketQueued")); + }), + ); +}); + +layer("WorkflowEngine WIP enforcement", (it) => { + it.effect("discounts only the moving ticket for same-lane capacity checks", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const committer = yield* WorkflowEventCommitter; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + yield* registry.register("b-same-capacity-open" as never, manualCapacityDefinition); + yield* registry.register("b-same-capacity-full" as never, manualCapacityDefinition); + + yield* seedAdmittedTicket( + committer, + "b-same-capacity-open", + "ticket-open-self", + "tok-open-self", + 1, + ); + yield* seedAdmittedTicket( + committer, + "b-same-capacity-open", + "ticket-open-other", + "tok-open-other", + 2, + ); + + yield* engine.moveTicket("ticket-open-self" as never, "impl" as never); + + const openDetail = yield* read.getTicketDetail("ticket-open-self" as never); + const openEvents = yield* Stream.runCollect( + store.readByTicket("ticket-open-self" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + const openMoves = openEvents.filter((event) => event.type === "TicketMovedToLane"); + assert.equal(openDetail?.ticket.status, "idle"); + assert.equal(openDetail?.ticket.currentLaneKey, "impl"); + assert.isNotNull(openDetail?.ticket.currentLaneEntryToken ?? null); + assert.notEqual(openDetail?.ticket.currentLaneEntryToken, "tok-open-self"); + assert.equal(openMoves.length, 2); + assert.isFalse(openEvents.some((event) => event.type === "TicketQueued")); + + yield* seedAdmittedTicket( + committer, + "b-same-capacity-full", + "ticket-full-self", + "tok-full-self", + 3, + ); + yield* seedAdmittedTicket( + committer, + "b-same-capacity-full", + "ticket-full-other-a", + "tok-full-other-a", + 4, + ); + yield* seedAdmittedTicket( + committer, + "b-same-capacity-full", + "ticket-full-other-b", + "tok-full-other-b", + 5, + ); + + yield* engine.moveTicket("ticket-full-self" as never, "impl" as never); + + const fullDetail = yield* read.getTicketDetail("ticket-full-self" as never); + const fullEvents = yield* Stream.runCollect( + store.readByTicket("ticket-full-self" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + assert.equal(fullDetail?.ticket.status, "queued"); + assert.equal(fullDetail?.ticket.currentLaneKey, "impl"); + assert.equal(fullDetail?.ticket.currentLaneEntryToken, null); + assert.isTrue(fullEvents.some((event) => event.type === "TicketQueued")); + }), + ); + + it.effect("queues a second initial entry into a full auto lane without starting a pipeline", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-wip" as never, wipDefinition); + const engine = yield* WorkflowEngine; + + const firstTicketId = yield* engine.createTicket({ + boardId: "b-wip" as never, + title: "First", + initialLane: "impl" as never, + }); + const firstDetail = yield* awaitTicketWhere( + firstTicketId as string, + (detail) => + detail?.ticket.status === "blocked" && + detail.ticket.currentLaneEntryToken !== null && + detail.steps.length === 1, + ); + assert.equal(firstDetail?.ticket.currentLaneKey, "impl"); + assert.isNotNull(firstDetail?.ticket.currentLaneEntryToken ?? null); + + const secondTicketId = yield* engine.createTicket({ + boardId: "b-wip" as never, + title: "Second", + initialLane: "impl" as never, + }); + const secondDetail = yield* awaitTicketWhere( + secondTicketId as string, + (detail) => detail?.ticket.status === "queued", + ); + + assert.equal(secondDetail?.ticket.currentLaneKey, "impl"); + assert.equal(secondDetail?.ticket.status, "queued"); + assert.equal(secondDetail?.ticket.currentLaneEntryToken, null); + assert.isNotNull(secondDetail?.ticket.queuedAt ?? null); + assert.equal(secondDetail?.steps.length, 0); + }), + ); + + it.effect("queues a routed ticket into a full lane and admits the source lane FIFO", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const committer = yield* WorkflowEventCommitter; + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + const read = yield* WorkflowReadModel; + yield* registry.register("b-routed-wip" as never, routedQueueDefinition); + + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-target-created" as never, + ticketId: "ticket-target-full" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-routed-wip" as never, + title: "Target full", + laneKey: "target" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-target-admitted" as never, + ticketId: "ticket-target-full" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "target" as never, + laneEntryToken: "tok-target-full" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-source-created" as never, + ticketId: "ticket-source-routing" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + boardId: "b-routed-wip" as never, + title: "Source routing", + laneKey: "source" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-source-admitted" as never, + ticketId: "ticket-source-routing" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + toLane: "source" as never, + laneEntryToken: "tok-source-routing" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-source-pipeline" as never, + ticketId: "ticket-source-routing" as never, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + pipelineRunId: "pipeline-source-routing" as never, + laneKey: "source" as never, + laneEntryToken: "tok-source-routing" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-source-step" as never, + ticketId: "ticket-source-routing" as never, + occurredAt: "2026-06-07T00:00:05.000Z" as never, + payload: { + pipelineRunId: "pipeline-source-routing" as never, + stepRunId: "step-source-routing" as never, + stepKey: "code" as never, + stepType: "agent", + }, + } as never); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-source-queued-created" as never, + ticketId: "ticket-source-queued" as never, + occurredAt: "2026-06-07T00:00:06.000Z" as never, + payload: { + boardId: "b-routed-wip" as never, + title: "Source queued", + laneKey: "source" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketQueued", + eventId: "evt-source-queued" as never, + ticketId: "ticket-source-queued" as never, + occurredAt: "2026-06-07T00:00:07.000Z" as never, + payload: { lane: "source" as never }, + } as never); + + yield* engine.completeRecoveredStep("step-source-routing" as never, { _tag: "completed" }); + + const routedDetail = yield* read.getTicketDetail("ticket-source-routing" as never); + const admittedDetail = yield* read.getTicketDetail("ticket-source-queued" as never); + assert.equal(routedDetail?.ticket.currentLaneKey, "target"); + assert.equal(routedDetail?.ticket.status, "queued"); + assert.equal(routedDetail?.ticket.currentLaneEntryToken, null); + assert.isNotNull(routedDetail?.ticket.queuedAt ?? null); + assert.equal(admittedDetail?.ticket.currentLaneKey, "source"); + assert.equal(admittedDetail?.ticket.status, "idle"); + assert.isNotNull(admittedDetail?.ticket.currentLaneEntryToken ?? null); + assert.equal(admittedDetail?.ticket.queuedAt, null); + + const events = yield* Stream.runCollect( + store.readByTicket("ticket-source-routing" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + const routeIndex = events.findIndex((event) => event.type === "TicketRouteDecided"); + const queueIndex = events.findIndex((event) => event.type === "TicketQueued"); + assert.isTrue(routeIndex >= 0 && queueIndex > routeIndex); + assert.isFalse( + events.some( + (event) => event.type === "TicketMovedToLane" && event.payload.reason === "routed", + ), + ); + }), + ); + + it.effect("admits only one queued ticket after two concurrent exits from an overfull lane", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const committer = yield* WorkflowEventCommitter; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + yield* registry.register("b-concurrent-exit" as never, concurrentExitDefinition); + + const seedAdmitted = (ticketId: string, token: string, offset: number) => + Effect.gen(function* () { + yield* committer.commit({ + type: "TicketCreated", + eventId: `evt-${ticketId}-created` as never, + ticketId: ticketId as never, + occurredAt: `2026-06-07T00:01:0${offset}.000Z` as never, + payload: { + boardId: "b-concurrent-exit" as never, + title: ticketId, + laneKey: "source" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: `evt-${ticketId}-admitted` as never, + ticketId: ticketId as never, + occurredAt: `2026-06-07T00:01:1${offset}.000Z` as never, + payload: { + toLane: "source" as never, + laneEntryToken: token as never, + reason: "initial", + }, + } as never); + }); + const seedQueued = (ticketId: string, offset: number) => + Effect.gen(function* () { + yield* committer.commit({ + type: "TicketCreated", + eventId: `evt-${ticketId}-created` as never, + ticketId: ticketId as never, + occurredAt: `2026-06-07T00:02:0${offset}.000Z` as never, + payload: { + boardId: "b-concurrent-exit" as never, + title: ticketId, + laneKey: "source" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketQueued", + eventId: `evt-${ticketId}-queued` as never, + ticketId: ticketId as never, + occurredAt: `2026-06-07T00:02:1${offset}.000Z` as never, + payload: { lane: "source" as never }, + } as never); + }); + + yield* seedAdmitted("ticket-exit-a", "tok-exit-a", 1); + yield* seedAdmitted("ticket-exit-b", "tok-exit-b", 2); + yield* seedQueued("ticket-queued-a", 1); + yield* seedQueued("ticket-queued-b", 2); + + yield* Effect.all( + [ + engine.moveTicket("ticket-exit-a" as never, "done" as never), + engine.moveTicket("ticket-exit-b" as never, "done" as never), + ], + { concurrency: "unbounded" }, + ); + + const queuedA = yield* read.getTicketDetail("ticket-queued-a" as never); + const queuedB = yield* read.getTicketDetail("ticket-queued-b" as never); + const admittedCount = yield* read.countAdmittedInLane( + "b-concurrent-exit" as never, + "source" as never, + ); + const admittedQueuedTickets = [queuedA, queuedB].filter( + (detail) => detail !== null && detail.ticket.currentLaneEntryToken !== null, + ); + + assert.equal(admittedCount, 1); + assert.equal(admittedQueuedTickets.length, 1); + assert.equal(queuedA?.ticket.status, "idle"); + assert.equal(queuedA?.ticket.queuedAt, null); + assert.equal(queuedB?.ticket.status, "queued"); + assert.equal(queuedB?.ticket.currentLaneEntryToken, null); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.workSource.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.workSource.test.ts new file mode 100644 index 00000000000..528a43c2b88 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.workSource.test.ts @@ -0,0 +1,614 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +// A step that blocks forever so a ticket admitted into an auto lane keeps a +// running pipeline we can prove the external supersession path interrupts. +const blockingExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.never, +} satisfies StepExecutorShape); + +const layer = it.layer( + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(blockingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +// inbox lane is WIP-limited (1) so a second create queues; work lane is auto so +// an admitted ticket starts a (blocking) pipeline we can supersede; done is the +// terminal lane work_source closes tickets into. +const definition = { + name: "work source", + lanes: [ + { + key: "inbox", + name: "Inbox", + entry: "manual", + wipLimit: 1, + }, + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +// The committer holds the board save lock + an open transaction once per chunk; +// the unlocked engine ops assume that context. This mirrors how Task 9's syncer +// will drive them — fetch the lock + sql from context and wrap the body. +const inLockAndTx = <A, E>(boardId: string, body: Effect.Effect<A, E, SqlClient.SqlClient>) => + Effect.gen(function* () { + const saveLocks = yield* WorkflowBoardSaveLocks; + const sql = yield* SqlClient.SqlClient; + return yield* saveLocks.withSaveLock(boardId as never, sql.withTransaction(body)); + }); + +layer("WorkflowEngine work_source unlocked ops", (it) => { + it.effect("createTicketAndEnterUnlocked admits into an empty lane", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-empty" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const result = yield* inLockAndTx( + "b-ws-empty", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-empty" as never, + title: "First", + description: "from a work source", + destinationLane: "inbox" as never, + }), + ); + + assert.equal(result.outcome, "moved"); + + const detail = yield* read.getTicketDetail(result.ticketId); + assert.equal(detail?.ticket.currentLaneKey, "inbox"); + assert.equal(detail?.ticket.title, "First"); + assert.equal(detail?.ticket.description, "from a work source"); + + const events = yield* Stream.runCollect(store.readByTicket(result.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isDefined(events.find((event) => event.type === "TicketCreated")); + assert.isDefined(events.find((event) => event.type === "TicketMovedToLane")); + }), + ); + + it.effect("createTicketAndEnterUnlocked queues when the WIP-1 lane is occupied", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-wip" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const first = yield* inLockAndTx( + "b-ws-wip", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-wip" as never, + title: "Occupant", + destinationLane: "inbox" as never, + }), + ); + assert.equal(first.outcome, "moved"); + + const second = yield* inLockAndTx( + "b-ws-wip", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-wip" as never, + title: "Queued", + destinationLane: "inbox" as never, + }), + ); + assert.equal(second.outcome, "queued"); + + const detail = yield* read.getTicketDetail(second.ticketId); + assert.equal(detail?.ticket.currentLaneKey, "inbox"); + assert.equal(detail?.ticket.queuedAt !== null, true); + + const events = yield* Stream.runCollect(store.readByTicket(second.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isDefined(events.find((event) => event.type === "TicketCreated")); + assert.isDefined(events.find((event) => event.type === "TicketQueued")); + }), + ); + + it.effect("closeTicketFromSourceUnlocked moves to the closed lane and records work_source", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-close" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const created = yield* inLockAndTx( + "b-ws-close", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-close" as never, + title: "Close me", + destinationLane: "inbox" as never, + }), + ); + + yield* inLockAndTx( + "b-ws-close", + engine.closeTicketFromSourceUnlocked(created.ticketId, "done" as never), + ); + + const detail = yield* read.getTicketDetail(created.ticketId); + assert.equal(detail?.ticket.currentLaneKey, "done"); + + const events = yield* Stream.runCollect(store.readByTicket(created.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const decision = events.find((event) => event.type === "TicketRouteDecided"); + assert.isDefined(decision); + if (decision?.type === "TicketRouteDecided") { + assert.equal(decision.payload.source, "work_source"); + assert.equal(decision.payload.toLane, "done"); + assert.equal(decision.payload.fromLane, "inbox"); + } + const externalMove = events.find( + (event) => + event.type === "TicketMovedToLane" && + event.payload.reason === "external" && + event.payload.toLane === ("done" as string), + ); + assert.isDefined(externalMove); + + const decisions = yield* read.listTicketRouteDecisions(created.ticketId); + assert.isDefined(decisions.find((row) => row.source === "work_source")); + }), + ); + + it.effect("closeTicketFromSourceUnlocked supersedes running work via the external path", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-supersede" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + const created = yield* inLockAndTx( + "b-ws-supersede", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-supersede" as never, + title: "Running work", + destinationLane: "inbox" as never, + }), + ); + + // Seed a pending dispatch outbox row standing in for in-flight work. The + // external supersession path (which closeTicketFromSourceUnlocked reuses) + // tombstones pending/started rows to 'confirmed' so restart recovery never + // re-dispatches the superseded work. + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, ticket_id, step_run_id, thread_id, provider_instance, + model, instruction, worktree_path, status, created_at + ) VALUES ( + 'dispatch-ws-1', ${created.ticketId}, 'step-ws-1', 'thread-ws-1', 'claude_main', + 'sonnet', 'do it', '/tmp/wt', 'pending', '2026-06-13T00:00:00.000Z' + ) + `; + + yield* inLockAndTx( + "b-ws-supersede", + engine.closeTicketFromSourceUnlocked(created.ticketId, "done" as never), + ); + + const live = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_dispatch_outbox + WHERE ticket_id = ${created.ticketId} AND status IN ('pending', 'started') + `; + assert.equal(live[0]?.count ?? 0, 0); + + const detail = yield* read.getTicketDetail(created.ticketId); + assert.equal(detail?.ticket.currentLaneKey, "done"); + }), + ); + + it.effect("editTicketFieldsUnlocked appends a TicketEdited event", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-edit" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const created = yield* inLockAndTx( + "b-ws-edit", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-edit" as never, + title: "Old title", + destinationLane: "inbox" as never, + }), + ); + + yield* inLockAndTx( + "b-ws-edit", + engine.editTicketFieldsUnlocked(created.ticketId, { + title: "New title", + description: "New description", + }), + ); + + const detail = yield* read.getTicketDetail(created.ticketId); + assert.equal(detail?.ticket.title, "New title"); + assert.equal(detail?.ticket.description, "New description"); + + const events = yield* Stream.runCollect(store.readByTicket(created.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isDefined(events.find((event) => event.type === "TicketEdited")); + }), + ); + + it.effect( + "editTicketFieldsUnlocked does not blank the stored title for a whitespace-only title", + () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-blank" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const created = yield* inLockAndTx( + "b-ws-blank", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-blank" as never, + title: "Keep me", + destinationLane: "inbox" as never, + }), + ); + + // A whitespace-only title must be OMITTED (mirrors locked editTicket): + // with no other field, nothing changes and no event is emitted; the + // stored title stays intact rather than being overwritten to "". + yield* inLockAndTx( + "b-ws-blank", + engine.editTicketFieldsUnlocked(created.ticketId, { title: " " }), + ); + + const detail = yield* read.getTicketDetail(created.ticketId); + assert.equal(detail?.ticket.title, "Keep me"); + + const events = yield* Stream.runCollect(store.readByTicket(created.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isUndefined(events.find((event) => event.type === "TicketEdited")); + + // A whitespace-only title alongside a real description still drops the + // title but applies the description. + yield* inLockAndTx( + "b-ws-blank", + engine.editTicketFieldsUnlocked(created.ticketId, { + title: " ", + description: "real desc", + }), + ); + const after = yield* read.getTicketDetail(created.ticketId); + assert.equal(after?.ticket.title, "Keep me"); + assert.equal(after?.ticket.description, "real desc"); + }), + ); + + it.effect( + "Fix 1: editTicketFieldsUnlocked WRITES an empty-string description (clear), distinct from undefined (leave)", + () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-clear" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const created = yield* inLockAndTx( + "b-ws-clear", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-clear" as never, + title: "Title", + description: "Has a body", + destinationLane: "inbox" as never, + }), + ); + const before = yield* read.getTicketDetail(created.ticketId); + assert.equal(before?.ticket.description, "Has a body"); + + // A PROVIDED empty-string description is a valid CLEAR: it must emit a + // TicketEdited{description:""} and the projection must show "" — NOT keep + // the old body and NOT be dropped like an empty title. + yield* inLockAndTx( + "b-ws-clear", + engine.editTicketFieldsUnlocked(created.ticketId, { description: "" }), + ); + + const after = yield* read.getTicketDetail(created.ticketId); + assert.equal(after?.ticket.description, ""); + + const events = yield* Stream.runCollect(store.readByTicket(created.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const edited = events.filter((event) => event.type === "TicketEdited"); + assert.isAbove(edited.length, 0); + }), + ); + + it.effect("withBoardAdmissionLock mutually excludes bodies for the same board", () => + Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const inside = yield* Ref.make(0); + const maxConcurrent = yield* Ref.make(0); + const firstEntered = yield* Deferred.make<void>(); + const release = yield* Deferred.make<void>(); + + // Body A enters the admission lock, signals it is inside, then blocks on + // `release`. If the lock did NOT mutually exclude, body B would enter + // concurrently and push the observed concurrency above 1. + const bodyA = engine.withBoardAdmissionLock( + "b-admit-mutex" as never, + Effect.gen(function* () { + const n = yield* Ref.updateAndGet(inside, (c) => c + 1); + yield* Ref.update(maxConcurrent, (m) => Math.max(m, n)); + yield* Deferred.succeed(firstEntered, undefined); + yield* Deferred.await(release); + yield* Ref.update(inside, (c) => c - 1); + }), + ); + + const bodyB = engine.withBoardAdmissionLock( + "b-admit-mutex" as never, + Effect.gen(function* () { + const n = yield* Ref.updateAndGet(inside, (c) => c + 1); + yield* Ref.update(maxConcurrent, (m) => Math.max(m, n)); + yield* Ref.update(inside, (c) => c - 1); + }), + ); + + const fiberA = yield* bodyA.pipe(Effect.forkScoped); + yield* Deferred.await(firstEntered); + // B is launched while A is provably still inside the lock. + const fiberB = yield* bodyB.pipe(Effect.forkScoped); + // Give B a chance to (wrongly) enter if the lock were not exclusive. + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(fiberA); + yield* Fiber.join(fiberB); + + assert.equal(yield* Ref.get(maxConcurrent), 1); + }), + ); + + it.effect( + "withBoardAdmissionLock serializes an unlocked admit against the public path: exactly one admitted into a WIP-1 lane", + () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-admit-race" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + // Seed one ticket sitting (admitted) in a NON-target lane so the public + // path can move it into the WIP-1 `inbox` lane. The unlocked path + // creates a second ticket destined for the same `inbox` lane. + const mover = yield* inLockAndTx( + "b-admit-race", + engine.createTicketAndEnterUnlocked({ + boardId: "b-admit-race" as never, + title: "Mover", + destinationLane: "done" as never, + }), + ); + + // PUBLIC path: moveTicket wraps its WIP read-decide in the admission + // lock (admission OUTER) and takes the save lock at commit (INNER). + const publicAdmit = engine.moveTicket(mover.ticketId, "inbox" as never); + + // UNLOCKED path mirrors the source committer: admission lock (OUTER) -> + // save lock (INNER) -> transaction, then the unlocked create+enter. + const unlockedAdmit = engine.withBoardAdmissionLock( + "b-admit-race" as never, + inLockAndTx( + "b-admit-race", + engine.createTicketAndEnterUnlocked({ + boardId: "b-admit-race" as never, + title: "Syncer", + destinationLane: "inbox" as never, + }), + ), + ); + + // Run them concurrently. Because both serialize their WIP read-decide + // under the SAME per-board admission semaphore, exactly one wins + // admission into the WIP-1 lane; the other is queued. Without the shared + // admission lock both could read occupancy=0 and both admit. + yield* Effect.all([publicAdmit, unlockedAdmit], { concurrency: "unbounded" }); + + const admittedCount = yield* read.countAdmittedInLane( + "b-admit-race" as never, + "inbox" as never, + ); + assert.equal(admittedCount, 1); + }), + ); +}); + +// Fix 1 (stale-token start guard). recoverBoardWip / runLane snapshot a ticket's +// lane+token, then start its pipeline LATER. A user/source move in between can +// change the ticket's current_lane_entry_token, leaving the snapshot stale. +// startPipeline must re-read the live projection and SKIP a start whose +// lane/token no longer matches. We prove it by decorating the read model so +// runLane is handed a STALE token while the live projection (which the guard +// reads via raw SQL) still holds the real, current token: the guard must skip, +// so no pipeline run is ever created for the stale token. +it.effect("startPipeline skips a stale-token start whose ticket has moved on", () => + Effect.gen(function* () { + const staleToken = yield* Ref.make<{ + readonly ticketId: string; + readonly token: string; + } | null>(null); + + // Decorator: requires the real WorkflowReadModel and re-publishes it, + // overriding getTicketDetail to swap in a stale token for the targeted + // ticket. Everything else delegates unchanged. + const StaleReadModel = Layer.effect( + WorkflowReadModel, + Effect.gen(function* () { + const base = yield* WorkflowReadModel; + const override: typeof base.getTicketDetail = (ticketId) => + Effect.gen(function* () { + const detail = yield* base.getTicketDetail(ticketId); + const stale = yield* Ref.get(staleToken); + if (detail === null || stale === null || (ticketId as string) !== stale.ticketId) { + return detail; + } + return { + ...detail, + ticket: { ...detail.ticket, currentLaneEntryToken: stale.token }, + }; + }); + return { ...base, getTicketDetail: override } satisfies typeof base; + }), + ).pipe(Layer.provide(WorkflowReadModelLive)); + + const testLayer = WorkflowEngineLayer.pipe( + Layer.provide(StaleReadModel), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(blockingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-stale-start" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + // Admit a ticket into the auto `work` lane (start dropped under the + // unlocked path). It now holds the real, current token in `work`. + const created = yield* inLockAndTx( + "b-stale-start", + engine.createTicketAndEnterUnlocked({ + boardId: "b-stale-start" as never, + title: "Moved on", + destinationLane: "work" as never, + }), + ); + + // Point the decorator at this ticket with a token that does NOT match the + // live projection — modelling a move that re-tokened the ticket after the + // snapshot but before the start. + yield* Ref.set(staleToken, { + ticketId: created.ticketId as string, + token: "stale-entry-token", + }); + + // runLane reads the (stale) detail and asks startPipeline to start the + // stale token. The guard re-reads the live token and skips. + yield* engine.runLane(created.ticketId); + yield* Effect.yieldNow; + + const staleRuns = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_pipeline_run + WHERE ticket_id = ${created.ticketId as string} + AND lane_entry_token = 'stale-entry-token' + `; + assert.equal(staleRuns[0]?.count ?? 0, 0); + + // No pipeline run at all was created from the stale start. + const anyRuns = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_pipeline_run + WHERE ticket_id = ${created.ticketId as string} + `; + assert.equal(anyRuns[0]?.count ?? 0, 0); + + // With the stale override cleared, the ticket is still legitimately + // admitted in `work`: runLane now starts the real, current token. + yield* Ref.set(staleToken, null); + const liveDetail = yield* read.getTicketDetail(created.ticketId); + assert.equal(liveDetail?.ticket.currentLaneKey, "work"); + yield* engine.runLane(created.ticketId); + yield* Effect.yieldNow; + const liveRuns = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_pipeline_run + WHERE ticket_id = ${created.ticketId as string} + AND lane_entry_token = ${liveDetail?.ticket.currentLaneEntryToken as string} + `; + assert.isAbove(liveRuns[0]?.count ?? 0, 0); + }).pipe(Effect.provide(testLayer)); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowEventCommitter.outbound.test.ts b/apps/server/src/workflow/Layers/WorkflowEventCommitter.outbound.test.ts new file mode 100644 index 00000000000..75618517082 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventCommitter.outbound.test.ts @@ -0,0 +1,509 @@ +import { assert, it } from "@effect/vitest"; +import type { BoardId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { PredicateEvaluationError, PredicateEvaluator } from "../Services/PredicateEvaluator.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; + +const layer = it.layer( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +// A PredicateEvaluator that always errors — used to prove the committer isolates +// predicate-eval failures (skips the rule, never fails the commit). The board +// lint shares the same static JSONLogic inspector as the live evaluator, so any +// rule that registers cannot statically fail at eval time; a failing-evaluator +// stub deterministically reproduces a runtime eval error. +const failingEvaluatorLayer = WorkflowEventCommitterLive.pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge( + Layer.succeed(PredicateEvaluator, { + evaluate: () => Effect.fail(new PredicateEvaluationError({ message: "forced eval failure" })), + } satisfies PredicateEvaluator["Service"]), + ), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), +); + +// A board with multiple lanes, one of which is terminal, so terminal/`done` +// rules and lane_entered `when` predicates can be exercised. +const registerBoard = (boardId: string, outbound: ReadonlyArray<Record<string, unknown>>) => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + yield* registry.register(boardId as never, { + name: boardId, + outbound: outbound as never, + lanes: [ + { key: "impl", name: "Impl", entry: "manual" }, + { key: "in-progress", name: "In Progress", entry: "manual" }, + { key: "needs-attention", name: "Needs Attention", entry: "manual" }, + { key: "shipped", name: "Shipped", entry: "manual", terminal: true }, + ] as never, + }); + yield* read.registerBoard({ + boardId: boardId as never, + projectId: "project-outbound" as never, + name: boardId, + workflowFilePath: `.t3/boards/${boardId}.json`, + workflowVersionHash: `hash-${boardId}`, + maxConcurrentTickets: 3, + }); + }); + +const insertProjectedTicket = (input: { + readonly ticketId: string; + readonly boardId: string; + readonly title: string; + readonly lane?: string; + readonly status?: string; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) VALUES ( + ${input.ticketId}, ${input.boardId}, ${input.title}, + ${input.lane ?? "impl"}, ${input.status ?? "running"}, ${now}, ${now} + ) + `; + }); + +interface DeliveryRow { + readonly deliveryId: string; + readonly boardId: string; + readonly ticketId: string; + readonly ruleId: string; + readonly eventSequence: number; + readonly connectionRef: string; + readonly formatter: string; + readonly contextJson: string; + readonly deliveryState: string; + readonly attemptCount: number; + readonly nextAttemptAt: string | null; +} + +const deliveryRows = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<DeliveryRow>` + SELECT + delivery_id AS "deliveryId", + board_id AS "boardId", + ticket_id AS "ticketId", + rule_id AS "ruleId", + event_sequence AS "eventSequence", + connection_ref AS "connectionRef", + formatter, + context_json AS "contextJson", + delivery_state AS "deliveryState", + attempt_count AS "attemptCount", + next_attempt_at AS "nextAttemptAt" + FROM workflow_outbound_delivery + WHERE ticket_id = ${ticketId} + ORDER BY rule_id ASC + `; + }); + +const eventSequence = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly sequence: number }>` + SELECT sequence FROM workflow_events WHERE ticket_id = ${ticketId} ORDER BY sequence ASC + `; + return rows; + }); + +layer("WorkflowEventCommitter outbound", (it) => { + it.effect("writes exactly one delivery row for a matching, enabled rule", () => + Effect.gen(function* () { + const boardId = "b-out-blocked"; + const ticketId = "t-out-blocked"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId, [ + { id: "r1", on: "blocked", to: "conn-1", as: "slack", enabled: true }, + { id: "r2", on: "done", to: "conn-1", as: "generic", enabled: true }, + { id: "r3", on: "blocked", to: "conn-1", as: "slack", enabled: false }, + ]); + yield* insertProjectedTicket({ ticketId, boardId, title: "Blocked T", status: "running" }); + + yield* committer.commit({ + type: "TicketBlocked", + eventId: "e-out-blocked" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "dep missing" }, + }); + + const rows = yield* deliveryRows(ticketId); + assert.equal(rows.length, 1); + const row = rows[0]!; + assert.equal(row.ruleId, "r1"); + assert.equal(row.connectionRef, "conn-1"); + assert.equal(row.formatter, "slack"); + assert.equal(row.deliveryState, "pending"); + assert.equal(row.attemptCount, 0); + assert.isNull(row.nextAttemptAt); + + const seq = yield* eventSequence(ticketId); + assert.equal(row.deliveryId, `dlv-${seq[0]!.sequence}-r1`); + assert.equal(row.eventSequence, seq[0]!.sequence); + + // @effect-diagnostics-next-line preferSchemaOverJson:off - decoding the stored context_json for assertions in a test. + const ctx = JSON.parse(row.contextJson) as Record<string, unknown>; + assert.equal(ctx.trigger, "blocked"); + assert.equal(ctx.reason, "dep missing"); + assert.equal(ctx.ticketId, ticketId); + assert.equal(ctx.boardId, boardId); + }), + ); + + it.effect("evaluates a when-predicate and writes only when it matches", () => + Effect.gen(function* () { + const boardId = "b-out-when"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId, [ + { + id: "r-when", + on: "lane_entered", + when: { "==": [{ var: "toLane" }, "needs-attention"] }, + to: "conn-1", + as: "generic", + enabled: true, + }, + ]); + + // Move into needs-attention → predicate true → 1 row. + const matchTicket = "t-out-when-match"; + yield* insertProjectedTicket({ + ticketId: matchTicket, + boardId, + title: "Match", + lane: "impl", + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "e-out-when-match" as never, + ticketId: matchTicket as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "needs-attention" as never, + laneEntryToken: "tok-match" as never, + reason: "routed", + }, + }); + assert.equal((yield* deliveryRows(matchTicket)).length, 1); + + // Move into in-progress → predicate false → 0 rows. + const missTicket = "t-out-when-miss"; + yield* insertProjectedTicket({ ticketId: missTicket, boardId, title: "Miss", lane: "impl" }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "e-out-when-miss" as never, + ticketId: missTicket as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + toLane: "in-progress" as never, + laneEntryToken: "tok-miss" as never, + reason: "routed", + }, + }); + assert.equal((yield* deliveryRows(missTicket)).length, 0); + }), + ); + + it.effect("a `done` rule fires only on entry into a terminal lane", () => + Effect.gen(function* () { + const boardId = "b-out-done"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId, [ + { id: "r-done", on: "done", to: "conn-1", as: "generic", enabled: true }, + ]); + + // Into terminal lane → 1 row. + const termTicket = "t-out-done-term"; + yield* insertProjectedTicket({ ticketId: termTicket, boardId, title: "Term", lane: "impl" }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "e-out-done-term" as never, + ticketId: termTicket as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "shipped" as never, + laneEntryToken: "tok-term" as never, + reason: "routed", + }, + }); + const termRows = yield* deliveryRows(termTicket); + assert.equal(termRows.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off - decoding the stored context_json for assertions in a test. + const ctx = JSON.parse(termRows[0]!.contextJson) as Record<string, unknown>; + assert.equal(ctx.isTerminal, true); + // A rule matched as `done` stores trigger="done" (not the lane_entered label). + assert.equal(ctx.trigger, "done"); + + // Into a non-terminal lane → 0 rows. + const nonTermTicket = "t-out-done-nonterm"; + yield* insertProjectedTicket({ + ticketId: nonTermTicket, + boardId, + title: "NonTerm", + lane: "impl", + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "e-out-done-nonterm" as never, + ticketId: nonTermTicket as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + toLane: "in-progress" as never, + laneEntryToken: "tok-nonterm" as never, + reason: "routed", + }, + }); + assert.equal((yield* deliveryRows(nonTermTicket)).length, 0); + }), + ); + + it.effect( + 'a `done` rule with a trigger=="done" predicate matches on a terminal move, not a non-terminal one', + () => + Effect.gen(function* () { + const boardId = "b-out-done-pred"; + const committer = yield* WorkflowEventCommitter; + // The board-editor UI suggests exactly this predicate for `done` rules. + yield* registerBoard(boardId, [ + { + id: "r-done-pred", + on: "done", + when: { "==": [{ var: "trigger" }, "done"] }, + to: "conn-1", + as: "generic", + enabled: true, + }, + ]); + + // Into a terminal lane → matchesTrigger passes, ruleCtx.trigger === "done" + // → predicate true → 1 row, stored trigger is "done". + const termTicket = "t-out-done-pred-term"; + yield* insertProjectedTicket({ + ticketId: termTicket, + boardId, + title: "Term", + lane: "impl", + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "e-out-done-pred-term" as never, + ticketId: termTicket as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "shipped" as never, + laneEntryToken: "tok-done-pred-term" as never, + reason: "routed", + }, + }); + const termRows = yield* deliveryRows(termTicket); + assert.equal(termRows.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off - decoding the stored context_json for assertions in a test. + const ctx = JSON.parse(termRows[0]!.contextJson) as Record<string, unknown>; + assert.equal(ctx.trigger, "done"); + + // Into a non-terminal lane → matchesTrigger fails → 0 rows. + const nonTermTicket = "t-out-done-pred-nonterm"; + yield* insertProjectedTicket({ + ticketId: nonTermTicket, + boardId, + title: "NonTerm", + lane: "impl", + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "e-out-done-pred-nonterm" as never, + ticketId: nonTermTicket as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + toLane: "in-progress" as never, + laneEntryToken: "tok-done-pred-nonterm" as never, + reason: "routed", + }, + }); + assert.equal((yield* deliveryRows(nonTermTicket)).length, 0); + }), + ); + + it.effect("the stored context occurredAt is the event's occurrence time, not commit time", () => + Effect.gen(function* () { + const boardId = "b-out-occurred"; + const ticketId = "t-out-occurred"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId, [ + { id: "r-any", on: "lane_entered", to: "conn-1", as: "generic", enabled: true }, + ]); + yield* insertProjectedTicket({ ticketId, boardId, title: "Occurred", lane: "impl" }); + + // A distinct, known occurrence time — deliberately not "now"/commit time. + const eventOccurredAt = "2024-01-02T03:04:05.000Z"; + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "e-out-occurred" as never, + ticketId: ticketId as never, + occurredAt: eventOccurredAt as never, + payload: { + toLane: "in-progress" as never, + laneEntryToken: "tok-occurred" as never, + reason: "routed", + }, + }); + + const rows = yield* deliveryRows(ticketId); + assert.equal(rows.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off - decoding the stored context_json for assertions in a test. + const ctx = JSON.parse(rows[0]!.contextJson) as Record<string, unknown>; + assert.equal(ctx.occurredAt, eventOccurredAt); + }), + ); + + it.effect("re-running the same supersede+insert for one sequence stays exactly-once", () => + Effect.gen(function* () { + // The event store enforces UNIQUE(event_id), so a duplicate commit fails at + // append before reaching the outbound path. Exercise the INSERT OR IGNORE + + // UNIQUE(event_sequence, rule_id) directly: a re-insert at the same + // (sequence, ruleId) must not create a second row. + const boardId = "b-out-idem"; + const ticketId = "t-out-idem"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId, [ + { id: "r1", on: "blocked", to: "conn-1", as: "slack", enabled: true }, + ]); + yield* insertProjectedTicket({ ticketId, boardId, title: "Idem", status: "running" }); + + yield* committer.commit({ + type: "TicketBlocked", + eventId: "e-out-idem" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "first" }, + }); + + const rows = yield* deliveryRows(ticketId); + assert.equal(rows.length, 1); + const seq = rows[0]!.eventSequence; + + // Replay the exact same deterministic insert for the same (sequence, rule). + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT OR IGNORE INTO workflow_outbound_delivery ( + delivery_id, board_id, ticket_id, rule_id, event_sequence, + connection_ref, formatter, context_json, delivery_state, attempt_count, + next_attempt_at, created_at + ) VALUES ( + ${`dlv-${seq}-r1`}, ${boardId}, ${ticketId}, 'r1', ${seq}, + 'conn-1', 'slack', '{}', 'pending', 0, NULL, '2026-06-07T00:00:02.000Z' + ) + `; + assert.equal((yield* deliveryRows(ticketId)).length, 1); + }), + ); + + it.effect("the stored context fromLane is the ticket's PRE-move lane", () => + Effect.gen(function* () { + const boardId = "b-out-fromlane"; + const ticketId = "t-out-fromlane"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId, [ + { id: "r-any", on: "lane_entered", to: "conn-1", as: "generic", enabled: true }, + ]); + // Ticket currently sits in "impl". + yield* insertProjectedTicket({ ticketId, boardId, title: "FromLane", lane: "impl" }); + + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "e-out-fromlane" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "in-progress" as never, + laneEntryToken: "tok-fromlane" as never, + reason: "routed", + }, + }); + + const rows = yield* deliveryRows(ticketId); + assert.equal(rows.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off - decoding the stored context_json for assertions in a test. + const ctx = JSON.parse(rows[0]!.contextJson) as Record<string, unknown>; + assert.equal(ctx.fromLane, "impl"); + assert.equal(ctx.toLane, "in-progress"); + }), + ); +}); + +it.effect("a predicate-eval error skips the rule without failing the commit", () => + Effect.gen(function* () { + const boardId = "b-out-evalerr"; + const ticketId = "t-out-evalerr"; + const committer = yield* WorkflowEventCommitter; + // A registrable rule with a valid static `when`. The injected evaluator + // forces a runtime eval error: the rule must be skipped, but the commit + // (append + projection) must still succeed and no delivery row is written. + yield* registerBoard(boardId, [ + { + id: "r-err", + on: "blocked", + when: { "==": [{ var: "toLane" }, "x"] }, + to: "conn-1", + as: "slack", + enabled: true, + }, + ]); + yield* insertProjectedTicket({ ticketId, boardId, title: "EvalErr", status: "running" }); + + const exit = yield* Effect.exit( + committer.commit({ + type: "TicketBlocked", + eventId: "e-out-evalerr" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "boom" }, + }), + ); + assert.isTrue(exit._tag === "Success"); + + // Event persisted + projection applied (status flipped to blocked) ... + const seq = yield* eventSequence(ticketId); + assert.equal(seq.length, 1); + const sql = yield* SqlClient.SqlClient; + const tickets = yield* sql<{ readonly status: string }>` + SELECT status FROM projection_ticket WHERE ticket_id = ${ticketId} + `; + assert.equal(tickets[0]?.status, "blocked"); + // ... but the erroring rule produced no delivery row. + assert.equal((yield* deliveryRows(ticketId)).length, 0); + }).pipe(Effect.provide(failingEvaluatorLayer)), +); diff --git a/apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts b/apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts new file mode 100644 index 00000000000..f2a0b9b9c35 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts @@ -0,0 +1,950 @@ +import { assert, it } from "@effect/vitest"; +import type { BoardId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStore, type PersistedWorkflowEvent } from "../Services/WorkflowEventStore.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; + +const layer = it.layer( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const registerBoard = (boardId: string) => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + yield* registry.register(boardId as never, { + name: boardId, + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }); + yield* read.registerBoard({ + boardId: boardId as never, + projectId: "project-committer" as never, + name: boardId, + workflowFilePath: `.t3/boards/${boardId}.json`, + workflowVersionHash: `hash-${boardId}`, + maxConcurrentTickets: 3, + }); + }); + +const insertProjectedTicket = (input: { + readonly ticketId: string; + readonly boardId: string; + readonly title: string; + readonly lane?: string; + readonly status?: string; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + ${input.ticketId}, + ${input.boardId}, + ${input.title}, + ${input.lane ?? "impl"}, + ${input.status ?? "running"}, + ${now}, + ${now} + ) + `; + }); + +const workflowEventCount = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = ${ticketId} + `; + return rows[0]?.count ?? 0; + }); + +interface OutboxRow { + readonly outboxId: string; + readonly ticketId: string; + readonly boardId: string; + readonly sequence: number; + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + readonly deliveryState: string; + readonly attemptCount: number; +} + +const outboxRows = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<OutboxRow>` + SELECT + outbox_id AS "outboxId", + ticket_id AS "ticketId", + board_id AS "boardId", + sequence, + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason", + delivery_state AS "deliveryState", + attempt_count AS "attemptCount" + FROM workflow_notification_outbox + WHERE ticket_id = ${ticketId} + ORDER BY sequence ASC + `; + }); + +const outboxCount = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_notification_outbox + WHERE ticket_id = ${ticketId} + `; + return rows[0]?.count ?? 0; + }); + +const commitManyLayerWithSaveLockInterposition = ( + expectedBoardId: BoardId, + beforeLockedEffect: (sql: SqlClient.SqlClient) => Effect.Effect<void, SqlError>, +) => { + const saveLocksLayer = Layer.effect( + WorkflowBoardSaveLocks, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return { + withSaveLock: (lockBoardId, effect) => + Effect.gen(function* () { + if (lockBoardId !== expectedBoardId) { + return yield* Effect.die(`unexpected board lock ${lockBoardId as string}`); + } + yield* beforeLockedEffect(sql).pipe(Effect.orDie); + return yield* effect; + }), + } satisfies WorkflowBoardSaveLocks["Service"]; + }), + ); + + return WorkflowEventCommitterLive.pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(saveLocksLayer), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); +}; + +it.effect( + "WorkflowEventCommitter.commitMany acquires the board save lock before its transaction without re-entering it", + () => + Effect.gen(function* () { + const boardId = "b-commit-many-delete-lock-order" as BoardId; + const persistedEvents: PersistedWorkflowEvent[] = []; + const projectedEvents: PersistedWorkflowEvent[] = []; + let inTransaction = false; + let boardLockHeld = false; + let saveLockAcquisitions = 0; + + const unsupportedEffect = () => Effect.die("unsupported fake committer dependency") as never; + const unsupportedStream = () => Stream.die("unsupported fake committer dependency") as never; + const fakeSql = Object.assign( + // Tagged queries (status diff selects + outbox insert) return empty rows; + // this batch never crosses into a needs-you status so no insert is asserted. + (() => Effect.succeed([])) as unknown as SqlClient.SqlClient, + { + withTransaction: <R, E, A>(effect: Effect.Effect<A, E, R>) => + Effect.gen(function* () { + if (inTransaction) { + return yield* Effect.die("commitMany opened a nested transaction"); + } + inTransaction = true; + return yield* effect.pipe( + Effect.ensuring( + Effect.sync(() => { + inTransaction = false; + }), + ), + ); + }), + } satisfies Partial<SqlClient.SqlClient>, + ) as SqlClient.SqlClient; + const fakeSaveLocks = Layer.succeed(WorkflowBoardSaveLocks, { + withSaveLock: (lockBoardId, effect) => + Effect.gen(function* () { + if (lockBoardId !== boardId) { + return yield* Effect.die(`unexpected board lock ${lockBoardId as string}`); + } + if (inTransaction) { + return yield* Effect.die("commitMany acquired the save lock inside a transaction"); + } + if (boardLockHeld) { + return yield* Effect.die("commitMany re-entered the non-reentrant save lock"); + } + saveLockAcquisitions += 1; + boardLockHeld = true; + return yield* effect.pipe( + Effect.ensuring( + Effect.sync(() => { + boardLockHeld = false; + }), + ), + ); + }), + } satisfies WorkflowBoardSaveLocks["Service"]); + const fakeStore = Layer.succeed(WorkflowEventStore, { + append: (event) => + Effect.sync(() => { + const persisted = { + ...event, + streamVersion: persistedEvents.length, + sequence: persistedEvents.length + 1, + } as PersistedWorkflowEvent; + persistedEvents.push(persisted); + return persisted; + }), + readByTicket: unsupportedStream, + readFromSequence: unsupportedStream, + readAll: unsupportedStream, + deleteForBoard: unsupportedEffect, + deleteForTicket: unsupportedEffect, + } satisfies WorkflowEventStore["Service"]); + const fakeProjectionPipeline = Layer.succeed(WorkflowProjectionPipeline, { + projectEvent: (event) => + Effect.sync(() => { + projectedEvents.push(event as PersistedWorkflowEvent); + }), + } satisfies WorkflowProjectionPipeline["Service"]); + const fakeReadModel = Layer.succeed(WorkflowReadModel, { + registerBoard: unsupportedEffect, + getBoard: unsupportedEffect, + deleteBoard: unsupportedEffect, + deleteBoardTicketState: unsupportedEffect, + deleteTicketState: unsupportedEffect, + listBoardsForProject: unsupportedEffect, + listTickets: unsupportedEffect, + countAdmittedInLane: unsupportedEffect, + oldestQueuedForLane: unsupportedEffect, + getTicketDetail: () => Effect.succeed(null), + listTicketMessages: unsupportedEffect, + listStepRunsForPipeline: unsupportedEffect, + countLanePipelineRuns: unsupportedEffect, + listTicketDiscussion: unsupportedEffect, + listReleasableDependents: unsupportedEffect, + getBoardDigest: unsupportedEffect, + getBoardMetrics: unsupportedEffect, + listNeedsAttentionTickets: () => Effect.succeed([]), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: unsupportedEffect, + getTicketPrState: unsupportedEffect, + recordBoardProposal: unsupportedEffect, + listBoardProposals: () => Effect.succeed([]), + getBoardProposal: () => Effect.succeed(null), + listLiveOccupiedLanes: () => Effect.succeed([]), + resolveBoardProposalStatus: () => Effect.succeed(1), + listWorkSourceMappingsForBoard: () => Effect.succeed([]), + } satisfies WorkflowReadModel["Service"]); + const fakeRegistry = Layer.succeed(BoardRegistry, { + register: unsupportedEffect, + unregister: unsupportedEffect, + getDefinition: (requestedBoardId) => + Effect.succeed( + requestedBoardId === boardId + ? ({ + name: "Fake", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }], + } as never) + : null, + ), + listDefinitions: unsupportedEffect, + getLane: unsupportedEffect, + } satisfies BoardRegistry["Service"]); + const fakeIds = Layer.succeed(WorkflowIds, { + ticketId: unsupportedEffect, + pipelineRunId: unsupportedEffect, + scriptRunId: unsupportedEffect, + stepRunId: unsupportedEffect, + messageId: unsupportedEffect, + eventId: () => Effect.succeed("evt-fake" as never), + token: unsupportedEffect, + mappingId: unsupportedEffect, + } satisfies WorkflowIds["Service"]); + + yield* Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + + yield* committer.commitMany([ + { + type: "TicketCreated", + eventId: "e-commit-many-delete-lock-order-1" as never, + ticketId: "t-commit-many-delete-lock-order" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId, + title: "Lock order" as never, + laneKey: "backlog" as never, + }, + }, + { + type: "TicketMovedToLane", + eventId: "e-commit-many-delete-lock-order-2" as never, + ticketId: "t-commit-many-delete-lock-order" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "backlog" as never, + laneEntryToken: "tok-lock-order" as never, + reason: "routed", + }, + }, + ]); + + assert.equal(saveLockAcquisitions, 1); + assert.deepEqual( + persistedEvents.map((event) => event.type), + ["TicketCreated", "TicketMovedToLane"], + ); + assert.deepEqual( + projectedEvents.map((event) => event.type), + ["TicketCreated", "TicketMovedToLane"], + ); + }).pipe( + Effect.provide( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(fakeRegistry), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(fakeSaveLocks), + Layer.provideMerge(fakeStore), + Layer.provideMerge(fakeProjectionPipeline), + Layer.provideMerge(fakeReadModel), + Layer.provideMerge(fakeIds), + Layer.provideMerge(Layer.succeed(SqlClient.SqlClient, fakeSql)), + ), + ), + ); + }), +); + +it.effect( + "commitMany skips stale events when an existing ticket was deleted under the save lock", + () => + Effect.gen(function* () { + const boardId = "b-commit-many-retention-delete" as BoardId; + const ticketId = "t-commit-many-retention-delete"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ + ticketId, + boardId, + title: "Retention deleted", + }); + + yield* committer.commitMany([ + { + type: "TicketBlocked", + eventId: "e-commit-many-retention-delete" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "stale" }, + }, + ]); + + assert.equal(yield* workflowEventCount(ticketId), 0); + }).pipe( + Effect.provide( + commitManyLayerWithSaveLockInterposition( + "b-commit-many-retention-delete" as BoardId, + (sql) => + sql` + DELETE FROM projection_ticket + WHERE ticket_id = ${"t-commit-many-retention-delete"} + `.pipe(Effect.asVoid), + ), + ), + ), +); + +it.effect( + "commitMany skips stale events when an existing ticket moved to another board under the save lock", + () => + Effect.gen(function* () { + const originalBoardId = "b-commit-many-move-original" as BoardId; + const movedBoardId = "b-commit-many-move-target" as BoardId; + const ticketId = "t-commit-many-move"; + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + yield* registerBoard(originalBoardId); + yield* registerBoard(movedBoardId); + yield* insertProjectedTicket({ + ticketId, + boardId: originalBoardId, + title: "Moved", + }); + + yield* committer.commitMany([ + { + type: "TicketBlocked", + eventId: "e-commit-many-move" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "wrong-board" }, + }, + ]); + + const tickets = yield* sql<{ readonly boardId: string; readonly status: string }>` + SELECT board_id AS "boardId", status + FROM projection_ticket + WHERE ticket_id = ${ticketId} + `; + assert.equal(yield* workflowEventCount(ticketId), 0); + assert.deepEqual(tickets, [{ boardId: movedBoardId, status: "running" }]); + }).pipe( + Effect.provide( + commitManyLayerWithSaveLockInterposition("b-commit-many-move-original" as BoardId, (sql) => + sql` + UPDATE projection_ticket + SET board_id = ${"b-commit-many-move-target"} + WHERE ticket_id = ${"t-commit-many-move"} + `.pipe(Effect.asVoid), + ), + ), + ), +); + +layer("WorkflowEventCommitter", (it) => { + it.effect("appends and projects in one call", () => + Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + yield* registerBoard("b-1"); + + yield* committer.commit({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "X" as never, laneKey: "backlog" as never }, + }); + + const rows = yield* sql<{ readonly title: string }>` + SELECT title FROM projection_ticket WHERE ticket_id = 't-1' + `; + assert.equal(rows[0]?.title, "X"); + }), + ); + + it.effect("commitMany appends and projects all events in one transaction", () => + Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + yield* registerBoard("b-1"); + + yield* committer.commitMany([ + { + type: "TicketCreated", + eventId: "e-many-1" as never, + ticketId: "t-many" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "Many" as never, laneKey: "backlog" as never }, + }, + { + type: "TicketMovedToLane", + eventId: "e-many-2" as never, + ticketId: "t-many" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-many" as never, + reason: "routed", + }, + }, + ]); + + const events = yield* sql<{ readonly eventType: string; readonly streamVersion: number }>` + SELECT event_type AS "eventType", stream_version AS "streamVersion" + FROM workflow_events + WHERE ticket_id = 't-many' + ORDER BY stream_version ASC + `; + const tickets = yield* sql<{ readonly lane: string; readonly token: string | null }>` + SELECT current_lane_key AS lane, current_lane_entry_token AS token + FROM projection_ticket + WHERE ticket_id = 't-many' + `; + assert.deepEqual(events, [ + { eventType: "TicketCreated", streamVersion: 0 }, + { eventType: "TicketMovedToLane", streamVersion: 1 }, + ]); + assert.deepEqual(tickets, [{ lane: "impl", token: "tok-many" }]); + }), + ); + + it.effect("commitMany rolls back earlier appends and projections when a later append fails", () => + Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + yield* registerBoard("b-rollback"); + + const exit = yield* Effect.exit( + committer.commitMany([ + { + type: "TicketCreated", + eventId: "e-rollback-shared" as never, + ticketId: "t-rollback-a" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-rollback" as never, + title: "Rollback A" as never, + laneKey: "backlog" as never, + }, + }, + { + type: "TicketCreated", + eventId: "e-rollback-shared" as never, + ticketId: "t-rollback-b" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + boardId: "b-rollback" as never, + title: "Rollback B" as never, + laneKey: "backlog" as never, + }, + }, + ]), + ); + assert.isTrue(Exit.isFailure(exit)); + + const eventRows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events + WHERE ticket_id IN ('t-rollback-a', 't-rollback-b') + `; + const projectionRows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM projection_ticket + WHERE ticket_id IN ('t-rollback-a', 't-rollback-b') + `; + assert.equal(eventRows[0]?.count, 0); + assert.equal(projectionRows[0]?.count, 0); + }), + ); + + it.effect( + "commitMany appends and projects an event for an existing ticket that still matches the board", + () => + Effect.gen(function* () { + const boardId = "b-commit-many-existing" as BoardId; + const ticketId = "t-commit-many-existing"; + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ + ticketId, + boardId, + title: "Existing", + }); + + yield* committer.commitMany([ + { + type: "TicketBlocked", + eventId: "e-commit-many-existing" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "normal" }, + }, + ]); + + const tickets = yield* sql<{ readonly status: string }>` + SELECT status + FROM projection_ticket + WHERE ticket_id = ${ticketId} + `; + assert.equal(yield* workflowEventCount(ticketId), 1); + assert.deepEqual(tickets, [{ status: "blocked" }]); + }), + ); + + it.effect("does not append a step event when board deletion wins the save lock", () => + Effect.gen(function* () { + const boardId = "b-committer-delete-race" as never; + const ticketId = "t-committer-delete-race" as never; + const now = "2026-06-07T00:00:00.000Z"; + const committer = yield* WorkflowEventCommitter; + const eventStore = yield* WorkflowEventStore; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const saveLocks = yield* WorkflowBoardSaveLocks; + const sql = yield* SqlClient.SqlClient; + const deleteReady = yield* Deferred.make<void>(); + const releaseDelete = yield* Deferred.make<void>(); + + yield* registerBoard(boardId); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES (${ticketId}, ${boardId}, 'Delete race', 'impl', 'running', ${now}, ${now}) + `; + + const deleteFiber = yield* saveLocks + .withSaveLock( + boardId, + Effect.gen(function* () { + yield* eventStore.deleteForBoard(boardId); + yield* read.deleteBoardTicketState(boardId); + yield* registry.unregister(boardId); + yield* read.deleteBoard(boardId); + yield* Deferred.succeed(deleteReady, undefined); + yield* Deferred.await(releaseDelete); + }), + ) + .pipe(Effect.forkChild); + + yield* Deferred.await(deleteReady).pipe(Effect.timeout("1 second")); + const commitFiber = yield* committer + .commit({ + type: "StepCompleted", + eventId: "evt-delete-race-step-completed" as never, + ticketId, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { stepRunId: "step-delete-race" as never }, + }) + .pipe(Effect.exit, Effect.forkChild); + + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseDelete, undefined); + yield* Fiber.join(deleteFiber).pipe(Effect.timeout("1 second")); + yield* Fiber.join(commitFiber).pipe(Effect.timeout("1 second")); + + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = ${ticketId} + `; + assert.equal(rows[0]?.count, 0); + }), + ); + + it.effect("writes exactly one outbox row when an event flips a ticket into waiting_on_user", () => + Effect.gen(function* () { + const boardId = "b-outbox-waiting"; + const ticketId = "t-outbox-waiting"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ ticketId, boardId, title: "Waiting", status: "running" }); + + const persisted = yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "e-outbox-waiting" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + stepRunId: "step-outbox-waiting" as never, + waitingReason: "Need input", + providerResponseKind: "user-input", + }, + }); + + const rows = yield* outboxRows(ticketId); + assert.equal(rows.length, 1); + const row = rows[0]!; + assert.equal(row.ticketId, ticketId); + assert.equal(row.boardId, boardId); + assert.equal(row.status, "waiting_on_user"); + assert.equal(row.attentionKind, "waiting_for_input"); + assert.equal(row.attentionReason, "Need input"); + assert.equal(row.deliveryState, "pending"); + assert.equal(row.attemptCount, 0); + // sequence matches the persisted event's sequence (the commit returns it) + const eventRows = yield* (yield* SqlClient.SqlClient)<{ readonly sequence: number }>` + SELECT sequence FROM workflow_events WHERE ticket_id = ${ticketId} + `; + assert.equal(row.sequence, eventRows[0]?.sequence); + assert.isNotNull(persisted); + }), + ); + + it.effect("writes a blocked outbox row when a ticket is blocked", () => + Effect.gen(function* () { + const boardId = "b-outbox-blocked"; + const ticketId = "t-outbox-blocked"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ ticketId, boardId, title: "Blocked", status: "running" }); + + yield* committer.commit({ + type: "TicketBlocked", + eventId: "e-outbox-blocked" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "dependency missing" }, + }); + + const rows = yield* outboxRows(ticketId); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.status, "blocked"); + assert.equal(rows[0]?.attentionKind, "blocked"); + assert.equal(rows[0]?.attentionReason, "dependency missing"); + }), + ); + + it.effect("writes no outbox row when an event does not cross into a needs-you state", () => + Effect.gen(function* () { + const boardId = "b-outbox-no-cross"; + const ticketId = "t-outbox-no-cross"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ ticketId, boardId, title: "Plain", status: "running" }); + + // A plain lane move keeps the ticket out of any needs-you status. + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "e-outbox-no-cross" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-no-cross" as never, + reason: "routed", + }, + }); + + assert.equal(yield* outboxCount(ticketId), 0); + }), + ); + + it.effect( + "does not write a second outbox row when the ticket stays in the same needs-you status", + () => + Effect.gen(function* () { + const boardId = "b-outbox-stay"; + const ticketId = "t-outbox-stay"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ ticketId, boardId, title: "Stay", status: "running" }); + + // First transition into waiting_on_user → one row. + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "e-outbox-stay-1" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + stepRunId: "step-outbox-stay" as never, + waitingReason: "First", + providerResponseKind: "user-input", + }, + }); + assert.equal(yield* outboxCount(ticketId), 1); + + // A second StepAwaitingUser while already waiting_on_user → still one row + // (newStatus is needs-you but newStatus === prevStatus, so no new transition). + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "e-outbox-stay-2" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + stepRunId: "step-outbox-stay" as never, + waitingReason: "Second", + providerResponseKind: "user-input", + }, + }); + assert.equal(yield* outboxCount(ticketId), 1); + }), + ); + + it.effect( + "supersedes the prior pending row when a ticket rapidly transitions to a new needs-you state", + () => + Effect.gen(function* () { + const boardId = "b-outbox-supersede"; + const ticketId = "t-outbox-supersede"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ ticketId, boardId, title: "Supersede", status: "running" }); + + // 1) waiting_on_user → outbox row A (pending) + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "e-outbox-supersede-1" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + stepRunId: "step-outbox-supersede" as never, + waitingReason: "approve deploy?", + providerResponseKind: "request", + }, + }); + // 2) blocked → outbox row B (pending); row A must be superseded + yield* committer.commit({ + type: "TicketBlocked", + eventId: "e-outbox-supersede-2" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { reason: "merge conflict" }, + }); + + const rows = yield* outboxRows(ticketId); + assert.equal(rows.length, 2); + const pending = rows.filter((row) => row.deliveryState === "pending"); + const superseded = rows.filter((row) => row.deliveryState === "superseded"); + // Exactly one pending row remains — the latest (blocked) transition. + assert.equal(pending.length, 1); + assert.equal(pending[0]?.status, "blocked"); + assert.equal(pending[0]?.attentionKind, "blocked"); + assert.equal(pending[0]?.attentionReason, "merge conflict"); + // The earlier waiting row is superseded (never delivered). + assert.equal(superseded.length, 1); + assert.equal(superseded[0]?.status, "waiting_on_user"); + // The pending row is the highest sequence. + assert.isAbove(pending[0]!.sequence, superseded[0]!.sequence); + }), + ); + + it.effect( + "the supersede guard does not strand the current row on idempotent re-projection of the same sequence", + () => + Effect.gen(function* () { + // The committer's event store enforces UNIQUE(event_id), so a duplicate + // commit fails at append before reaching the outbox path. This test instead + // exercises the load-bearing `sequence != persisted.sequence` guard SQL + // directly: a row already pending at sequence S must survive a re-run of the + // supersede+insert pair for that SAME sequence S, while a genuinely older + // pending row (different sequence) gets superseded. + const ticketId = "t-outbox-idempotent"; + const boardId = "b-outbox-idempotent"; + const sql = yield* SqlClient.SqlClient; + + const insertPending = (outboxId: string, sequence: number) => + sql` + INSERT OR IGNORE INTO workflow_notification_outbox ( + outbox_id, ticket_id, board_id, sequence, status, + attention_kind, attention_reason, delivery_state, attempt_count, created_at + ) VALUES ( + ${outboxId}, ${ticketId}, ${boardId}, ${sequence}, 'waiting_on_user', + 'waiting_for_input', 'r', 'pending', 0, '2026-06-07T00:00:00.000Z' + ) + `; + const supersedeOthers = (sequence: number) => + sql` + UPDATE workflow_notification_outbox + SET delivery_state = 'superseded' + WHERE ticket_id = ${ticketId} + AND delivery_state = 'pending' + AND sequence != ${sequence} + `; + + // Older pending row at sequence 1, current pending row at sequence 2. + yield* insertPending("ob-old", 1); + yield* insertPending("ob-current", 2); + + // Re-projection of the SAME event (sequence 2): supersede others, then + // re-insert (ignored as a duplicate). The current row must stay pending. + yield* supersedeOthers(2); + yield* insertPending("ob-current", 2); + + const rows = yield* outboxRows(ticketId); + assert.equal(rows.length, 2); + const current = rows.find((row) => row.sequence === 2); + const older = rows.find((row) => row.sequence === 1); + // The != guard protected the current sequence's own row. + assert.equal(current?.deliveryState, "pending"); + // Genuinely older pending row was superseded. + assert.equal(older?.deliveryState, "superseded"); + }), + ); +}); + +it.effect( + "rolls back both the event and the outbox row when projection fails inside the commit transaction", + () => + Effect.gen(function* () { + const boardId = "b-outbox-atomic" as BoardId; + const ticketId = "t-outbox-atomic"; + + const committer = yield* WorkflowEventCommitter; + + // Match the other integration tests' setup: register the board (registry + + // projection_board) then seed a running projection_ticket row. + yield* registerBoard(boardId); + yield* insertProjectedTicket({ ticketId, boardId, title: "Atomic", status: "running" }); + + const exit = yield* Effect.exit( + committer.commit({ + type: "TicketBlocked", + eventId: "e-outbox-atomic" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "boom" }, + }), + ); + assert.isTrue(Exit.isFailure(exit)); + + // The failing projection must roll back the appended event AND prevent any + // outbox row from being written (single-commit path is transactional). + assert.equal(yield* workflowEventCount(ticketId), 0); + assert.equal(yield* outboxCount(ticketId), 0); + }).pipe( + Effect.provide( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + // Replace the real projection pipeline with one that always fails so the + // surrounding transaction must roll back the appended event. + Layer.provideMerge( + Layer.succeed(WorkflowProjectionPipeline, { + projectEvent: () => Effect.fail("projection blew up") as never, + } satisfies WorkflowProjectionPipeline["Service"]), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ), + ), +); diff --git a/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts b/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts new file mode 100644 index 00000000000..26b7ea08314 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts @@ -0,0 +1,544 @@ +import type { + BoardId, + BoardTicketView, + LaneKey, + TicketId, + TicketStatus, + WorkflowTicketAttentionKind, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { WorkflowBoardEvents } from "../Services/WorkflowBoardEvents.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { PredicateEvaluator } from "../Services/PredicateEvaluator.ts"; +import { + OUTBOUND_EVENT_TYPES, + buildOutboundContext, + contextForRule, + matchesTrigger, +} from "../outbound/outboundEventContext.ts"; +import { + WorkflowEventCommitter, + type WorkflowEventCommitterShape, +} from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowEventStore, type PersistedWorkflowEvent } from "../Services/WorkflowEventStore.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +// Statuses that mean "a human needs to act". Crossing INTO one of these (and only +// into — not staying) emits exactly one durable notification outbox row. +const NEEDS_YOU_STATUSES = new Set(["waiting_on_user", "blocked"]); + +// Only these two event types can ever project a needs-you status (per the +// projection audit: StepAwaitingUser → waiting_on_user, TicketBlocked → blocked). +// Every other event skips the status-diff reads entirely, keeping the hot step +// loop (StepStarted/StepCompleted/StepRefsCaptured/PipelineStarted/...) free of +// the two extra projection_ticket point-reads. +const NOTIFIABLE_EVENT_TYPES = new Set(["StepAwaitingUser", "TicketBlocked"]); + +const isWorkflowEventStoreError = Schema.is(WorkflowEventStoreError); +const toCommitterError = (cause: unknown) => + isWorkflowEventStoreError(cause) + ? cause + : new WorkflowEventStoreError({ message: "workflow commit transaction failed", cause }); + +const boardNotRegistered = (boardId: BoardId) => + new WorkflowEventStoreError({ message: `Workflow board ${boardId} is no longer registered` }); + +const make = Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const pipeline = yield* WorkflowProjectionPipeline; + const readModel = yield* WorkflowReadModel; + const registry = yield* BoardRegistry; + const predicates = yield* PredicateEvaluator; + const saveLocks = yield* WorkflowBoardSaveLocks; + const ids = yield* WorkflowIds; + const sql = yield* SqlClient.SqlClient; + type CommitEvent = Parameters<WorkflowEventCommitterShape["commit"]>[0]; + interface ResolvedCommitEvent { + readonly event: CommitEvent; + readonly boardId: BoardId | undefined; + } + interface RecheckedCommitEvent extends ResolvedCommitEvent { + readonly shouldCommit: boolean; + } + + const getOptionalServices = Effect.context<never>().pipe( + Effect.map((context) => ({ + boardEvents: Context.getOption( + context as Context.Context<WorkflowBoardEvents>, + WorkflowBoardEvents, + ), + })), + ); + + const resolveBoardId = (event: CommitEvent) => + Effect.gen(function* () { + if (event.type === "TicketCreated") { + return event.payload.boardId; + } + const detail = yield* readModel.getTicketDetail(event.ticketId); + return detail?.ticket.boardId as BoardId | undefined; + }); + + const recheckRegisteredBoard = (boardId: BoardId, event: CommitEvent) => + Effect.gen(function* () { + const definitionExit = yield* Effect.exit(registry.getDefinition(boardId)); + if (Exit.isSuccess(definitionExit) && definitionExit.value === null) { + if (event.type === "TicketCreated") { + return yield* boardNotRegistered(boardId); + } + return false; + } + if (event.type === "TicketCreated") { + return true; + } + const detail = yield* readModel.getTicketDetail(event.ticketId); + return detail?.ticket.boardId === boardId; + }); + + // Shared by both commit paths. Runs inside a transaction (single-commit wraps it + // in sql.withTransaction; commitMany wraps the whole batch). Diffs the ticket + // status across the projection and, when the event crosses INTO a needs-you + // status, writes one durable outbox row keyed by the event sequence (UNIQUE). + const appendAndProjectUnlocked = (event: CommitEvent) => + Effect.gen(function* () { + const needsNotification = NOTIFIABLE_EVENT_TYPES.has(event.type); + const needsOutbound = OUTBOUND_EVENT_TYPES.has(event.type); + // Fast path: events that can never notify nor fire an outbound rule skip the + // two projection_ticket point-reads and the insert(s) entirely. + if (!needsNotification && !needsOutbound) { + const persisted = yield* store.append(event); + yield* pipeline.projectEvent(persisted); + return persisted; + } + // PREV-state read (before projection): status drives the notification diff, + // current_lane_key is the outbound `fromLane` snapshot. + const prevRows = yield* sql<{ + readonly status: string; + readonly currentLaneKey: string; + }>` + SELECT status, current_lane_key AS "currentLaneKey" + FROM projection_ticket WHERE ticket_id = ${event.ticketId} + `; + const prevStatus = prevRows[0]?.status ?? null; + const prevLane = prevRows[0]?.currentLaneKey ?? null; + const persisted = yield* store.append(event); + yield* pipeline.projectEvent(persisted); + const nextRows = yield* sql<{ + readonly status: string; + readonly boardId: string; + readonly title: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT status, board_id AS "boardId", title, + attention_kind AS "attentionKind", attention_reason AS "attentionReason" + FROM projection_ticket WHERE ticket_id = ${event.ticketId} + `; + const next = nextRows[0]; + if ( + needsNotification && + next !== undefined && + NEEDS_YOU_STATUSES.has(next.status) && + next.status !== prevStatus + ) { + const outboxId = yield* ids.eventId(); + const createdAt = yield* nowIso; + // Supersede any prior PENDING rows for this ticket so at most one pending + // row (the latest transition) ever reaches the dispatcher. Without this, a + // ticket that rapidly transitions through multiple needs-you states within + // one sweep window would push a stale earlier row's content. The + // `sequence != persisted.sequence` guard is load-bearing: an idempotent + // re-projection of the SAME event (row already pending at this sequence) + // must NOT supersede its own row and strand it — only genuinely older + // pending rows (different sequence) get superseded. + yield* sql` + UPDATE workflow_notification_outbox + SET delivery_state = 'superseded' + WHERE ticket_id = ${event.ticketId} + AND delivery_state = 'pending' + AND sequence != ${persisted.sequence} + `; + yield* sql` + INSERT OR IGNORE INTO workflow_notification_outbox ( + outbox_id, ticket_id, board_id, sequence, status, + attention_kind, attention_reason, delivery_state, attempt_count, created_at + ) VALUES ( + ${outboxId}, ${event.ticketId}, ${next.boardId}, ${persisted.sequence}, ${next.status}, + ${next.attentionKind}, ${next.attentionReason}, 'pending', 0, ${createdAt} + ) + `; + } + // Outbound delivery: for the broader OUTBOUND_EVENT_TYPES gate, evaluate the + // board's outbound rules against a bounded context and write one durable + // delivery row per surviving rule. Mirrors the notification block: bounded, + // pure, in-tx — the network happens later in the dispatcher. This block must + // never be able to fail the commit on outbound-specific causes: a missing def + // → no-op; a predicate-eval error → that rule is skipped + logged. + if (needsOutbound && next !== undefined) { + // The registry load must never fail the commit (mirrors recheckRegisteredBoard, + // which also wraps getDefinition in Effect.exit): a failed/dying registry or a + // null definition → no outbound work. + const definitionExit = yield* Effect.exit(registry.getDefinition(next.boardId as BoardId)); + const def = Exit.isSuccess(definitionExit) ? definitionExit.value : null; + const rules = def?.outbound ?? []; + if (rules.length > 0) { + const toLane = + event.type === "TicketMovedToLane" + ? (event.payload.toLane as string) + : event.type === "TicketAdmitted" + ? (event.payload.lane as string) + : null; + const isTerminal = + toLane !== null && def?.lanes?.find((lane) => lane.key === toLane)?.terminal === true; + const reason = + event.type === "TicketBlocked" + ? event.payload.reason + : event.type === "TicketMovedToLane" + ? event.payload.reason + : undefined; + // Row created_at is commit time; the context carries the event's OWN + // occurrence time (persisted.occurredAt) so replayed/batched/delayed + // events render with when they actually happened, not when committed. + const createdAt = yield* nowIso; + const ctx = buildOutboundContext({ + eventType: event.type, + ticketId: event.ticketId as string, + boardId: next.boardId, + title: next.title, + fromLane: prevLane, + toLane, + postStatus: next.status, + isTerminal, + reason, + occurredAt: persisted.occurredAt, + }); + for (const rule of rules) { + if (!rule.enabled) { + continue; + } + // matchesTrigger gates against the BASE ctx (its `done` case checks + // lane_entered && isTerminal). Only AFTER a match do we derive the + // rule-specific context: a `done` match carries trigger="done". + if (!matchesTrigger(rule, ctx)) { + continue; + } + const ruleCtx = contextForRule(rule, ctx); + if (rule.when !== undefined) { + // Predicate-eval errors must NEVER fail the commit: capture the exit, + // skip the rule, and log a warning. Effect.exit (not Effect.either) + // is the repo idiom used elsewhere in this committer for in-tx + // best-effort sub-effects (see recheckRegisteredBoard). + const evaluationExit = yield* Effect.exit(predicates.evaluate(rule.when, ruleCtx)); + if (Exit.isFailure(evaluationExit)) { + yield* Effect.logWarning( + `outbound rule ${rule.id} predicate evaluation failed; skipping`, + ).pipe(Effect.annotateLogs({ ruleId: rule.id, ticketId: event.ticketId })); + continue; + } + if (!evaluationExit.value.result) { + continue; + } + } + // Serialize the rule-specific OutboundEventContext into the delivery + // row's TEXT column for the dispatcher to read back. + // @effect-diagnostics-next-line preferSchemaOverJson:off - OutboundEventContext is a fixed, validated shape serialized verbatim into context_json. + const contextJson = JSON.stringify(ruleCtx); + const deliveryId = `dlv-${persisted.sequence}-${rule.id}`; + yield* sql` + INSERT OR IGNORE INTO workflow_outbound_delivery ( + delivery_id, board_id, ticket_id, rule_id, event_sequence, + connection_ref, formatter, context_json, delivery_state, attempt_count, + next_attempt_at, created_at + ) VALUES ( + ${deliveryId}, ${next.boardId}, ${event.ticketId}, ${rule.id}, ${persisted.sequence}, + ${rule.to}, ${rule.as}, ${contextJson}, 'pending', 0, + ${null}, ${createdAt} + ) + `; + } + } + } + return persisted; + }); + + const appendAndProject = ( + event: CommitEvent, + ): Effect.Effect<PersistedWorkflowEvent | null, WorkflowEventStoreError> => + Effect.gen(function* () { + const boardId = yield* resolveBoardId(event); + if (boardId === undefined) { + return null; + } + return yield* saveLocks.withSaveLock( + boardId, + Effect.gen(function* () { + const isRegistered = yield* recheckRegisteredBoard(boardId, event); + if (!isRegistered) { + return null; + } + // Lock OUTSIDE, transaction INSIDE. appendAndProjectUnlocked never opens + // its own transaction, so commitMany (which already wraps the batch in a + // single withTransaction) does not nest. + return yield* sql + .withTransaction(appendAndProjectUnlocked(event)) + .pipe(Effect.mapError(toCommitterError)); + }), + ); + }); + + const resolveBatchBoardIds = (events: ReadonlyArray<CommitEvent>) => + Effect.gen(function* () { + const ticketBoardIds = new Map<string, BoardId>(); + const resolved: Array<ResolvedCommitEvent> = []; + + for (const event of events) { + let boardId: BoardId | undefined; + if (event.type === "TicketCreated") { + boardId = event.payload.boardId; + } else { + boardId = ticketBoardIds.get(event.ticketId as string); + if (boardId === undefined) { + boardId = yield* resolveBoardId(event); + } + } + + if (boardId !== undefined) { + ticketBoardIds.set(event.ticketId as string, boardId); + } + resolved.push({ event, boardId }); + } + + return resolved; + }); + + const distinctSortedBoardIds = (events: ReadonlyArray<ResolvedCommitEvent>) => + Array.from( + new Set(events.flatMap(({ boardId }) => (boardId === undefined ? [] : [boardId]))), + ).sort((left, right) => (left as string).localeCompare(right as string)); + + const withBoardSaveLocks = <A, E, R>( + boardIds: ReadonlyArray<BoardId>, + effect: Effect.Effect<A, E, R>, + ) => + boardIds.reduceRight( + (lockedEffect, boardId) => saveLocks.withSaveLock(boardId, lockedEffect), + effect, + ); + + const recheckRegisteredBoards = ( + resolved: ReadonlyArray<ResolvedCommitEvent>, + boardIds: ReadonlyArray<BoardId>, + ) => + Effect.gen(function* () { + const registeredBoards = new Map<string, boolean>(); + + for (const boardId of boardIds) { + const definitionExit = yield* Effect.exit(registry.getDefinition(boardId)); + const isRegistered = !(Exit.isSuccess(definitionExit) && definitionExit.value === null); + if ( + !isRegistered && + resolved.some( + ({ boardId: eventBoardId, event }) => + eventBoardId === boardId && event.type === "TicketCreated", + ) + ) { + return yield* boardNotRegistered(boardId); + } + registeredBoards.set(boardId as string, isRegistered); + } + + return registeredBoards; + }); + + const recheckBatchTickets = ( + resolved: ReadonlyArray<ResolvedCommitEvent>, + registeredBoards: ReadonlyMap<string, boolean>, + ) => + Effect.gen(function* () { + const createdTicketIds = new Set<string>(); + const rechecked: Array<RecheckedCommitEvent> = []; + + for (const resolvedEvent of resolved) { + const { event, boardId } = resolvedEvent; + const ticketId = event.ticketId as string; + + if (boardId === undefined || registeredBoards.get(boardId as string) !== true) { + rechecked.push({ ...resolvedEvent, shouldCommit: false }); + continue; + } + + if (event.type === "TicketCreated") { + createdTicketIds.add(ticketId); + rechecked.push({ ...resolvedEvent, shouldCommit: true }); + continue; + } + + if (createdTicketIds.has(ticketId)) { + rechecked.push({ ...resolvedEvent, shouldCommit: true }); + continue; + } + + const detail = yield* readModel.getTicketDetail(event.ticketId); + rechecked.push({ + ...resolvedEvent, + shouldCommit: detail?.ticket.boardId === boardId, + }); + } + + return rechecked; + }); + + const publishTicketView = (ticketId: PersistedWorkflowEvent["ticketId"]) => + Effect.gen(function* () { + const detail = yield* readModel.getTicketDetail(ticketId); + const { boardEvents } = yield* getOptionalServices; + if (detail && Option.isSome(boardEvents)) { + const ticket = detail.ticket; + yield* boardEvents.value.publish({ + ticketId: ticket.ticketId as TicketId, + boardId: ticket.boardId as BoardId, + title: ticket.title, + ...(ticket.description === null ? {} : { description: ticket.description }), + currentLaneKey: ticket.currentLaneKey as LaneKey, + status: ticket.status as TicketStatus, + ...(ticket.queuedAt === null ? {} : { queuedAt: ticket.queuedAt }), + ...(ticket.dependsOn === undefined || ticket.dependsOn.length === 0 + ? {} + : { dependsOn: ticket.dependsOn as ReadonlyArray<TicketId> }), + ...(ticket.unresolvedDependencyCount === undefined || + ticket.unresolvedDependencyCount === 0 + ? {} + : { unresolvedDependencyCount: ticket.unresolvedDependencyCount }), + ...(typeof ticket.tokenBudget === "number" ? { tokenBudget: ticket.tokenBudget } : {}), + ...(ticket.updatedAt === undefined ? {} : { updatedAt: ticket.updatedAt }), + ...(typeof ticket.totalTokens === "number" && ticket.totalTokens > 0 + ? { totalTokens: ticket.totalTokens } + : {}), + ...(typeof ticket.totalDurationMs === "number" && ticket.totalDurationMs > 0 + ? { totalDurationMs: ticket.totalDurationMs } + : {}), + ...(ticket.pr === undefined ? {} : { pr: ticket.pr }), + // Attention fields are populated when the ticket is waiting_on_user / + // blocked — exactly the states a publish is triggered for — so the + // live BoardTicketView carries the same attention detail as a refetch. + ...(ticket.attentionKind === undefined || ticket.attentionKind === null + ? {} + : { attentionKind: ticket.attentionKind as WorkflowTicketAttentionKind }), + ...(ticket.attentionReason === undefined || ticket.attentionReason === null + ? {} + : { attentionReason: ticket.attentionReason }), + } satisfies BoardTicketView); + } + }); + + const publishTicket = (persisted: PersistedWorkflowEvent) => + Effect.gen(function* () { + yield* publishTicketView(persisted.ticketId); + // Lane moves can change dependents' unresolved counts (terminal entry + // resolves them, leaving a terminal lane un-resolves them) — republish + // every dependent so waiting badges stay live. + if (persisted.type === "TicketMovedToLane" || persisted.type === "TicketDependenciesSet") { + const dependents = yield* readModel + .listDependentTicketIds(persisted.ticketId) + .pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach(dependents, (dependent) => publishTicketView(dependent as never), { + discard: true, + }); + } + }); + + const commit: WorkflowEventCommitterShape["commit"] = (event) => + appendAndProject(event).pipe( + Effect.flatMap((persisted) => (persisted === null ? Effect.void : publishTicket(persisted))), + ); + + const commitMany: WorkflowEventCommitterShape["commitMany"] = (events) => + Effect.gen(function* () { + const resolved = yield* resolveBatchBoardIds(events); + const boardIds = distinctSortedBoardIds(resolved); + if (boardIds.length === 0) { + return; + } + + const persisted = yield* withBoardSaveLocks( + boardIds, + Effect.gen(function* () { + const registeredBoards = yield* recheckRegisteredBoards(resolved, boardIds); + const rechecked = yield* recheckBatchTickets(resolved, registeredBoards); + return yield* sql + .withTransaction( + Effect.forEach( + rechecked, + ({ event, shouldCommit }) => + !shouldCommit ? Effect.succeed(null) : appendAndProjectUnlocked(event), + { concurrency: 1 }, + ), + ) + .pipe(Effect.mapError(toCommitterError)); + }), + ); + yield* Effect.forEach( + persisted, + (event) => (event === null ? Effect.void : publishTicket(event)), + { discard: true }, + ); + }); + + // CALLER MUST already hold the board save lock for every affected board AND be + // inside an open sql.withTransaction. This intentionally does NOT take the lock + // or open a transaction (it would deadlock / nest) and does NOT publish ticket + // views — it only appends+projects each event in order, returning the persisted + // rows for the caller to publish after releasing the lock. + const appendManyUnlocked: WorkflowEventCommitterShape["appendManyUnlocked"] = (events) => + Effect.forEach(events, (event) => appendAndProjectUnlocked(event), { + concurrency: 1, + }).pipe(Effect.mapError(toCommitterError)); + + // Public, post-lock ticket-view publish for batch syncers driving + // appendManyUnlocked (which does NOT publish). Mirrors publishTicket: emits the + // ticket's current view and, when requested (a terminal/lane move), republishes + // dependents so waiting badges stay live. + const publishTicketView_: WorkflowEventCommitterShape["publishTicketView"] = ( + ticketId, + options, + ) => + Effect.gen(function* () { + yield* publishTicketView(ticketId); + if (options?.republishDependents === true) { + const dependents = yield* readModel + .listDependentTicketIds(ticketId) + .pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach(dependents, (dependent) => publishTicketView(dependent as never), { + discard: true, + }); + } + }); + + return { + commit, + commitMany, + appendManyUnlocked, + publishTicketView: publishTicketView_, + } satisfies WorkflowEventCommitterShape; +}); + +// The committer evaluates outbound `when` predicates with the SAME JSONLogic +// evaluator routing/onEvent use, so PredicateEvaluator is a real RIn dependency +// (mirroring WorkflowEngine). Prod wires PredicateEvaluatorLive in the same merge +// (WorkflowRuntimeLive / WorkflowEngineLive). Tests provide PredicateEvaluatorLive +// — or a failing stub to exercise eval-error isolation. +export const WorkflowEventCommitterLive = Layer.effect(WorkflowEventCommitter, make); diff --git a/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts b/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts new file mode 100644 index 00000000000..f13114a26f7 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts @@ -0,0 +1,179 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEventStoreLive } from "./WorkflowEventStore.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("workflow migration", (it) => { + it.effect("creates workflow_events and projection tables", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const tables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' + AND name IN ( + 'workflow_events', + 'projection_board', + 'projection_ticket', + 'projection_pipeline_run', + 'projection_step_run' + ) + `; + assert.equal(tables.length, 5); + }), + ); +}); + +const storeLayer = it.layer( + WorkflowEventStoreLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +storeLayer("WorkflowEventStore", (it) => { + it.effect("appends and replays a decoded event with assigned version", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const appended = yield* store.append({ + type: "TicketCreated", + eventId: "evt-a" as never, + ticketId: "t-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "X" as never, laneKey: "backlog" as never }, + }); + assert.equal(appended.streamVersion, 0); + + const events = yield* Stream.runCollect(store.readByTicket("t-1" as never)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.equal(events.length, 1); + assert.equal(events[0]?.type, "TicketCreated"); + }), + ); + + it.effect("assigns incrementing stream versions per ticket", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + yield* store.append({ + type: "TicketCreated", + eventId: "evt-b" as never, + ticketId: "t-2" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "Y" as never, laneKey: "backlog" as never }, + }); + const second = yield* store.append({ + type: "TicketBlocked", + eventId: "evt-c" as never, + ticketId: "t-2" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "scope unclear" }, + }); + assert.equal(second.streamVersion, 1); + }), + ); + + it.effect("deletes events for tickets that belong to a board", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES + ('ticket-events-delete', 'board-events-delete', 'Delete', 'backlog', 'idle', ${now}, ${now}), + ('ticket-events-keep', 'board-events-keep', 'Keep', 'backlog', 'idle', ${now}, ${now}) + `; + yield* store.append({ + type: "TicketCreated", + eventId: "evt-delete" as never, + ticketId: "ticket-events-delete" as never, + occurredAt: now as never, + payload: { + boardId: "board-events-delete" as never, + title: "Delete" as never, + laneKey: "backlog" as never, + }, + }); + yield* store.append({ + type: "TicketCreated", + eventId: "evt-keep" as never, + ticketId: "ticket-events-keep" as never, + occurredAt: now as never, + payload: { + boardId: "board-events-keep" as never, + title: "Keep" as never, + laneKey: "backlog" as never, + }, + }); + + yield* store.deleteForBoard("board-events-delete" as never); + + const rows = yield* sql<{ readonly ticketId: string; readonly count: number }>` + SELECT ticket_id AS "ticketId", COUNT(*) AS count + FROM workflow_events + WHERE ticket_id IN ('ticket-events-delete', 'ticket-events-keep') + GROUP BY ticket_id + ORDER BY ticket_id ASC + `; + assert.deepEqual(rows, [{ ticketId: "ticket-events-keep", count: 1 }]); + }), + ); + + it.effect("deletes events for exactly one ticket", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* store.append({ + type: "TicketCreated", + eventId: "evt-ticket-delete" as never, + ticketId: "ticket-events-delete-one" as never, + occurredAt: now as never, + payload: { + boardId: "board-events-delete-one" as never, + title: "Delete" as never, + laneKey: "backlog" as never, + }, + }); + yield* store.append({ + type: "TicketCreated", + eventId: "evt-ticket-keep" as never, + ticketId: "ticket-events-keep-one" as never, + occurredAt: now as never, + payload: { + boardId: "board-events-delete-one" as never, + title: "Keep" as never, + laneKey: "backlog" as never, + }, + }); + + yield* store.deleteForTicket("ticket-events-delete-one" as never); + + const rows = yield* sql<{ readonly ticketId: string; readonly count: number }>` + SELECT ticket_id AS "ticketId", COUNT(*) AS count + FROM workflow_events + WHERE ticket_id IN ('ticket-events-delete-one', 'ticket-events-keep-one') + GROUP BY ticket_id + ORDER BY ticket_id ASC + `; + assert.deepEqual(rows, [{ ticketId: "ticket-events-keep-one", count: 1 }]); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEventStore.ts b/apps/server/src/workflow/Layers/WorkflowEventStore.ts new file mode 100644 index 00000000000..244ed7e19ce --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventStore.ts @@ -0,0 +1,177 @@ +import { WorkflowEvent } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowEventStore, + type PersistedWorkflowEvent, + type WorkflowEventStoreShape, +} from "../Services/WorkflowEventStore.ts"; + +interface Row { + readonly sequence: number; + readonly eventId: string; + readonly ticketId: string; + readonly streamVersion: number; + readonly type: string; + readonly occurredAt: string; + readonly payloadJson: string; +} + +const decodePayloadJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeWorkflowEvent = Schema.decodeUnknownEffect(WorkflowEvent); +const encodePayloadJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); + +const toStoreError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const decodeEvent = (row: Row): Effect.Effect<PersistedWorkflowEvent, WorkflowEventStoreError> => + Effect.gen(function* () { + const payload = yield* decodePayloadJson(row.payloadJson); + const event = yield* decodeWorkflowEvent({ + type: row.type, + eventId: row.eventId, + ticketId: row.ticketId, + streamVersion: row.streamVersion, + occurredAt: row.occurredAt, + payload, + }); + return { ...event, sequence: row.sequence } as PersistedWorkflowEvent; + }).pipe(Effect.mapError(toStoreError("Failed to decode workflow event"))); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // INVARIANT: append derives the next stream_version with an inline + // `SELECT MAX(stream_version)+1` subquery and has NO retry on the + // (ticket_id, stream_version) UNIQUE index (idx_workflow_events_stream_version, + // migration 033). It is therefore NOT internally safe against concurrent + // appends for the same ticket — callers MUST serialize per-ticket appends + // through that ticket's board save lock (every commit/commitMany path in + // WorkflowEventCommitter does so via saveLocks.withSaveLock / + // withBoardSaveLock). Two unserialized appenders would read the same MAX, + // both compute version N+1, and one INSERT would violate the UNIQUE index, + // surfacing as a generic "append failed" rather than an optimistic-concurrency + // retry. Any new append path must hold the board save lock (or this must be + // reworked into an explicit retry-on-conflict loop). + const append: WorkflowEventStoreShape["append"] = (event) => + Effect.gen(function* () { + const payloadJson = yield* encodePayloadJson(event.payload); + const rows = yield* sql<Row>` + INSERT INTO workflow_events + (event_id, ticket_id, stream_version, event_type, occurred_at, payload_json) + VALUES ( + ${event.eventId}, + ${event.ticketId}, + COALESCE( + ( + SELECT stream_version + 1 + FROM workflow_events + WHERE ticket_id = ${event.ticketId} + ORDER BY stream_version DESC + LIMIT 1 + ), + 0 + ), + ${event.type}, + ${event.occurredAt}, + ${payloadJson} + ) + RETURNING + sequence, + event_id AS "eventId", + ticket_id AS "ticketId", + stream_version AS "streamVersion", + event_type AS "type", + occurred_at AS "occurredAt", + payload_json AS "payloadJson" + `; + const row = rows[0]; + if (!row) { + return yield* new WorkflowEventStoreError({ message: "append returned no row" }); + } + return yield* decodeEvent(row); + }).pipe(Effect.mapError(toStoreError("append failed"))); + + const streamRows = ( + query: Effect.Effect<ReadonlyArray<Row>, SqlError>, + ): Stream.Stream<PersistedWorkflowEvent, WorkflowEventStoreError> => + Stream.fromEffect(query.pipe(Effect.mapError(toStoreError("read failed")))).pipe( + Stream.flatMap((rows) => Stream.fromIterable(rows)), + Stream.mapEffect(decodeEvent), + ); + + const readByTicket: WorkflowEventStoreShape["readByTicket"] = (ticketId) => + streamRows(sql<Row>` + SELECT + sequence, + event_id AS "eventId", + ticket_id AS "ticketId", + stream_version AS "streamVersion", + event_type AS "type", + occurred_at AS "occurredAt", + payload_json AS "payloadJson" + FROM workflow_events + WHERE ticket_id = ${ticketId} + ORDER BY stream_version ASC + `); + + const readFromSequence: WorkflowEventStoreShape["readFromSequence"] = ( + sequenceExclusive, + limit = 1_000, + ) => { + const normalizedLimit = Math.max(0, Math.floor(limit)); + if (normalizedLimit === 0) { + return Stream.empty; + } + return streamRows(sql<Row>` + SELECT + sequence, + event_id AS "eventId", + ticket_id AS "ticketId", + stream_version AS "streamVersion", + event_type AS "type", + occurred_at AS "occurredAt", + payload_json AS "payloadJson" + FROM workflow_events + WHERE sequence > ${sequenceExclusive} + ORDER BY sequence ASC + LIMIT ${normalizedLimit} + `); + }; + + const readAll: WorkflowEventStoreShape["readAll"] = () => + readFromSequence(0, Number.MAX_SAFE_INTEGER); + + const deleteForBoard: WorkflowEventStoreShape["deleteForBoard"] = (boardId) => + sql` + DELETE FROM workflow_events + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `.pipe(Effect.mapError(toStoreError("delete failed")), Effect.asVoid); + + const deleteForTicket: WorkflowEventStoreShape["deleteForTicket"] = (ticketId) => + sql` + DELETE FROM workflow_events + WHERE ticket_id = ${ticketId} + `.pipe(Effect.mapError(toStoreError("delete failed")), Effect.asVoid); + + return { + append, + readByTicket, + readFromSequence, + readAll, + deleteForBoard, + deleteForTicket, + } satisfies WorkflowEventStoreShape; +}); + +export const WorkflowEventStoreLive = Layer.effect(WorkflowEventStore, make); diff --git a/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts b/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts new file mode 100644 index 00000000000..e7895177bef --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts @@ -0,0 +1,613 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +import { assert, it } from "@effect/vitest"; +import { + WorkflowDefinition, + WorkflowRpcError, + type BoardId, + type ProjectId, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { + WorkflowFileLoader, + WorkflowFilePort, + WorkflowProviderInstancePort, +} from "../Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowFileLoaderLive } from "./WorkflowFileLoader.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; + +const workflowJson = (providerInstance = "codex_main") => + JSON.stringify({ + name: "Delivery Board", + settings: { maxConcurrentTickets: 2 }, + lanes: [ + { + key: "code", + name: "Code", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: providerInstance, model: "gpt-5.5" }, + instruction: { file: "prompts/implement.md" }, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + +const scriptTimeoutWorkflowJson = () => + JSON.stringify({ + name: "Script Timeout Board", + lanes: [ + { + key: "run", + name: "Run", + entry: "auto", + pipeline: [{ key: "smoke", type: "script", run: "echo hi", timeout: "1 minute" }], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + +const invalidWipWorkflowJson = () => + JSON.stringify({ + name: "Invalid WIP Board", + lanes: [ + { key: "queue", name: "Queue", entry: "manual", wipLimit: 0 }, + { key: "done", name: "Done", entry: "manual", terminal: true, wipLimit: 1 }, + ], + }); + +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); + +const mk = (providerInstanceExists: (instanceId: string) => boolean) => + it.layer( + WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.succeed(workflowJson()), + instructionFileExists: ({ repoRelativePath }) => + Effect.succeed(repoRelativePath === "prompts/implement.md"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => + Effect.succeed(providerInstanceExists(instanceId)), + providerInstanceSupportsResume: (instanceId) => + Effect.succeed(providerInstanceExists(instanceId)), + }), + ), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ); + +mk((instanceId) => instanceId === "codex_main")("WorkflowFileLoader", (it) => { + it.effect("loads, lints, registers, and persists a workflow board", () => + Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const boardId = "board-loader" as BoardId; + + const loadedBoardId = yield* loader.loadAndRegister({ + boardId, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + relativePath: ".t3/boards/delivery.json", + }); + + const definition = yield* registry.getDefinition(boardId); + const board = yield* read.getBoard(boardId); + + assert.equal(loadedBoardId, boardId); + assert.equal(definition?.name, "Delivery Board"); + assert.equal(board?.name, "Delivery Board"); + assert.equal(board?.workflowFilePath, ".t3/boards/delivery.json"); + assert.equal(board?.maxConcurrentTickets, 2); + assert.isTrue((board?.workflowVersionHash.length ?? 0) > 0); + }), + ); +}); + +it.effect("WorkflowFileLoader lintDefinition reuses provider and instruction-file context", () => { + const providerChecks: string[] = []; + const instructionChecks: Array<{ readonly repoRoot: string; readonly repoRelativePath: string }> = + []; + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: (filePath) => + String(filePath).endsWith("prompts/implement.md") + ? Effect.succeed("Implement {{ticket.title}}.") + : Effect.die("lintDefinition must not read a workflow file"), + instructionFileExists: (input) => { + instructionChecks.push(input); + return Effect.succeed( + input.repoRoot === "/repo" && input.repoRelativePath === "prompts/implement.md", + ); + }, + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => { + providerChecks.push(instanceId); + return Effect.succeed(instanceId === "codex_main"); + }, + providerInstanceSupportsResume: (instanceId) => Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const definition = yield* decodeWorkflowDefinitionJson(workflowJson()); + const errors = yield* loader.lintDefinition({ + definition, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + }); + + assert.deepEqual(errors, []); + assert.deepEqual(providerChecks, ["codex_main"]); + assert.deepEqual(instructionChecks, [ + { repoRoot: "/repo", repoRelativePath: "prompts/implement.md" }, + ]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("WorkflowFileLoader lintDefinition returns lint errors without registering", () => { + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.die("lintDefinition must not read a workflow file"), + instructionFileExists: () => Effect.succeed(false), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: () => Effect.succeed(false), + providerInstanceSupportsResume: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const registry = yield* BoardRegistry; + const boardId = "board-lint-only" as BoardId; + const definition = yield* decodeWorkflowDefinitionJson(workflowJson()); + const errors = yield* loader.lintDefinition({ + definition, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + }); + + assert.deepEqual( + errors.map((error) => ({ + code: error.code, + laneKey: error.laneKey, + stepKey: error.stepKey, + })), + [ + { code: "unknown_provider_instance", laneKey: "code", stepKey: "implement" }, + { code: "missing_instruction_file", laneKey: "code", stepKey: "implement" }, + ], + ); + assert.isNull(yield* registry.getDefinition(boardId)); + }).pipe(Effect.provide(layer)); +}); + +it.effect( + "WorkflowFileLoader lintDefinition rejects unsafe instruction paths before file checks", + () => { + const instructionChecks: Array<{ + readonly repoRoot: string; + readonly repoRelativePath: string; + }> = []; + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.die("lintDefinition must not read a workflow file"), + instructionFileExists: (input) => { + instructionChecks.push(input); + return Effect.succeed(true); + }, + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => Effect.succeed(instanceId === "codex_main"), + providerInstanceSupportsResume: (instanceId) => + Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const definition = yield* decodeWorkflowDefinition({ + name: "Unsafe Instruction Board", + lanes: [ + { + key: "code", + name: "Code", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: "codex_main", model: "gpt-5.5" }, + instruction: { file: "../escape.md" }, + }, + ], + }, + ], + }); + const errors = yield* loader.lintDefinition({ + definition, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + }); + + assert.deepEqual( + errors.map((error) => ({ + code: error.code, + laneKey: error.laneKey, + stepKey: error.stepKey, + })), + [{ code: "unsafe_instruction_path", laneKey: "code", stepKey: "implement" }], + ); + assert.deepEqual(instructionChecks, []); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect( + "WorkflowFileLoader lintDefinition gates continueSession on provider resume support", + () => { + const continueSessionJson = (providerInstance: string) => + JSON.stringify({ + name: "Resume Board", + lanes: [ + { + key: "code", + name: "Code", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: providerInstance, model: "gpt-5.5" }, + instruction: "Implement {{ticket.title}}.", + continueSession: true, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.die("lintDefinition must not read a workflow file"), + instructionFileExists: () => Effect.succeed(true), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + // Both instances exist; only opencode lacks resume support. + providerInstanceExists: () => Effect.succeed(true), + providerInstanceSupportsResume: (instanceId) => + Effect.succeed(instanceId !== "opencode_main"), + }), + ), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + + const resumableDef = yield* decodeWorkflowDefinitionJson(continueSessionJson("codex_main")); + const resumableErrors = yield* loader.lintDefinition({ + definition: resumableDef, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + }); + assert.deepEqual(resumableErrors, []); + + const nonResumableDef = yield* decodeWorkflowDefinitionJson( + continueSessionJson("opencode_main"), + ); + const nonResumableErrors = yield* loader.lintDefinition({ + definition: nonResumableDef, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + }); + assert.deepEqual( + nonResumableErrors.map((error) => ({ code: error.code, stepKey: error.stepKey })), + [{ code: "invalid_continue_session", stepKey: "implement" }], + ); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect("WorkflowFileLoader registers a workflow board whose script step has a timeout", () => { + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.succeed(scriptTimeoutWorkflowJson()), + instructionFileExists: () => Effect.succeed(false), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: () => Effect.succeed(false), + providerInstanceSupportsResume: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const boardId = "board-script-timeout" as BoardId; + + const loadedBoardId = yield* loader.loadAndRegister({ + boardId, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + relativePath: ".t3/boards/script-timeout.json", + }); + + const definition = yield* registry.getDefinition(boardId); + const board = yield* read.getBoard(boardId); + const step = definition?.lanes[0]?.pipeline?.[0]; + + assert.equal(loadedBoardId, boardId); + assert.equal(definition?.name, "Script Timeout Board"); + assert.equal(step?.type, "script"); + if (step?.type === "script") { + const timeout = step.timeout; + assert.isDefined(timeout); + if (timeout !== undefined) { + assert.equal(Duration.toMillis(timeout), 60_000); + } + } + assert.equal(board?.name, "Script Timeout Board"); + assert.equal(board?.workflowFilePath, ".t3/boards/script-timeout.json"); + }).pipe(Effect.provide(layer)); +}); + +it.effect( + "WorkflowFileLoader reads from the workspace-root path and persists the relative path", + () => { + let workspaceRoot = ""; + return Effect.gen(function* () { + workspaceRoot = mkdtempSync(join(tmpdir(), "t3-workflow-loader-")); + const relativePath = ".t3/boards/split.json"; + const absolutePath = resolve(workspaceRoot, relativePath); + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, workflowJson(), "utf8"); + + const readPath = yield* Ref.make<string | null>(null); + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: (filePath) => + Effect.gen(function* () { + if (String(filePath).endsWith(".json")) { + yield* Ref.set(readPath, filePath); + } + return yield* Effect.try({ + try: () => readFileSync(filePath, "utf8"), + catch: (cause) => + new WorkflowRpcError({ message: "test workflow file read failed", cause }), + }); + }), + instructionFileExists: ({ repoRelativePath }) => + Effect.succeed(repoRelativePath === "prompts/implement.md"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => Effect.succeed(instanceId === "codex_main"), + providerInstanceSupportsResume: (instanceId) => + Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const read = yield* WorkflowReadModel; + const boardId = "board-split-path" as BoardId; + + yield* loader.loadAndRegister({ + boardId, + projectId: "project-loader" as ProjectId, + workspaceRoot, + relativePath, + }); + + assert.equal(yield* Ref.get(readPath), absolutePath); + const board = yield* read.getBoard(boardId); + assert.equal(board?.workflowFilePath, relativePath); + }).pipe(Effect.provide(layer)); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (workspaceRoot !== "") { + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }), + ), + ); + }, +); + +it.effect("WorkflowFileLoader blocks activation for invalid WIP limits", () => { + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.succeed(invalidWipWorkflowJson()), + instructionFileExists: () => Effect.succeed(false), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: () => Effect.succeed(false), + providerInstanceSupportsResume: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const result = yield* Effect.exit( + loader.loadAndRegister({ + boardId: "board-invalid-wip" as BoardId, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + relativePath: ".t3/boards/invalid-wip.json", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(result.cause.toString().includes("invalid_wip_limit")); + } + }).pipe(Effect.provide(layer)); +}); + +it.effect( + "WorkflowFileLoader rejects an oversized on-disk definition without registering it", + () => { + // A hand-edited file with more lanes than MAX_IMPORT_LANES (1000): recovery/ + // discovery must reject it via the shared caps, never register it. + const oversizedJson = JSON.stringify({ + name: "Oversized Board", + lanes: [ + ...Array.from({ length: 1001 }, (_, index) => ({ + key: `lane-${index}`, + name: `Lane ${index}`, + entry: "manual", + })), + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.succeed(oversizedJson), + instructionFileExists: () => Effect.succeed(false), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: () => Effect.succeed(false), + providerInstanceSupportsResume: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const boardId = "board-oversized" as BoardId; + + const result = yield* Effect.exit( + loader.loadAndRegister({ + boardId, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + relativePath: ".t3/boards/oversized.json", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(result.cause.toString().includes("too large")); + } + // Neither the registry nor the projection should have the board. + assert.isNull(yield* registry.getDefinition(boardId)); + assert.isNull(yield* read.getBoard(boardId)); + }).pipe(Effect.provide(layer)); + }, +); + +mk(() => false)("WorkflowFileLoader lint failure", (it) => { + it.effect("fails when the workflow references an unknown provider instance", () => + Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + + const result = yield* Effect.exit( + loader.loadAndRegister({ + boardId: "board-loader-fail" as BoardId, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + relativePath: ".t3/boards/delivery.json", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowFileLoader.ts b/apps/server/src/workflow/Layers/WorkflowFileLoader.ts new file mode 100644 index 00000000000..1d276660a74 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowFileLoader.ts @@ -0,0 +1,251 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import { ProviderInstanceId, WorkflowDefinition, WorkflowRpcError } from "@t3tools/contracts"; +import { AsanaSelector, GithubSelector, JiraSelector } from "@t3tools/contracts/workSource"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { + WorkflowFileLoader, + WorkflowFilePort, + WorkflowProviderInstancePort, + type WorkflowFileLoaderShape, + type WorkflowFilePortShape, + type WorkflowProviderInstancePortShape, +} from "../Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { + isSafeWorkflowInstructionPath, + resolveWorkflowInstructionPath, +} from "../instructionPath.ts"; +import { sha256Hex } from "../workflowVersionHash.ts"; +import { + MAX_IMPORT_DEFINITION_CHARS, + definitionLaneCapViolation, + exceedsDefinitionCharCap, +} from "../definitionCaps.ts"; +import { lintWorkflowDefinition, type LintContext } from "../workflowFile.ts"; + +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); +const decodeUnknownJsonString = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); +const decodeProviderInstanceId = Schema.decodeUnknownEffect(ProviderInstanceId); + +const toWorkflowRpcError = (message: string) => (cause: unknown) => + new WorkflowRpcError({ message, cause }); + +const unique = (values: ReadonlyArray<string>) => Array.from(new Set(values)); + +const make = Effect.gen(function* () { + const files = yield* WorkflowFilePort; + const providers = yield* WorkflowProviderInstancePort; + const boardRegistry = yield* BoardRegistry; + const readModel = yield* WorkflowReadModel; + + const lintContextForDefinition = ( + definition: WorkflowDefinition, + workspaceRoot: string, + ): Effect.Effect<LintContext, WorkflowRpcError> => + Effect.gen(function* () { + const agentSteps = definition.lanes.flatMap((lane) => + (lane.pipeline ?? []).flatMap((step) => (step.type === "agent" ? [step] : [])), + ); + const providerEntries = yield* Effect.forEach( + unique( + agentSteps.flatMap((step) => [ + step.agent.instance as string, + ...(step.retry?.escalate?.instance === undefined + ? [] + : [step.retry.escalate.instance as string]), + ]), + ), + (instanceId) => + providers + .providerInstanceExists(instanceId) + .pipe(Effect.map((exists) => [instanceId, exists] as const)), + { concurrency: "unbounded" }, + ); + // Resume support only matters for steps that opt into continueSession. A + // retry can escalate to a different instance that still runs with + // continueSession, so both base and escalation instances must be probed — + // the lint in workflowFile.ts checks resume support for both. + const resumeEntries = yield* Effect.forEach( + unique( + agentSteps.flatMap((step) => + step.continueSession === true + ? [ + step.agent.instance as string, + ...(step.retry?.escalate?.instance === undefined + ? [] + : [step.retry.escalate.instance as string]), + ] + : [], + ), + ), + (instanceId) => + providers + .providerInstanceSupportsResume(instanceId) + .pipe(Effect.map((supports) => [instanceId, supports] as const)), + { concurrency: "unbounded" }, + ); + const instructionEntries = yield* Effect.forEach( + unique( + agentSteps.flatMap((step) => + typeof step.instruction === "object" && + isSafeWorkflowInstructionPath(step.instruction.file as string) + ? [step.instruction.file as string] + : [], + ), + ), + (repoRelativePath) => + files + .instructionFileExists({ repoRoot: workspaceRoot, repoRelativePath }) + .pipe(Effect.map((exists) => [repoRelativePath, exists] as const)), + { concurrency: "unbounded" }, + ); + const providerExists = new Map(providerEntries); + const providerResumeSupport = new Map(resumeEntries); + const instructionExists = new Map(instructionEntries); + const instructionContentEntries = yield* Effect.forEach( + instructionEntries.flatMap(([repoRelativePath, exists]) => + exists ? [repoRelativePath] : [], + ), + (repoRelativePath) => { + const instructionPath = resolveWorkflowInstructionPath(workspaceRoot, repoRelativePath); + return instructionPath === null + ? Effect.succeed([repoRelativePath, null] as const) + : files.readFileString(instructionPath).pipe( + Effect.map((content) => [repoRelativePath, content] as const), + Effect.orElseSucceed(() => [repoRelativePath, null] as const), + ); + }, + { concurrency: "unbounded" }, + ); + const instructionContents = new Map(instructionContentEntries); + + return { + providerInstanceExists: (instanceId) => providerExists.get(instanceId) ?? false, + providerInstanceSupportsResume: (instanceId) => + providerResumeSupport.get(instanceId) ?? false, + instructionFileExists: (repoRelativePath) => + instructionExists.get(repoRelativePath) ?? false, + readInstructionFile: (repoRelativePath) => + instructionContents.get(repoRelativePath) ?? null, + selectorSchemaFor: (p) => + p === "github" ? GithubSelector : p === "asana" ? AsanaSelector : p === "jira" ? JiraSelector : null, + }; + }); + + const lintDefinition: WorkflowFileLoaderShape["lintDefinition"] = (input) => + Effect.gen(function* () { + const lintContext = yield* lintContextForDefinition(input.definition, input.workspaceRoot); + return lintWorkflowDefinition(input.definition, lintContext); + }); + + const loadAndRegister: WorkflowFileLoaderShape["loadAndRegister"] = (input) => + Effect.gen(function* () { + const raw = yield* files.readFileString( + path.resolve(input.workspaceRoot, input.relativePath), + ); + // DoS backstop on the raw file BEFORE decode — recovery/discovery can load + // a hand-edited on-disk definition that never went through the import/save + // caps; apply the SAME shared caps here so a huge file is rejected, not + // registered. Char cap on the raw string; lane caps after decode below. + if (exceedsDefinitionCharCap(raw.length)) { + return yield* new WorkflowRpcError({ + message: `Workflow file too large (exceeds ${MAX_IMPORT_DEFINITION_CHARS} characters)`, + }); + } + const encodedDefinition = yield* decodeUnknownJsonString(raw).pipe( + Effect.mapError(toWorkflowRpcError("workflow file decode failed")), + ); + const definition = yield* decodeWorkflowDefinition(encodedDefinition).pipe( + Effect.mapError(toWorkflowRpcError("workflow file decode failed")), + ); + const laneCapViolation = definitionLaneCapViolation(definition); + if (laneCapViolation !== null) { + return yield* new WorkflowRpcError({ message: laneCapViolation }); + } + + if (input.lintMode !== "skip") { + const lintErrors = yield* lintDefinition({ + definition, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + }); + if (lintErrors.length > 0) { + return yield* new WorkflowRpcError({ + message: `Workflow lint failed: ${lintErrors.map((error) => error.code).join(", ")}`, + }); + } + } + + yield* boardRegistry + .register(input.boardId, encodedDefinition) + .pipe(Effect.mapError(toWorkflowRpcError("workflow board registration failed"))); + yield* readModel + .registerBoard({ + boardId: input.boardId, + projectId: input.projectId, + name: definition.name, + workflowFilePath: input.relativePath, + workflowVersionHash: sha256Hex(raw), + maxConcurrentTickets: definition.settings?.maxConcurrentTickets ?? 3, + }) + .pipe(Effect.mapError(toWorkflowRpcError("workflow board projection registration failed"))); + return input.boardId; + }); + + return { lintDefinition, loadAndRegister } satisfies WorkflowFileLoaderShape; +}); + +export const WorkflowFileLoaderLive = Layer.effect(WorkflowFileLoader, make); + +export const WorkflowFilePortLive = Layer.effect( + WorkflowFilePort, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return { + readFileString: (filePath) => + fileSystem + .readFileString(filePath) + .pipe(Effect.mapError(toWorkflowRpcError("workflow file read failed"))), + instructionFileExists: ({ repoRoot, repoRelativePath }) => + Effect.gen(function* () { + const instructionPath = resolveWorkflowInstructionPath(repoRoot, repoRelativePath); + if (instructionPath === null) { + return false; + } + return yield* fileSystem.exists(instructionPath).pipe( + Effect.map((exists): boolean => exists), + Effect.orElseSucceed(() => false), + ); + }), + } satisfies WorkflowFilePortShape; + }), +); + +export const WorkflowProviderInstancePortLive = Layer.effect( + WorkflowProviderInstancePort, + Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry; + return { + providerInstanceExists: (instanceId) => + decodeProviderInstanceId(instanceId).pipe( + Effect.flatMap((decoded) => registry.getInstance(decoded)), + Effect.map((instance) => instance !== undefined), + Effect.orElseSucceed(() => false), + ), + providerInstanceSupportsResume: (instanceId) => + decodeProviderInstanceId(instanceId).pipe( + Effect.flatMap((decoded) => registry.getInstance(decoded)), + Effect.map((instance) => instance?.adapter.capabilities.supportsSessionResume === true), + Effect.orElseSucceed(() => false), + ), + } satisfies WorkflowProviderInstancePortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowGitHubPoller.test.ts b/apps/server/src/workflow/Layers/WorkflowGitHubPoller.test.ts new file mode 100644 index 00000000000..75eb758afc2 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowGitHubPoller.test.ts @@ -0,0 +1,1057 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { + GitHubPort, + type GitHubPrDetail, + type GitHubPortShape, + type GitHubReviewItem, +} from "../Services/GitHubPort.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowGitHubPoller } from "../Services/WorkflowGitHubPoller.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { MAX_TICKET_MESSAGE_BODY_LENGTH } from "../ticketMessageBody.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { makeWorkflowGitHubPollerLive } from "./WorkflowGitHubPoller.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +// --- Scriptable gh stub ----------------------------------------------------- +// +// Tests push a SEQUENCE of prDetail responses per prNumber; each prDetail() +// call pops the next one (the last is sticky). failingCheckLogs / review +// feedback are configured per prNumber too. A prNumber can be flagged to fail +// prDetail (gh error during observe). + +interface PrScript { + details: GitHubPrDetail[]; + failingLogs: string | null; + feedback: GitHubReviewItem[]; + failPrDetail: boolean; + // Optional side effect run when failingCheckLogs is invoked (during observe, + // before phase 1) — used to simulate a concurrent delete landing mid-sweep. + onFailingCheckLogs?: (sql: SqlClient.SqlClient) => Effect.Effect<void>; +} + +const scripts = new Map<number, PrScript>(); +// Optional wrapper to force ingestExternalEvent to fail transiently N times. +let ingestFailureCount = 0; +// When true, ingestExternalEvent always fails with a non-terminal, +// non-transient error (poison-pill simulation). +let ingestAlwaysFails = false; +// When true, postTicketMessage always fails (persistently-failing post +// poison-pill simulation). +let postAlwaysFails = false; + +const resetScripts = () => { + scripts.clear(); + ingestFailureCount = 0; + ingestAlwaysFails = false; + postAlwaysFails = false; +}; + +const scriptPr = (prNumber: number, script: Partial<PrScript>) => { + scripts.set(prNumber, { + details: script.details ?? [], + failingLogs: script.failingLogs ?? null, + feedback: script.feedback ?? [], + failPrDetail: script.failPrDetail ?? false, + ...(script.onFailingCheckLogs === undefined + ? {} + : { onFailingCheckLogs: script.onFailingCheckLogs }), + }); +}; + +const GitHubPortStub = Layer.effect( + GitHubPort, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return { + preflight: () => Effect.succeed({ ok: true as const }), + resolveRemote: () => Effect.succeed({ remoteName: "origin", repo: "acme/widgets" }), + defaultBranch: () => Effect.succeed("main"), + openPr: () => Effect.succeed({ number: 0, url: "", adopted: false }), + prDetail: ({ prNumber }) => + Effect.suspend(() => { + const script = scripts.get(prNumber); + if (script === undefined || script.failPrDetail) { + return Effect.fail( + new WorkflowEventStoreError({ message: `gh prDetail failed for #${prNumber}` }), + ); + } + const next = script.details.length > 1 ? script.details.shift()! : script.details[0]; + if (next === undefined) { + return Effect.fail( + new WorkflowEventStoreError({ message: `no scripted detail for #${prNumber}` }), + ); + } + return Effect.succeed(next); + }), + findPrForBranch: () => Effect.succeed(null), + mergePr: () => Effect.succeed({ ok: true as const }), + failingCheckLogs: ({ prNumber }) => + Effect.gen(function* () { + const script = scripts.get(prNumber); + if (script?.onFailingCheckLogs !== undefined) { + yield* script.onFailingCheckLogs(sql); + } + return script?.failingLogs ?? null; + }), + listReviewFeedback: ({ prNumber }) => + Effect.sync(() => scripts.get(prNumber)?.feedback ?? []), + } satisfies GitHubPortShape; + }), +); + +const succeedingExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.succeed({ _tag: "completed" as const }), +} satisfies StepExecutorShape); + +// Wrap the real engine so ingestExternalEvent can be made to fail transiently +// for the retry test, without disturbing every other engine method. +const EngineWrapper = Layer.effect( + WorkflowEngine, + Effect.gen(function* () { + const inner = yield* WorkflowEngine; + return { + ...inner, + postTicketMessage: (input) => + Effect.suspend(() => + postAlwaysFails + ? Effect.fail(new WorkflowEventStoreError({ message: "poison-pill post failure" })) + : inner.postTicketMessage(input), + ), + ingestExternalEvent: (input) => + Effect.suspend(() => { + if (ingestAlwaysFails) { + return Effect.fail( + new WorkflowEventStoreError({ message: "poison-pill ingest failure" }), + ); + } + if (ingestFailureCount > 0) { + ingestFailureCount -= 1; + return Effect.fail( + new WorkflowEventStoreError({ message: "transient ingest failure" }), + ); + } + return inner.ingestExternalEvent(input); + }), + } satisfies typeof inner; + }), +).pipe(Layer.provide(WorkflowEngineLayer)); + +const baseEngine = WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(succeedingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), +); + +// Engine + persistence + gh stub all share one SqlClient (the in-memory DB), +// so the poller, the wrapped engine and the gh stub's side-effecting SQL all +// hit the same database. +const supportLayer = Layer.mergeAll(EngineWrapper, GitHubPortStub).pipe( + Layer.provideMerge(baseEngine), +); + +const pollerLayer = makeWorkflowGitHubPollerLive({ + sweepIntervalMs: 60_000, + maxTicketsPerSweep: 20, +}).pipe(Layer.provideMerge(supportLayer)); + +const layer = it.layer(pollerLayer); + +// --- helpers ---------------------------------------------------------------- + +const prBoard = { + name: "pr-flow", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + onEvent: [ + { name: "ci.failed", to: "work" }, + { name: "pr.changes_requested", to: "work" }, + { name: "pr.merged", to: "done" }, + { name: "pr.closed", to: "done" }, + ], + }, + { + key: "work", + name: "Work", + entry: "manual", + onEvent: [ + { name: "ci.failed", to: "work" }, + { name: "pr.changes_requested", to: "work" }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const seedPrState = ( + sql: SqlClient.SqlClient, + input: { + ticketId: string; + prNumber: number; + lastHeadSha?: string | null; + lastCiState?: string | null; + lastReviewDecision?: string | null; + lastCommentCursor?: string | null; + prState?: string; + }, +) => + sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, + pr_state, last_head_sha, last_ci_state, last_review_decision, + last_comment_cursor, updated_at + ) VALUES ( + ${input.ticketId}, + ${input.prNumber}, + ${`https://github.com/acme/widgets/pull/${input.prNumber}`}, + ${`workflow/${input.ticketId}`}, + 'origin', + 'acme/widgets', + ${input.prState ?? "open"}, + ${input.lastHeadSha ?? null}, + ${input.lastCiState ?? null}, + ${input.lastReviewDecision ?? null}, + ${input.lastCommentCursor ?? null}, + '2026-06-12T00:00:00.000Z' + ) + `; + +const detail = (over: Partial<GitHubPrDetail>): GitHubPrDetail => ({ + number: over.number ?? 1, + url: over.url ?? "https://github.com/acme/widgets/pull/1", + state: over.state ?? "open", + headSha: over.headSha ?? "sha1", + reviewDecision: over.reviewDecision ?? "none", + ciState: over.ciState ?? "pending", +}); + +interface ObservationRow { + readonly dedupKey: string; + readonly eventName: string; + readonly status: string; + readonly messageBody: string | null; + readonly payloadJson: string; + readonly attemptCount: number; +} + +const observationsFor = (sql: SqlClient.SqlClient, ticketId: string) => + sql<ObservationRow>` + SELECT + dedup_key AS "dedupKey", + event_name AS "eventName", + status, + message_body AS "messageBody", + payload_json AS "payloadJson", + attempt_count AS "attemptCount" + FROM workflow_pr_observation + WHERE ticket_id = ${ticketId} + ORDER BY created_at ASC, observation_id ASC + `; + +// it.layer shares one in-memory DB across the suite. Clear the PR tables at the +// start of each test so sweep-wide totals (observedTickets / recordedObservations) +// reflect only this test's tickets. +const resetDb = (sql: SqlClient.SqlClient) => + Effect.gen(function* () { + yield* sql`DELETE FROM workflow_pr_observation`; + yield* sql`DELETE FROM workflow_pr_state`; + }); + +const prStateFor = (sql: SqlClient.SqlClient, ticketId: string) => + sql<{ + readonly prState: string; + readonly lastCiState: string | null; + readonly lastReviewDecision: string | null; + readonly lastCommentCursor: string | null; + readonly lastHeadSha: string | null; + }>` + SELECT + pr_state AS "prState", + last_ci_state AS "lastCiState", + last_review_decision AS "lastReviewDecision", + last_comment_cursor AS "lastCommentCursor", + last_head_sha AS "lastHeadSha" + FROM workflow_pr_state + WHERE ticket_id = ${ticketId} + `.pipe(Effect.map((rows) => rows[0])); + +layer("WorkflowGitHubPoller", (it) => { + it.effect("1. ci pending -> failure: observation, message, ci.failed ingested, applied", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b1" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b1" as never, + title: "PR ticket", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 1, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + scriptPr(1, { + details: [detail({ number: 1, headSha: "sha1", ciState: "failure" })], + failingLogs: "boom: secret=ghp_" + "aaaaaaaaaaaaaaaaaaaaaaaa\nassertion failed", + }); + + const result = yield* poller.sweep(); + assert.equal(result.recordedObservations, 1); + assert.equal(result.appliedObservations, 1); + + const obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs.length, 1); + assert.equal(obs[0]!.dedupKey, `ci:${ticketId as string}:sha1:failure`); + assert.equal(obs[0]!.eventName, "ci.failed"); + assert.equal(obs[0]!.status, "applied"); + // message_body cleared after posting. + assert.equal(obs[0]!.messageBody, null); + // token redacted in the persisted payload summary. + assert.isFalse(obs[0]!.payloadJson.includes("ghp_aaaa")); + + // Message posted to the discussion (redacted). + const messages = yield* read.listTicketMessages(ticketId as never); + assert.equal(messages.length, 1); + assert.isTrue(messages[0]!.body.includes("[redacted]")); + assert.isFalse(messages[0]!.body.includes("ghp_aaaa")); + + // ci.failed routed review -> work. + const ticketDetail = yield* read.getTicketDetail(ticketId as never); + assert.equal(ticketDetail?.ticket.currentLaneKey, "work"); + + const state = yield* prStateFor(sql, ticketId as string); + assert.equal(state?.lastCiState, "failure"); + }), + ); + + it.effect("2. same prDetail observed twice -> no second observation (dedup)", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b2" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b2" as never, + title: "dedup", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 2, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + scriptPr(2, { + details: [detail({ number: 2, headSha: "sha1", ciState: "success" })], + }); + + const first = yield* poller.sweep(); + assert.equal(first.recordedObservations, 1); + const second = yield* poller.sweep(); + assert.equal(second.recordedObservations, 0); + + const obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs.length, 1); + assert.equal(obs[0]!.dedupKey, `ci:${ticketId as string}:sha1:success`); + }), + ); + + it.effect("3. new head sha after failure -> fresh ci:<sha2> fires again", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b3" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b3" as never, + title: "newsha", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 3, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + // First sweep: sha1 failure. Second sweep: sha2 failure. + scriptPr(3, { + details: [ + detail({ number: 3, headSha: "sha1", ciState: "failure" }), + detail({ number: 3, headSha: "sha2", ciState: "failure" }), + ], + failingLogs: "still broken", + }); + + yield* poller.sweep(); + yield* poller.sweep(); + + const obs = yield* observationsFor(sql, ticketId as string); + const keys = obs.map((row) => row.dedupKey).sort(); + assert.deepEqual(keys, [ + `ci:${ticketId as string}:sha1:failure`, + `ci:${ticketId as string}:sha2:failure`, + ]); + }), + ); + + it.effect("4. transient ingest failure stays pending, re-drives next sweep", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b4" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b4" as never, + title: "retry", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 4, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + scriptPr(4, { + details: [detail({ number: 4, headSha: "sha1", ciState: "success" })], + }); + + // First ingest attempt fails transiently. + ingestFailureCount = 1; + const first = yield* poller.sweep(); + assert.equal(first.recordedObservations, 1); + // Observation recorded but NOT applied (ingest failed, left pending). + assert.equal(first.appliedObservations, 0); + let obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "pending"); + + // Second sweep: observe is a no-op (dedup) but phase 2 re-drives pending. + const second = yield* poller.sweep(); + assert.equal(second.recordedObservations, 0); + assert.equal(second.appliedObservations, 1); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "applied"); + }), + ); + + it.effect("5. changes_requested with 2 feedback items -> 2 messages + 1 routing event", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b5" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b5" as never, + title: "review", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 5, + lastHeadSha: "sha1", + lastReviewDecision: "none", + }); + scriptPr(5, { + details: [ + detail({ + number: 5, + headSha: "sha1", + ciState: "pending", + reviewDecision: "changes_requested", + }), + ], + feedback: [ + { + id: "c1", + author: "alice", + body: "fix this", + submittedAt: "2026-06-12T01:00:00.000Z", + }, + { + id: "c2", + author: "bob", + body: "and that", + submittedAt: "2026-06-12T02:00:00.000Z", + }, + ], + }); + + const result = yield* poller.sweep(); + // 2 comment observations (with body) + 1 routing observation. + assert.equal(result.recordedObservations, 3); + + const obs = yield* observationsFor(sql, ticketId as string); + const keys = obs.map((row) => row.dedupKey).sort(); + assert.deepEqual(keys, [ + `comment:${ticketId as string}:c1`, + `comment:${ticketId as string}:c2`, + // Routing key now carries the newest feedback id so a later round on the + // same head re-fires (and a quiet re-poll is deduped). + `review:${ticketId as string}:sha1:changes_requested:c2`, + ]); + const withBody = obs.filter((row) => row.eventName === "pr.changes_requested"); + assert.equal(withBody.length, 3); + + // 2 messages posted. + const messages = yield* read.listTicketMessages(ticketId as never); + assert.equal(messages.length, 2); + assert.isTrue(messages.some((m) => m.body.includes("**@alice**"))); + assert.isTrue(messages.some((m) => m.body.includes("**@bob**"))); + + // Cursor advanced to newest feedback. + const state = yield* prStateFor(sql, ticketId as string); + assert.equal(state?.lastCommentCursor, "2026-06-12T02:00:00.000Z"); + assert.equal(state?.lastReviewDecision, "changes_requested"); + + // Routed to work. + const ticketDetail = yield* read.getTicketDetail(ticketId as never); + assert.equal(ticketDetail?.ticket.currentLaneKey, "work"); + + // Re-sweep adds nothing (same detail, cursor caught up). + scriptPr(5, { + details: [ + detail({ + number: 5, + headSha: "sha1", + ciState: "pending", + reviewDecision: "changes_requested", + }), + ], + feedback: [ + { + id: "c1", + author: "alice", + body: "fix this", + submittedAt: "2026-06-12T01:00:00.000Z", + }, + { + id: "c2", + author: "bob", + body: "and that", + submittedAt: "2026-06-12T02:00:00.000Z", + }, + ], + }); + const reSweep = yield* poller.sweep(); + assert.equal(reSweep.recordedObservations, 0); + }), + ); + + it.effect( + "5b. a later changes_requested round on the same head re-fires + surfaces new feedback (H3)", + () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b5b" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b5b" as never, + title: "review", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 55, + lastHeadSha: "sha1", + lastReviewDecision: "none", + }); + // Shared array we grow between sweeps to simulate a SECOND review round + // arriving on the same head WITHOUT GitHub's reviewDecision flipping (it + // stays CHANGES_REQUESTED). Pre-fix this round was silently dropped. + const feedback: GitHubReviewItem[] = [ + { id: "r1", author: "alice", body: "round one", submittedAt: "2026-06-12T01:00:00.000Z" }, + ]; + scriptPr(55, { + details: [ + detail({ + number: 55, + headSha: "sha1", + ciState: "pending", + reviewDecision: "changes_requested", + }), + detail({ + number: 55, + headSha: "sha1", + ciState: "pending", + reviewDecision: "changes_requested", + }), + ], + feedback, + }); + + yield* poller.sweep(); // round 1: records r1 + routing key ...:r1 + + feedback.push({ + id: "r2", + author: "bob", + body: "round two", + submittedAt: "2026-06-12T03:00:00.000Z", + }); + + const second = yield* poller.sweep(); + // r2 comment + a NEW routing event keyed by the newest feedback id. + assert.equal(second.recordedObservations, 2); + + const keys = new Set( + (yield* observationsFor(sql, ticketId as string)).map((row) => row.dedupKey), + ); + assert.isTrue(keys.has(`comment:${ticketId as string}:r2`)); + assert.isTrue(keys.has(`review:${ticketId as string}:sha1:changes_requested:r2`)); + + const messages = yield* read.listTicketMessages(ticketId as never); + assert.isTrue(messages.some((m) => m.body.includes("round two"))); + }), + ); + + it.effect("6. merged -> pr.merged ingested, pr_state merged, not scanned next sweep", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b6" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b6" as never, + title: "merge", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 6, + lastHeadSha: "sha1", + lastCiState: "success", + lastReviewDecision: "approved", + }); + scriptPr(6, { + details: [ + detail({ + number: 6, + headSha: "sha1", + ciState: "success", + reviewDecision: "approved", + state: "merged", + }), + ], + }); + + const result = yield* poller.sweep(); + assert.equal(result.observedTickets, 1); + + const obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs.length, 1); + assert.equal(obs[0]!.dedupKey, `lifecycle:${ticketId as string}:merged`); + assert.equal(obs[0]!.eventName, "pr.merged"); + assert.equal(obs[0]!.status, "applied"); + + const state = yield* prStateFor(sql, ticketId as string); + assert.equal(state?.prState, "merged"); + + // Next sweep: ticket no longer watched (pr_state != open). + const second = yield* poller.sweep(); + assert.equal(second.observedTickets, 0); + }), + ); + + it.effect("7. ticket deleted between observe and phase 1 -> recheck skips, no rows", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b7" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b7" as never, + title: "deleted", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 7, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + // The ticket IS in the watched set when observe selects it. prDetail + // returns failure, so observe calls failingCheckLogs — we hook that call + // to delete the pr_state row, simulating a concurrent retention/board + // delete landing AFTER the watched-select but BEFORE phase 1's + // in-transaction recheck. The recheck must then find no open row and write + // nothing — even though observe produced an observation in memory. + scriptPr(7, { + details: [detail({ number: 7, headSha: "sha1", ciState: "failure" })], + failingLogs: "boom", + onFailingCheckLogs: (s) => + s`DELETE FROM workflow_pr_state WHERE ticket_id = ${ticketId as string}`.pipe( + Effect.asVoid, + Effect.orDie, + ), + }); + + const result = yield* poller.sweep(); + // Observe selected + ran (1), but the in-tx recheck found the row gone and + // wrote nothing. + assert.equal(result.observedTickets, 1); + assert.equal(result.recordedObservations, 0); + const obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs.length, 0); + }), + ); + + it.effect("8. gh error during observe -> sweep logs + continues, fiber survives", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b8" as never, prBoard); + const failTicket = yield* engine.createTicket({ + boardId: "b8" as never, + title: "fail", + initialLane: "review" as never, + }); + const okTicket = yield* engine.createTicket({ + boardId: "b8" as never, + title: "ok", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: failTicket as string, + prNumber: 81, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + yield* seedPrState(sql, { + ticketId: okTicket as string, + prNumber: 82, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + scriptPr(81, { failPrDetail: true }); + scriptPr(82, { + details: [detail({ number: 82, headSha: "sha1", ciState: "success" })], + }); + + // sweep() returns without throwing. + const result = yield* poller.sweep(); + assert.equal(result.observedTickets, 2); + assert.equal(result.failedTickets, 1); + // The healthy ticket still got its observation + ingest. + assert.equal(result.recordedObservations, 1); + + const okObs = yield* observationsFor(sql, okTicket as string); + assert.equal(okObs.length, 1); + assert.equal(okObs[0]!.dedupKey, `ci:${okTicket as string}:sha1:success`); + + const failObs = yield* observationsFor(sql, failTicket as string); + assert.equal(failObs.length, 0); + }), + ); + + it.effect("9. two tickets on one board both merge -> distinct lifecycle obs + both ingest", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b9" as never, prBoard); + const ticketA = yield* engine.createTicket({ + boardId: "b9" as never, + title: "A", + initialLane: "review" as never, + }); + const ticketB = yield* engine.createTicket({ + boardId: "b9" as never, + title: "B", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketA as string, + prNumber: 91, + lastHeadSha: "sha1", + }); + yield* seedPrState(sql, { + ticketId: ticketB as string, + prNumber: 92, + lastHeadSha: "sha1", + }); + // Both PRs reach merged. Bare `lifecycle:merged` keys would collide on the + // table-wide UNIQUE dedup_key, dropping B's observation while still + // flipping B's pr_state to merged — stranding B. Per-ticket keys avoid it. + scriptPr(91, { details: [detail({ number: 91, headSha: "sha1", state: "merged" })] }); + scriptPr(92, { details: [detail({ number: 92, headSha: "sha1", state: "merged" })] }); + + const result = yield* poller.sweep(); + assert.equal(result.recordedObservations, 2); + + const obsA = yield* observationsFor(sql, ticketA as string); + const obsB = yield* observationsFor(sql, ticketB as string); + assert.equal(obsA.length, 1); + assert.equal(obsB.length, 1); + assert.equal(obsA[0]!.dedupKey, `lifecycle:${ticketA as string}:merged`); + assert.equal(obsB[0]!.dedupKey, `lifecycle:${ticketB as string}:merged`); + // BOTH pr.merged events ingested (the bug dropped B's). + assert.equal(obsA[0]!.status, "applied"); + assert.equal(obsB[0]!.status, "applied"); + + const detailA = yield* read.getTicketDetail(ticketA as never); + const detailB = yield* read.getTicketDetail(ticketB as never); + assert.equal(detailA?.ticket.currentLaneKey, "done"); + assert.equal(detailB?.ticket.currentLaneKey, "done"); + }), + ); + + it.effect("10. poison-pill ingest: retried up to 5 times then marked failed", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b10" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b10" as never, + title: "poison", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 10, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + scriptPr(10, { + details: [detail({ number: 10, headSha: "sha1", ciState: "success" })], + }); + + // Ingest always fails with a non-terminal, non-transient error. + ingestAlwaysFails = true; + + // Sweep 1 records the observation; ingest fails -> attempt_count = 1, pending. + const first = yield* poller.sweep(); + assert.equal(first.recordedObservations, 1); + assert.equal(first.appliedObservations, 0); + let obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "pending"); + assert.equal(obs[0]!.attemptCount, 1); + + // Sweeps 2..4: still pending, attempt_count climbs (no new observation). + for (let i = 2; i <= 4; i += 1) { + yield* poller.sweep(); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "pending"); + assert.equal(obs[0]!.attemptCount, i); + } + + // Sweep 5: 5th failed attempt hits the ceiling -> marked 'failed'. + yield* poller.sweep(); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "failed"); + assert.equal(obs[0]!.attemptCount, 5); + + // Sweep 6: 'failed' is no longer drained -> attempt_count frozen at 5. + const sixth = yield* poller.sweep(); + assert.equal(sixth.appliedObservations, 0); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "failed"); + assert.equal(obs[0]!.attemptCount, 5); + }), + ); + + it.effect("11. oversized redacted message body posts (capped <= body limit, no throw)", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b11" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b11" as never, + title: "huge log", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 11, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + // A failing-check log far larger than the ticket body limit. redactAndCap + // (truncateKeepingTail with the marker INCLUDED in the budget) must bring + // it under MAX_TICKET_MESSAGE_BODY_LENGTH so postTicketMessage accepts it. + scriptPr(11, { + details: [detail({ number: 11, headSha: "sha1", ciState: "failure" })], + failingLogs: "x".repeat(MAX_TICKET_MESSAGE_BODY_LENGTH * 3), + }); + + const result = yield* poller.sweep(); + // Recorded AND applied — the post did not throw, the event ingested. + assert.equal(result.recordedObservations, 1); + assert.equal(result.appliedObservations, 1); + + const obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "applied"); + assert.equal(obs[0]!.attemptCount, 0); + + // The message landed in the discussion and fits under the body limit. + const messages = yield* read.listTicketMessages(ticketId as never); + assert.equal(messages.length, 1); + assert.isTrue(messages[0]!.body.length <= MAX_TICKET_MESSAGE_BODY_LENGTH); + assert.isTrue(messages[0]!.body.startsWith("…[truncated]\n")); + + // Routed review -> work via ci.failed. + const ticketDetail = yield* read.getTicketDetail(ticketId as never); + assert.equal(ticketDetail?.ticket.currentLaneKey, "work"); + }), + ); + + it.effect("12. poison-pill post: persistently-failing post retried to ceiling then failed", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b12" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b12" as never, + title: "bad post", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 12, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + // ci.failed carries a message body, so phase 2 hits postTicketMessage. + scriptPr(12, { + details: [detail({ number: 12, headSha: "sha1", ciState: "failure" })], + failingLogs: "boom", + }); + + // Every post attempt fails (non-terminal). It must count toward the + // ceiling, not retry forever. + postAlwaysFails = true; + + // Sweep 1: observation recorded; post fails -> attempt_count = 1, pending. + const first = yield* poller.sweep(); + assert.equal(first.recordedObservations, 1); + assert.equal(first.appliedObservations, 0); + let obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "pending"); + assert.equal(obs[0]!.attemptCount, 1); + // message_body NOT cleared (post never succeeded) so the message is still + // pending delivery. + assert.isNotNull(obs[0]!.messageBody); + + // Sweeps 2..4: still pending, attempt_count climbs. + for (let i = 2; i <= 4; i += 1) { + yield* poller.sweep(); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "pending"); + assert.equal(obs[0]!.attemptCount, i); + } + + // Sweep 5: 5th failed post hits the ceiling -> marked 'failed'. + yield* poller.sweep(); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "failed"); + assert.equal(obs[0]!.attemptCount, 5); + + // No message was ever delivered, and a failed row is not re-attempted. + const messages = yield* read.listTicketMessages(ticketId as never); + assert.equal(messages.length, 0); + const sixth = yield* poller.sweep(); + assert.equal(sixth.appliedObservations, 0); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.attemptCount, 5); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowGitHubPoller.ts b/apps/server/src/workflow/Layers/WorkflowGitHubPoller.ts new file mode 100644 index 00000000000..8ece4d8bc4f --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowGitHubPoller.ts @@ -0,0 +1,667 @@ +import type { BoardId, TicketId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { WorkflowEventStoreErrorCode } from "../Services/Errors.ts"; +import { GitHubPort, type GitHubPrDetail } from "../Services/GitHubPort.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { + WorkflowGitHubPoller, + type WorkflowGitHubPollerShape, + type WorkflowGitHubPollerSweepResult, +} from "../Services/WorkflowGitHubPoller.ts"; +import { sanitizeExternalEventPayload } from "../externalEvent.ts"; +import { redactSensitiveText, truncateKeepingTail } from "../redactSensitiveText.ts"; +import { MAX_TICKET_MESSAGE_BODY_LENGTH } from "../ticketMessageBody.ts"; + +const DEFAULT_SWEEP_INTERVAL_MS = 45_000; +const DEFAULT_MAX_TICKETS_PER_SWEEP = 20; + +export interface WorkflowGitHubPollerLiveOptions { + readonly sweepIntervalMs?: number; + readonly maxTicketsPerSweep?: number; +} + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +// JSON encode/decode via Schema (the codebase convention for persisted JSON — +// see WorkflowProjectionPipeline). Payloads are already sanitized + bounded. +const encodePayloadJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const decodePayloadJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); + +// A watched ticket: an open PR whose projection row is still non-terminal. +interface WatchedTicketRow { + readonly ticketId: TicketId; + readonly boardId: BoardId; + readonly repoRoot: string | null; + readonly prNumber: number; + readonly repo: string; + readonly lastHeadSha: string | null; + readonly lastCiState: string | null; + readonly lastReviewDecision: string | null; + readonly lastCommentCursor: string | null; +} + +// One durable outbox record produced by observing a single PR transition. +interface PendingObservation { + readonly observationId: string; + readonly ticketId: TicketId; + readonly dedupKey: string; + readonly eventName: string; + readonly payloadJson: string; + readonly messageBody: string | null; +} + +// The new `last_*` snapshot to persist for a ticket after observing it. +interface ObservedState { + readonly headSha: string | null; + readonly ciState: string | null; + readonly reviewDecision: string; + readonly commentCursor: string | null; + readonly prState: "open" | "merged" | "closed"; +} + +// A phase-2 work item: a pending observation joined to its board. +interface PendingPhase2Row { + readonly observationId: string; + readonly ticketId: TicketId; + readonly boardId: BoardId; + readonly eventName: string; + readonly payloadJson: string; + readonly messageBody: string | null; + readonly attemptCount: number; +} + +// A pending observation whose ingest keeps failing with a non-terminal, +// non-transient error (e.g. a predicate-eval error) would otherwise be retried +// every sweep forever. After this many failed attempts we give up and mark it +// 'failed' so it stops being drained. +const MAX_INGEST_ATTEMPTS = 5; + +const redactAndCap = (text: string): string => + truncateKeepingTail(redactSensitiveText(text), MAX_TICKET_MESSAGE_BODY_LENGTH); + +const makeWorkflowGitHubPoller = (options?: WorkflowGitHubPollerLiveOptions) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const saveLocks = yield* WorkflowBoardSaveLocks; + const engine = yield* WorkflowEngine; + const gitHub = yield* GitHubPort; + + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + const maxTicketsPerSweep = Math.max( + 1, + Math.floor(options?.maxTicketsPerSweep ?? DEFAULT_MAX_TICKETS_PER_SWEEP), + ); + + // Round-robin cursor over watched tickets (same mechanic as the retention + // sweeper's lane cursor): we remember the ticket to *start* from next sweep + // so a cap'd sweep eventually covers every watched ticket. + let nextSweepCursorTicketId: string | null = null; + + // observation_id is an internal opaque PK — a v4 UUID is fine and avoids + // pulling the Crypto service into this layer (which the engine test harness + // does not provide). + const newObservationId = Effect.sync( + // @effect-diagnostics-next-line cryptoRandomUUIDInEffect:off + () => globalThis.crypto.randomUUID() as string, + ); + + const watchedTickets = () => + sql<WatchedTicketRow>` + SELECT + pr.ticket_id AS "ticketId", + ticket.board_id AS "boardId", + ( + SELECT projects.workspace_root + FROM projection_board AS board + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE board.board_id = ticket.board_id + ) AS "repoRoot", + pr.pr_number AS "prNumber", + pr.repo, + pr.last_head_sha AS "lastHeadSha", + pr.last_ci_state AS "lastCiState", + pr.last_review_decision AS "lastReviewDecision", + pr.last_comment_cursor AS "lastCommentCursor" + FROM workflow_pr_state AS pr + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = pr.ticket_id + WHERE pr.pr_state = 'open' + AND ticket.terminal_at IS NULL + ORDER BY pr.ticket_id ASC + `; + + const rotateTickets = (rows: ReadonlyArray<WatchedTicketRow>) => { + const cursor = nextSweepCursorTicketId; + if (cursor === null) { + return rows; + } + let startIndex = rows.findIndex((row) => (row.ticketId as string) === cursor); + if (startIndex === -1) { + // The cursor ticket is no longer watched (merged/closed). Resume at the + // next ticket past it in id order rather than resetting to the head, so + // the tail of the list isn't starved on every churn. rows are ORDER BY + // ticket_id ASC, so the first id greater than the cursor is the resume + // point; if the cursor is past the end, wrap to the head. + startIndex = rows.findIndex((row) => (row.ticketId as string) > cursor); + if (startIndex === -1) { + return rows; + } + } + if (startIndex <= 0) { + return rows; + } + return [...rows.slice(startIndex), ...rows.slice(0, startIndex)]; + }; + + // Phase 1: build the durable observation records + the new last_* snapshot + // for one ticket by diffing live PR detail against the stored row. Pure + // computation + (for ci.failed / changes_requested) read-only gh fetches; + // NO database writes and NO engine/committer calls. + const observeTicket = (ticket: WatchedTicketRow) => + Effect.gen(function* () { + const cwd = ticket.repoRoot ?? "."; + const detail: GitHubPrDetail = yield* gitHub.prDetail({ + cwd, + prNumber: ticket.prNumber, + }); + + const observations: PendingObservation[] = []; + + const push = (record: { + readonly dedupKey: string; + readonly eventName: string; + readonly payload: unknown; + readonly messageBody: string | null; + }) => + Effect.gen(function* () { + const payloadJson = yield* encodePayloadJson( + sanitizeExternalEventPayload(record.payload), + ).pipe(Effect.orDie); + observations.push({ + observationId: yield* newObservationId, + ticketId: ticket.ticketId, + dedupKey: record.dedupKey, + eventName: record.eventName, + payloadJson, + messageBody: record.messageBody, + }); + }); + + // A new head sha resets the CI comparison: each push earns its own CI + // verdict and the dedup_key embeds the sha so per-push events stay + // distinct. When the sha changed, treat lastCiState as unknown. + const shaChanged = detail.headSha !== null && detail.headSha !== ticket.lastHeadSha; + const ciBaseline = shaChanged ? null : ticket.lastCiState; + + // dedup_key is a TABLE-WIDE UNIQUE constraint, so every key is scoped by + // ticketId — otherwise two tickets sharing a head sha (monorepo) or both + // reaching merged would collide and the second observation would be + // silently dropped by INSERT OR IGNORE. + const tid = ticket.ticketId as string; + + // --- CI transitions (keyed by head sha) --- + if (detail.headSha !== null && detail.ciState !== ciBaseline) { + if (detail.ciState === "success") { + yield* push({ + dedupKey: `ci:${tid}:${detail.headSha}:success`, + eventName: "ci.passed", + payload: { sha: detail.headSha }, + messageBody: null, + }); + } else if (detail.ciState === "failure") { + const rawLogs = yield* gitHub + .failingCheckLogs({ cwd, prNumber: ticket.prNumber }) + .pipe(Effect.orElseSucceed(() => null)); + const summary = rawLogs === null ? null : redactAndCap(rawLogs); + yield* push({ + dedupKey: `ci:${tid}:${detail.headSha}:failure`, + eventName: "ci.failed", + payload: { + sha: detail.headSha, + ...(summary === null ? {} : { summary }), + }, + messageBody: summary, + }); + } + } + + // --- Review decision transitions --- + let nextCommentCursor = ticket.lastCommentCursor; + const reviewDecisionChanged = detail.reviewDecision !== ticket.lastReviewDecision; + // Sync feedback whenever the PR CURRENTLY has changes requested — not only + // on the first transition. A reviewer can request changes across multiple + // rounds without GitHub's aggregate reviewDecision flipping (it stays + // CHANGES_REQUESTED), so gating on the transition alone silently drops + // every round after the first. Per-comment dedup (comment:<tid>:<id>) and + // the head-sha/newest-id routing key keep re-polls idempotent. + if (detail.reviewDecision === "changes_requested") { + const feedback = yield* gitHub + .listReviewFeedback({ + cwd, + prNumber: ticket.prNumber, + repo: ticket.repo, + }) + .pipe(Effect.orElseSucceed(() => [])); + const cursor = ticket.lastCommentCursor; + // Inclusive lower bound (>=) so an item whose timestamp exactly equals + // the stored cursor is never permanently skipped (two items can share a + // timestamp); per-comment dedup prevents re-recording ones already seen. + const fresh = feedback + .filter((item) => cursor === null || item.submittedAt >= cursor) + .sort((a, b) => + a.submittedAt === b.submittedAt + ? a.id < b.id + ? -1 + : a.id > b.id + ? 1 + : 0 + : a.submittedAt < b.submittedAt + ? -1 + : 1, + ); + for (const item of fresh) { + const body = redactAndCap( + `**@${item.author}** on PR #${ticket.prNumber}:\n${item.body}`, + ); + yield* push({ + dedupKey: `comment:${tid}:${item.id}`, + eventName: "pr.changes_requested", + payload: {}, + messageBody: body, + }); + } + // Routing event (no body). Keyed by head sha AND the newest feedback id + // so a new push re-fires (head sha changes), a new comment on the same + // head re-fires (newest id changes), and a quiet re-poll is deduped. + const newestId = fresh.length > 0 ? fresh[fresh.length - 1]!.id : null; + if (reviewDecisionChanged || newestId !== null) { + yield* push({ + dedupKey: `review:${tid}:${detail.headSha ?? "nohead"}:changes_requested:${newestId ?? "init"}`, + eventName: "pr.changes_requested", + payload: {}, + messageBody: null, + }); + } + // Advance the cursor to the newest feedback item observed. + for (const item of fresh) { + if (nextCommentCursor === null || item.submittedAt > nextCommentCursor) { + nextCommentCursor = item.submittedAt; + } + } + } else if (reviewDecisionChanged && detail.reviewDecision === "approved") { + yield* push({ + dedupKey: `review:${tid}:${detail.headSha ?? "nohead"}:approved`, + eventName: "pr.approved", + payload: {}, + messageBody: null, + }); + } + + // --- Lifecycle transitions --- + if (detail.state === "merged") { + yield* push({ + dedupKey: `lifecycle:${tid}:merged`, + eventName: "pr.merged", + payload: {}, + messageBody: null, + }); + } else if (detail.state === "closed") { + yield* push({ + dedupKey: `lifecycle:${tid}:closed`, + eventName: "pr.closed", + payload: {}, + messageBody: null, + }); + } + + const observed: ObservedState = { + headSha: detail.headSha, + // When the sha changed and no new CI verdict is in yet, record the + // live verdict (pending) so a later transition still diffs cleanly. + ciState: detail.ciState, + reviewDecision: detail.reviewDecision, + commentCursor: nextCommentCursor, + prState: detail.state, + }; + + return { observations, observed }; + }); + + // Phase 1 write: under the save lock + a transaction, recheck the PR is + // still watched, INSERT OR IGNORE the observations, advance last_*. + // PLAIN SQL ONLY — never engine.* / committer.* here (they self-acquire the + // same non-reentrant save lock → deadlock). + const persistObservations = ( + ticket: WatchedTicketRow, + observations: ReadonlyArray<PendingObservation>, + observed: ObservedState, + ) => + saveLocks.withSaveLock( + ticket.boardId, + sql.withTransaction( + Effect.gen(function* () { + const rows = yield* sql<{ readonly prState: string }>` + SELECT pr_state AS "prState" + FROM workflow_pr_state + WHERE ticket_id = ${ticket.ticketId} + `; + const current = rows[0]; + // Gone (ticket deleted between observe and now) or already terminal + // → skip every write. + if (current === undefined || current.prState !== "open") { + return 0; + } + const ticketExists = yield* sql<{ readonly one: number }>` + SELECT 1 AS "one" + FROM projection_ticket + WHERE ticket_id = ${ticket.ticketId} + `; + if (ticketExists[0] === undefined) { + return 0; + } + + const createdAt = yield* nowIso; + let recorded = 0; + for (const observation of observations) { + // Count only the rows that are genuinely new: a UNIQUE dedup_key + // collision means this transition was already recorded (a + // re-observation no-op). Checking before the INSERT OR IGNORE + // gives an accurate `recorded` count without relying on a + // driver-specific affected-rows shape. + const existing = yield* sql<{ readonly one: number }>` + SELECT 1 AS "one" + FROM workflow_pr_observation + WHERE dedup_key = ${observation.dedupKey} + `; + if (existing[0] !== undefined) { + continue; + } + yield* sql` + INSERT OR IGNORE INTO workflow_pr_observation ( + observation_id, + ticket_id, + dedup_key, + event_name, + payload_json, + message_body, + status, + created_at + ) VALUES ( + ${observation.observationId}, + ${observation.ticketId}, + ${observation.dedupKey}, + ${observation.eventName}, + ${observation.payloadJson}, + ${observation.messageBody}, + 'pending', + ${createdAt} + ) + `; + recorded += 1; + } + + yield* sql` + UPDATE workflow_pr_state + SET last_head_sha = ${observed.headSha}, + last_ci_state = ${observed.ciState}, + last_review_decision = ${observed.reviewDecision}, + last_comment_cursor = ${observed.commentCursor}, + pr_state = ${observed.prState}, + updated_at = ${createdAt} + WHERE ticket_id = ${ticket.ticketId} + `; + + return recorded; + }), + ), + ); + + // Phase 2: drain pending observations across ALL still-reachable boards + // (joined to projection_ticket for boardId), oldest first. NO save lock is + // held here — engine.* self-acquire it. An observation whose PR has since + // merged is still drained because we select by status, not pr_state. + const drainPendingObservations = () => + Effect.gen(function* () { + const pending = yield* sql<PendingPhase2Row>` + SELECT + obs.observation_id AS "observationId", + obs.ticket_id AS "ticketId", + ticket.board_id AS "boardId", + obs.event_name AS "eventName", + obs.payload_json AS "payloadJson", + obs.message_body AS "messageBody", + obs.attempt_count AS "attemptCount" + FROM workflow_pr_observation AS obs + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = obs.ticket_id + WHERE obs.status = 'pending' + ORDER BY obs.created_at ASC, obs.observation_id ASC + `; + + let applied = 0; + for (const row of pending) { + const outcome = yield* applyPendingObservation(row).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.github-poller.apply-failed", { + observationId: row.observationId, + ticketId: row.ticketId, + eventName: row.eventName, + cause, + }).pipe(Effect.as("pending" as const)), + ), + ); + if (outcome === "applied") { + applied += 1; + } + } + return applied; + }); + + // Apply one pending observation: post its message (if any), then ingest the + // external event. Both a successful outcome AND a "ticket not found on this + // board" error are terminal (the ticket/board is gone or moved — re-ingest + // would never help) → mark 'applied'. Any OTHER ingest error increments + // attempt_count and leaves the row 'pending'; once attempt_count would + // reach MAX_INGEST_ATTEMPTS the row is given up on (status 'failed') so a + // poison pill stops being retried every sweep. Returns the row's resulting + // state this pass: "applied" (ingested), "failed" (given up), or "pending" + // (will retry next sweep). + // A non-terminal phase-2 failure (a message post OR an ingest that is not a + // terminal "ticket not found"): count the attempt and leave the row + // 'pending', or give up at the ceiling by marking it 'failed' so a poison + // pill — whether the poison is the post or the ingest — stops being retried + // every sweep. Returns "failed" (given up) or "pending" (will retry). + const recordPhase2Failure = (row: PendingPhase2Row, stage: "post" | "ingest") => + Effect.gen(function* () { + const nextAttempt = row.attemptCount + 1; + if (nextAttempt >= MAX_INGEST_ATTEMPTS) { + yield* sql` + UPDATE workflow_pr_observation + SET status = 'failed', + attempt_count = ${nextAttempt} + WHERE observation_id = ${row.observationId} + `; + yield* Effect.logError("workflow.github-poller.observation-given-up", { + observationId: row.observationId, + ticketId: row.ticketId, + eventName: row.eventName, + stage, + attemptCount: nextAttempt, + }); + return "failed" as const; + } + yield* sql` + UPDATE workflow_pr_observation + SET attempt_count = ${nextAttempt} + WHERE observation_id = ${row.observationId} + `; + return "pending" as const; + }); + + const applyPendingObservation = (row: PendingPhase2Row) => + Effect.gen(function* () { + if (row.messageBody !== null) { + // A persistently-failing post must not retry forever — treat ANY post + // error as a non-terminal phase-2 failure that counts toward the + // ceiling (rather than throwing out to the sweep-level catch, which + // left the row 'pending' without incrementing attempt_count). + const posted = yield* engine + .postTicketMessage({ + ticketId: row.ticketId, + text: row.messageBody, + }) + .pipe( + Effect.as(true as const), + Effect.orElseSucceed(() => false as const), + ); + if (!posted) { + return yield* recordPhase2Failure(row, "post"); + } + // Posted-marker: clearing message_body makes a re-drive (crash before + // the 'applied' mark, or a later ingest give-up pass) skip the post. + // The tiny window between post and marker is an accepted + // at-least-once double-post. + yield* sql` + UPDATE workflow_pr_observation + SET message_body = NULL + WHERE observation_id = ${row.observationId} + `; + } + + const payload = yield* decodePayloadJson(row.payloadJson).pipe( + Effect.orElseSucceed(() => null as unknown), + ); + const ingestOutcome = yield* engine + .ingestExternalEvent({ + boardId: row.boardId, + name: row.eventName, + ticketId: row.ticketId, + payload, + }) + .pipe( + Effect.as("applied" as const), + Effect.catch((error) => + // Terminal condition (ticket no longer on this board) → give up and + // mark applied; anything else is retryable. Match the typed code + // rather than the message text so a message reword can't silently + // turn this into a retry-forever. + error.code === WorkflowEventStoreErrorCode.ticketNotOnBoard + ? Effect.succeed("applied" as const) + : Effect.succeed("error" as const), + ), + ); + + if (ingestOutcome === "applied") { + yield* sql` + UPDATE workflow_pr_observation + SET status = 'applied' + WHERE observation_id = ${row.observationId} + `; + return "applied"; + } + + return yield* recordPhase2Failure(row, "ingest"); + }); + + const sweep: WorkflowGitHubPollerShape["sweep"] = () => + Effect.gen(function* () { + const allWatched = yield* watchedTickets().pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.github-poller.watch-query-failed", { + cause, + }).pipe(Effect.as([] as ReadonlyArray<WatchedTicketRow>)), + ), + ); + const ordered = rotateTickets(allWatched); + const toProcess = ordered.slice(0, maxTicketsPerSweep); + + // Advance the round-robin cursor to the first ticket we did NOT process + // this sweep, so the next sweep starts there. + if (ordered.length > toProcess.length) { + nextSweepCursorTicketId = ordered[toProcess.length]!.ticketId as string; + } else { + nextSweepCursorTicketId = null; + } + + let observedTickets = 0; + let recordedObservations = 0; + let failedTickets = 0; + + for (const ticket of toProcess) { + observedTickets += 1; + const outcome = yield* observeTicket(ticket).pipe( + Effect.flatMap(({ observations, observed }) => + persistObservations(ticket, observations, observed), + ), + Effect.catchCause((cause) => + Effect.logWarning("workflow.github-poller.observe-failed", { + ticketId: ticket.ticketId, + prNumber: ticket.prNumber, + cause, + }).pipe(Effect.as(null)), + ), + ); + if (outcome === null) { + failedTickets += 1; + } else { + recordedObservations += outcome; + } + } + + // Phase 2 runs regardless: it also drains leftover pending rows from a + // prior crashed process (the watched set is unrelated to which rows are + // pending). + const appliedObservations = yield* drainPendingObservations().pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.github-poller.drain-failed", { + cause, + }).pipe(Effect.as(0)), + ), + ); + + if (recordedObservations > 0 || appliedObservations > 0 || failedTickets > 0) { + yield* Effect.logInfo("workflow.github-poller.sweep-complete", { + observedTickets, + recordedObservations, + appliedObservations, + failedTickets, + }); + } + + return { + observedTickets, + recordedObservations, + appliedObservations, + failedTickets, + } satisfies WorkflowGitHubPollerSweepResult; + }); + + const start: WorkflowGitHubPollerShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + sweep().pipe( + Effect.catchDefect((defect: unknown) => + Effect.logWarning("workflow.github-poller.sweep-defect", { defect }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), + ), + ); + yield* Effect.logInfo("workflow.github-poller.started", { sweepIntervalMs }); + }); + + return { sweep, start } satisfies WorkflowGitHubPollerShape; + }); + +export const makeWorkflowGitHubPollerLive = (options?: WorkflowGitHubPollerLiveOptions) => + Layer.effect(WorkflowGitHubPoller, makeWorkflowGitHubPoller(options)); + +export const WorkflowGitHubPollerLive = makeWorkflowGitHubPollerLive(); diff --git a/apps/server/src/workflow/Layers/WorkflowIds.test.ts b/apps/server/src/workflow/Layers/WorkflowIds.test.ts new file mode 100644 index 00000000000..9a6df6acb29 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowIds.test.ts @@ -0,0 +1,19 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; + +const layer = it.layer(DeterministicWorkflowIds); + +layer("DeterministicWorkflowIds", (it) => { + it.effect("produces stable, prefixed, incrementing ids", () => + Effect.gen(function* () { + const ids = yield* WorkflowIds; + assert.equal(yield* ids.ticketId(), "ticket-1"); + assert.equal(yield* ids.ticketId(), "ticket-2"); + assert.equal(yield* ids.token(), "token-1"); + assert.equal(yield* ids.stepRunId(), "steprun-1"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowIds.ts b/apps/server/src/workflow/Layers/WorkflowIds.ts new file mode 100644 index 00000000000..7deb5f09803 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowIds.ts @@ -0,0 +1,61 @@ +import { + LaneEntryToken, + MessageId, + PipelineRunId, + ScriptRunId, + StepRunId, + TicketId, + WorkflowEventId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { WorkflowIds, type WorkflowIdsShape } from "../Services/WorkflowIds.ts"; + +export const DeterministicWorkflowIds = Layer.effect( + WorkflowIds, + Effect.gen(function* () { + const counters = yield* Ref.make<Record<string, number>>({}); + const next = (prefix: string) => + Ref.modify(counters, (counters) => { + const value = (counters[prefix] ?? 0) + 1; + return [`${prefix}-${value}`, { ...counters, [prefix]: value }] as const; + }); + + return { + ticketId: () => next("ticket").pipe(Effect.map(TicketId.make)), + pipelineRunId: () => next("pipelinerun").pipe(Effect.map(PipelineRunId.make)), + scriptRunId: () => next("scriptrun").pipe(Effect.map(ScriptRunId.make)), + stepRunId: () => next("steprun").pipe(Effect.map(StepRunId.make)), + messageId: () => next("message").pipe(Effect.map(MessageId.make)), + eventId: () => next("evt").pipe(Effect.map(WorkflowEventId.make)), + token: () => next("token").pipe(Effect.map(LaneEntryToken.make)), + mappingId: () => next("mapping"), + } satisfies WorkflowIdsShape; + }), +); + +export const WorkflowIdsLive = Layer.effect( + WorkflowIds, + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const next = (prefix: string) => + crypto.randomUUIDv4.pipe( + Effect.orDie, + Effect.map((uuid) => `${prefix}-${uuid}`), + ); + + return { + ticketId: () => next("ticket").pipe(Effect.map(TicketId.make)), + pipelineRunId: () => next("pipelinerun").pipe(Effect.map(PipelineRunId.make)), + scriptRunId: () => next("scriptrun").pipe(Effect.map(ScriptRunId.make)), + stepRunId: () => next("steprun").pipe(Effect.map(StepRunId.make)), + messageId: () => next("message").pipe(Effect.map(MessageId.make)), + eventId: () => next("evt").pipe(Effect.map(WorkflowEventId.make)), + token: () => next("token").pipe(Effect.map(LaneEntryToken.make)), + mappingId: () => next("mapping"), + } satisfies WorkflowIdsShape; + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowIntake.test.ts b/apps/server/src/workflow/Layers/WorkflowIntake.test.ts new file mode 100644 index 00000000000..500299e9a1b --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowIntake.test.ts @@ -0,0 +1,291 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { ProviderTurnPort, type DispatchRequest } from "../Services/ProviderDispatchOutbox.ts"; +import { TurnStateReader, type TurnState } from "../Services/TurnStateReader.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowIntakeService } from "../Services/WorkflowIntake.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { parseIntakeProposals, WorkflowIntakeLive } from "./WorkflowIntake.ts"; + +describe("parseIntakeProposals", () => { + it("keeps valid proposals, drops junk, and caps the list", () => { + const proposals = parseIntakeProposals({ + tickets: [ + { title: "Fix login", description: "Users get logged out" }, + { title: " " }, + "not an object", + { title: "No description" }, + ...Array.from({ length: 30 }, (_, index) => ({ title: `extra ${index}` })), + ], + }); + + assert.equal(proposals.length, 20); + assert.deepEqual(proposals[0], { title: "Fix login", description: "Users get logged out" }); + assert.deepEqual(proposals[1], { title: "No description" }); + }); + + it("truncates overlong fields instead of failing", () => { + const proposals = parseIntakeProposals({ + tickets: [{ title: "t".repeat(500), description: "d".repeat(9000) }], + }); + assert.equal(proposals[0]?.title.length, 200); + assert.equal(proposals[0]?.description?.length, 4000); + }); + + it("keeps backward dependency indices and drops self/forward/junk", () => { + const proposals = parseIntakeProposals({ + tickets: [ + { title: "API" }, + { title: "UI", dependsOn: [0] }, + { title: "Docs", dependsOn: [0, 1, 2, 7, -1, "0", 1] }, + { title: "Free", dependsOn: "nope" }, + ], + }); + + assert.equal(proposals[0]?.dependsOn, undefined); + assert.deepEqual(proposals[1]?.dependsOn, [0]); + assert.deepEqual(proposals[2]?.dependsOn, [0, 1]); + assert.equal(proposals[3]?.dependsOn, undefined); + }); + + it("returns nothing for unusable shapes", () => { + assert.deepEqual(parseIntakeProposals(null), []); + assert.deepEqual(parseIntakeProposals({ tickets: "nope" }), []); + assert.deepEqual(parseIntakeProposals([]), []); + }); +}); + +const baseInput = { + boardId: "board-intake" as never, + braindump: "Fix the login flow and add rate limiting", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, +}; + +// A full ProviderServiceShape stub. Only getCapabilities/interruptTurn/stopSession +// are exercised by intake; the rest fail loudly so any unexpected use is caught. +const providerServiceStub = (capabilities: { + readonly maxInputChars?: number; +}): ProviderServiceShape => + ({ + getCapabilities: () => + Effect.succeed({ + sessionModelSwitch: "in-session" as const, + ...(capabilities.maxInputChars === undefined + ? {} + : { maxInputChars: capabilities.maxInputChars }), + }), + interruptTurn: () => Effect.void, + stopSession: () => Effect.void, + }) as never; + +const makeLayer = (options: { + readonly turnState: TurnState; + readonly capturedOutput?: unknown; + readonly onStart?: (req: DispatchRequest) => void; + readonly failIfTurnStarts?: boolean; + readonly provider?: { readonly maxInputChars?: number }; +}) => { + const base = WorkflowIntakeLive.pipe( + Layer.provide( + Layer.succeed(WorkflowReadModel, { + getBoard: () => + Effect.succeed({ + boardId: "board-intake", + projectId: "project-intake", + name: "Intake board", + workflowFilePath: ".t3/boards/intake.json", + workflowVersionHash: "hash", + maxConcurrentTickets: 1, + }), + } as never), + ), + Layer.provide( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/project-intake"), + }), + ), + Layer.provide( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: (req) => + Effect.sync(() => { + if (options.failIfTurnStarts === true) { + throw new Error("ensureTurnStarted must not be called for an over-budget braindump"); + } + options.onStart?.(req); + return { turnId: "turn-intake" as never }; + }), + }), + ), + Layer.provide( + Layer.succeed(TurnStateReader, { read: () => Effect.succeed(options.turnState) }), + ), + Layer.provide( + Layer.succeed(CapturedStepOutputReader, { + read: () => Effect.succeed(options.capturedOutput), + }), + ), + Layer.provide( + Layer.succeed(WorkflowIds, { + eventId: () => Effect.succeed("evt-intake-1" as never), + ticketId: () => Effect.succeed("ticket-x" as never), + pipelineRunId: () => Effect.succeed("pipeline-x" as never), + stepRunId: () => Effect.succeed("step-x" as never), + laneEntryToken: () => Effect.succeed("token-x" as never), + } as never), + ), + ); + return options.provider === undefined + ? base + : base.pipe( + Layer.provide(Layer.succeed(ProviderService, providerServiceStub(options.provider))), + ); +}; + +describe("WorkflowIntakeService", () => { + it.effect("dispatches a one-shot turn and returns parsed proposals", () => { + const starts: DispatchRequest[] = []; + return Effect.gen(function* () { + const intake = yield* WorkflowIntakeService; + const proposals = yield* intake.proposeTickets(baseInput); + + assert.deepEqual(proposals, [ + { title: "Fix login", description: "Restore session persistence" }, + ]); + assert.equal(starts.length, 1); + const request = starts[0]; + assert.equal(request?.worktreePath, "/tmp/project-intake"); + assert.include(request?.instruction, "Fix the login flow and add rate limiting"); + assert.include(request?.instruction, '"tickets"'); + assert.match(String(request?.ticketId), /^intake-/); + }).pipe( + Effect.provide( + makeLayer({ + turnState: { _tag: "completed" }, + capturedOutput: { + tickets: [{ title: "Fix login", description: "Restore session persistence" }], + }, + onStart: (req) => starts.push(req), + }), + ), + ); + }); + + it.effect("fails when the agent asks a question", () => + Effect.gen(function* () { + const intake = yield* WorkflowIntakeService; + const result = yield* intake.proposeTickets(baseInput).pipe(Effect.flip); + assert.include(result.message, "asked a question"); + }).pipe( + Effect.provide( + makeLayer({ + turnState: { + _tag: "awaiting_user", + waitingReason: "Which auth provider?", + providerThreadId: "thread-1" as never, + providerRequestId: "request-1" as never, + providerResponseKind: "user-input", + }, + }), + ), + ), + ); + + it.effect("fails when the turn fails", () => + Effect.gen(function* () { + const intake = yield* WorkflowIntakeService; + const result = yield* intake.proposeTickets(baseInput).pipe(Effect.flip); + assert.include(result.message, "boom"); + }).pipe(Effect.provide(makeLayer({ turnState: { _tag: "failed", error: "boom" } }))), + ); + + it.effect("fails when no usable proposals come back", () => + Effect.gen(function* () { + const intake = yield* WorkflowIntakeService; + const result = yield* intake.proposeTickets(baseInput).pipe(Effect.flip); + assert.include(result.message, "usable ticket proposals"); + }).pipe( + Effect.provide( + makeLayer({ turnState: { _tag: "completed" }, capturedOutput: { tickets: [] } }), + ), + ), + ); + + it.effect("rejects an over-budget braindump before starting the turn", () => + Effect.gen(function* () { + const intake = yield* WorkflowIntakeService; + const result = yield* intake + .proposeTickets({ ...baseInput, braindump: "x".repeat(2000) }) + .pipe(Effect.flip); + assert.include(result.message, "too long"); + assert.include(result.message, "gpt-5.5"); + // The assembled prompt (wrapper + 2000-char braindump) and the 900 budget + // both appear in the actionable message. + assert.include(result.message, "900"); + }).pipe( + Effect.provide( + makeLayer({ + // ensureTurnStarted must never run for an over-budget braindump. + turnState: { _tag: "completed" }, + failIfTurnStarts: true, + provider: { maxInputChars: 900 }, + }), + ), + ), + ); + + it.effect("proceeds when the braindump fits the provider budget", () => { + const starts: DispatchRequest[] = []; + return Effect.gen(function* () { + const intake = yield* WorkflowIntakeService; + const proposals = yield* intake.proposeTickets(baseInput); + assert.deepEqual(proposals, [ + { title: "Fix login", description: "Restore session persistence" }, + ]); + assert.equal(starts.length, 1); + }).pipe( + Effect.provide( + makeLayer({ + turnState: { _tag: "completed" }, + capturedOutput: { + tickets: [{ title: "Fix login", description: "Restore session persistence" }], + }, + onStart: (req) => starts.push(req), + // Comfortably above the ~790-char wrapper plus the short braindump. + provider: { maxInputChars: 5000 }, + }), + ), + ); + }); + + it.effect("falls back to the 120k budget when the provider declares no limit", () => { + const starts: DispatchRequest[] = []; + return Effect.gen(function* () { + const intake = yield* WorkflowIntakeService; + const proposals = yield* intake.proposeTickets(baseInput); + assert.deepEqual(proposals, [ + { title: "Fix login", description: "Restore session persistence" }, + ]); + assert.equal(starts.length, 1); + }).pipe( + Effect.provide( + makeLayer({ + turnState: { _tag: "completed" }, + capturedOutput: { + tickets: [{ title: "Fix login", description: "Restore session persistence" }], + }, + onStart: (req) => starts.push(req), + // No maxInputChars → providerInputBudget falls back to 120k. + provider: {}, + }), + ), + ); + }); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowIntake.ts b/apps/server/src/workflow/Layers/WorkflowIntake.ts new file mode 100644 index 00000000000..9936c3082e6 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowIntake.ts @@ -0,0 +1,252 @@ +import type { ProjectId, ProviderInstanceId, WorkflowTicketProposal } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { providerInputBudget } from "../instructionTemplate.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowIntakeService, type WorkflowIntakeShape } from "../Services/WorkflowIntake.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; + +const INTAKE_TIMEOUT = "3 minutes"; +const MAX_PROPOSALS = 20; +const TITLE_MAX_LENGTH = 200; +const DESCRIPTION_MAX_LENGTH = 4000; + +// Intake runs in approval-required mode, where any tool use (even a read) +// would stall on an approval nobody is there to grant — so the prompt forbids +// tools entirely and works from the braindump text alone. +const intakeInstruction = (braindump: string): string => + [ + "You are an intake assistant for a kanban board on this repository.", + "Break the braindump below into independent, actionable tickets. Each", + "ticket gets a short imperative title and a description with enough", + "context for another engineer (or agent) to pick it up cold. Skip vague", + "asides that are not actionable; merge duplicates.", + "", + "Work ONLY from the braindump text. Do not run commands, read files, or", + "modify anything — answer directly.", + "", + "When the braindump implies ordering (build X, then Y on top of it), add", + '"dependsOn" with the zero-based indices of EARLIER tickets in your list', + "that must land first. Only reference earlier tickets.", + "", + "Braindump:", + "---", + braindump, + "---", + "", + "End your final message with a single fenced ```json block of the form", + '{"tickets": [{"title": "...", "description": "...", "dependsOn": [0]}]}.', + ].join("\n"); + +/** + * Validate the agent's parsed output into bounded proposals. Invalid entries + * are dropped rather than failing the whole intake; overlong fields are + * truncated. Returns an empty array when the shape is unusable. + */ +export const parseIntakeProposals = (output: unknown): ReadonlyArray<WorkflowTicketProposal> => { + if (typeof output !== "object" || output === null || Array.isArray(output)) { + return []; + } + const tickets = (output as Record<string, unknown>)["tickets"]; + if (!Array.isArray(tickets)) { + return []; + } + const proposals: WorkflowTicketProposal[] = []; + for (const raw of tickets) { + if (proposals.length >= MAX_PROPOSALS) { + break; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + continue; + } + const entry = raw as Record<string, unknown>; + const title = typeof entry["title"] === "string" ? entry["title"].trim() : ""; + if (title === "") { + continue; + } + const description = typeof entry["description"] === "string" ? entry["description"].trim() : ""; + // Backward-only index references: anything else (forward, self, junk) is + // dropped rather than failing the proposal. + const index = proposals.length; + const rawDependsOn = entry["dependsOn"]; + const dependsOn = Array.isArray(rawDependsOn) + ? [ + ...new Set( + rawDependsOn.filter( + (value): value is number => + typeof value === "number" && Number.isInteger(value) && value >= 0 && value < index, + ), + ), + ] + : []; + proposals.push({ + title: title.slice(0, TITLE_MAX_LENGTH) as never, + ...(description === "" ? {} : { description: description.slice(0, DESCRIPTION_MAX_LENGTH) }), + ...(dependsOn.length === 0 ? {} : { dependsOn }), + }); + } + return proposals; +}; + +const intakeError = (message: string) => new WorkflowEventStoreError({ message }); + +const make = Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const workspaces = yield* ProjectWorkspaceResolver; + const turnPort = yield* ProviderTurnPort; + const turnState = yield* TurnStateReader; + const capturedOutputs = yield* CapturedStepOutputReader; + const ids = yield* WorkflowIds; + const providerService = yield* Effect.serviceOption(ProviderService); + const orchestration = yield* Effect.serviceOption(OrchestrationEngineService); + + const cleanupSession = (threadId: string, turnId: unknown) => + Option.match(providerService, { + onNone: () => Effect.void, + onSome: (provider) => + provider.interruptTurn({ threadId: threadId as never, turnId: turnId as never }).pipe( + Effect.catch(() => Effect.void), + Effect.andThen( + provider + .stopSession({ threadId: threadId as never }) + .pipe(Effect.catch(() => Effect.void)), + ), + ), + }).pipe( + // Intake threads are one-shot scratch space — delete them once the + // proposals (or the failure) have been extracted so they never + // accumulate as orphaned hidden threads. + Effect.andThen( + Option.match(orchestration, { + onNone: () => Effect.void, + onSome: (engine) => + engine + .dispatch({ + type: "thread.delete", + commandId: `workflow-intake-delete-${threadId}` as never, + threadId: threadId as never, + }) + .pipe( + Effect.catch(() => Effect.void), + Effect.asVoid, + ), + }), + ), + ); + + const proposeTickets: WorkflowIntakeShape["proposeTickets"] = (input) => + Effect.gen(function* () { + const board = yield* read.getBoard(input.boardId); + if (board === null) { + return yield* intakeError(`Workflow board ${input.boardId} was not found`); + } + const cwd = yield* workspaces + .resolve(board.projectId as ProjectId) + .pipe( + Effect.mapError( + (cause) => + new WorkflowEventStoreError({ message: "intake workspace lookup failed", cause }), + ), + ); + + // Reject an over-budget braindump before the turn starts: the assembled + // prompt (wrapper + braindump) is what we'll send, so budget that string + // against the selected model's input limit. Resolution failures (unknown + // instance, no ProviderService) fall back to the 120k cap, which a + // contract-capped (20k) braindump always clears. + const maxInputChars = Option.isSome(providerService) + ? yield* providerService.value + .getCapabilities(input.agent.instance as ProviderInstanceId) + .pipe( + Effect.map((c) => c.maxInputChars), + Effect.orElseSucceed(() => undefined), + ) + : undefined; + const budget = providerInputBudget(maxInputChars); + const prompt = intakeInstruction(input.braindump); + if (prompt.length > budget) { + return yield* intakeError( + `This braindump is too long for ${input.agent.model} ` + + `(${prompt.length} of ${budget} characters). ` + + `Shorten it, or choose a larger-context model for intake.`, + ); + } + + const threadId = (yield* ids.eventId()) as string; + // Synthetic ids: intake never writes to the dispatch outbox or any + // ticket projection — the live turn port only uses thread/cwd/model. + const syntheticId = `intake-${threadId}`; + const { turnId } = yield* turnPort.ensureTurnStarted({ + dispatchId: syntheticId as never, + ticketId: syntheticId as never, + stepRunId: syntheticId as never, + threadId: threadId as never, + providerInstance: input.agent.instance as string, + model: input.agent.model as string, + instruction: prompt, + worktreePath: cwd, + ...(input.agent.options === undefined ? {} : { options: input.agent.options }), + projectId: board.projectId, + threadTitle: "Ticket intake", + // Intake runs at the real project root, not a disposable worktree — + // never give an unreviewed braindump write access. A write attempt + // surfaces as awaiting_user, which intake treats as failure. + runtimeMode: "approval-required", + }); + + const readProposals = Effect.gen(function* () { + const awaitTerminal = Effect.gen(function* () { + let state = yield* turnState.read(threadId as never); + while (state._tag === "running") { + yield* Effect.sleep("500 millis"); + state = yield* turnState.read(threadId as never); + } + return state; + }); + const state = yield* awaitTerminal.pipe( + Effect.timeoutOption(INTAKE_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => intakeError("the intake agent did not finish in time"), + onSome: Effect.succeed, + }), + ), + ); + if (state._tag === "awaiting_user") { + return yield* intakeError( + "the intake agent asked a question or requested write access — refine the braindump and retry", + ); + } + if (state._tag === "failed") { + return yield* intakeError(`intake agent turn failed: ${state.error}`); + } + + const output = yield* capturedOutputs.read({ + stepRunId: syntheticId as never, + threadId: threadId as never, + turnId, + }); + const proposals = parseIntakeProposals(output); + if (proposals.length === 0) { + return yield* intakeError("the intake agent did not produce any usable ticket proposals"); + } + return proposals; + }); + // One-shot turn: whatever happens, never leave the provider session + // (or a dangling question) running once intake returns. + return yield* readProposals.pipe(Effect.ensuring(cleanupSession(threadId, turnId))); + }); + + return { proposeTickets } satisfies WorkflowIntakeShape; +}); + +export const WorkflowIntakeLive = Layer.effect(WorkflowIntakeService, make); diff --git a/apps/server/src/workflow/Layers/WorkflowOutboundConnectionStore.test.ts b/apps/server/src/workflow/Layers/WorkflowOutboundConnectionStore.test.ts new file mode 100644 index 00000000000..7e3d89ce916 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowOutboundConnectionStore.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import { WorkflowOutboundConnectionStore } from "../Services/WorkflowOutboundConnectionStore.ts"; +import { WorkflowOutboundConnectionStoreLayer } from "./WorkflowOutboundConnectionStore.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import type { UrlValidatorDeps } from "../outbound/OutboundUrlValidator.ts"; +import { OutboundUrlError } from "../outbound/OutboundUrlValidator.ts"; + +// --------------------------------------------------------------------------- +// Stub ServerSecretStore backed by an in-memory Map +// --------------------------------------------------------------------------- +const makeInMemorySecretStore = () => { + const store = new Map<string, Uint8Array>(); + const layer = Layer.succeed(ServerSecretStore.ServerSecretStore, { + get: (name) => Effect.succeed(store.get(name) ?? null), + set: (name, value) => + Effect.sync(() => { + store.set(name, value); + }), + create: (name, value) => + Effect.sync(() => { + store.set(name, value); + }), + getOrCreateRandom: (_name, _bytes) => Effect.die("not needed in test"), + remove: (name) => + Effect.sync(() => { + store.delete(name); + }), + } satisfies ServerSecretStore.ServerSecretStoreShape); + return { layer, store }; +}; + +// --------------------------------------------------------------------------- +// Stub validator deps: public IP resolves ok; private IP is SSRF-blocked +// --------------------------------------------------------------------------- +const makePublicLookup = (): UrlValidatorDeps => ({ + lookup: (_host) => Effect.succeed(["140.82.112.3"]), +}); + +const makePrivateLookup = (): UrlValidatorDeps => ({ + lookup: (_host) => Effect.succeed(["127.0.0.1"]), +}); + +const makeFailLookup = (): UrlValidatorDeps => ({ + lookup: (_host) => + Effect.fail(new OutboundUrlError({ reason: "DNS resolution failed for test (ENOTFOUND)" })), +}); + +// --------------------------------------------------------------------------- +// Test layer builder +// --------------------------------------------------------------------------- +const buildTestLayer = (validatorDeps: UrlValidatorDeps = makePublicLookup()) => { + const { layer: secretStoreLayer, store: secretStore } = makeInMemorySecretStore(); + + const layer = WorkflowOutboundConnectionStoreLayer(validatorDeps).pipe( + Layer.provide(DeterministicWorkflowIds), + Layer.provide(secretStoreLayer), + Layer.provide(MigrationsLive), + Layer.provide(SqlitePersistenceMemory), + ); + return { layer, secretStore }; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe("WorkflowOutboundConnectionStore", () => { + it.effect("create → list → getTarget round-trips the secret URL", () => + Effect.gen(function* () { + const store = yield* WorkflowOutboundConnectionStore; + + const view = yield* store.create({ + kind: "slack", + displayName: "My Slack Webhook", + url: "https://hooks.slack.com/x", + }); + + expect(view.kind).toBe("slack"); + expect(view.displayName).toBe("My Slack Webhook"); + expect(typeof view.connectionRef).toBe("string"); + expect(view.connectionRef.length).toBeGreaterThan(0); + // URL must NOT be in the view + expect((view as Record<string, unknown>)["url"]).toBeUndefined(); + + const connections = yield* store.list(); + expect(connections).toHaveLength(1); + expect(connections[0]!.connectionRef).toBe(view.connectionRef); + + const target = yield* store.getTarget(view.connectionRef); + expect(target.kind).toBe("slack"); + expect(target.url).toBe("https://hooks.slack.com/x"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("create rejects an SSRF-blocked URL and writes no row", () => + Effect.gen(function* () { + const store = yield* WorkflowOutboundConnectionStore; + + // This URL resolves to 127.0.0.1 (loopback) — SSRF blocked. + const result = yield* Effect.exit( + store.create({ + kind: "webhook", + displayName: "Internal Target", + url: "https://internal.example.com/hook", + }), + ); + expect(result._tag).toBe("Failure"); + + // No row should have been inserted + const connections = yield* store.list(); + expect(connections).toHaveLength(0); + }).pipe(Effect.provide(buildTestLayer(makePrivateLookup()).layer)), + ); + + it.effect("create rejects when DNS lookup fails entirely", () => + Effect.gen(function* () { + const store = yield* WorkflowOutboundConnectionStore; + + const result = yield* Effect.exit( + store.create({ + kind: "webhook", + displayName: "Unknown Host", + url: "https://totally-nonexistent.example.invalid/hook", + }), + ); + expect(result._tag).toBe("Failure"); + + const connections = yield* store.list(); + expect(connections).toHaveLength(0); + }).pipe(Effect.provide(buildTestLayer(makeFailLookup()).layer)), + ); + + it.effect("getTarget fails for an unknown connectionRef", () => + Effect.gen(function* () { + const store = yield* WorkflowOutboundConnectionStore; + const result = yield* Effect.exit(store.getTarget("conn-nonexistent-ref")); + expect(result._tag).toBe("Failure"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("remove deletes the row (dangling refs allowed; no board scan)", () => + Effect.gen(function* () { + const store = yield* WorkflowOutboundConnectionStore; + + const view = yield* store.create({ + kind: "webhook", + displayName: "To Remove", + url: "https://hooks.example.com/webhook", + }); + + const before = yield* store.list(); + expect(before.some((c) => c.connectionRef === view.connectionRef)).toBe(true); + + yield* store.remove(view.connectionRef); + + const after = yield* store.list(); + expect(after.some((c) => c.connectionRef === view.connectionRef)).toBe(false); + + // getTarget also fails after removal + const targetResult = yield* Effect.exit(store.getTarget(view.connectionRef)); + expect(targetResult._tag).toBe("Failure"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("list returns all views without the URL field", () => + Effect.gen(function* () { + const store = yield* WorkflowOutboundConnectionStore; + + yield* store.create({ + kind: "webhook", + displayName: "First", + url: "https://a.example.com/hook", + }); + yield* store.create({ + kind: "slack", + displayName: "Second", + url: "https://b.example.com/hook", + }); + + const connections = yield* store.list(); + expect(connections).toHaveLength(2); + expect(connections.map((c) => c.displayName).sort()).toEqual(["First", "Second"]); + // URL must NOT be present in any view + for (const conn of connections) { + expect((conn as Record<string, unknown>)["url"]).toBeUndefined(); + } + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect( + "getTarget fails gracefully when secret is missing (row exists, secret deleted)", + () => { + const { layer, secretStore } = buildTestLayer(); + return Effect.gen(function* () { + const store = yield* WorkflowOutboundConnectionStore; + + const view = yield* store.create({ + kind: "webhook", + displayName: "Orphaned Row", + url: "https://hooks.example.com/orphan", + }); + + // Simulate out-of-band secret removal: delete from the backing map while + // the row remains in SQLite (mirrors the WorkSourceConnectionStore test). + secretStore.delete(`outbound-target:${view.connectionRef}`); + + const result = yield* Effect.exit(store.getTarget(view.connectionRef)); + expect(result._tag).toBe("Failure"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("create writes no listable row when the secret store fails", () => { + // Secret-before-row ordering: if the secret write fails, create aborts + // before inserting the row, so no half-created connection (row present but + // secret missing → getTarget fails) is ever listable. + const failingSecretStoreLayer = Layer.succeed(ServerSecretStore.ServerSecretStore, { + get: () => Effect.succeed(null), + set: () => Effect.fail(new ServerSecretStore.SecretStoreError({ message: "backend down" })), + create: () => Effect.fail(new ServerSecretStore.SecretStoreError({ message: "unused" })), + getOrCreateRandom: () => Effect.die("not needed in test"), + remove: () => Effect.void, + } satisfies ServerSecretStore.ServerSecretStoreShape); + const layer = WorkflowOutboundConnectionStoreLayer(makePublicLookup()).pipe( + Layer.provide(DeterministicWorkflowIds), + Layer.provide(failingSecretStoreLayer), + Layer.provide(MigrationsLive), + Layer.provide(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const store = yield* WorkflowOutboundConnectionStore; + const result = yield* Effect.exit( + store.create({ + kind: "webhook", + displayName: "Secret Write Fails", + url: "https://hooks.example.com/x", + }), + ); + expect(result._tag).toBe("Failure"); + const connections = yield* store.list(); + expect(connections).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.effect("remove of a nonexistent connectionRef succeeds (idempotent)", () => + Effect.gen(function* () { + const store = yield* WorkflowOutboundConnectionStore; + // Should not throw — no row to delete is fine + yield* store.remove("conn-does-not-exist"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); +}); + +// Suppress unused import warning for FileSystem / Path (used indirectly via SqlitePersistenceMemory) +void FileSystem; +void Path; diff --git a/apps/server/src/workflow/Layers/WorkflowOutboundConnectionStore.ts b/apps/server/src/workflow/Layers/WorkflowOutboundConnectionStore.ts new file mode 100644 index 00000000000..5e0a41f2cc4 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowOutboundConnectionStore.ts @@ -0,0 +1,223 @@ +/** + * WorkflowOutboundConnectionStore — Layer implementation. + * + * Persists outbound connection metadata to the `workflow_outbound_connection` + * SQLite table and stores the destination URL bytes in `ServerSecretStore` + * under `outbound-target:<connectionRef>`. + * + * create: SSRF-validate the URL via OutboundUrlValidator first (fail early, + * no row or secret written). Then generate connectionRef via + * WorkflowIds.eventId() (produces "evt-<uuid>"), prefix to "conn-<id>", + * store URL in secret store, INSERT row, return view (no URL). + * + * list: SELECT all rows DESC by created_at → views (no URL). + * + * getTarget: SELECT kind + secret_name WHERE connection_ref=?, then read + * the secret → { kind, url }. Fails OutboundConfigError if not found. + * + * remove: read secret_name, DELETE row, best-effort delete secret. + * Does NOT scan boards for dangling refs (matches WorkSourceConnectionStore). + * + * Validator injection seam: the Live layer accepts an optional `lookup` + * function (UrlValidatorDeps) that is forwarded to OutboundUrlValidator.validate. + * Production wiring uses the real DNS default (undefined → defaultLookup). + * Tests inject a stub lookup that resolves to a fixed public IP (SSRF ok) or + * a private IP (SSRF reject) — no real DNS queries in the test suite. + */ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import type { OutboundConnectionKind, OutboundConnectionView } from "@t3tools/contracts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import { OutboundUrlValidator, type UrlValidatorDeps } from "../outbound/OutboundUrlValidator.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { + OutboundConfigError, + WorkflowOutboundConnectionStore, + type WorkflowOutboundConnectionStoreShape, +} from "../Services/WorkflowOutboundConnectionStore.ts"; + +interface ConnectionRow { + readonly connection_ref: string; + readonly kind: string; + readonly display_name: string; + readonly secret_name: string; + readonly created_at: string; +} + +const toOutboundConfigError = (reason: string) => (cause: unknown) => + new OutboundConfigError({ reason: `${reason}: ${String(cause)}` }); + +const toView = (row: ConnectionRow): OutboundConnectionView => ({ + connectionRef: row.connection_ref, + kind: row.kind as OutboundConnectionKind, + displayName: row.display_name, + createdAt: row.created_at, +}); + +/** + * Build the WorkflowOutboundConnectionStore implementation. + * + * @param validatorDeps - Optional DNS lookup override for OutboundUrlValidator. + * Pass undefined (default) to use the real DNS resolver in production. + * Pass a stub lookup in tests to keep them hermetic. + */ +const makeWithDeps = (validatorDeps: UrlValidatorDeps | undefined) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const ids = yield* WorkflowIds; + + const create: WorkflowOutboundConnectionStoreShape["create"] = Effect.fn( + "WorkflowOutboundConnectionStore.create", + )(function* (input) { + // SSRF validation first — fail before touching DB or secret store. + yield* OutboundUrlValidator.validate(input.url, validatorDeps).pipe( + Effect.mapError((e) => new OutboundConfigError({ reason: e.reason })), + ); + + const eventId = yield* ids.eventId(); + const connectionRef = `conn-${eventId}`; + const secretName = `outbound-target:${connectionRef}`; + const now = yield* DateTime.now; + const createdAt = DateTime.formatIso(now); + + // Store the secret BEFORE inserting the row so a LISTED connection always + // has a usable secret (no row-exists-but-secret-missing state, which would + // make getTarget fail at delivery time for a connection the UI shows as + // valid). If the row insert then fails, best-effort remove the just-written + // secret so it isn't orphaned (secretStore.remove is idempotent on missing; + // connectionRef is unique per call, so there is nothing else to clobber). + yield* secretStore + .set(secretName, new TextEncoder().encode(input.url)) + .pipe(Effect.mapError(toOutboundConfigError("Failed to store connection URL secret"))); + + yield* sql` + INSERT INTO workflow_outbound_connection ( + connection_ref, + kind, + display_name, + secret_name, + created_at + ) VALUES ( + ${connectionRef}, + ${input.kind}, + ${input.displayName}, + ${secretName}, + ${createdAt} + ) + `.pipe( + Effect.tapError(() => secretStore.remove(secretName).pipe(Effect.ignore)), + Effect.mapError(toOutboundConfigError("Failed to insert outbound connection")), + ); + + return { + connectionRef, + kind: input.kind, + displayName: input.displayName, + createdAt, + } satisfies OutboundConnectionView; + }); + + const list: WorkflowOutboundConnectionStoreShape["list"] = () => + sql<ConnectionRow>` + SELECT connection_ref, kind, display_name, secret_name, created_at + FROM workflow_outbound_connection + ORDER BY created_at DESC + `.pipe( + Effect.map((rows) => rows.map(toView)), + Effect.mapError(toOutboundConfigError("Failed to list outbound connections")), + Effect.withSpan("WorkflowOutboundConnectionStore.list"), + ); + + const getTarget: WorkflowOutboundConnectionStoreShape["getTarget"] = Effect.fn( + "WorkflowOutboundConnectionStore.getTarget", + )(function* (connectionRef) { + const rows = yield* sql<{ readonly kind: string; readonly secret_name: string }>` + SELECT kind, secret_name + FROM workflow_outbound_connection + WHERE connection_ref = ${connectionRef} + `.pipe(Effect.mapError(toOutboundConfigError("Failed to look up outbound connection"))); + + const row = rows[0]; + if (row === undefined) { + return yield* new OutboundConfigError({ + reason: `No outbound connection found for ref: ${connectionRef}`, + }); + } + + const bytes = yield* secretStore + .get(row.secret_name) + .pipe( + Effect.mapError(toOutboundConfigError("Failed to read outbound connection URL secret")), + ); + + if (bytes === null) { + return yield* new OutboundConfigError({ + reason: `Secret missing for outbound connection: ${connectionRef}`, + }); + } + + return { + kind: row.kind as OutboundConnectionKind, + url: new TextDecoder().decode(bytes), + }; + }); + + const remove: WorkflowOutboundConnectionStoreShape["remove"] = Effect.fn( + "WorkflowOutboundConnectionStore.remove", + )(function* (connectionRef) { + const rows = yield* sql<{ readonly secret_name: string }>` + SELECT secret_name FROM workflow_outbound_connection WHERE connection_ref = ${connectionRef} + `.pipe( + Effect.mapError(toOutboundConfigError("Failed to look up outbound connection for removal")), + ); + + // Delete the secret BEFORE the row, surfacing errors (do NOT ignore). + // This makes the durable step retryable: if the secret delete fails the + // row still exists, so a retried remove can complete the full deletion. + // ServerSecretStore.remove is idempotent on a missing secret (catches + // NotFound → void), so re-removal of an already-secretless row succeeds. + // Mirrors WorkSourceConnectionStore.remove exactly. + const row = rows[0]; + if (row !== undefined) { + yield* secretStore + .remove(row.secret_name) + .pipe(Effect.mapError(toOutboundConfigError("Failed to remove connection URL secret"))); + } + + yield* sql` + DELETE FROM workflow_outbound_connection WHERE connection_ref = ${connectionRef} + `.pipe(Effect.mapError(toOutboundConfigError("Failed to delete outbound connection row"))); + }); + + return { + create, + list, + getTarget, + remove, + } satisfies WorkflowOutboundConnectionStoreShape; + }); + +/** Production layer — uses real DNS for SSRF validation. */ +export const WorkflowOutboundConnectionStoreLive = Layer.effect( + WorkflowOutboundConnectionStore, + makeWithDeps(undefined), +); + +/** + * Factory for test/custom layers — inject a stub `lookup` to avoid real DNS. + * + * Example (test): + * const stubDeps = { lookup: (_host) => Effect.succeed(["140.82.112.3"]) } + * WorkflowOutboundConnectionStoreLayer(stubDeps) + */ +export const WorkflowOutboundConnectionStoreLayer = ( + validatorDeps: UrlValidatorDeps, +): Layer.Layer< + WorkflowOutboundConnectionStore, + never, + SqlClient.SqlClient | ServerSecretStore.ServerSecretStore | WorkflowIds +> => Layer.effect(WorkflowOutboundConnectionStore, makeWithDeps(validatorDeps)); diff --git a/apps/server/src/workflow/Layers/WorkflowOutboundDispatcher.concurrency.test.ts b/apps/server/src/workflow/Layers/WorkflowOutboundDispatcher.concurrency.test.ts new file mode 100644 index 00000000000..9105ec047b5 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowOutboundDispatcher.concurrency.test.ts @@ -0,0 +1,152 @@ +import { assert, it } from "@effect/vitest"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { + OutboundConfigError, + WorkflowOutboundConnectionStore, +} from "../Services/WorkflowOutboundConnectionStore.ts"; +import { WorkflowOutboundDispatcher } from "../Services/WorkflowOutboundDispatcher.ts"; +import { + claimOutboundDeliveryRow, + makeWorkflowOutboundDispatcherLive, +} from "./WorkflowOutboundDispatcher.ts"; + +const ENV_ID = "env-1" as EnvironmentId; + +// --------------------------------------------------------------------------- +// Minimal stubs for the dispatcher's deps. `claimRow` is internal, so its +// invariant is asserted at the SQL level (the exact UPDATE ... RETURNING run +// via SqlClient against a seeded row). `recoverStaleClaims` IS public, so it is +// called directly through the service — that path only touches SqlClient, but +// constructing the layer still requires HttpClient / connection store / +// ServerEnvironment, which we stub as never-called (Effect.die) since no sweep +// runs in these tests. +// --------------------------------------------------------------------------- +const stubHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))), + ), +); + +const stubConnectionStoreLayer = Layer.succeed(WorkflowOutboundConnectionStore, { + getTarget: () => Effect.fail(new OutboundConfigError({ reason: "not needed in test" })), + create: () => Effect.die("not needed in test"), + list: () => Effect.die("not needed in test"), + remove: () => Effect.die("not needed in test"), +} satisfies WorkflowOutboundConnectionStore["Service"]); + +const serverEnvironmentLayer = Layer.succeed(ServerEnvironment, { + getEnvironmentId: Effect.succeed(ENV_ID), + getDescriptor: Effect.die("unsupported descriptor read"), +} as unknown as ServerEnvironment["Service"]) as Layer.Layer<ServerEnvironment>; + +const layer = it.layer( + makeWorkflowOutboundDispatcherLive().pipe( + Layer.provideMerge(stubHttpClientLayer), + Layer.provideMerge(stubConnectionStoreLayer), + Layer.provideMerge(serverEnvironmentLayer), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +// Seed one workflow_outbound_delivery row. Column set + NOT-NULL requirements +// mirror the committer's INSERT (WorkflowEventCommitter.ts ~L254): delivery_id, +// board_id, ticket_id, rule_id, event_sequence, connection_ref, formatter, +// context_json and created_at are required; delivery_state / attempt_count have +// DB defaults but we set delivery_state explicitly. +// it.layer shares one DB across the tests in this suite, so each seeded row +// uses a distinct (event_sequence, rule_id) to satisfy the table's UNIQUE +// constraint — keyed off the unique deliveryId. +const seedRow = (over: { + readonly deliveryId: string; + readonly deliveryState: string; + readonly eventSequence: number; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO workflow_outbound_delivery ( + delivery_id, board_id, ticket_id, rule_id, event_sequence, + connection_ref, formatter, context_json, delivery_state, attempt_count, + next_attempt_at, created_at + ) VALUES ( + ${over.deliveryId}, 'board-1', 'ticket-1', ${over.deliveryId}, ${over.eventSequence}, + 'conn-1', 'generic', '{}', ${over.deliveryState}, 0, + ${null}, '2026-06-07T00:00:00.000Z' + ) + `; + }); + +const readState = (deliveryId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly deliveryState: string }>` + SELECT delivery_state AS "deliveryState" + FROM workflow_outbound_delivery WHERE delivery_id = ${deliveryId} + `; + return rows[0]!.deliveryState; + }); + +// The PRODUCTION claim statement, imported (not copied) so this test cannot +// silently pass while production claimRow drifts. Returns the rows array; a row +// is yielded only when the conditional UPDATE actually transitioned +// 'pending' → 'processing'. +const claimRowSql = (deliveryId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* claimOutboundDeliveryRow(sql, deliveryId); + }); + +layer("WorkflowOutboundDispatcher atomic claim + stale recovery (concurrency)", (it) => { + it.effect( + "two concurrent claimRow UPDATEs on one pending row: EXACTLY ONE returns a row (the winner)", + () => + Effect.gen(function* () { + yield* seedRow({ deliveryId: "dlv-claim", deliveryState: "pending", eventSequence: 1 }); + + // Fire the exact claimRow conditional UPDATE concurrently. The + // UPDATE ... WHERE delivery_state='pending' ... RETURNING guarantees only + // the call that actually flips the row yields a RETURNING row; the loser + // matches zero rows (state already 'processing') and gets an empty array. + // Only the claimant proceeds to POST. We assert the invariant (exactly one + // non-empty result), not a specific interleaving — the in-memory SqlClient + // may serialize writes, but the conditional UPDATE must still elect one + // winner. + const results = yield* Effect.all([claimRowSql("dlv-claim"), claimRowSql("dlv-claim")], { + concurrency: "unbounded", + }); + + const winners = results.filter((rows) => rows.length > 0); + const losers = results.filter((rows) => rows.length === 0); + assert.strictEqual(winners.length, 1, "exactly one claimant wins the row"); + assert.strictEqual(losers.length, 1, "the other claim sees an empty result"); + + // The row ends up 'processing' (claimed exactly once). + assert.strictEqual(yield* readState("dlv-claim"), "processing"); + }), + ); + + it.effect("recoverStaleClaims resets a stranded 'processing' row back to 'pending'", () => + Effect.gen(function* () { + // A crash after claimRow but before markSent/recordFailure leaves the row + // stranded 'processing'; the sweep selects only 'pending', so without + // recovery it is never retried. + yield* seedRow({ deliveryId: "dlv-stranded", deliveryState: "processing", eventSequence: 2 }); + + const dispatcher = yield* WorkflowOutboundDispatcher; + yield* dispatcher.recoverStaleClaims(); + + // Back to 'pending' so the next sweep re-selects it. + assert.strictEqual(yield* readState("dlv-stranded"), "pending"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowOutboundDispatcher.test.ts b/apps/server/src/workflow/Layers/WorkflowOutboundDispatcher.test.ts new file mode 100644 index 00000000000..cc77203b5ae --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowOutboundDispatcher.test.ts @@ -0,0 +1,724 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { OutboundEventContext } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + OutboundConfigError, + type OutboundTarget, + WorkflowOutboundConnectionStore, +} from "../Services/WorkflowOutboundConnectionStore.ts"; +import { OutboundUrlError, OutboundUrlValidator } from "../outbound/OutboundUrlValidator.ts"; +import { WorkflowOutboundDispatcher } from "../Services/WorkflowOutboundDispatcher.ts"; +import { makeWorkflowOutboundDispatcherLive } from "./WorkflowOutboundDispatcher.ts"; + +const ENV_ID = "env-1" as EnvironmentId; + +// --------------------------------------------------------------------------- +// Stub HttpClient — records requests, returns a programmable response per call. +// --------------------------------------------------------------------------- + +interface RecordedRequest { + readonly url: string; + readonly method: string; + readonly headers: Record<string, string>; + readonly bodyText: string; + readonly contentType: string | undefined; +} + +interface CannedResponse { + readonly status: number; + readonly headers?: Record<string, string>; + readonly body?: string; + /** Virtual delay before the response resolves (used to exercise the timeout). */ + readonly delayMs?: number; +} + +interface HttpRecorder { + requests: Array<RecordedRequest>; + responses: Array<CannedResponse>; +} + +const makeHttpRecorder = (responses: ReadonlyArray<CannedResponse>): HttpRecorder => ({ + requests: [], + responses: [...responses], +}); + +const decodeBody = ( + request: HttpClientRequest.HttpClientRequest, +): { + readonly bodyText: string; + readonly contentType: string | undefined; +} => { + const body = request.body as { readonly _tag: string }; + if (body._tag === "Uint8Array") { + const u8 = body as unknown as { readonly body: Uint8Array; readonly contentType: string }; + return { bodyText: new TextDecoder().decode(u8.body), contentType: u8.contentType }; + } + return { bodyText: "", contentType: undefined }; +}; + +const stubHttpClientLayer = (recorder: HttpRecorder) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + const decoded = decodeBody(request); + recorder.requests.push({ + url: request.url, + method: request.method, + headers: { ...(request.headers as Record<string, string>) }, + bodyText: decoded.bodyText, + contentType: decoded.contentType, + }); + const canned = recorder.responses.shift() ?? { status: 200 }; + if (canned.delayMs !== undefined) { + // Virtual sleep (test clock) so the dispatcher's timeout can win the race. + yield* Effect.sleep(Duration.millis(canned.delayMs)); + } + return HttpClientResponse.fromWeb( + request, + new Response(canned.body ?? "", { + status: canned.status, + headers: { ...canned.headers }, + }), + ); + }), + ), + ); + +// --------------------------------------------------------------------------- +// Stub connection store — getTarget resolves to a programmed target or fails. +// --------------------------------------------------------------------------- + +const stubConnectionStoreLayer = (byRef: Record<string, OutboundTarget | "missing">) => + Layer.succeed(WorkflowOutboundConnectionStore, { + getTarget: (connectionRef: string) => { + const entry = byRef[connectionRef]; + if (entry === undefined || entry === "missing") { + return Effect.fail( + new OutboundConfigError({ reason: `no connection for ${connectionRef}` }), + ); + } + return Effect.succeed(entry); + }, + create: () => Effect.die("not needed in test"), + list: () => Effect.die("not needed in test"), + remove: () => Effect.die("not needed in test"), + } satisfies WorkflowOutboundConnectionStore["Service"]); + +const serverEnvironmentLayer = Layer.succeed(ServerEnvironment, { + getEnvironmentId: Effect.succeed(ENV_ID), + getDescriptor: Effect.die("unsupported descriptor read"), +} as unknown as ServerEnvironment["Service"]) as Layer.Layer<ServerEnvironment>; + +// --------------------------------------------------------------------------- +// Validator stub: ok (resolves to the parsed URL) or blocked (fails). +// --------------------------------------------------------------------------- + +const okValidator: typeof OutboundUrlValidator.validate = (rawUrl) => + Effect.sync(() => new URL(rawUrl)); + +const blockedValidator: typeof OutboundUrlValidator.validate = () => + Effect.fail(new OutboundUrlError({ reason: "blocked host (test)" })); + +// --------------------------------------------------------------------------- +// Seed helpers +// --------------------------------------------------------------------------- + +const sampleContext = (over: Partial<OutboundEventContext> = {}): OutboundEventContext => ({ + trigger: "blocked", + ticketId: "ticket-1", + boardId: "board-1", + title: "Fix the thing", + status: "blocked", + fromLane: "impl", + toLane: "review", + isTerminal: false, + reason: "needs help", + occurredAt: "2026-06-07T00:00:01.000Z", + ...over, +}); + +const insertDelivery = (over: { + readonly deliveryId: string; + readonly boardId?: string; + readonly ticketId?: string; + readonly ruleId?: string; + readonly eventSequence?: number; + readonly connectionRef: string; + readonly formatter?: string; + readonly context?: OutboundEventContext; + /** Escape hatch: store this exact string as context_json (e.g. malformed). */ + readonly rawContextJson?: string; + readonly deliveryState?: string; + readonly attemptCount?: number; + readonly nextAttemptAt?: string | null; + readonly createdAt?: string; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const ctx = over.context ?? sampleContext(); + // @effect-diagnostics-next-line preferSchemaOverJson:off - serializing the test fixture context into the stored context_json column. + const contextJson = over.rawContextJson ?? JSON.stringify(ctx); + yield* sql` + INSERT INTO workflow_outbound_delivery ( + delivery_id, board_id, ticket_id, rule_id, event_sequence, + connection_ref, formatter, context_json, delivery_state, attempt_count, + next_attempt_at, created_at + ) VALUES ( + ${over.deliveryId}, + ${over.boardId ?? ctx.boardId}, + ${over.ticketId ?? ctx.ticketId}, + ${over.ruleId ?? "r1"}, + ${over.eventSequence ?? 1}, + ${over.connectionRef}, + ${over.formatter ?? "generic"}, + ${contextJson}, + ${over.deliveryState ?? "pending"}, + ${over.attemptCount ?? 0}, + ${over.nextAttemptAt ?? null}, + ${over.createdAt ?? "2026-06-07T00:00:00.000Z"} + ) + `; + }); + +const readDelivery = (deliveryId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ + readonly delivery_state: string; + readonly attempt_count: number; + readonly next_attempt_at: string | null; + readonly last_error: string | null; + }>` + SELECT + delivery_state AS "delivery_state", + attempt_count AS "attempt_count", + next_attempt_at AS "next_attempt_at", + last_error AS "last_error" + FROM workflow_outbound_delivery WHERE delivery_id = ${deliveryId} + `; + return rows[0]!; + }); + +// MAX_ATTEMPTS is 5 (mirrors the notification dispatcher / source syncer). +const MAX_ATTEMPTS = 5; + +// Module-level (non-Effect) ISO→epoch-ms helper for test assertions, so we do +// not construct `new Date()` inside an Effect generator. +const isoToMs = (iso: string): number => Date.parse(iso); + +const buildLayer = (input: { + readonly http: HttpRecorder; + readonly connections: Record<string, OutboundTarget | "missing">; + readonly validator?: typeof OutboundUrlValidator.validate; + readonly webBaseUrl?: string | URL; + readonly httpTimeoutMs?: number; +}) => + makeWorkflowOutboundDispatcherLive({ + validate: input.validator ?? okValidator, + ...(input.webBaseUrl !== undefined && { webBaseUrl: input.webBaseUrl }), + ...(input.httpTimeoutMs !== undefined && { httpTimeoutMs: input.httpTimeoutMs }), + }).pipe( + Layer.provideMerge(stubHttpClientLayer(input.http)), + Layer.provideMerge(stubConnectionStoreLayer(input.connections)), + Layer.provideMerge(serverEnvironmentLayer), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +const WEBHOOK_TARGET: OutboundTarget = { kind: "webhook", url: "https://hooks.example.com/x" }; +const SLACK_TARGET: OutboundTarget = { kind: "slack", url: "https://hooks.slack.com/services/x" }; + +describe.sequential("WorkflowOutboundDispatcher", () => { + it.effect("delivers a pending generic row → sent, with Idempotency-Key + Content-Type", () => { + const http = makeHttpRecorder([{ status: 200 }]); + return Effect.gen(function* () { + yield* insertDelivery({ deliveryId: "dlv-1", connectionRef: "conn-1" }); + const dispatcher = yield* WorkflowOutboundDispatcher; + yield* dispatcher.sweep(); + + const row = yield* readDelivery("dlv-1"); + assert.strictEqual(row.delivery_state, "sent"); + assert.strictEqual(http.requests.length, 1); + const req = http.requests[0]!; + assert.strictEqual(req.method, "POST"); + assert.strictEqual(req.url, WEBHOOK_TARGET.url); + assert.strictEqual(req.headers["idempotency-key"], "dlv-1"); + assert.strictEqual(req.contentType, "application/json"); + // @effect-diagnostics-next-line preferSchemaOverJson:off - decoding the POSTed body for assertions in a test. + const sent = JSON.parse(req.bodyText) as Record<string, unknown>; + assert.strictEqual(sent.event, "blocked"); + assert.deepStrictEqual(sent.board, { id: "board-1" }); + const ticket = sent.ticket as Record<string, unknown>; + assert.strictEqual(ticket.id, "ticket-1"); + assert.strictEqual(ticket.title, "Fix the thing"); + }).pipe(Effect.provide(buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET } }))); + }); + + it.effect( + "HTTP 500 → attempt_count=1, stays pending with future next_attempt_at + last_error", + () => { + const http = makeHttpRecorder([{ status: 500, body: "boom" }]); + return Effect.gen(function* () { + yield* insertDelivery({ deliveryId: "dlv-2", connectionRef: "conn-1" }); + const dispatcher = yield* WorkflowOutboundDispatcher; + const now = yield* Clock.currentTimeMillis; + yield* dispatcher.sweep(); + + const row = yield* readDelivery("dlv-2"); + assert.strictEqual(row.delivery_state, "pending"); + assert.strictEqual(row.attempt_count, 1); + assert.isNotNull(row.next_attempt_at); + assert.isNotNull(row.last_error); + assert.isTrue(isoToMs(row.next_attempt_at!) > now, "next_attempt_at must be in the future"); + }).pipe(Effect.provide(buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET } }))); + }, + ); + + it.effect("at MAX_ATTEMPTS-1 + HTTP 500 → row becomes 'failed'", () => { + const http = makeHttpRecorder([{ status: 500 }]); + return Effect.gen(function* () { + yield* insertDelivery({ + deliveryId: "dlv-3", + connectionRef: "conn-1", + attemptCount: MAX_ATTEMPTS - 1, + }); + const dispatcher = yield* WorkflowOutboundDispatcher; + yield* dispatcher.sweep(); + + const row = yield* readDelivery("dlv-3"); + assert.strictEqual(row.delivery_state, "failed"); + assert.strictEqual(row.attempt_count, MAX_ATTEMPTS); + }).pipe(Effect.provide(buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET } }))); + }); + + it.effect("HTTP 429 Retry-After: 120 → next_attempt_at ≈ now + 120s", () => { + const http = makeHttpRecorder([{ status: 429, headers: { "retry-after": "120" } }]); + return Effect.gen(function* () { + yield* insertDelivery({ deliveryId: "dlv-4", connectionRef: "conn-1" }); + const dispatcher = yield* WorkflowOutboundDispatcher; + const now = yield* Clock.currentTimeMillis; + yield* dispatcher.sweep(); + + const row = yield* readDelivery("dlv-4"); + assert.strictEqual(row.delivery_state, "pending"); + assert.strictEqual(row.attempt_count, 1); + const delta = isoToMs(row.next_attempt_at!) - now; + // within a small tolerance of 120_000ms + assert.isTrue(Math.abs(delta - 120_000) <= 2_000, `expected ≈120s, got ${delta}ms`); + }).pipe(Effect.provide(buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET } }))); + }); + + it.effect( + "isolation: one ok + one dangling-conn row → ok sent, missing backs off, sweep ok", + () => { + const http = makeHttpRecorder([{ status: 200 }]); + return Effect.gen(function* () { + yield* insertDelivery({ + deliveryId: "dlv-ok", + connectionRef: "conn-ok", + ticketId: "t-ok", + eventSequence: 1, + ruleId: "r-ok", + }); + yield* insertDelivery({ + deliveryId: "dlv-miss", + connectionRef: "conn-miss", + ticketId: "t-miss", + eventSequence: 2, + ruleId: "r-miss", + createdAt: "2026-06-07T00:00:01.000Z", + }); + const dispatcher = yield* WorkflowOutboundDispatcher; + // sweep must not throw even though one row's connection is missing. + yield* dispatcher.sweep(); + + assert.strictEqual((yield* readDelivery("dlv-ok")).delivery_state, "sent"); + const miss = yield* readDelivery("dlv-miss"); + assert.strictEqual(miss.delivery_state, "pending"); + assert.strictEqual(miss.attempt_count, 1); + assert.isNotNull(miss.next_attempt_at); + // Only one POST issued (the ok row); the missing one never reached HTTP. + assert.strictEqual(http.requests.length, 1); + }).pipe( + Effect.provide( + buildLayer({ + http, + connections: { "conn-ok": WEBHOOK_TARGET, "conn-miss": "missing" }, + }), + ), + ); + }, + ); + + it.effect("validator blocks the host → row backs off, NO POST issued", () => { + const http = makeHttpRecorder([{ status: 200 }]); + return Effect.gen(function* () { + yield* insertDelivery({ deliveryId: "dlv-ssrf", connectionRef: "conn-1" }); + const dispatcher = yield* WorkflowOutboundDispatcher; + yield* dispatcher.sweep(); + + const row = yield* readDelivery("dlv-ssrf"); + assert.strictEqual(row.delivery_state, "pending"); + assert.strictEqual(row.attempt_count, 1); + assert.isNotNull(row.last_error); + assert.strictEqual(http.requests.length, 0, "no POST when host is blocked"); + }).pipe( + Effect.provide( + buildLayer({ + http, + connections: { "conn-1": WEBHOOK_TARGET }, + validator: blockedValidator, + }), + ), + ); + }); + + it.effect("a row with future next_attempt_at is NOT picked up", () => { + const http = makeHttpRecorder([{ status: 200 }]); + return Effect.gen(function* () { + const future = "2999-01-01T00:00:00.000Z"; + yield* insertDelivery({ + deliveryId: "dlv-future", + connectionRef: "conn-1", + nextAttemptAt: future, + }); + const dispatcher = yield* WorkflowOutboundDispatcher; + yield* dispatcher.sweep(); + + const row = yield* readDelivery("dlv-future"); + assert.strictEqual(row.delivery_state, "pending"); + assert.strictEqual(row.attempt_count, 0); + assert.strictEqual(row.next_attempt_at, future); + assert.strictEqual(http.requests.length, 0, "future row not swept"); + }).pipe(Effect.provide(buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET } }))); + }); + + it.effect("slack row: webBaseUrl set → absolute button url; unset → no actions block", () => { + const httpWithBase = makeHttpRecorder([{ status: 200 }]); + const httpNoBase = makeHttpRecorder([{ status: 200 }]); + const base = "https://app.t3.example.com"; + const slackCtx = sampleContext({ boardId: "board-9", ticketId: "ticket-9" }); + + const withBase = Effect.gen(function* () { + yield* insertDelivery({ + deliveryId: "dlv-slack-base", + connectionRef: "conn-slack", + formatter: "slack", + context: slackCtx, + }); + const dispatcher = yield* WorkflowOutboundDispatcher; + yield* dispatcher.sweep(); + + assert.strictEqual(httpWithBase.requests.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off - decoding the POSTed Slack body for assertions in a test. + const sent = JSON.parse(httpWithBase.requests[0]!.bodyText) as { + readonly blocks: ReadonlyArray<Record<string, unknown>>; + }; + const actions = sent.blocks.find((b) => b.type === "actions"); + assert.isDefined(actions, "actions block present when webBaseUrl is set"); + const elements = actions!.elements as ReadonlyArray<Record<string, unknown>>; + assert.strictEqual( + elements[0]!.url, + `${base}/${encodeURIComponent(ENV_ID)}/board?boardId=${encodeURIComponent( + "board-9", + )}&ticket=${encodeURIComponent("ticket-9")}`, + ); + }).pipe( + Effect.provide( + buildLayer({ + http: httpWithBase, + connections: { "conn-slack": SLACK_TARGET }, + webBaseUrl: base, + }), + ), + ); + + const noBase = Effect.gen(function* () { + yield* insertDelivery({ + deliveryId: "dlv-slack-nobase", + connectionRef: "conn-slack", + formatter: "slack", + context: slackCtx, + }); + const dispatcher = yield* WorkflowOutboundDispatcher; + yield* dispatcher.sweep(); + + assert.strictEqual(httpNoBase.requests.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off - decoding the POSTed Slack body for assertions in a test. + const sent = JSON.parse(httpNoBase.requests[0]!.bodyText) as { + readonly blocks: ReadonlyArray<Record<string, unknown>>; + }; + const actions = sent.blocks.find((b) => b.type === "actions"); + assert.isUndefined(actions, "no actions block when webBaseUrl is unset"); + }).pipe( + Effect.provide(buildLayer({ http: httpNoBase, connections: { "conn-slack": SLACK_TARGET } })), + ); + + return Effect.gen(function* () { + yield* withBase; + yield* noBase; + }); + }); + + it.effect( + "malformed context_json → that row is parked failed; a SECOND healthy row still delivers", + () => { + // Two due rows, poison sorts FIRST (earlier created_at). The poison row must + // NOT abort the sweep — the healthy row must still get POSTed in the SAME sweep. + const http = makeHttpRecorder([{ status: 200 }]); + return Effect.gen(function* () { + yield* insertDelivery({ + deliveryId: "dlv-poison", + connectionRef: "conn-1", + ticketId: "t-poison", + eventSequence: 1, + ruleId: "r-poison", + rawContextJson: "{not json", + createdAt: "2026-06-07T00:00:00.000Z", + }); + yield* insertDelivery({ + deliveryId: "dlv-healthy", + connectionRef: "conn-1", + ticketId: "t-healthy", + eventSequence: 2, + ruleId: "r-healthy", + createdAt: "2026-06-07T00:00:01.000Z", + }); + const dispatcher = yield* WorkflowOutboundDispatcher; + // Must not throw despite the poison row. + yield* dispatcher.sweep(); + + // Poison row is non-retryable → parked 'failed', and it issued NO POST. + const poison = yield* readDelivery("dlv-poison"); + assert.strictEqual(poison.delivery_state, "failed"); + assert.isNotNull(poison.last_error); + + // The healthy row was still delivered in the same sweep — isolation proof. + const healthy = yield* readDelivery("dlv-healthy"); + assert.strictEqual(healthy.delivery_state, "sent"); + assert.strictEqual(http.requests.length, 1, "exactly one POST (the healthy row)"); + assert.strictEqual(http.requests[0]!.headers["idempotency-key"], "dlv-healthy"); + }).pipe(Effect.provide(buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET } }))); + }, + ); + + it.effect("unknown formatter → row parked failed, NO POST", () => { + const http = makeHttpRecorder([{ status: 200 }]); + return Effect.gen(function* () { + yield* insertDelivery({ + deliveryId: "dlv-badfmt", + connectionRef: "conn-1", + formatter: "teams", // not in {generic, slack} + }); + const dispatcher = yield* WorkflowOutboundDispatcher; + yield* dispatcher.sweep(); + + const row = yield* readDelivery("dlv-badfmt"); + assert.strictEqual(row.delivery_state, "failed"); + assert.strictEqual(row.attempt_count, 0, "non-retryable: attempt_count not burned"); + assert.isNotNull(row.last_error); + assert.strictEqual(http.requests.length, 0, "unknown formatter never POSTs"); + }).pipe(Effect.provide(buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET } }))); + }); + + // it.live (real clock): the dispatcher's Effect.timeoutOrElse and the stub's + // delay both rely on real time. With a 20ms test timeout and a 5s stub hang, + // the timeout wins at ~20ms and interrupts the hung sleep — no long wait. + it.live("a hung target times out → row backs off (retryable), sweep continues", () => { + const http = makeHttpRecorder([{ status: 200, delayMs: 5_000 }, { status: 200 }]); + return Effect.gen(function* () { + yield* insertDelivery({ + deliveryId: "dlv-hang", + connectionRef: "conn-1", + ticketId: "t-hang", + eventSequence: 1, + ruleId: "r-hang", + createdAt: "2026-06-07T00:00:00.000Z", + }); + yield* insertDelivery({ + deliveryId: "dlv-after", + connectionRef: "conn-1", + ticketId: "t-after", + eventSequence: 2, + ruleId: "r-after", + createdAt: "2026-06-07T00:00:01.000Z", + }); + const dispatcher = yield* WorkflowOutboundDispatcher; + yield* dispatcher.sweep(); + + // Hung row backed off (retryable), still pending with a future next_attempt_at. + const hung = yield* readDelivery("dlv-hang"); + assert.strictEqual(hung.delivery_state, "pending"); + assert.strictEqual(hung.attempt_count, 1); + assert.isNotNull(hung.next_attempt_at); + assert.isNotNull(hung.last_error); + + // The following row still got delivered — sweep did not freeze. + const after = yield* readDelivery("dlv-after"); + assert.strictEqual(after.delivery_state, "sent"); + }).pipe( + Effect.provide( + buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET }, httpTimeoutMs: 20 }), + ), + ); + }); + + it.effect( + "3xx redirect response → row backs off (retryable), NOT sent, and NO follow request is made", + () => { + // SSRF hardening: with redirect-following disabled, a webhook that responds + // with a 302 + Location pointing at a private host must NOT be followed. The + // stub returns the 3xx itself (mirroring fetch `redirect:"manual"`); the + // dispatcher must treat it as a non-2xx backoff and issue exactly ONE POST. + const http = makeHttpRecorder([ + { status: 302, headers: { location: "http://169.254.169.254/latest/meta-data/" } }, + ]); + return Effect.gen(function* () { + yield* insertDelivery({ deliveryId: "dlv-redir", connectionRef: "conn-1" }); + const dispatcher = yield* WorkflowOutboundDispatcher; + const now = yield* Clock.currentTimeMillis; + yield* dispatcher.sweep(); + + const row = yield* readDelivery("dlv-redir"); + assert.strictEqual(row.delivery_state, "pending", "3xx must not be treated as sent"); + assert.strictEqual(row.attempt_count, 1); + assert.isNotNull(row.next_attempt_at); + assert.isTrue( + isoToMs(row.next_attempt_at!) > now, + "next_attempt_at must be in the future (backoff)", + ); + assert.isNotNull(row.last_error); + // Exactly one POST — the redirect target was never requested. + assert.strictEqual(http.requests.length, 1, "redirect must not be followed"); + assert.strictEqual(http.requests[0]!.url, WEBHOOK_TARGET.url); + }).pipe(Effect.provide(buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET } }))); + }, + ); + + it.effect("slack row: non-http(s) webBaseUrl → no actions block (treated as absent)", () => { + // Finding 4: webBaseUrl is Config.url which accepts any scheme (ftp:, file:). + // A non-http(s) base must be treated as absent so the Slack button is omitted + // (Slack rejects non-http(s) button URLs with 400). + const http = makeHttpRecorder([{ status: 200 }]); + const slackCtx = sampleContext({ boardId: "board-9", ticketId: "ticket-9" }); + return Effect.gen(function* () { + yield* insertDelivery({ + deliveryId: "dlv-slack-ftp", + connectionRef: "conn-slack", + formatter: "slack", + context: slackCtx, + }); + const dispatcher = yield* WorkflowOutboundDispatcher; + yield* dispatcher.sweep(); + + assert.strictEqual(http.requests.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off - decoding the POSTed Slack body for assertions in a test. + const sent = JSON.parse(http.requests[0]!.bodyText) as { + readonly blocks: ReadonlyArray<Record<string, unknown>>; + }; + const actions = sent.blocks.find((b) => b.type === "actions"); + assert.isUndefined(actions, "no actions block when webBaseUrl is non-http(s)"); + }).pipe( + Effect.provide( + buildLayer({ + http, + connections: { "conn-slack": SLACK_TARGET }, + webBaseUrl: new URL("ftp://x/"), + }), + ), + ); + }); + + it.effect("Retry-After far in the future is CAPPED at the backoff cap", () => { + const http = makeHttpRecorder([{ status: 429, headers: { "retry-after": "99999999" } }]); + return Effect.gen(function* () { + yield* insertDelivery({ deliveryId: "dlv-cap", connectionRef: "conn-1" }); + const dispatcher = yield* WorkflowOutboundDispatcher; + const now = yield* Clock.currentTimeMillis; + yield* dispatcher.sweep(); + + const row = yield* readDelivery("dlv-cap"); + assert.strictEqual(row.delivery_state, "pending"); + const delta = isoToMs(row.next_attempt_at!) - now; + // Capped at BACKOFF_CAP_MS (1h), NOT ~99999999s (years). + assert.isAtMost(delta, 3_600_000 + 2_000, "429 delay must be capped at the backoff cap"); + }).pipe(Effect.provide(buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET } }))); + }); + + it.effect("a row already claimed by another instance ('processing') is NOT re-POSTed", () => { + const http = makeHttpRecorder([{ status: 200 }]); + return Effect.gen(function* () { + // Simulate another instance mid-flight: the row sits in 'processing'. The + // sweep selects only 'pending', and even a direct claim would fail, so no + // duplicate POST is issued. + yield* insertDelivery({ + deliveryId: "dlv-claimed", + connectionRef: "conn-1", + deliveryState: "processing", + }); + const dispatcher = yield* WorkflowOutboundDispatcher; + yield* dispatcher.sweep(); + + assert.strictEqual( + http.requests.length, + 0, + "a processing row is never POSTed by this instance", + ); + const row = yield* readDelivery("dlv-claimed"); + assert.strictEqual(row.delivery_state, "processing", "state untouched by this instance"); + }).pipe(Effect.provide(buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET } }))); + }); + + it.effect("recoverStaleClaims resets a stranded 'processing' row → it IS re-processed", () => { + const http = makeHttpRecorder([{ status: 200 }]); + return Effect.gen(function* () { + // A crash after claimRow but before markSent/recordFailure leaves the row + // stranded 'processing'. Without recovery the sweep (which selects only + // 'pending') would never re-select it. recoverStaleClaims resets it so the + // next sweep delivers it. + yield* insertDelivery({ + deliveryId: "dlv-stranded", + connectionRef: "conn-1", + deliveryState: "processing", + }); + const dispatcher = yield* WorkflowOutboundDispatcher; + + // Boot-time recovery flips the stranded claim back to 'pending'. + yield* dispatcher.recoverStaleClaims(); + const reclaimed = yield* readDelivery("dlv-stranded"); + assert.strictEqual(reclaimed.delivery_state, "pending", "stranded row reset to pending"); + + // The next sweep now re-selects and delivers it. + yield* dispatcher.sweep(); + assert.strictEqual(http.requests.length, 1, "reclaimed row is POSTed after recovery"); + const sent = yield* readDelivery("dlv-stranded"); + assert.strictEqual(sent.delivery_state, "sent"); + }).pipe(Effect.provide(buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET } }))); + }); + + it.effect("two concurrent drains of the same pending row POST it exactly once", () => { + // Both sweeps SELECT the single pending row, but only the one whose atomic + // claim (UPDATE ... WHERE state='pending') wins proceeds to POST; the loser + // skips it. Multi-instance double-POST protection. + const http = makeHttpRecorder([{ status: 200 }, { status: 200 }]); + return Effect.gen(function* () { + yield* insertDelivery({ deliveryId: "dlv-race", connectionRef: "conn-1" }); + const dispatcher = yield* WorkflowOutboundDispatcher; + + yield* Effect.all([dispatcher.sweep(), dispatcher.sweep()], { concurrency: 2 }); + + assert.strictEqual(http.requests.length, 1, "exactly one POST despite two concurrent drains"); + const row = yield* readDelivery("dlv-race"); + assert.strictEqual(row.delivery_state, "sent"); + }).pipe(Effect.provide(buildLayer({ http, connections: { "conn-1": WEBHOOK_TARGET } }))); + }); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowOutboundDispatcher.ts b/apps/server/src/workflow/Layers/WorkflowOutboundDispatcher.ts new file mode 100644 index 00000000000..14fe2098b31 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowOutboundDispatcher.ts @@ -0,0 +1,493 @@ +/** + * WorkflowOutboundDispatcher (Live) — recovery-gated fiber that drains durable + * `workflow_outbound_delivery` rows and POSTs each rendered payload to its + * connection's target URL. + * + * Mirrors the shipped WorkflowBoardNotificationDispatcher: same `{ sweep, start }` + * shape, the same `forkScoped` + catch-defect + `Schedule.spaced` start(), and + * the same per-row defect-handling idiom (re-raise dies/interrupts, swallow + * expected/transient failures as a recorded backoff so one bad row can never + * abort the sweep). Backoff mirrors WorkflowSourceSyncer.recordFailure: + * Retry-After on 429, else exponential `min(cap, base * 2^priorFailures)`. + * + * The network POST is strictly outside any transaction. The committer (Task 10) + * already wrote these rows durably in the commit tx; this dispatcher only reads + * them, POSTs, and records the outcome. + */ +import type { EnvironmentId } from "@t3tools/contracts"; +import { OutboundEventContext, type OutboundFormatter } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"; + +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import { WorkflowOutboundConnectionStore } from "../Services/WorkflowOutboundConnectionStore.ts"; +import { + WorkflowOutboundDispatcher, + type WorkflowOutboundDispatcherShape, +} from "../Services/WorkflowOutboundDispatcher.ts"; +import { OutboundUrlValidator } from "../outbound/OutboundUrlValidator.ts"; +import { renderOutbound } from "../outbound/outboundFormatters.ts"; + +// Mirror the notification dispatcher / source-syncer values — do not invent new +// ones. MAX_ATTEMPTS=5 (notification dispatcher); BACKOFF base/cap from the +// source syncer; a 5s sweep cadence like the notification dispatcher. +const DEFAULT_SWEEP_INTERVAL_MS = 5_000; +const DEFAULT_DRAIN_LIMIT = 20; +const MAX_ATTEMPTS = 5; +const BACKOFF_BASE_MS = 30_000; // 30s +const BACKOFF_CAP_MS = 3_600_000; // 1h +// Per-POST wall-clock cap. With concurrency:1 one hung target would otherwise +// freeze the whole sweep fiber (and Schedule.spaced cannot tick until sweep +// returns), so a timeout routes through the retryable backoff branch. +const HTTP_TIMEOUT_MS = 10_000; // 10s + +// Formatters this dispatcher knows how to render. renderOutbound silently +// falls back to "generic" for anything else, so we gate on this set first and +// treat an unknown formatter as a permanent (non-retryable) failure. +const KNOWN_FORMATTERS = new Set<string>(["generic", "slack"]); + +interface DeliveryRow { + readonly deliveryId: string; + readonly boardId: string; + readonly ticketId: string; + readonly connectionRef: string; + readonly formatter: string; + readonly contextJson: string; + readonly attemptCount: number; +} + +// Per-row delivery failure. `retryable=false` → permanent (e.g. malformed +// context_json, unknown formatter): park the row 'failed' immediately rather +// than burning attempts on something that can never succeed. `retryable=true` +// → transient (HTTP error, SSRF re-check, network/timeout, dangling conn): +// schedule a backoff. +interface DeliveryFailure { + readonly message: string; + readonly retryable: boolean; + readonly retryAfterMs?: number; +} + +const retryable = (message: string, retryAfterMs?: number): DeliveryFailure => ({ + message, + retryable: true, + ...(retryAfterMs !== undefined && { retryAfterMs }), +}); + +const permanent = (message: string): DeliveryFailure => ({ message, retryable: false }); + +// Per-POST body-drain timeout. With global fetch `client.execute` resolves once +// headers arrive; an undrained/slow body would otherwise block the concurrency:1 +// sweep forever (Schedule.spaced cannot tick until sweep returns). Bound it so a +// target that stalls its response body can never freeze the dispatcher. +const BODY_DRAIN_TIMEOUT_MS = 10_000; // 10s + +// Sanitize an HttpClientError for logging / `last_error` persistence WITHOUT +// leaking the target URL. The connection URL is the connection's stored SECRET +// (Slack/webhook URLs embed tokens), but `HttpClientError.message`/`String(err)` +// embed `${method} ${request.url}` via the reason's `methodAndUrl` getter — so we +// must NOT stringify the error. Surface only the reason `_tag` (TransportError, +// EncodeError, InvalidUrlError, …) and its URL-free `description` instead. +const describeTransportError = (cause: unknown): string => { + if (HttpClientError.isHttpClientError(cause)) { + const reason = cause.reason; + return reason.description === undefined ? reason._tag : `${reason._tag}: ${reason.description}`; + } + // Non-HttpClientError (should not happen on this channel) — fall back to the + // constructor name, never the stringified value, to stay URL-safe. + return cause instanceof Error ? cause.name : "transport error"; +}; + +// Decode the stored context_json through the schema (repo convention) so a +// malformed/truncated value is a TYPED failure (→ per-row backoff/park), +// never a synchronous throw that would become a sweep-aborting defect. +const decodeContext = Schema.decodeUnknownEffect(Schema.fromJsonString(OutboundEventContext)); + +const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, ""); + +/** Parse a 429 `Retry-After` header (delta-seconds OR HTTP-date) into a delay + * in ms from `nowMs`. Returns null if absent/unparseable → caller falls back to + * exponential backoff. */ +const parseRetryAfterMs = (raw: string | undefined, nowMs: number): number | null => { + if (raw === undefined) return null; + const trimmed = raw.trim(); + if (trimmed === "") return null; + // delta-seconds form + if (/^\d+$/.test(trimmed)) { + const seconds = Number(trimmed); + return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null; + } + // HTTP-date form + const dateMs = Date.parse(trimmed); + if (!Number.isNaN(dateMs)) { + return Math.max(0, dateMs - nowMs); + } + return null; +}; + +export interface WorkflowOutboundDispatcherLiveOptions { + readonly sweepIntervalMs?: number; + readonly drainLimit?: number; + /** Per-POST timeout (ms). Defaults to HTTP_TIMEOUT_MS; overridable in tests. */ + readonly httpTimeoutMs?: number; + /** Base URL for absolute ticket links (Slack buttons need an absolute URL). + * Undefined → ticketUrl is undefined → the Slack actions block is omitted. */ + readonly webBaseUrl?: URL | string | undefined; + /** Injectable for hermetic tests; defaults to the real SSRF validator. */ + readonly validate?: typeof OutboundUrlValidator.validate; +} + +/** + * The atomic outbound-delivery claim: flip a 'pending' row to 'processing', + * RETURNING the id ONLY when this call actually transitioned it. Exported so the + * concurrency test runs the SAME statement production does — a copied SQL string + * in the test could drift from this one (e.g. drop the `delivery_state = 'pending'` + * guard) and still pass, hiding a real regression. Both callers share this one. + */ +export const claimOutboundDeliveryRow = (sql: SqlClient.SqlClient, deliveryId: string) => + sql<{ readonly deliveryId: string }>` + UPDATE workflow_outbound_delivery + SET delivery_state = 'processing' + WHERE delivery_id = ${deliveryId} AND delivery_state = 'pending' + RETURNING delivery_id AS "deliveryId" + `; + +const makeWorkflowOutboundDispatcher = (options?: WorkflowOutboundDispatcherLiveOptions) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const client = yield* HttpClient.HttpClient; + const store = yield* WorkflowOutboundConnectionStore; + const serverEnvironment = yield* ServerEnvironment; + + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + const drainLimit = Math.max(1, Math.floor(options?.drainLimit ?? DEFAULT_DRAIN_LIMIT)); + const httpTimeoutMs = Math.max(1, options?.httpTimeoutMs ?? HTTP_TIMEOUT_MS); + const validate = options?.validate ?? OutboundUrlValidator.validate; + // webBaseUrl comes from Config.url, which accepts ANY scheme (ftp:, file:, …). + // Only http(s) bases can produce a valid Slack button URL; anything else would + // make Slack reject the message (400). Restrict the scheme here: a non-http(s) + // (or unparseable) base is treated as ABSENT → ticketUrl undefined → the Slack + // actions block is omitted and the message stays valid. + const webBaseUrl = (() => { + if (options?.webBaseUrl === undefined) return undefined; + try { + const parsed = + options.webBaseUrl instanceof URL + ? options.webBaseUrl + : new URL(String(options.webBaseUrl)); + return parsed.protocol === "http:" || parsed.protocol === "https:" + ? trimTrailingSlash(parsed.toString()) + : undefined; + } catch { + return undefined; + } + })(); + + const buildTicketUrl = ( + envId: EnvironmentId, + boardId: string, + ticketId: string, + ): string | undefined => + webBaseUrl === undefined + ? undefined + : `${webBaseUrl}/${encodeURIComponent(envId)}/board?boardId=${encodeURIComponent( + boardId, + )}&ticket=${encodeURIComponent(ticketId)}`; + + // Park a row 'failed' immediately (used for permanent failures + the + // retryable attempt ceiling). Records attempt_count + last_error. + const markFailed = (deliveryId: string, attempt: number, message: string) => + sql` + UPDATE workflow_outbound_delivery + SET delivery_state = 'failed', + attempt_count = ${attempt}, + last_error = ${message} + WHERE delivery_id = ${deliveryId} + `; + + // Record a delivery outcome. A PERMANENT failure parks the row 'failed' at + // once (no retry would ever succeed). A RETRYABLE failure increments attempt + // and schedules a backoff; at the attempt ceiling it is parked 'failed'. + // A 429/Retry-After delay is honored but CAPPED at BACKOFF_CAP_MS so a hostile + // header can't park the row years out. Mirrors WorkflowSourceSyncer.recordFailure. + const recordFailure = (row: DeliveryRow, failure: DeliveryFailure) => + Effect.gen(function* () { + if (!failure.retryable) { + // Permanent: keep attempt_count as-is; this is terminal, not a retry. + yield* markFailed(row.deliveryId, row.attemptCount, failure.message); + return; + } + const attempt = row.attemptCount + 1; + if (attempt >= MAX_ATTEMPTS) { + yield* markFailed(row.deliveryId, attempt, failure.message); + return; + } + const exponentialMs = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** row.attemptCount); + const delayMs = + failure.retryAfterMs !== undefined + ? Math.min(BACKOFF_CAP_MS, failure.retryAfterMs) + : exponentialMs; + const now = yield* DateTime.now; + const nextAttemptAt = DateTime.formatIso( + DateTime.addDuration(now, Duration.millis(delayMs)), + ); + // Reset the row to 'pending' (it was claimed 'processing' before the + // POST) so the backoff retry re-selects it on a future sweep. + yield* sql` + UPDATE workflow_outbound_delivery + SET delivery_state = 'pending', + attempt_count = ${attempt}, + next_attempt_at = ${nextAttemptAt}, + last_error = ${failure.message} + WHERE delivery_id = ${row.deliveryId} + `; + }); + + const markSent = (deliveryId: string) => + sql` + UPDATE workflow_outbound_delivery + SET delivery_state = 'sent', last_error = NULL + WHERE delivery_id = ${deliveryId} + `; + + // Atomically claim a 'pending' row by flipping it to 'processing'. The + // RETURNING row is yielded ONLY when this UPDATE actually transitioned the + // row, so across multiple server instances exactly one claimant proceeds to + // POST — the others see an empty result and skip the row this sweep. The + // Idempotency-Key header bounds duplicate side effects, but this prevents + // the duplicate POST (and duplicate work) in the first place. A claimed row + // is returned to 'pending' on a retryable failure (see recordFailure). + // Delegates to the module-level statement so the concurrency test exercises + // the EXACT same SQL (no copy that can silently drift from production). + const claimRow = (deliveryId: string) => + claimOutboundDeliveryRow(sql, deliveryId).pipe(Effect.map((rows) => rows.length > 0)); + + // Process ONE delivery row end-to-end. Network strictly outside any tx. + // The effect's error channel is DeliveryFailure (typed): every expected + // failure routes through the catch → recordFailure, so a bad row can never + // become a sweep-aborting defect. + const processRow = (row: DeliveryRow, envId: EnvironmentId): Effect.Effect<void> => + Effect.gen(function* () { + // Atomic claim BEFORE any work: only the instance that flips this row + // 'pending' → 'processing' proceeds; a concurrent sweep (same or another + // instance) that lost the race skips it, so the target is POSTed once. + const claimed = yield* claimRow(row.deliveryId); + if (!claimed) { + return; + } + + // Decode context_json through the schema — a malformed/truncated value + // is a PERMANENT failure (no retry could fix it), not a sweep-killing throw. + const ctx = yield* decodeContext(row.contextJson).pipe( + Effect.mapError((e) => permanent(`malformed context_json: ${e.message}`)), + ); + + // Unknown formatter: renderOutbound silently downgrades to generic, which + // would POST the wrong shape — reject it as a PERMANENT failure instead. + if (!KNOWN_FORMATTERS.has(row.formatter)) { + return yield* Effect.fail(permanent(`unknown formatter: ${row.formatter}`)); + } + const formatter = row.formatter as OutboundFormatter; + + // Resolve the connection target. A dangling ref → retryable failure + // (backoff), NOT a sweep abort. + const target = yield* store + .getTarget(row.connectionRef) + .pipe(Effect.mapError((e) => retryable(`connection unresolved: ${e.reason}`))); + + // TOCTOU re-check at delivery time: a now-private host → retryable failure + // + backoff (no POST). Mirrors the SSRF re-validation requirement. + yield* validate(target.url).pipe( + Effect.mapError((e) => retryable(`SSRF re-check failed: ${e.reason}`)), + ); + + const ticketUrl = buildTicketUrl(envId, row.boardId, row.ticketId); + // renderOutbound is pure but could throw on unexpected input; run it in a + // typed-failure boundary so a render throw is a backoff, not a defect. + // exactOptionalPropertyTypes: only set ticketUrl when defined. + const { body, contentType } = yield* Effect.try({ + try: () => + renderOutbound(formatter, ctx, { + connection: target, + ...(ticketUrl !== undefined && { ticketUrl }), + }), + catch: (e) => retryable(`render failed: ${String(e)}`), + }); + + const request = HttpClientRequest.post(target.url).pipe( + HttpClientRequest.setHeader("Idempotency-Key", row.deliveryId), + HttpClientRequest.bodyText(body, contentType), + ); + + // Bound the POST so one hung target can't freeze the (concurrency:1) + // sweep. timeoutOrElse maps the timeout to a retryable typed failure. + const response = yield* client.execute(request).pipe( + // Do NOT stringify `cause`: HttpClientError.message embeds the target + // URL, which is a stored secret (token-bearing webhook/Slack URL). Use a + // URL-free description so the secret never reaches `last_error`/logs. + Effect.mapError((cause) => + retryable(`HTTP network error: ${describeTransportError(cause)}`), + ), + Effect.timeoutOrElse({ + duration: Duration.millis(httpTimeoutMs), + orElse: () => Effect.fail(retryable(`HTTP timeout after ${httpTimeoutMs}ms`)), + }), + ); + + const { status, headers } = response; + + // Always drain (read + discard) the body before acting on status — with + // global fetch an undrained body can retain the socket. Errors here are + // ignored; the status has already been read. BOUND the drain: execute() + // resolves once headers arrive, so a target that trickles/never finishes + // the body would otherwise block this concurrency:1 sweep indefinitely + // (the execute() timeout above does NOT cover the lazy body stream). A + // drain timeout interrupts the read so the sweep always advances. + yield* response.text.pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(BODY_DRAIN_TIMEOUT_MS), + orElse: () => Effect.void, + }), + Effect.ignore, + ); + + if (status >= 200 && status < 300) { + yield* markSent(row.deliveryId); + return; + } + + if (status === 429) { + const now = yield* Clock.currentTimeMillis; + const retryAfterMs = parseRetryAfterMs(headers["retry-after"], now); + return yield* Effect.fail( + retryAfterMs !== null + ? retryable("HTTP 429 (rate limited)", retryAfterMs) + : retryable("HTTP 429 (rate limited)"), + ); + } + + return yield* Effect.fail(retryable(`HTTP ${status}`)); + }).pipe( + // Mirror the notification dispatcher's exact catchAllCause idiom: re-raise + // defects (programming bugs) to the sweep-level guard; only swallow + // expected/typed failures as a recorded per-row outcome so one bad row + // can't abort the whole sweep. + Effect.catchCause((cause) => + Cause.hasDies(cause) || Cause.hasInterrupts(cause) + ? Effect.die(Cause.squash(cause)) + : Effect.gen(function* () { + // Non-defect branch: squash yields the original DeliveryFailure. + const squashed = Cause.squash(cause); + const failure: DeliveryFailure = + squashed !== null && typeof squashed === "object" && "retryable" in squashed + ? (squashed as DeliveryFailure) + : retryable(String(squashed)); + yield* Effect.logWarning("workflow.outbound.row-failed", { + deliveryId: row.deliveryId, + ticketId: row.ticketId, + retryable: failure.retryable, + message: failure.message, + }); + yield* recordFailure(row, failure).pipe( + Effect.catchCause((recordCause) => + Cause.hasDies(recordCause) || Cause.hasInterrupts(recordCause) + ? Effect.die(Cause.squash(recordCause)) + : Effect.logWarning("workflow.outbound.record-failure-failed", { + deliveryId: row.deliveryId, + recordCause, + }), + ), + ); + }), + ), + ); + + const sweep: WorkflowOutboundDispatcherShape["sweep"] = () => + Effect.gen(function* () { + const nowIso = DateTime.formatIso(yield* DateTime.now); + const rows = yield* sql<DeliveryRow>` + SELECT + delivery_id AS "deliveryId", + board_id AS "boardId", + ticket_id AS "ticketId", + connection_ref AS "connectionRef", + formatter, + context_json AS "contextJson", + attempt_count AS "attemptCount" + FROM workflow_outbound_delivery + WHERE delivery_state = 'pending' + AND (next_attempt_at IS NULL OR next_attempt_at <= ${nowIso}) + ORDER BY created_at ASC + LIMIT ${drainLimit} + `.pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.outbound.select-failed", { cause }).pipe( + Effect.as([] as ReadonlyArray<DeliveryRow>), + ), + ), + ); + + if (rows.length === 0) { + return; + } + + // Resolve the environment id once per sweep (same source as the + // notification dispatcher — ServerEnvironment.getEnvironmentId). + const envId = yield* serverEnvironment.getEnvironmentId; + + yield* Effect.forEach(rows, (row) => processRow(row, envId), { + concurrency: 1, + discard: true, + }); + }); + + // Reset rows stranded 'processing' by a crash (after claimRow, before + // markSent/recordFailure) back to 'pending'. The sweep selects only + // 'pending', so without this a stranded row is never retried. Boot-time + // reset matches recovery-on-restart semantics: at startup no live fiber + // owns any 'processing' row, so flipping them all is safe. A select/UPDATE + // failure is logged and swallowed — it must not block dispatcher start. + const recoverStaleClaims: WorkflowOutboundDispatcherShape["recoverStaleClaims"] = () => + sql` + UPDATE workflow_outbound_delivery + SET delivery_state = 'pending' + WHERE delivery_state = 'processing' + `.pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("workflow.outbound.recover-stale-claims-failed", { cause }), + ), + ); + + const start: WorkflowOutboundDispatcherShape["start"] = () => + Effect.gen(function* () { + // Reclaim stranded rows before the sweep loop so the very first sweep + // picks them up. + yield* recoverStaleClaims(); + yield* Effect.forkScoped( + sweep().pipe( + Effect.catchDefect((defect: unknown) => + Effect.logWarning("workflow.outbound.sweep-defect", { defect }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), + ), + ); + + yield* Effect.logInfo("workflow.outbound.started", { sweepIntervalMs }); + }); + + return { sweep, recoverStaleClaims, start } satisfies WorkflowOutboundDispatcherShape; + }); + +export const makeWorkflowOutboundDispatcherLive = ( + options?: WorkflowOutboundDispatcherLiveOptions, +) => Layer.effect(WorkflowOutboundDispatcher, makeWorkflowOutboundDispatcher(options)); + +export const WorkflowOutboundDispatcherLive = makeWorkflowOutboundDispatcherLive(); diff --git a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts new file mode 100644 index 00000000000..194394b5f5d --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts @@ -0,0 +1,1389 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowProjectionPipelineLive } from "./WorkflowProjectionPipeline.ts"; + +const layer = it.layer( + WorkflowProjectionPipelineLive.pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowProjectionPipeline", (it) => { + it.effect("projects TicketCreated then TicketMovedToLane into projection_ticket", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + streamVersion: 0, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-1" as never, + title: "Export CSV" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + type: "TicketMovedToLane", + eventId: "e2" as never, + ticketId: "t-1" as never, + streamVersion: 1, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "implement" as never, + laneEntryToken: "tok-1" as never, + reason: "routed", + }, + }); + + const rows = yield* sql<{ + readonly currentLaneEntryToken: string | null; + readonly currentLaneKey: string; + readonly status: string; + }>` + SELECT + current_lane_entry_token AS "currentLaneEntryToken", + current_lane_key AS "currentLaneKey", + status + FROM projection_ticket + WHERE ticket_id = 't-1' + `; + assert.equal(rows[0]?.currentLaneEntryToken, "tok-1"); + assert.equal(rows[0]?.currentLaneKey, "implement"); + assert.equal(rows[0]?.status, "idle"); + }), + ); + + it.effect("projects ticket descriptions, edits, and ticket messages", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "ticket-collab-a" as never, + ticketId: "ticket-collab" as never, + streamVersion: 0, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + payload: { + boardId: "board-collab" as never, + title: "Original title" as never, + description: "Original description", + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + type: "TicketEdited", + eventId: "ticket-collab-b" as never, + ticketId: "ticket-collab" as never, + streamVersion: 1, + occurredAt: "2026-06-08T00:00:01.000Z" as never, + payload: { + title: "Updated title" as never, + description: "", + }, + }); + yield* pipeline.projectEvent({ + type: "TicketMessagePosted", + eventId: "ticket-collab-c" as never, + ticketId: "ticket-collab" as never, + streamVersion: 2, + occurredAt: "2026-06-08T00:00:02.000Z" as never, + payload: { + messageId: "message-collab" as never, + stepRunId: "step-collab" as never, + author: "user", + body: "Use the sandbox endpoint.", + attachments: [ + { + kind: "image", + id: "image-collab", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 1200, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + createdAt: "2026-06-08T00:00:02.000Z" as never, + }, + }); + + const tickets = yield* sql<{ + readonly title: string; + readonly description: string | null; + }>` + SELECT title, description + FROM projection_ticket + WHERE ticket_id = 'ticket-collab' + `; + const messages = yield* sql<{ + readonly messageId: string; + readonly stepRunId: string | null; + readonly author: string; + readonly body: string; + readonly attachmentsJson: string; + }>` + SELECT + message_id AS "messageId", + step_run_id AS "stepRunId", + author, + body, + attachments_json AS "attachmentsJson" + FROM projection_ticket_message + WHERE ticket_id = 'ticket-collab' + `; + + assert.equal(tickets[0]?.title, "Updated title"); + assert.equal(tickets[0]?.description, ""); + assert.equal(messages[0]?.messageId, "message-collab"); + assert.equal(messages[0]?.stepRunId, "step-collab"); + assert.equal(messages[0]?.author, "user"); + assert.equal(messages[0]?.body, "Use the sandbox endpoint."); + assert.include(messages[0]?.attachmentsJson ?? "", "data:image/png;base64,AAAA"); + }), + ); + + it.effect("projects TicketMessageEdited, updating body and edited_at (idempotent re-apply)", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "ticket-edit-a" as never, + ticketId: "ticket-edit" as never, + streamVersion: 0, + occurredAt: "2026-06-17T00:00:00.000Z" as never, + payload: { + boardId: "board-edit" as never, + title: "Edit message ticket" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + type: "TicketMessagePosted", + eventId: "ticket-edit-b" as never, + ticketId: "ticket-edit" as never, + streamVersion: 1, + occurredAt: "2026-06-17T00:00:01.000Z" as never, + payload: { + messageId: "message-edit" as never, + author: "user", + body: "Original body.", + attachments: [], + createdAt: "2026-06-17T00:00:01.000Z" as never, + }, + }); + + const beforeEdit = yield* sql<{ + readonly body: string; + readonly editedAt: string | null; + }>` + SELECT body, edited_at AS "editedAt" + FROM projection_ticket_message + WHERE message_id = 'message-edit' + `; + assert.equal(beforeEdit[0]?.body, "Original body."); + assert.equal(beforeEdit[0]?.editedAt, null); + + const editEvent = { + type: "TicketMessageEdited" as const, + eventId: "ticket-edit-c" as never, + ticketId: "ticket-edit" as never, + streamVersion: 2, + occurredAt: "2026-06-17T00:00:02.000Z" as never, + payload: { + messageId: "message-edit" as never, + body: "Edited body.", + editedAt: "2026-06-17T00:00:02.000Z" as never, + }, + }; + yield* pipeline.projectEvent(editEvent); + + const afterEdit = yield* sql<{ + readonly body: string; + readonly editedAt: string | null; + }>` + SELECT body, edited_at AS "editedAt" + FROM projection_ticket_message + WHERE message_id = 'message-edit' + `; + assert.equal(afterEdit[0]?.body, "Edited body."); + assert.equal(afterEdit[0]?.editedAt, "2026-06-17T00:00:02.000Z"); + + // Re-apply the same edit event — must be idempotent (no duplicate rows, same values). + yield* pipeline.projectEvent(editEvent); + + const afterReapply = yield* sql<{ + readonly body: string; + readonly editedAt: string | null; + }>` + SELECT body, edited_at AS "editedAt" + FROM projection_ticket_message + WHERE message_id = 'message-edit' + `; + assert.equal(afterReapply.length, 1); + assert.equal(afterReapply[0]?.body, "Edited body."); + assert.equal(afterReapply[0]?.editedAt, "2026-06-17T00:00:02.000Z"); + }), + ); + + it.effect("records terminal_at when a ticket enters a terminal lane without later bumps", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* registry.register("board-terminal-clock" as never, { + name: "terminal clock", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "terminal-clock-a" as never, + ticketId: "ticket-terminal-clock" as never, + streamVersion: 0, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + payload: { + boardId: "board-terminal-clock" as never, + title: "Ship cleanup" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + type: "TicketMovedToLane", + eventId: "terminal-clock-b" as never, + ticketId: "ticket-terminal-clock" as never, + streamVersion: 1, + occurredAt: "2026-06-08T00:00:01.000Z" as never, + payload: { + toLane: "done" as never, + laneEntryToken: "tok-terminal-clock" as never, + reason: "manual", + }, + }); + yield* pipeline.projectEvent({ + type: "TicketEdited", + eventId: "terminal-clock-c" as never, + ticketId: "ticket-terminal-clock" as never, + streamVersion: 2, + occurredAt: "2026-06-08T00:00:02.000Z" as never, + payload: { title: "Ship cleanup after comment" as never }, + }); + yield* pipeline.projectEvent({ + type: "TicketMessagePosted", + eventId: "terminal-clock-d" as never, + ticketId: "ticket-terminal-clock" as never, + streamVersion: 3, + occurredAt: "2026-06-08T00:00:03.000Z" as never, + payload: { + messageId: "message-terminal-clock" as never, + author: "user", + body: "Post-terminal note.", + attachments: [], + createdAt: "2026-06-08T00:00:03.000Z" as never, + }, + }); + + const rows = yield* sql<{ + readonly terminalAt: string | null; + readonly updatedAt: string; + }>` + SELECT + terminal_at AS "terminalAt", + updated_at AS "updatedAt" + FROM projection_ticket + WHERE ticket_id = 'ticket-terminal-clock' + `; + + assert.equal(rows[0]?.terminalAt, "2026-06-08T00:00:01.000Z"); + assert.equal(rows[0]?.updatedAt, "2026-06-08T00:00:02.000Z"); + }), + ); + + it.effect("projects queued and admitted ticket lane-entry state", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-queue" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "queue-a" as never, + streamVersion: 0, + payload: { + boardId: "b-queue" as never, + title: "Queued ticket" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketQueued", + eventId: "queue-b" as never, + streamVersion: 1, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { lane: "implement" as never }, + } as never); + + const queued = yield* sql<{ + readonly currentLaneEntryToken: string | null; + readonly currentLaneKey: string; + readonly queuedAt: string | null; + readonly status: string; + }>` + SELECT + current_lane_entry_token AS "currentLaneEntryToken", + current_lane_key AS "currentLaneKey", + queued_at AS "queuedAt", + status + FROM projection_ticket + WHERE ticket_id = 't-queue' + `; + assert.equal(queued[0]?.currentLaneEntryToken, null); + assert.equal(queued[0]?.currentLaneKey, "implement"); + assert.equal(queued[0]?.queuedAt, "2026-06-07T00:00:01.000Z"); + assert.equal(queued[0]?.status, "queued"); + + yield* pipeline.projectEvent({ + ...base, + type: "TicketAdmitted", + eventId: "queue-c" as never, + streamVersion: 2, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + lane: "implement" as never, + laneEntryToken: "tok-admitted" as never, + }, + } as never); + + const admitted = yield* sql<{ + readonly currentLaneEntryToken: string | null; + readonly queuedAt: string | null; + readonly status: string; + }>` + SELECT + current_lane_entry_token AS "currentLaneEntryToken", + queued_at AS "queuedAt", + status + FROM projection_ticket + WHERE ticket_id = 't-queue' + `; + assert.equal(admitted[0]?.currentLaneEntryToken, "tok-admitted"); + assert.equal(admitted[0]?.queuedAt, null); + assert.equal(admitted[0]?.status, "idle"); + + yield* pipeline.projectEvent({ + ...base, + type: "TicketQueued", + eventId: "queue-d" as never, + streamVersion: 3, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { lane: "review" as never }, + } as never); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMovedToLane", + eventId: "queue-e" as never, + streamVersion: 4, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + toLane: "done" as never, + laneEntryToken: "tok-moved" as never, + reason: "manual", + }, + }); + + const moved = yield* sql<{ + readonly currentLaneEntryToken: string | null; + readonly currentLaneKey: string; + readonly queuedAt: string | null; + readonly status: string; + }>` + SELECT + current_lane_entry_token AS "currentLaneEntryToken", + current_lane_key AS "currentLaneKey", + queued_at AS "queuedAt", + status + FROM projection_ticket + WHERE ticket_id = 't-queue' + `; + assert.equal(moved[0]?.currentLaneEntryToken, "tok-moved"); + assert.equal(moved[0]?.currentLaneKey, "done"); + assert.equal(moved[0]?.queuedAt, null); + assert.equal(moved[0]?.status, "idle"); + }), + ); + + it.effect("projects step lifecycle and waiting_on_user status", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { ticketId: "t-2" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "a" as never, + streamVersion: 0, + payload: { boardId: "b-1" as never, title: "Y" as never, laneKey: "implement" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-1" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-1" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-1" as never, + stepRunId: "sr-1" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "d" as never, + streamVersion: 3, + payload: { stepRunId: "sr-1" as never, waitingReason: "which API?" }, + }); + + const ticket = yield* sql<{ readonly status: string }>` + SELECT status FROM projection_ticket WHERE ticket_id = 't-2' + `; + const step = yield* sql<{ + readonly status: string; + readonly waitingReason: string; + readonly providerResponseKind: string | null; + }>` + SELECT + status, + waiting_reason AS "waitingReason", + provider_response_kind AS "providerResponseKind" + FROM projection_step_run + WHERE step_run_id = 'sr-1' + `; + assert.equal(ticket[0]?.status, "waiting_on_user"); + assert.equal(step[0]?.status, "awaiting_user"); + assert.equal(step[0]?.waitingReason, "which API?"); + assert.equal(step[0]?.providerResponseKind, null); + + yield* pipeline.projectEvent({ + ...base, + type: "StepUserResolved", + eventId: "e" as never, + streamVersion: 4, + payload: { stepRunId: "sr-1" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "f" as never, + streamVersion: 5, + payload: { + stepRunId: "sr-1" as never, + waitingReason: "approve command?", + providerResponseKind: "request", + }, + }); + + const requestStep = yield* sql<{ readonly providerResponseKind: string | null }>` + SELECT provider_response_kind AS "providerResponseKind" + FROM projection_step_run + WHERE step_run_id = 'sr-1' + `; + assert.equal(requestStep[0]?.providerResponseKind, "request"); + }), + ); + + it.effect("projects a blocked step as terminal with its blocked reason", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-blocked" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "blocked-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Blocked" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "blocked-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-blocked" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-blocked" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "blocked-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-blocked" as never, + stepRunId: "sr-blocked" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepBlocked", + eventId: "blocked-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-blocked" as never, + reason: "Project not trusted to run scripts", + }, + } as never); + + const rows = yield* sql<{ + readonly blockedReason: string | null; + readonly finishedAt: string | null; + readonly status: string; + }>` + SELECT + status, + error AS "blockedReason", + finished_at AS "finishedAt" + FROM projection_step_run + WHERE step_run_id = 'sr-blocked' + `; + assert.equal(rows[0]?.status, "blocked"); + assert.equal(rows[0]?.blockedReason, "Project not trusted to run scripts"); + assert.isNotNull(rows[0]?.finishedAt ?? null); + }), + ); + + it.effect("projects TicketPrOpened into workflow_pr_state (initial insert)", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "pr-opened-setup-a" as never, + ticketId: "ticket-pr-opened" as never, + streamVersion: 0, + occurredAt: "2026-06-12T00:00:00.000Z" as never, + payload: { + boardId: "board-pr-opened" as never, + title: "PR ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + type: "TicketPrOpened", + eventId: "pr-opened-b" as never, + ticketId: "ticket-pr-opened" as never, + streamVersion: 1, + occurredAt: "2026-06-12T00:00:01.000Z" as never, + payload: { + stepRunId: "sr-pr-opened" as never, + prNumber: 42, + url: "https://github.com/owner/repo/pull/42", + branch: "ft/my-feature", + remoteName: "origin", + repo: "owner/repo", + }, + } as never); + + const rows = yield* sql<{ + readonly ticketId: string; + readonly prNumber: number; + readonly prUrl: string; + readonly branch: string; + readonly remoteName: string; + readonly repo: string; + readonly prState: string; + readonly updatedAt: string; + readonly lastHeadSha: string | null; + readonly lastCiState: string | null; + }>` + SELECT + ticket_id AS "ticketId", + pr_number AS "prNumber", + pr_url AS "prUrl", + branch, + remote_name AS "remoteName", + repo, + pr_state AS "prState", + updated_at AS "updatedAt", + last_head_sha AS "lastHeadSha", + last_ci_state AS "lastCiState" + FROM workflow_pr_state + WHERE ticket_id = 'ticket-pr-opened' + `; + + assert.equal(rows.length, 1); + assert.equal(rows[0]?.ticketId, "ticket-pr-opened"); + assert.equal(rows[0]?.prNumber, 42); + assert.equal(rows[0]?.prUrl, "https://github.com/owner/repo/pull/42"); + assert.equal(rows[0]?.branch, "ft/my-feature"); + assert.equal(rows[0]?.remoteName, "origin"); + assert.equal(rows[0]?.repo, "owner/repo"); + assert.equal(rows[0]?.prState, "open"); + assert.equal(rows[0]?.updatedAt, "2026-06-12T00:00:01.000Z"); + assert.equal(rows[0]?.lastHeadSha, null); + assert.equal(rows[0]?.lastCiState, null); + }), + ); + + it.effect("projecting TicketPrOpened twice is idempotent (upsert)", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "pr-replay-setup" as never, + ticketId: "ticket-pr-replay" as never, + streamVersion: 0, + occurredAt: "2026-06-12T00:00:00.000Z" as never, + payload: { + boardId: "board-pr-replay" as never, + title: "PR replay ticket" as never, + laneKey: "implement" as never, + }, + }); + const prEvent = { + type: "TicketPrOpened" as const, + eventId: "pr-replay-b" as never, + ticketId: "ticket-pr-replay" as never, + streamVersion: 1, + occurredAt: "2026-06-12T00:00:01.000Z" as never, + payload: { + stepRunId: "sr-pr-replay" as never, + prNumber: 7, + url: "https://github.com/owner/repo/pull/7", + branch: "ft/replay", + remoteName: "upstream", + repo: "owner/repo", + }, + }; + yield* pipeline.projectEvent(prEvent as never); + yield* pipeline.projectEvent(prEvent as never); + + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_pr_state WHERE ticket_id = 'ticket-pr-replay' + `; + assert.equal(rows[0]?.count, 1); + }), + ); + + it.effect("projects script step start and exit into workflow_script_run", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-script-projection" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "script-projection-a" as never, + streamVersion: 0, + payload: { + boardId: "b-script" as never, + title: "Script projection" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "script-projection-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-script-projection" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-script-projection" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "script-projection-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-script-projection" as never, + stepRunId: "sr-script-projection" as never, + stepKey: "tests" as never, + stepType: "script", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepStarted", + eventId: "script-projection-d" as never, + streamVersion: 3, + payload: { + scriptRunId: "script-run-projection" as never, + stepRunId: "sr-script-projection" as never, + scriptThreadId: "workflow-script:script-run-projection" as never, + terminalId: "script-script-run-projection" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepExited", + eventId: "script-projection-e" as never, + streamVersion: 4, + payload: { + scriptRunId: "script-run-projection" as never, + exitCode: 7, + signal: null, + outcome: "exited", + }, + }); + + const rows = yield* sql<{ + readonly exitCode: number | null; + readonly scriptThreadId: string; + readonly signal: number | null; + readonly status: string; + readonly terminalId: string; + }>` + SELECT + script_thread_id AS "scriptThreadId", + terminal_id AS "terminalId", + status, + exit_code AS "exitCode", + signal + FROM workflow_script_run + WHERE script_run_id = 'script-run-projection' + `; + + assert.equal(rows[0]?.scriptThreadId, "workflow-script:script-run-projection"); + assert.equal(rows[0]?.terminalId, "script-script-run-projection"); + assert.equal(rows[0]?.status, "exited"); + assert.equal(rows[0]?.exitCode, 7); + assert.equal(rows[0]?.signal, null); + }), + ); + + it.effect( + "StepAwaitingUser with providerResponseKind=request sets attention_kind=waiting_for_approval", + () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-attn-approval" as never, + occurredAt: "2026-06-13T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attn-approval-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attn" as never, + title: "Approval ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "attn-approval-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-attn-approval" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-attn-approval" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "attn-approval-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-attn-approval" as never, + stepRunId: "sr-attn-approval" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "attn-approval-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-attn-approval" as never, + waitingReason: "approve shell command?", + providerResponseKind: "request", + }, + }); + + const rows = yield* sql<{ + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason" + FROM projection_ticket + WHERE ticket_id = 't-attn-approval' + `; + assert.equal(rows[0]?.status, "waiting_on_user"); + assert.equal(rows[0]?.attentionKind, "waiting_for_approval"); + assert.equal(rows[0]?.attentionReason, "approve shell command?"); + }), + ); + + it.effect( + "StepAwaitingUser with providerResponseKind=user-input sets attention_kind=waiting_for_input", + () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-attn-input" as never, + occurredAt: "2026-06-13T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attn-input-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attn" as never, + title: "Input ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "attn-input-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-attn-input" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-attn-input" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "attn-input-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-attn-input" as never, + stepRunId: "sr-attn-input" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "attn-input-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-attn-input" as never, + waitingReason: "which endpoint?", + providerResponseKind: "user-input", + }, + }); + + const rows = yield* sql<{ + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT + attention_kind AS "attentionKind", + attention_reason AS "attentionReason" + FROM projection_ticket + WHERE ticket_id = 't-attn-input' + `; + assert.equal(rows[0]?.attentionKind, "waiting_for_input"); + assert.equal(rows[0]?.attentionReason, "which endpoint?"); + }), + ); + + it.effect( + "StepAwaitingUser without providerResponseKind sets attention_kind=waiting_for_input", + () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-attn-null-kind" as never, + occurredAt: "2026-06-13T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attn-null-kind-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attn" as never, + title: "No-kind ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "attn-null-kind-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-attn-null-kind" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-attn-null-kind" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "attn-null-kind-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-attn-null-kind" as never, + stepRunId: "sr-attn-null-kind" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "attn-null-kind-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-attn-null-kind" as never, + waitingReason: "what to do?", + }, + }); + + const rows = yield* sql<{ + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT + attention_kind AS "attentionKind", + attention_reason AS "attentionReason" + FROM projection_ticket + WHERE ticket_id = 't-attn-null-kind' + `; + assert.equal(rows[0]?.attentionKind, "waiting_for_input"); + assert.equal(rows[0]?.attentionReason, "what to do?"); + }), + ); + + it.effect("TicketBlocked sets attention_kind=blocked and attention_reason", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-attn-blocked" as never, + occurredAt: "2026-06-13T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attn-blocked-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attn" as never, + title: "Blocked ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketBlocked", + eventId: "attn-blocked-b" as never, + streamVersion: 1, + payload: { reason: "missing credentials" }, + }); + + const rows = yield* sql<{ + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason" + FROM projection_ticket + WHERE ticket_id = 't-attn-blocked' + `; + assert.equal(rows[0]?.status, "blocked"); + assert.equal(rows[0]?.attentionKind, "blocked"); + assert.equal(rows[0]?.attentionReason, "missing credentials"); + }), + ); + + it.effect( + "StepUserResolved after StepAwaitingUser clears attention_kind and attention_reason", + () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-attn-clear-resolved" as never, + occurredAt: "2026-06-13T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attn-clear-resolved-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attn" as never, + title: "Clear resolved ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "attn-clear-resolved-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-attn-clear-resolved" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-attn-clear-resolved" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "attn-clear-resolved-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-attn-clear-resolved" as never, + stepRunId: "sr-attn-clear-resolved" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "attn-clear-resolved-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-attn-clear-resolved" as never, + waitingReason: "approve command?", + providerResponseKind: "request", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepUserResolved", + eventId: "attn-clear-resolved-e" as never, + streamVersion: 4, + payload: { stepRunId: "sr-attn-clear-resolved" as never }, + }); + + const rows = yield* sql<{ + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason" + FROM projection_ticket + WHERE ticket_id = 't-attn-clear-resolved' + `; + assert.equal(rows[0]?.status, "running"); + assert.isNull(rows[0]?.attentionKind); + assert.isNull(rows[0]?.attentionReason); + }), + ); + + it.effect( + "TicketMovedToLane after TicketBlocked clears attention_kind and attention_reason", + () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-attn-clear-moved" as never, + occurredAt: "2026-06-13T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attn-clear-moved-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attn" as never, + title: "Clear moved ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketBlocked", + eventId: "attn-clear-moved-b" as never, + streamVersion: 1, + payload: { reason: "blocked for now" }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMovedToLane", + eventId: "attn-clear-moved-c" as never, + streamVersion: 2, + occurredAt: "2026-06-13T00:00:01.000Z" as never, + payload: { + toLane: "review" as never, + laneEntryToken: "tok-attn-clear-moved" as never, + reason: "manual", + }, + }); + + const rows = yield* sql<{ + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason" + FROM projection_ticket + WHERE ticket_id = 't-attn-clear-moved' + `; + assert.equal(rows[0]?.status, "idle"); + assert.isNull(rows[0]?.attentionKind); + assert.isNull(rows[0]?.attentionReason); + }), + ); + + it.effect("TicketAdmitted sets current_lane_entered_at to the event's occurredAt", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-entered-at-admit" as never, + occurredAt: "2026-06-14T10:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "entered-at-admit-a" as never, + streamVersion: 0, + payload: { + boardId: "b-entered-at" as never, + title: "Entered at admit" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketQueued", + eventId: "entered-at-admit-b" as never, + streamVersion: 1, + occurredAt: "2026-06-14T10:00:01.000Z" as never, + payload: { lane: "implement" as never }, + } as never); + yield* pipeline.projectEvent({ + ...base, + type: "TicketAdmitted", + eventId: "entered-at-admit-c" as never, + streamVersion: 2, + occurredAt: "2026-06-14T10:00:02.000Z" as never, + payload: { + lane: "implement" as never, + laneEntryToken: "tok-entered-at-admit" as never, + }, + } as never); + + const rows = yield* sql<{ + readonly currentLaneEnteredAt: string | null; + }>` + SELECT current_lane_entered_at AS "currentLaneEnteredAt" + FROM projection_ticket + WHERE ticket_id = 't-entered-at-admit' + `; + assert.equal(rows[0]?.currentLaneEnteredAt, "2026-06-14T10:00:02.000Z"); + }), + ); + + it.effect("TicketMovedToLane sets current_lane_entered_at to the event's occurredAt", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-entered-at-move" as never, + occurredAt: "2026-06-14T11:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "entered-at-move-a" as never, + streamVersion: 0, + payload: { + boardId: "b-entered-at" as never, + title: "Entered at move" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMovedToLane", + eventId: "entered-at-move-b" as never, + streamVersion: 1, + occurredAt: "2026-06-14T11:00:01.000Z" as never, + payload: { + toLane: "implement" as never, + laneEntryToken: "tok-entered-at-move-1" as never, + reason: "manual", + }, + }); + + const afterFirst = yield* sql<{ + readonly currentLaneEnteredAt: string | null; + }>` + SELECT current_lane_entered_at AS "currentLaneEnteredAt" + FROM projection_ticket + WHERE ticket_id = 't-entered-at-move' + `; + assert.equal(afterFirst[0]?.currentLaneEnteredAt, "2026-06-14T11:00:01.000Z"); + + yield* pipeline.projectEvent({ + ...base, + type: "TicketMovedToLane", + eventId: "entered-at-move-c" as never, + streamVersion: 2, + occurredAt: "2026-06-14T11:00:05.000Z" as never, + payload: { + toLane: "review" as never, + laneEntryToken: "tok-entered-at-move-2" as never, + reason: "manual", + }, + }); + + const afterSecond = yield* sql<{ + readonly currentLaneEnteredAt: string | null; + }>` + SELECT current_lane_entered_at AS "currentLaneEnteredAt" + FROM projection_ticket + WHERE ticket_id = 't-entered-at-move' + `; + assert.equal(afterSecond[0]?.currentLaneEnteredAt, "2026-06-14T11:00:05.000Z"); + }), + ); + + it.effect("TicketQueued does NOT set current_lane_entered_at", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-entered-at-queued" as never, + occurredAt: "2026-06-14T12:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "entered-at-queued-a" as never, + streamVersion: 0, + payload: { + boardId: "b-entered-at" as never, + title: "Entered at queued" as never, + laneKey: "backlog" as never, + }, + }); + + // First admit to set current_lane_entered_at to a known value + yield* pipeline.projectEvent({ + ...base, + type: "TicketAdmitted", + eventId: "entered-at-queued-b" as never, + streamVersion: 1, + occurredAt: "2026-06-14T12:00:01.000Z" as never, + payload: { + lane: "backlog" as never, + laneEntryToken: "tok-entered-at-queued-1" as never, + }, + } as never); + + // Now queue to a new lane — this must NOT change current_lane_entered_at + yield* pipeline.projectEvent({ + ...base, + type: "TicketQueued", + eventId: "entered-at-queued-c" as never, + streamVersion: 2, + occurredAt: "2026-06-14T12:00:02.000Z" as never, + payload: { lane: "implement" as never }, + } as never); + + const rows = yield* sql<{ + readonly currentLaneEnteredAt: string | null; + }>` + SELECT current_lane_entered_at AS "currentLaneEnteredAt" + FROM projection_ticket + WHERE ticket_id = 't-entered-at-queued' + `; + // Must still be the admit time, not the queued time + assert.equal(rows[0]?.currentLaneEnteredAt, "2026-06-14T12:00:01.000Z"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts new file mode 100644 index 00000000000..e1eb0f5138a --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts @@ -0,0 +1,522 @@ +import { + TicketAttachment, + type BoardId, + type LaneKey, + type WorkflowEvent, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowProjectionPipeline, + type WorkflowProjectionPipelineShape, +} from "../Services/WorkflowProjectionPipeline.ts"; + +const toProjectionError = (cause: unknown) => + new WorkflowEventStoreError({ message: "projection failed", cause }); + +const encodeOutputJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const encodeTicketAttachmentsJson = Schema.encodeUnknownEffect( + Schema.fromJsonString(Schema.Array(TicketAttachment)), +); + +const encodeStepOutput = (output: unknown) => + output === undefined ? Effect.succeed(null) : encodeOutputJson(output); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const getOptionalServices = Effect.context<never>().pipe( + Effect.map((context) => ({ + registry: Context.getOption(context as Context.Context<BoardRegistry>, BoardRegistry), + })), + ); + + const isTerminalLane = (boardId: BoardId, laneKey: LaneKey) => + Effect.gen(function* () { + const { registry } = yield* getOptionalServices; + if (Option.isNone(registry)) { + return false; + } + const lane = yield* registry.value.getLane(boardId, laneKey); + return lane?.terminal === true; + }); + + const terminalAtForBoardLane = (boardId: BoardId, laneKey: LaneKey, occurredAt: string) => + isTerminalLane(boardId, laneKey).pipe( + Effect.map((isTerminal) => (isTerminal ? occurredAt : null)), + ); + + const terminalAtForTicketLane = (ticketId: string, laneKey: LaneKey, occurredAt: string) => + Effect.gen(function* () { + const rows = yield* sql<{ + readonly boardId: BoardId; + readonly currentLaneKey: LaneKey; + readonly terminalAt: string | null; + }>` + SELECT + board_id AS "boardId", + current_lane_key AS "currentLaneKey", + terminal_at AS "terminalAt" + FROM projection_ticket + WHERE ticket_id = ${ticketId} + `; + const row = rows[0]; + if (!row) { + return null; + } + if (!(yield* isTerminalLane(row.boardId, laneKey))) { + return null; + } + return row.currentLaneKey === laneKey && row.terminalAt !== null + ? row.terminalAt + : occurredAt; + }); + + const projectEvent: WorkflowProjectionPipelineShape["projectEvent"] = (event: WorkflowEvent) => + Effect.gen(function* () { + switch (event.type) { + case "TicketCreated": { + const terminalAt = yield* terminalAtForBoardLane( + event.payload.boardId, + event.payload.laneKey, + event.occurredAt, + ); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + description, + current_lane_key, + status, + terminal_at, + token_budget, + created_at, + updated_at + ) + VALUES ( + ${event.ticketId}, + ${event.payload.boardId}, + ${event.payload.title}, + ${event.payload.description ?? null}, + ${event.payload.laneKey}, + 'idle', + ${terminalAt}, + ${event.payload.tokenBudget ?? null}, + ${event.occurredAt}, + ${event.occurredAt} + ) + ON CONFLICT(ticket_id) DO NOTHING + `; + break; + } + case "TicketMovedToLane": { + const terminalAt = yield* terminalAtForTicketLane( + event.ticketId, + event.payload.toLane, + event.occurredAt, + ); + yield* sql` + UPDATE projection_ticket + SET current_lane_key = ${event.payload.toLane}, + status = 'idle', + attention_kind = NULL, + attention_reason = NULL, + current_lane_entry_token = ${event.payload.laneEntryToken}, + current_lane_entered_at = ${event.occurredAt}, + queued_at = NULL, + terminal_at = ${terminalAt}, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketEdited": { + const hasTitle = Object.prototype.hasOwnProperty.call(event.payload, "title"); + const hasDescription = Object.prototype.hasOwnProperty.call(event.payload, "description"); + const hasTokenBudget = Object.prototype.hasOwnProperty.call(event.payload, "tokenBudget"); + yield* sql` + UPDATE projection_ticket + SET title = CASE + WHEN ${hasTitle ? 1 : 0} = 1 THEN ${event.payload.title ?? ""} + ELSE title + END, + description = CASE + WHEN ${hasDescription ? 1 : 0} = 1 THEN ${event.payload.description ?? ""} + ELSE description + END, + token_budget = CASE + WHEN ${hasTokenBudget ? 1 : 0} = 1 THEN ${event.payload.tokenBudget ?? null} + ELSE token_budget + END, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketDependenciesSet": { + yield* sql` + DELETE FROM projection_ticket_dependency + WHERE ticket_id = ${event.ticketId} + `; + yield* Effect.forEach( + event.payload.dependsOn, + (dependsOn) => sql` + INSERT INTO projection_ticket_dependency (ticket_id, depends_on_ticket_id) + VALUES (${event.ticketId}, ${dependsOn}) + ON CONFLICT DO NOTHING + `, + { discard: true }, + ); + break; + } + case "TicketMessagePosted": { + const attachmentsJson = yield* encodeTicketAttachmentsJson(event.payload.attachments); + yield* sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES ( + ${event.payload.messageId}, + ${event.ticketId}, + ${event.payload.stepRunId ?? null}, + ${event.payload.author}, + ${event.payload.body}, + ${attachmentsJson}, + ${event.payload.createdAt} + ) + ON CONFLICT(message_id) DO UPDATE SET + ticket_id = excluded.ticket_id, + step_run_id = excluded.step_run_id, + author = excluded.author, + body = excluded.body, + attachments_json = excluded.attachments_json, + created_at = excluded.created_at + `; + break; + } + case "TicketMessageEdited": { + yield* sql` + UPDATE projection_ticket_message + SET body = ${event.payload.body}, edited_at = ${event.payload.editedAt} + WHERE message_id = ${event.payload.messageId} + `; + break; + } + case "TicketQueued": { + yield* sql` + UPDATE projection_ticket + SET current_lane_key = ${event.payload.lane}, + status = 'queued', + attention_kind = NULL, + attention_reason = NULL, + current_lane_entry_token = NULL, + queued_at = ${event.occurredAt}, + terminal_at = NULL, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketAdmitted": { + const terminalAt = yield* terminalAtForTicketLane( + event.ticketId, + event.payload.lane, + event.occurredAt, + ); + yield* sql` + UPDATE projection_ticket + SET current_lane_key = ${event.payload.lane}, + status = 'idle', + attention_kind = NULL, + attention_reason = NULL, + current_lane_entry_token = ${event.payload.laneEntryToken}, + current_lane_entered_at = ${event.occurredAt}, + queued_at = NULL, + terminal_at = ${terminalAt}, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketRouted": { + const terminalAt = yield* terminalAtForTicketLane( + event.ticketId, + event.payload.toLane, + event.occurredAt, + ); + yield* sql` + UPDATE projection_ticket + SET current_lane_key = ${event.payload.toLane}, + terminal_at = ${terminalAt}, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketBlocked": { + yield* sql` + UPDATE projection_ticket + SET status = 'blocked', + attention_kind = 'blocked', + attention_reason = ${event.payload.reason}, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "PipelineStarted": { + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, + ticket_id, + lane_key, + lane_entry_token, + status, + started_at + ) + VALUES ( + ${event.payload.pipelineRunId}, + ${event.ticketId}, + ${event.payload.laneKey}, + ${event.payload.laneEntryToken}, + 'running', + ${event.occurredAt} + ) + ON CONFLICT(pipeline_run_id) DO NOTHING + `; + yield* sql` + UPDATE projection_ticket + SET status = 'running', + attention_kind = NULL, + attention_reason = NULL, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "PipelineCompleted": { + yield* sql` + UPDATE projection_pipeline_run + SET status = ${event.payload.result}, + finished_at = ${event.occurredAt} + WHERE pipeline_run_id = ${event.payload.pipelineRunId} + `; + break; + } + case "StepStarted": { + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + attempt, + status, + started_at + ) + VALUES ( + ${event.payload.stepRunId}, + ${event.payload.pipelineRunId}, + ${event.ticketId}, + ${event.payload.stepKey}, + ${event.payload.stepType}, + ${event.payload.attempt ?? 1}, + 'running', + ${event.occurredAt} + ) + ON CONFLICT(step_run_id) DO NOTHING + `; + break; + } + case "StepAwaitingUser": { + yield* sql` + UPDATE projection_step_run + SET status = 'awaiting_user', + waiting_reason = ${event.payload.waitingReason}, + provider_response_kind = ${event.payload.providerResponseKind ?? null} + WHERE step_run_id = ${event.payload.stepRunId} + `; + yield* sql` + UPDATE projection_ticket + SET status = 'waiting_on_user', + attention_kind = ${event.payload.providerResponseKind === "request" ? "waiting_for_approval" : "waiting_for_input"}, + attention_reason = ${event.payload.waitingReason}, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "StepUserResolved": { + yield* sql` + UPDATE projection_step_run + SET status = 'running', + waiting_reason = NULL, + provider_response_kind = NULL + WHERE step_run_id = ${event.payload.stepRunId} + `; + yield* sql` + UPDATE projection_ticket + SET status = 'running', + attention_kind = NULL, + attention_reason = NULL, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "StepRefsCaptured": { + yield* sql` + UPDATE projection_step_run + SET pre_checkpoint_ref = ${event.payload.preRef}, + post_checkpoint_ref = ${event.payload.postRef} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } + case "StepCompleted": { + const outputJson = yield* encodeStepOutput(event.payload.output); + const usage = event.payload.usage; + yield* sql` + UPDATE projection_step_run + SET status = 'completed', + waiting_reason = NULL, + provider_response_kind = NULL, + output_json = ${outputJson}, + input_tokens = ${usage?.inputTokens ?? null}, + cached_input_tokens = ${usage?.cachedInputTokens ?? null}, + output_tokens = ${usage?.outputTokens ?? null}, + total_tokens = ${usage?.totalTokens ?? null}, + finished_at = ${event.occurredAt} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } + case "StepFailed": { + const usage = event.payload.usage; + yield* sql` + UPDATE projection_step_run + SET status = 'failed', + waiting_reason = NULL, + provider_response_kind = NULL, + error = ${event.payload.error}, + retryable = ${event.payload.retryable === undefined ? null : event.payload.retryable ? 1 : 0}, + input_tokens = ${usage?.inputTokens ?? null}, + cached_input_tokens = ${usage?.cachedInputTokens ?? null}, + output_tokens = ${usage?.outputTokens ?? null}, + total_tokens = ${usage?.totalTokens ?? null}, + finished_at = ${event.occurredAt} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } + case "StepBlocked": { + yield* sql` + UPDATE projection_step_run + SET status = 'blocked', + waiting_reason = NULL, + provider_response_kind = NULL, + error = ${event.payload.reason}, + finished_at = ${event.occurredAt} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } + case "ScriptStepStarted": { + yield* sql` + INSERT INTO workflow_script_run ( + script_run_id, + step_run_id, + ticket_id, + script_thread_id, + terminal_id, + status, + started_at + ) + VALUES ( + ${event.payload.scriptRunId}, + ${event.payload.stepRunId}, + ${event.ticketId}, + ${event.payload.scriptThreadId}, + ${event.payload.terminalId}, + 'running', + ${event.occurredAt} + ) + ON CONFLICT(script_run_id) DO UPDATE SET + step_run_id = excluded.step_run_id, + ticket_id = excluded.ticket_id, + script_thread_id = excluded.script_thread_id, + terminal_id = excluded.terminal_id, + status = 'running', + exit_code = NULL, + signal = NULL, + started_at = excluded.started_at, + finished_at = NULL + `; + break; + } + case "ScriptStepExited": { + yield* sql` + UPDATE workflow_script_run + SET status = ${event.payload.outcome}, + exit_code = ${event.payload.exitCode}, + signal = ${event.payload.signal}, + finished_at = ${event.occurredAt} + WHERE script_run_id = ${event.payload.scriptRunId} + `; + break; + } + case "TicketPrOpened": { + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, + pr_number, + pr_url, + branch, + remote_name, + repo, + pr_state, + updated_at + ) + VALUES ( + ${event.ticketId}, + ${event.payload.prNumber}, + ${event.payload.url}, + ${event.payload.branch}, + ${event.payload.remoteName}, + ${event.payload.repo}, + 'open', + ${event.occurredAt} + ) + ON CONFLICT(ticket_id) DO UPDATE SET + pr_number = excluded.pr_number, + pr_url = excluded.pr_url, + branch = excluded.branch, + remote_name = excluded.remote_name, + repo = excluded.repo, + pr_state = 'open', + updated_at = excluded.updated_at + `; + break; + } + } + }).pipe(Effect.mapError(toProjectionError), Effect.asVoid); + + return { projectEvent } satisfies WorkflowProjectionPipelineShape; +}); + +export const WorkflowProjectionPipelineLive = Layer.effect(WorkflowProjectionPipeline, make); diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts new file mode 100644 index 00000000000..f4ab86364ff --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts @@ -0,0 +1,3089 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +const encodeUnknownJsonString = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowProjectionPipelineLive } from "./WorkflowProjectionPipeline.ts"; +import { WorkflowReadModelLive, percentileNearestRank } from "./WorkflowReadModel.ts"; + +const layer = it.layer( + Layer.mergeAll(WorkflowReadModelLive, WorkflowProjectionPipelineLive).pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowReadModel", (it) => { + it.effect("registers a board and lists its tickets", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + + yield* read.registerBoard({ + boardId: "b-1" as never, + projectId: "p-1" as never, + name: "Delivery", + workflowFilePath: ".t3/boards/delivery.json", + workflowVersionHash: "hash1", + maxConcurrentTickets: 3, + }); + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + streamVersion: 0, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-1" as never, + title: "Export" as never, + description: "Export the current list", + laneKey: "backlog" as never, + }, + }); + + const board = yield* read.getBoard("b-1" as never); + assert.equal(board?.name, "Delivery"); + const tickets = yield* read.listTickets("b-1" as never); + assert.equal(tickets.length, 1); + assert.equal(tickets[0]?.title, "Export"); + assert.equal(tickets[0]?.description, "Export the current list"); + }), + ); + + it.effect("counts token-admitted tickets and returns the oldest queued ticket", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "queue-read-a" as never, + ticketId: "t-admitted" as never, + streamVersion: 0, + payload: { + boardId: "b-queue-read" as never, + title: "Admitted" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMovedToLane", + eventId: "queue-read-b" as never, + ticketId: "t-admitted" as never, + streamVersion: 1, + payload: { + toLane: "implement" as never, + laneEntryToken: "tok-admitted" as never, + reason: "initial", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "queue-read-c" as never, + ticketId: "t-created-no-token" as never, + streamVersion: 0, + payload: { + boardId: "b-queue-read" as never, + title: "Created but not admitted" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "queue-read-d" as never, + ticketId: "t-queued-newer" as never, + streamVersion: 0, + payload: { + boardId: "b-queue-read" as never, + title: "Queued newer" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketQueued", + eventId: "queue-read-e" as never, + ticketId: "t-queued-newer" as never, + streamVersion: 1, + occurredAt: "2026-06-07T00:00:05.000Z" as never, + payload: { lane: "implement" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "queue-read-f" as never, + ticketId: "t-queued-older" as never, + streamVersion: 0, + payload: { + boardId: "b-queue-read" as never, + title: "Queued older" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketQueued", + eventId: "queue-read-g" as never, + ticketId: "t-queued-older" as never, + streamVersion: 1, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { lane: "implement" as never }, + }); + + const admittedCount = yield* read.countAdmittedInLane( + "b-queue-read" as never, + "implement" as never, + ); + const oldestQueued = yield* read.oldestQueuedForLane( + "b-queue-read" as never, + "implement" as never, + ); + const tickets = yield* read.listTickets("b-queue-read" as never); + const queuedDetail = yield* read.getTicketDetail("t-queued-older" as never); + + assert.equal(admittedCount, 1); + assert.equal(oldestQueued?.ticketId, "t-queued-older"); + assert.equal(oldestQueued?.queuedAt, "2026-06-07T00:00:04.000Z"); + assert.equal(oldestQueued?.currentLaneEntryToken, null); + assert.equal(tickets.find((ticket) => ticket.ticketId === "t-admitted")?.queuedAt, null); + assert.equal( + tickets.find((ticket) => ticket.ticketId === "t-queued-newer")?.queuedAt, + "2026-06-07T00:00:05.000Z", + ); + assert.equal(queuedDetail?.ticket.queuedAt, "2026-06-07T00:00:04.000Z"); + }), + ); + + it.effect("returns ticket detail with step runs", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { ticketId: "t-9" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Z" as never, + description: "Ticket detail context", + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr" as never, + laneKey: "implement" as never, + laneEntryToken: "tok" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr" as never, + stepRunId: "sr" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMessagePosted", + eventId: "d" as never, + streamVersion: 3, + payload: { + messageId: "message-agent" as never, + stepRunId: "sr" as never, + author: "agent", + body: "Which API should I use?", + attachments: [], + createdAt: "2026-06-07T00:00:01.000Z" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMessagePosted", + eventId: "e" as never, + streamVersion: 4, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + messageId: "message-user" as never, + stepRunId: "sr" as never, + author: "user", + body: "Use the sandbox endpoint.", + attachments: [ + { + kind: "image", + id: "image-detail", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 1200, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + createdAt: "2026-06-07T00:00:02.000Z" as never, + }, + }); + + const detail = yield* read.getTicketDetail("t-9" as never); + const messages = yield* read.listTicketMessages("t-9" as never); + assert.equal(detail?.ticket.title, "Z"); + assert.equal(detail?.ticket.description, "Ticket detail context"); + assert.equal(detail?.steps.length, 1); + assert.equal(detail?.steps[0]?.stepKey, "code"); + assert.deepEqual( + detail?.messages.map((message) => message.body), + ["Which API should I use?", "Use the sandbox endpoint."], + ); + assert.deepEqual( + messages.map((message) => message.messageId), + ["message-agent", "message-user"], + ); + assert.equal(messages[1]?.attachments[0]?.kind, "image"); + }), + ); + + it.effect("surfaces editedAt after a TicketMessageEdited event", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-edit" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Editable" as never, + description: "Edit detail context", + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMessagePosted", + eventId: "b" as never, + streamVersion: 1, + payload: { + messageId: "message-edited" as never, + author: "user", + body: "original body", + attachments: [], + createdAt: "2026-06-07T00:00:01.000Z" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMessagePosted", + eventId: "c" as never, + streamVersion: 2, + payload: { + messageId: "message-untouched" as never, + author: "user", + body: "untouched body", + attachments: [], + createdAt: "2026-06-07T00:00:02.000Z" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMessageEdited", + eventId: "d" as never, + streamVersion: 3, + payload: { + messageId: "message-edited" as never, + body: "edited body", + editedAt: "2026-06-07T00:00:03.000Z" as never, + }, + }); + + const detail = yield* read.getTicketDetail("t-edit" as never); + const messages = yield* read.listTicketMessages("t-edit" as never); + + const edited = detail?.messages.find((m) => m.messageId === "message-edited"); + const untouched = detail?.messages.find((m) => m.messageId === "message-untouched"); + assert.equal(edited?.body, "edited body"); + assert.equal(edited?.editedAt, "2026-06-07T00:00:03.000Z"); + assert.equal(untouched?.editedAt, null); + + const editedRow = messages.find((m) => m.messageId === "message-edited"); + const untouchedRow = messages.find((m) => m.messageId === "message-untouched"); + assert.equal(editedRow?.editedAt, "2026-06-07T00:00:03.000Z"); + assert.equal(untouchedRow?.editedAt, null); + }), + ); + + it.effect( + "skips queued tickets with unresolved dependencies and releases them when resolved", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const insertTicket = (input: { + readonly ticketId: string; + readonly queuedAt: string | null; + readonly terminalAt?: string | null; + }) => sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, + queued_at, terminal_at, created_at, updated_at + ) + VALUES ( + ${input.ticketId}, 'board-deps', ${input.ticketId}, 'work', 'queued', + ${input.queuedAt}, ${input.terminalAt ?? null}, + '2026-06-07T00:00:00.000Z', '2026-06-07T00:00:00.000Z' + ) + `; + yield* insertTicket({ ticketId: "ticket-dep-a", queuedAt: null }); + yield* insertTicket({ ticketId: "ticket-dep-b", queuedAt: "2026-06-07T00:00:01.000Z" }); + yield* insertTicket({ ticketId: "ticket-dep-c", queuedAt: "2026-06-07T00:00:02.000Z" }); + yield* sql` + INSERT INTO projection_ticket_dependency (ticket_id, depends_on_ticket_id) + VALUES ('ticket-dep-b', 'ticket-dep-a') + `; + + // B is older but blocked by A; admission picks C. + const eligible = yield* read.oldestQueuedForLane("board-deps" as never, "work" as never); + assert.equal(eligible?.ticketId, "ticket-dep-c"); + + const tickets = yield* read.listTickets("board-deps" as never); + const blocked = tickets.find((ticket) => ticket.ticketId === "ticket-dep-b"); + assert.deepEqual(blocked?.dependsOn, ["ticket-dep-a"]); + assert.equal(blocked?.unresolvedDependencyCount, 1); + + // Nothing releasable while A is not terminal. + assert.deepEqual(yield* read.listReleasableDependents("ticket-dep-a" as never), []); + + yield* sql` + UPDATE projection_ticket + SET terminal_at = '2026-06-07T00:01:00.000Z' + WHERE ticket_id = 'ticket-dep-a' + `; + + const releasable = yield* read.listReleasableDependents("ticket-dep-a" as never); + assert.deepEqual( + releasable.map((row) => [row.ticketId, row.boardId, row.laneKey]), + [["ticket-dep-b", "board-deps", "work"]], + ); + const nowEligible = yield* read.oldestQueuedForLane("board-deps" as never, "work" as never); + assert.equal(nowEligible?.ticketId, "ticket-dep-b"); + assert.equal(nowEligible?.unresolvedDependencyCount, 0); + + // A dependency on a deleted/unknown ticket never blocks. + yield* sql` + INSERT INTO projection_ticket_dependency (ticket_id, depends_on_ticket_id) + VALUES ('ticket-dep-c', 'ticket-gone') + `; + const stillEligible = yield* read.oldestQueuedForLane( + "board-deps" as never, + "work" as never, + ); + assert.equal(stillEligible?.ticketId, "ticket-dep-b"); + }), + ); + + it.effect("lists a capped ticket discussion newest-last without decoding attachments", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES + ('message-d-1', 'ticket-discussion', NULL, 'user', 'first', '[]', '2026-06-07T00:00:00.000Z'), + ('message-d-2', 'ticket-discussion', NULL, 'agent', 'second', '[{"kind":"image"},{"kind":"image"}]', '2026-06-07T00:01:00.000Z'), + ('message-d-3', 'ticket-discussion', NULL, 'user', 'third', '[]', '2026-06-07T00:02:00.000Z') + `; + + const all = yield* read.listTicketDiscussion("ticket-discussion" as never, 10); + assert.deepEqual( + all.map((row) => [row.author, row.body, row.attachmentCount]), + [ + ["user", "first", 0], + ["agent", "second", 2], + ["user", "third", 0], + ], + ); + assert.equal(all[0]?.createdAt, "2026-06-07T00:00:00.000Z"); + + const capped = yield* read.listTicketDiscussion("ticket-discussion" as never, 2); + assert.deepEqual( + capped.map((row) => row.body), + ["second", "third"], + ); + }), + ); + + it.effect("lists route decisions with snapshot highlights and manual moves", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const insertEvent = (input: { + readonly eventId: string; + readonly streamVersion: number; + readonly eventType: string; + readonly occurredAt: string; + readonly payload: unknown; + }) => sql` + INSERT INTO workflow_events ( + event_id, ticket_id, stream_version, event_type, occurred_at, payload_json + ) + VALUES ( + ${input.eventId}, + 'ticket-route-history', + ${input.streamVersion}, + ${input.eventType}, + ${input.occurredAt}, + ${JSON.stringify(input.payload)} + ) + `; + yield* insertEvent({ + eventId: "event-route-1", + streamVersion: 0, + eventType: "TicketRouteDecided", + occurredAt: "2026-06-07T00:00:01.000Z", + payload: { + pipelineRunId: "pipeline-1", + fromLane: "implement", + toLane: "review", + source: "lane_transition", + matchedTransitionIndex: 1, + contextSnapshot: { + pipeline: { result: "success" }, + lane: { runCount: 2 }, + status: "idle", + steps: { + verdict: { status: "completed", exitCode: 0, output: { verdict: "approve" } }, + }, + }, + }, + }); + // The routed TicketMovedToLane twin of the decision above must NOT + // produce a duplicate history entry. + yield* insertEvent({ + eventId: "event-route-2", + streamVersion: 1, + eventType: "TicketMovedToLane", + occurredAt: "2026-06-07T00:00:01.000Z", + payload: { toLane: "review", laneEntryToken: "token-1", reason: "routed" }, + }); + yield* insertEvent({ + eventId: "event-route-3", + streamVersion: 2, + eventType: "TicketMovedToLane", + occurredAt: "2026-06-07T00:00:02.000Z", + payload: { toLane: "implement", laneEntryToken: "token-2", reason: "manual" }, + }); + // Malformed snapshot degrades to just the lanes instead of failing. + yield* insertEvent({ + eventId: "event-route-4", + streamVersion: 3, + eventType: "TicketRouteDecided", + occurredAt: "2026-06-07T00:00:03.000Z", + payload: { + pipelineRunId: "pipeline-2", + fromLane: "implement", + toLane: "stuck", + source: "lane_on", + contextSnapshot: "not an object", + }, + }); + + const decisions = yield* read.listTicketRouteDecisions("ticket-route-history" as never); + + assert.deepEqual( + decisions.map((row) => [row.source, row.fromLane, row.toLane]), + [ + ["lane_transition", "implement", "review"], + ["manual", null, "implement"], + ["lane_on", "implement", "stuck"], + ], + ); + const first = decisions[0]; + assert.equal(first?.matchedTransitionIndex, 1); + assert.equal(first?.pipelineResult, "success"); + assert.equal(first?.laneRunCount, 2); + assert.deepEqual(first?.steps, { + verdict: { status: "completed", exitCode: 0, verdict: "approve" }, + }); + const malformed = decisions[2]; + assert.equal(malformed?.pipelineResult, null); + assert.equal(malformed?.laneRunCount, null); + assert.equal(malformed?.steps, null); + }), + ); + + it.effect("parses a work_source route decision into history", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const insertRouteDecision = (payload: unknown) => sql` + INSERT INTO workflow_events ( + event_id, ticket_id, stream_version, event_type, occurred_at, payload_json + ) + VALUES ( + 'event-work-source-1', + 'ticket-work-source', + 0, + 'TicketRouteDecided', + '2026-06-13T00:00:01.000Z', + ${JSON.stringify(payload)} + ) + `; + yield* insertRouteDecision({ + fromLane: "implement", + toLane: "done", + source: "work_source", + }); + + const decisions = yield* read.listTicketRouteDecisions("ticket-work-source" as never); + + assert.deepEqual( + decisions.map((row) => [row.source, row.fromLane, row.toLane]), + [["work_source", "implement", "done"]], + ); + }), + ); + + it.effect("caps route decisions to the newest events", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + yield* Effect.forEach( + Array.from({ length: 105 }, (_, index) => index), + (index) => sql` + INSERT INTO workflow_events ( + event_id, ticket_id, stream_version, event_type, occurred_at, payload_json + ) + VALUES ( + ${`event-route-cap-${index}`}, + 'ticket-route-cap', + ${index}, + 'TicketMovedToLane', + ${`2026-06-07T00:00:${String(index % 60).padStart(2, "0")}.000Z`}, + ${JSON.stringify({ toLane: `lane-${index}`, laneEntryToken: `token-${index}`, reason: "manual" })} + ) + `, + ); + + const decisions = yield* read.listTicketRouteDecisions("ticket-route-cap" as never); + + assert.equal(decisions.length, 100); + assert.equal(decisions[0]?.toLane, "lane-5"); + assert.equal(decisions.at(-1)?.toLane, "lane-104"); + }), + ); + + it.effect("returns blockedReason for blocked step runs", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-blocked-detail" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "blocked-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Blocked detail" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "blocked-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-blocked-detail" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-blocked-detail" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "blocked-detail-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-blocked-detail" as never, + stepRunId: "sr-blocked-detail" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepBlocked", + eventId: "blocked-detail-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-blocked-detail" as never, + reason: "Project not trusted to run scripts", + }, + } as never); + + const detail = yield* read.getTicketDetail("t-blocked-detail" as never); + assert.equal(detail?.steps[0]?.status, "blocked"); + assert.equal(detail?.steps[0]?.blockedReason, "Project not trusted to run scripts"); + assert.equal(detail?.steps[0]?.waitingReason, null); + }), + ); + + it.effect("returns script terminal metadata in ticket detail", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-script-detail" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "script-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Script detail" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "script-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-script-detail" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-script-detail" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "script-detail-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-script-detail" as never, + stepRunId: "sr-script-detail" as never, + stepKey: "tests" as never, + stepType: "script", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepStarted", + eventId: "script-detail-d" as never, + streamVersion: 3, + payload: { + scriptRunId: "script-run-detail" as never, + stepRunId: "sr-script-detail" as never, + scriptThreadId: "workflow-script:script-run-detail" as never, + terminalId: "script-script-run-detail" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepExited", + eventId: "script-detail-e" as never, + streamVersion: 4, + payload: { + scriptRunId: "script-run-detail" as never, + exitCode: 0, + signal: null, + outcome: "exited", + }, + }); + + const detail = yield* read.getTicketDetail("t-script-detail" as never); + const step = detail?.steps[0] as any; + + assert.equal(step?.scriptThreadId, "workflow-script:script-run-detail"); + assert.equal(step?.terminalId, "script-script-run-detail"); + assert.equal(step?.scriptStatus, "exited"); + assert.equal(step?.exitCode, 0); + assert.equal(step?.signal, null); + }), + ); + + it.effect("returns completed step output in ticket detail", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-output-detail" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "output-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Output detail" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "output-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-output-detail" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-output-detail" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "output-detail-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-output-detail" as never, + stepRunId: "sr-output-detail" as never, + stepKey: "review" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepCompleted", + eventId: "output-detail-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-output-detail" as never, + output: { verdict: "pass", score: 0.98 }, + }, + } as never); + + const detail = yield* read.getTicketDetail("t-output-detail" as never); + assert.deepEqual((detail?.steps[0] as any)?.output, { verdict: "pass", score: 0.98 }); + }), + ); + + it.effect("lists step runs scoped to one pipeline run with script exit codes and output", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-pipeline-steps" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "pipeline-steps-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Pipeline scoped steps" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "pipeline-steps-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-target" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-target" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "pipeline-steps-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-target" as never, + stepRunId: "sr-tests" as never, + stepKey: "tests" as never, + stepType: "script", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepStarted", + eventId: "pipeline-steps-d" as never, + streamVersion: 3, + payload: { + scriptRunId: "script-run-target" as never, + stepRunId: "sr-tests" as never, + scriptThreadId: "workflow-script:script-run-target" as never, + terminalId: "script-target" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepExited", + eventId: "pipeline-steps-e" as never, + streamVersion: 4, + payload: { + scriptRunId: "script-run-target" as never, + exitCode: 2, + signal: null, + outcome: "exited", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepCompleted", + eventId: "pipeline-steps-f" as never, + streamVersion: 5, + payload: { stepRunId: "sr-tests" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "pipeline-steps-g" as never, + streamVersion: 6, + payload: { + pipelineRunId: "pr-target" as never, + stepRunId: "sr-review" as never, + stepKey: "review" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepCompleted", + eventId: "pipeline-steps-h" as never, + streamVersion: 7, + payload: { + stepRunId: "sr-review" as never, + output: { verdict: "needs_attention" }, + }, + } as never); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "pipeline-steps-i" as never, + streamVersion: 8, + payload: { + pipelineRunId: "pr-other" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-other" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "pipeline-steps-j" as never, + streamVersion: 9, + payload: { + pipelineRunId: "pr-other" as never, + stepRunId: "sr-other" as never, + stepKey: "other" as never, + stepType: "agent", + }, + }); + + const rows = yield* read.listStepRunsForPipeline("pr-target" as never); + + assert.deepEqual(rows, [ + { + stepKey: "tests", + stepType: "script", + status: "completed", + exitCode: 2, + output: null, + }, + { + stepKey: "review", + stepType: "agent", + status: "completed", + exitCode: null, + output: { verdict: "needs_attention" }, + }, + ]); + }), + ); + + it.effect("returns provider response kind in ticket step detail", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-provider-kind-detail" as never, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "provider-kind-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-provider-kind-detail" as never, + title: "Provider kind detail" as never, + laneKey: "review" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "provider-kind-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-provider-kind-detail" as never, + stepRunId: "sr-provider-kind-detail" as never, + stepKey: "review" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "provider-kind-detail-c" as never, + streamVersion: 2, + payload: { + stepRunId: "sr-provider-kind-detail" as never, + waitingReason: "Approve this command?", + providerResponseKind: "request", + }, + }); + + const detail = yield* read.getTicketDetail("t-provider-kind-detail" as never); + assert.equal((detail?.steps[0] as any)?.providerResponseKind, "request"); + }), + ); + + it.effect("lists boards for a project and deletes one", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + yield* read.registerBoard({ + boardId: "p1__a" as never, + projectId: "p1" as never, + name: "A", + workflowFilePath: ".t3/boards/a.json", + workflowVersionHash: "h", + maxConcurrentTickets: 3, + }); + + const before = yield* read.listBoardsForProject("p1" as never); + assert.equal(before.length, 1); + assert.equal(before[0]?.filePath, ".t3/boards/a.json"); + + yield* read.deleteBoard("p1__a" as never); + assert.deepEqual(yield* read.listBoardsForProject("p1" as never), []); + }), + ); + + it.effect("deletes ticket-scoped projections for a board without deleting other boards", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES + ('ticket-cascade', 'board-cascade', 'Cascade', 'backlog', 'idle', ${now}, ${now}), + ('ticket-keep', 'board-keep', 'Keep', 'backlog', 'idle', ${now}, ${now}) + `; + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, + ticket_id, + lane_key, + lane_entry_token, + status, + started_at + ) + VALUES + ('pipeline-cascade', 'ticket-cascade', 'backlog', 'token-cascade', 'running', ${now}), + ('pipeline-keep', 'ticket-keep', 'backlog', 'token-keep', 'running', ${now}) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES + ('step-cascade', 'pipeline-cascade', 'ticket-cascade', 'build', 'script', 'running', ${now}), + ('step-keep', 'pipeline-keep', 'ticket-keep', 'build', 'script', 'running', ${now}) + `; + yield* sql` + INSERT INTO workflow_script_run ( + script_run_id, + step_run_id, + ticket_id, + script_thread_id, + terminal_id, + status, + started_at + ) + VALUES + ('script-cascade', 'step-cascade', 'ticket-cascade', 'thread-cascade', 'terminal-cascade', 'running', ${now}), + ('script-keep', 'step-keep', 'ticket-keep', 'thread-keep', 'terminal-keep', 'running', ${now}) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES + ('dispatch-cascade', 'ticket-cascade', 'step-cascade', 'thread-cascade', 'codex', 'gpt-5.5', 'Do cascade', '/tmp/cascade', 'pending', ${now}), + ('dispatch-keep', 'ticket-keep', 'step-keep', 'thread-keep', 'codex', 'gpt-5.5', 'Keep going', '/tmp/keep', 'pending', ${now}) + `; + yield* sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES + ('setup-cascade', 'ticket-cascade', 'worktree-cascade', 'running', ${now}), + ('setup-keep', 'ticket-keep', 'worktree-keep', 'running', ${now}) + `; + yield* sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES + ('message-cascade', 'ticket-cascade', 'step-cascade', 'user', 'Delete me', '[]', ${now}), + ('message-keep', 'ticket-keep', 'step-keep', 'user', 'Keep me', '[]', ${now}) + `; + + yield* read.deleteBoardTicketState("board-cascade" as never); + + const remaining = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE ticket_id = 'ticket-cascade' + UNION ALL + SELECT 'projection_pipeline_run' AS tableName, COUNT(*) AS count + FROM projection_pipeline_run + WHERE ticket_id = 'ticket-cascade' + UNION ALL + SELECT 'projection_step_run' AS tableName, COUNT(*) AS count + FROM projection_step_run + WHERE ticket_id = 'ticket-cascade' + UNION ALL + SELECT 'workflow_script_run' AS tableName, COUNT(*) AS count + FROM workflow_script_run + WHERE ticket_id = 'ticket-cascade' + UNION ALL + SELECT 'workflow_dispatch_outbox' AS tableName, COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE ticket_id = 'ticket-cascade' + UNION ALL + SELECT 'workflow_setup_run' AS tableName, COUNT(*) AS count + FROM workflow_setup_run + WHERE ticket_id = 'ticket-cascade' + UNION ALL + SELECT 'projection_ticket_message' AS tableName, COUNT(*) AS count + FROM projection_ticket_message + WHERE ticket_id = 'ticket-cascade' + `; + assert.deepEqual( + remaining.map((row) => [row.tableName, row.count]), + [ + ["projection_ticket", 0], + ["projection_pipeline_run", 0], + ["projection_step_run", 0], + ["workflow_script_run", 0], + ["workflow_dispatch_outbox", 0], + ["workflow_setup_run", 0], + ["projection_ticket_message", 0], + ], + ); + + const kept = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE ticket_id = 'ticket-keep' + UNION ALL + SELECT 'projection_pipeline_run' AS tableName, COUNT(*) AS count + FROM projection_pipeline_run + WHERE ticket_id = 'ticket-keep' + UNION ALL + SELECT 'projection_step_run' AS tableName, COUNT(*) AS count + FROM projection_step_run + WHERE ticket_id = 'ticket-keep' + UNION ALL + SELECT 'workflow_script_run' AS tableName, COUNT(*) AS count + FROM workflow_script_run + WHERE ticket_id = 'ticket-keep' + UNION ALL + SELECT 'workflow_dispatch_outbox' AS tableName, COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE ticket_id = 'ticket-keep' + UNION ALL + SELECT 'workflow_setup_run' AS tableName, COUNT(*) AS count + FROM workflow_setup_run + WHERE ticket_id = 'ticket-keep' + UNION ALL + SELECT 'projection_ticket_message' AS tableName, COUNT(*) AS count + FROM projection_ticket_message + WHERE ticket_id = 'ticket-keep' + `; + assert.deepEqual( + kept.map((row) => [row.tableName, row.count]), + [ + ["projection_ticket", 1], + ["projection_pipeline_run", 1], + ["projection_step_run", 1], + ["workflow_script_run", 1], + ["workflow_dispatch_outbox", 1], + ["workflow_setup_run", 1], + ["projection_ticket_message", 1], + ], + ); + }), + ); + + it.effect( + "deletes ticket-scoped projections for one ticket without deleting sibling tickets", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES + ('ticket-delete-one', 'board-ticket-delete', 'Delete one', 'done', 'done', ${now}, ${now}), + ('ticket-keep-one', 'board-ticket-delete', 'Keep one', 'done', 'done', ${now}, ${now}) + `; + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, + ticket_id, + lane_key, + lane_entry_token, + status, + started_at + ) + VALUES + ('pipeline-delete-one', 'ticket-delete-one', 'done', 'token-delete-one', 'completed', ${now}), + ('pipeline-keep-one', 'ticket-keep-one', 'done', 'token-keep-one', 'completed', ${now}) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES + ('step-delete-one', 'pipeline-delete-one', 'ticket-delete-one', 'cleanup', 'script', 'completed', ${now}), + ('step-keep-one', 'pipeline-keep-one', 'ticket-keep-one', 'cleanup', 'script', 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_script_run ( + script_run_id, + step_run_id, + ticket_id, + script_thread_id, + terminal_id, + status, + started_at + ) + VALUES + ('script-delete-one', 'step-delete-one', 'ticket-delete-one', 'thread-delete-one', 'terminal-delete-one', 'completed', ${now}), + ('script-keep-one', 'step-keep-one', 'ticket-keep-one', 'thread-keep-one', 'terminal-keep-one', 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES + ('dispatch-delete-one', 'ticket-delete-one', 'step-delete-one', 'thread-delete-one', 'codex', 'gpt-5.5', 'Delete one', '/tmp/delete-one', 'completed', ${now}), + ('dispatch-keep-one', 'ticket-keep-one', 'step-keep-one', 'thread-keep-one', 'codex', 'gpt-5.5', 'Keep one', '/tmp/keep-one', 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES + ('setup-delete-one', 'ticket-delete-one', 'worktree-delete-one', 'completed', ${now}), + ('setup-keep-one', 'ticket-keep-one', 'worktree-keep-one', 'completed', ${now}) + `; + yield* sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES + ('message-delete-one', 'ticket-delete-one', 'step-delete-one', 'user', 'Delete me', '[]', ${now}), + ('message-keep-one', 'ticket-keep-one', 'step-keep-one', 'user', 'Keep me', '[]', ${now}) + `; + + yield* read.deleteTicketState("ticket-delete-one" as never); + + const counts = yield* sql<{ + readonly tableName: string; + readonly deleted: number; + readonly kept: number; + }>` + SELECT 'projection_ticket' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM projection_ticket + UNION ALL + SELECT 'projection_pipeline_run' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM projection_pipeline_run + UNION ALL + SELECT 'projection_step_run' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM projection_step_run + UNION ALL + SELECT 'workflow_script_run' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM workflow_script_run + UNION ALL + SELECT 'workflow_dispatch_outbox' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM workflow_dispatch_outbox + UNION ALL + SELECT 'workflow_setup_run' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM workflow_setup_run + UNION ALL + SELECT 'projection_ticket_message' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM projection_ticket_message + `; + + assert.deepEqual( + counts.map((row) => [row.tableName, row.deleted, row.kept]), + [ + ["projection_ticket", 0, 1], + ["projection_pipeline_run", 0, 1], + ["projection_step_run", 0, 1], + ["workflow_script_run", 0, 1], + ["workflow_dispatch_outbox", 0, 1], + ["workflow_setup_run", 0, 1], + ["projection_ticket_message", 0, 1], + ], + ); + }), + ); + + it.effect( + "listTickets and getTicketDetail include pr field when workflow_pr_state row exists", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-12T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES + ('ticket-pr-view', 'board-pr-view', 'PR ticket', 'implement', 'idle', ${now}, ${now}), + ('ticket-no-pr', 'board-pr-view', 'No PR ticket', 'implement', 'idle', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, pr_state, + last_ci_state, updated_at + ) + VALUES ( + 'ticket-pr-view', 99, 'https://github.com/owner/repo/pull/99', + 'ft/feature', 'origin', 'owner/repo', 'open', 'success', ${now} + ) + `; + + const tickets = yield* read.listTickets("board-pr-view" as never); + const prTicket = tickets.find((t) => t.ticketId === "ticket-pr-view"); + const noPrTicket = tickets.find((t) => t.ticketId === "ticket-no-pr"); + + assert.isDefined(prTicket?.pr); + assert.equal(prTicket?.pr?.number, 99); + assert.equal(prTicket?.pr?.url, "https://github.com/owner/repo/pull/99"); + assert.equal(prTicket?.pr?.state, "open"); + assert.equal(prTicket?.pr?.ciState, "success"); + assert.isUndefined(noPrTicket?.pr); + + const detail = yield* read.getTicketDetail("ticket-pr-view" as never); + assert.isDefined(detail?.ticket.pr); + assert.equal(detail?.ticket.pr?.number, 99); + assert.equal(detail?.ticket.pr?.url, "https://github.com/owner/repo/pull/99"); + assert.equal(detail?.ticket.pr?.state, "open"); + assert.equal(detail?.ticket.pr?.ciState, "success"); + + const detailNoPr = yield* read.getTicketDetail("ticket-no-pr" as never); + assert.isUndefined(detailNoPr?.ticket.pr); + }), + ); + + it.effect("pr.ciState is omitted when last_ci_state is NULL", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-12T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-pr-no-ci', 'board-pr-no-ci', 'PR no CI', 'implement', 'idle', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, pr_state, updated_at + ) + VALUES ( + 'ticket-pr-no-ci', 3, 'https://github.com/owner/repo/pull/3', + 'ft/no-ci', 'origin', 'owner/repo', 'merged', ${now} + ) + `; + + const tickets = yield* read.listTickets("board-pr-no-ci" as never); + const ticket = tickets[0]; + assert.isDefined(ticket?.pr); + assert.equal(ticket?.pr?.state, "merged"); + assert.isUndefined(ticket?.pr?.ciState); + }), + ); + + it.effect("getTicketPrState returns full row or null", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-12T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-pr-state-full', 'board-pr-state-full', 'Full PR state', 'implement', 'idle', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, pr_state, + last_head_sha, last_ci_state, last_review_decision, last_comment_cursor, updated_at + ) + VALUES ( + 'ticket-pr-state-full', 11, 'https://github.com/owner/repo/pull/11', + 'ft/full', 'origin', 'owner/repo', 'open', + 'abc123', 'pending', 'APPROVED', 'cursor-xyz', ${now} + ) + `; + + const prState = yield* read.getTicketPrState("ticket-pr-state-full" as never); + assert.isNotNull(prState); + assert.equal(prState?.prNumber, 11); + assert.equal(prState?.prUrl, "https://github.com/owner/repo/pull/11"); + assert.equal(prState?.branch, "ft/full"); + assert.equal(prState?.remoteName, "origin"); + assert.equal(prState?.repo, "owner/repo"); + assert.equal(prState?.prState, "open"); + assert.equal(prState?.lastHeadSha, "abc123"); + assert.equal(prState?.lastCiState, "pending"); + assert.equal(prState?.lastReviewDecision, "APPROVED"); + assert.equal(prState?.lastCommentCursor, "cursor-xyz"); + + const missing = yield* read.getTicketPrState("ticket-no-such" as never); + assert.isNull(missing); + }), + ); + + it.effect("drops the pr view when pr_state is unrecognized", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-12T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-pr-bogus', 'board-pr-bogus', 'Bogus PR state', 'implement', 'idle', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, pr_state, updated_at + ) + VALUES ( + 'ticket-pr-bogus', 5, 'https://github.com/owner/repo/pull/5', + 'ft/bogus', 'origin', 'owner/repo', 'reopened', ${now} + ) + `; + + // An unrecognized pr_state is an invariant violation: the view degrades + // to "no pr" and the read model logs one warning per query (log output + // is not captured by this harness, so only the view shape is asserted). + const tickets = yield* read.listTickets("board-pr-bogus" as never); + assert.equal(tickets.length, 1); + assert.isUndefined(tickets[0]?.pr); + + const detail = yield* read.getTicketDetail("ticket-pr-bogus" as never); + assert.isUndefined(detail?.ticket.pr); + }), + ); + + it.effect( + "ticket detail carries attention fields and current-lane actions for a waiting ticket", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const registry = yield* BoardRegistry; + const base = { + ticketId: "t-attention-detail" as never, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + }; + + yield* registry.register("b-attention-detail" as never, { + name: "Attention board", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + actions: [ + { label: "Approve", to: "done", hint: "Ship it" }, + { label: "Send back", to: "implement" }, + ], + }, + { key: "implement", name: "Implement", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attention-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attention-detail" as never, + title: "Needs you" as never, + laneKey: "review" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "attention-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-attention-detail" as never, + stepRunId: "sr-attention-detail" as never, + stepKey: "review" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "attention-detail-c" as never, + streamVersion: 2, + payload: { + stepRunId: "sr-attention-detail" as never, + waitingReason: "Approve this command?", + providerResponseKind: "request", + }, + }); + + const detail = yield* read.getTicketDetail("t-attention-detail" as never); + assert.equal(detail?.ticket.attentionKind, "waiting_for_approval"); + assert.equal(detail?.ticket.attentionReason, "Approve this command?"); + assert.deepEqual(detail?.ticket.currentLane, { + key: "review", + name: "Review", + actions: [ + { label: "Approve", to: "done", hint: "Ship it" }, + { label: "Send back", to: "implement" }, + ], + }); + }), + ); + + it.effect("ticket detail reports no attention and an action-less lane for a running ticket", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const registry = yield* BoardRegistry; + const base = { + ticketId: "t-running-detail" as never, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + }; + + yield* registry.register("b-running-detail" as never, { + name: "Running board", + lanes: [{ key: "implement", name: "Implement", entry: "manual" }], + }); + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "running-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-running-detail" as never, + title: "In progress" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "running-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-running-detail" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-running" as never, + }, + }); + + const detail = yield* read.getTicketDetail("t-running-detail" as never); + assert.equal(detail?.ticket.status, "running"); + assert.equal(detail?.ticket.attentionKind, null); + assert.equal(detail?.ticket.attentionReason, null); + // Lane resolved but has no actions configured. + assert.deepEqual(detail?.ticket.currentLane, { + key: "implement", + name: "Implement", + actions: [], + }); + }), + ); + + it.effect( + "ticket detail falls back to a key-only lane when the board definition is unregistered", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-fallback-detail" as never, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + }; + + // No registry.register for this board — definition is unresolvable. + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "fallback-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-fallback-detail" as never, + title: "Orphan" as never, + laneKey: "mystery_lane" as never, + }, + }); + + const detail = yield* read.getTicketDetail("t-fallback-detail" as never); + assert.deepEqual(detail?.ticket.currentLane, { + key: "mystery_lane", + name: "mystery_lane", + actions: [], + }); + }), + ); + + it.effect( + "listNeedsAttentionTickets returns only waiting/blocked tickets with board name, oldest first", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + yield* read.registerBoard({ + boardId: "b-needs-attention" as never, + projectId: "p-needs-attention" as never, + name: "Attention Board" as never, + workflowFilePath: ".t3/boards/attention.json", + workflowVersionHash: "h", + maxConcurrentTickets: 3, + }); + + const insertTicket = (input: { + readonly ticketId: string; + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + readonly updatedAt: string; + }) => sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, + attention_kind, attention_reason, created_at, updated_at + ) + VALUES ( + ${input.ticketId}, 'b-needs-attention', ${input.ticketId}, 'review', ${input.status}, + ${input.attentionKind}, ${input.attentionReason}, + '2026-06-08T00:00:00.000Z', ${input.updatedAt} + ) + `; + + // Newer waiting ticket, older blocked ticket, and an excluded running one. + yield* insertTicket({ + ticketId: "ticket-waiting", + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "Which API?", + updatedAt: "2026-06-08T02:00:00.000Z", + }); + yield* insertTicket({ + ticketId: "ticket-blocked", + status: "blocked", + attentionKind: "blocked", + attentionReason: "Missing creds", + updatedAt: "2026-06-08T01:00:00.000Z", + }); + yield* insertTicket({ + ticketId: "ticket-running", + status: "running", + attentionKind: null, + attentionReason: null, + updatedAt: "2026-06-08T03:00:00.000Z", + }); + + const rows = yield* read.listNeedsAttentionTickets(); + assert.deepEqual( + rows.map((row) => row.ticketId), + ["ticket-blocked", "ticket-waiting"], + ); + assert.equal(rows[0]?.boardName, "Attention Board"); + assert.equal(rows[0]?.status, "blocked"); + assert.equal(rows[0]?.attentionKind, "blocked"); + assert.equal(rows[0]?.attentionReason, "Missing creds"); + assert.equal(rows[0]?.currentLaneKey, "review"); + assert.equal(rows[1]?.attentionKind, "waiting_for_input"); + }), + ); + + it.effect("deleteTicketState removes the ticket's notification outbox rows", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-08T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-outbox', 'b-outbox', 'Outbox', 'review', 'waiting_on_user', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_notification_outbox ( + outbox_id, ticket_id, board_id, sequence, status, created_at + ) + VALUES ('outbox-1', 'ticket-outbox', 'b-outbox', 1, 'waiting_on_user', ${now}) + `; + + yield* read.deleteTicketState("ticket-outbox" as never); + + const remaining = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_notification_outbox WHERE ticket_id = 'ticket-outbox' + `; + assert.equal(remaining[0]?.count, 0); + }), + ); + + it.effect("deleteBoardTicketState removes the board's notification outbox rows", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-08T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-board-outbox', 'b-board-outbox', 'Outbox', 'review', 'blocked', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_notification_outbox ( + outbox_id, ticket_id, board_id, sequence, status, created_at + ) + VALUES ('board-outbox-1', 'ticket-board-outbox', 'b-board-outbox', 2, 'blocked', ${now}) + `; + + yield* read.deleteBoardTicketState("b-board-outbox" as never); + + const remaining = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_notification_outbox WHERE board_id = 'b-board-outbox' + `; + assert.equal(remaining[0]?.count, 0); + }), + ); + + it.effect( + "deleteBoardTicketState removes work_source_mapping and work_source_state rows for the board", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + // Register the board via the proper API + yield* read.registerBoard({ + boardId: "b-ws-cascade" as never, + projectId: "proj-ws" as never, + name: "WS Board", + workflowFilePath: ".t3/boards/ws.json", + workflowVersionHash: "hash-ws", + maxConcurrentTickets: 5, + }); + + // Insert ticket row + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-ws-cascade', 'b-ws-cascade', 'Synced', 'inbox', 'running', ${now}, ${now}) + `; + + // Insert work_source_mapping row + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, created_at, last_synced_at + ) + VALUES ( + 'map-ws-cascade', 'b-ws-cascade', 'src-1', 'github', '42', + 'ticket-ws-cascade', 'hash123', 'open', 'active', ${now}, ${now} + ) + `; + + // Insert work_source_state row (board-scoped) + yield* sql` + INSERT INTO work_source_state ( + board_id, source_id, consecutive_failures + ) + VALUES ('b-ws-cascade', 'src-1', 0) + `; + + yield* read.deleteBoardTicketState("b-ws-cascade" as never); + + const mappingCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM work_source_mapping WHERE ticket_id = 'ticket-ws-cascade' + `; + assert.equal(mappingCount[0]?.count, 0, "work_source_mapping should be deleted"); + + const stateCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM work_source_state WHERE board_id = 'b-ws-cascade' + `; + assert.equal(stateCount[0]?.count, 0, "work_source_state should be deleted"); + }), + ); + + it.effect("board deletion cascades workflow_outbound_delivery rows", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + // Register the board via the proper API + yield* read.registerBoard({ + boardId: "b1" as never, + projectId: "proj-outbound" as never, + name: "Outbound Board", + workflowFilePath: ".t3/boards/outbound.json", + workflowVersionHash: "hash-outbound", + maxConcurrentTickets: 5, + }); + + // Insert ticket row + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-outbound', 'b1', 'Outbound', 'inbox', 'running', ${now}, ${now}) + `; + + // Insert a global outbound connection row (must NOT be cascaded) + yield* sql` + INSERT INTO workflow_outbound_connection ( + connection_ref, kind, display_name, secret_name, created_at + ) + VALUES ('conn-keep', 'slack', 'Keep me', 'outbound-target:conn-keep', ${now}) + `; + + // Insert an outbound delivery row scoped to board 'b1' + yield* sql` + INSERT INTO workflow_outbound_delivery ( + delivery_id, board_id, ticket_id, rule_id, event_sequence, + connection_ref, formatter, context_json, delivery_state, + attempt_count, created_at + ) + VALUES ( + 'delivery-b1', 'b1', 'ticket-outbound', 'rule-1', 1, + 'conn-keep', 'slack', '{}', 'pending', 0, ${now} + ) + `; + + yield* read.deleteBoardTicketState("b1" as never); + + const deliveryCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_outbound_delivery WHERE board_id = 'b1' + `; + assert.equal(deliveryCount[0]?.count, 0, "workflow_outbound_delivery should be deleted"); + + // Connections are global, not board-scoped — board deletion must NOT remove them. + const connectionCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_outbound_connection WHERE connection_ref = 'conn-keep' + `; + assert.equal( + connectionCount[0]?.count, + 1, + "workflow_outbound_connection should NOT be cascaded by board deletion", + ); + }), + ); + + it.effect( + "deleteTicketState removes work_source_mapping for the ticket but leaves board-scoped work_source_state intact", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + // Register the board via the proper API + yield* read.registerBoard({ + boardId: "b-ws-ticket" as never, + projectId: "proj-ws-t" as never, + name: "WS Ticket Board", + workflowFilePath: ".t3/boards/wst.json", + workflowVersionHash: "hash-wst", + maxConcurrentTickets: 5, + }); + + // Insert ticket row + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-ws-single', 'b-ws-ticket', 'Synced Single', 'inbox', 'running', ${now}, ${now}) + `; + + // Insert work_source_mapping row for the ticket + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, created_at, last_synced_at + ) + VALUES ( + 'map-ws-single', 'b-ws-ticket', 'src-2', 'github', '99', + 'ticket-ws-single', 'hash456', 'open', 'active', ${now}, ${now} + ) + `; + + // Insert board-scoped work_source_state row (should NOT be deleted) + yield* sql` + INSERT INTO work_source_state ( + board_id, source_id, consecutive_failures + ) + VALUES ('b-ws-ticket', 'src-2', 0) + `; + + yield* read.deleteTicketState("ticket-ws-single" as never); + + const mappingCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM work_source_mapping WHERE ticket_id = 'ticket-ws-single' + `; + assert.equal( + mappingCount[0]?.count, + 0, + "work_source_mapping should be deleted for the ticket", + ); + + const stateCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM work_source_state WHERE board_id = 'b-ws-ticket' + `; + assert.equal( + stateCount[0]?.count, + 1, + "work_source_state (board-scoped) should remain untouched", + ); + }), + ); + + it.effect( + "getTicketDetail returns syncedSource when work_source_mapping row has valid source_metadata_json", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + yield* read.registerBoard({ + boardId: "b-synced-detail" as never, + projectId: "proj-synced" as never, + name: "Synced Board", + workflowFilePath: ".t3/boards/synced.json", + workflowVersionHash: "hash-synced", + maxConcurrentTickets: 5, + }); + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-synced', 'b-synced-detail', 'Synced Ticket', 'inbox', 'running', ${now}, ${now}) + `; + + const metadataJson = + '{"provider":"github","url":"https://github.com/owner/repo/issues/42","assignees":["alice","bob"],"labels":["bug","high-priority"],"lifecycle":"open"}'; + + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, source_metadata_json, created_at, last_synced_at + ) + VALUES ( + 'map-synced', 'b-synced-detail', 'src-synced', 'github', '42', + 'ticket-synced', 'hashXYZ', 'open', 'active', ${metadataJson}, ${now}, ${now} + ) + `; + + const detail = yield* read.getTicketDetail("ticket-synced" as never); + assert.isDefined(detail, "detail should not be null"); + assert.isDefined(detail?.syncedSource, "syncedSource should be present"); + assert.equal(detail?.syncedSource?.provider, "github"); + assert.equal(detail?.syncedSource?.url, "https://github.com/owner/repo/issues/42"); + assert.deepEqual(detail?.syncedSource?.assignees, ["alice", "bob"]); + assert.deepEqual(detail?.syncedSource?.labels, ["bug", "high-priority"]); + }), + ); + + it.effect( + "getTicketDetail returns syncedSource for orphaned mapping (sync_status=orphaned)", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + yield* read.registerBoard({ + boardId: "b-orphaned-detail" as never, + projectId: "proj-orphaned" as never, + name: "Orphaned Board", + workflowFilePath: ".t3/boards/orphaned.json", + workflowVersionHash: "hash-orphaned", + maxConcurrentTickets: 5, + }); + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-orphaned', 'b-orphaned-detail', 'Orphaned Ticket', 'inbox', 'running', ${now}, ${now}) + `; + + const metadataJson = + '{"provider":"asana","url":"https://app.asana.com/0/proj/task123","labels":["v2"]}'; + + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, source_metadata_json, created_at, last_synced_at + ) + VALUES ( + 'map-orphaned', 'b-orphaned-detail', 'src-asana', 'asana', 'task123', + 'ticket-orphaned', 'hashABC', 'closed', 'orphaned', ${metadataJson}, ${now}, ${now} + ) + `; + + const detail = yield* read.getTicketDetail("ticket-orphaned" as never); + assert.isDefined( + detail?.syncedSource, + "syncedSource should be present even for orphaned mapping", + ); + assert.equal(detail?.syncedSource?.provider, "asana"); + assert.equal(detail?.syncedSource?.url, "https://app.asana.com/0/proj/task123"); + assert.deepEqual(detail?.syncedSource?.labels, ["v2"]); + assert.isUndefined(detail?.syncedSource?.assignees); + }), + ); + + it.effect("getTicketDetail returns syncedSource: undefined for a non-synced ticket", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + yield* read.registerBoard({ + boardId: "b-non-synced" as never, + projectId: "proj-non-synced" as never, + name: "Non-Synced Board", + workflowFilePath: ".t3/boards/non-synced.json", + workflowVersionHash: "hash-non-synced", + maxConcurrentTickets: 5, + }); + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-non-synced', 'b-non-synced', 'Non-Synced Ticket', 'inbox', 'running', ${now}, ${now}) + `; + + const detail = yield* read.getTicketDetail("ticket-non-synced" as never); + assert.isDefined(detail, "detail should not be null"); + assert.isUndefined( + detail?.syncedSource, + "syncedSource should be undefined for non-synced ticket", + ); + }), + ); + + it.effect( + "getTicketDetail returns syncedSource: undefined when source_metadata_json is malformed", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + yield* read.registerBoard({ + boardId: "b-malformed" as never, + projectId: "proj-malformed" as never, + name: "Malformed Board", + workflowFilePath: ".t3/boards/malformed.json", + workflowVersionHash: "hash-malformed", + maxConcurrentTickets: 5, + }); + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-malformed', 'b-malformed', 'Malformed Ticket', 'inbox', 'running', ${now}, ${now}) + `; + + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, source_metadata_json, created_at, last_synced_at + ) + VALUES ( + 'map-malformed', 'b-malformed', 'src-malformed', 'github', '99', + 'ticket-malformed', 'hashBAD', 'open', 'active', 'not valid json!!!', ${now}, ${now} + ) + `; + + const detail = yield* read.getTicketDetail("ticket-malformed" as never); + assert.isDefined(detail, "detail should not be null (no crash)"); + assert.isUndefined( + detail?.syncedSource, + "syncedSource should be undefined for malformed source_metadata_json", + ); + }), + ); + + it.effect( + "getTicketDetail returns syncedSource: undefined when source_metadata_json is null", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + yield* read.registerBoard({ + boardId: "b-null-meta" as never, + projectId: "proj-null-meta" as never, + name: "Null Meta Board", + workflowFilePath: ".t3/boards/null-meta.json", + workflowVersionHash: "hash-null-meta", + maxConcurrentTickets: 5, + }); + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-null-meta', 'b-null-meta', 'Null Meta Ticket', 'inbox', 'running', ${now}, ${now}) + `; + + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, source_metadata_json, created_at, last_synced_at + ) + VALUES ( + 'map-null-meta', 'b-null-meta', 'src-null', 'github', '77', + 'ticket-null-meta', 'hashNULL', 'open', 'active', NULL, ${now}, ${now} + ) + `; + + const detail = yield* read.getTicketDetail("ticket-null-meta" as never); + assert.isDefined(detail, "detail should not be null"); + assert.isUndefined( + detail?.syncedSource, + "syncedSource should be undefined when source_metadata_json is NULL", + ); + }), + ); + + // ── getBoardMetrics ──────────────────────────────────────────────────────── + + it.effect("getBoardMetrics aggregates throughput, cycle time, and breakdowns", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + const nowIso = DateTime.formatIso(now); + const daysAgo = (d: number) => DateTime.formatIso(DateTime.subtract(now, { days: d })); + + const insertTicket = (input: { + readonly ticketId: string; + readonly boardId: string; + readonly title: string; + readonly lane: string; + readonly status: string; + readonly createdAt: string; + readonly terminalAt?: string | null; + readonly entryToken?: string | null; + readonly queuedAt?: string | null; + readonly laneEnteredAt?: string | null; + }) => sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, + current_lane_entry_token, current_lane_entered_at, queued_at, terminal_at, + created_at, updated_at + ) + VALUES ( + ${input.ticketId}, ${input.boardId}, ${input.title}, ${input.lane}, ${input.status}, + ${input.entryToken ?? null}, ${input.laneEnteredAt ?? null}, ${input.queuedAt ?? null}, + ${input.terminalAt ?? null}, ${input.createdAt}, ${input.createdAt} + ) + `; + + // Five shipped tickets with known cycle-time durations (in ms) so the + // percentile assertions are exact: 10, 20, 30, 40, 50 minutes. + const minute = 60_000; + const durations = [10, 20, 30, 40, 50]; + const plusMinutesIso = (iso: string, mins: number) => + DateTime.formatIso(DateTime.add(DateTime.makeUnsafe(iso), { minutes: mins })); + yield* Effect.forEach(durations, (mins, idx) => { + const createdAt = daysAgo(2); + const terminalAt = plusMinutesIso(createdAt, mins); + return insertTicket({ + ticketId: `m-ship-${idx}`, + boardId: "b-metrics", + title: `Shipped ${idx}`, + lane: "done", + status: "idle", + createdAt, + terminalAt, + }); + }); + + // WIP tickets (non-terminal): admitted (entry token), queued (no token), + // plus blocked / waiting_on_user for attention. + yield* insertTicket({ + ticketId: "m-wip-admitted", + boardId: "b-metrics", + title: "Admitted", + lane: "implement", + status: "running", + createdAt: daysAgo(1), + entryToken: "tok-1", + laneEnteredAt: daysAgo(1), + }); + yield* insertTicket({ + ticketId: "m-wip-queued", + boardId: "b-metrics", + title: "Queued", + lane: "implement", + status: "queued", + createdAt: daysAgo(1), + queuedAt: daysAgo(1), + }); + yield* insertTicket({ + ticketId: "m-blocked", + boardId: "b-metrics", + title: "Blocked", + lane: "review", + status: "blocked", + createdAt: daysAgo(3), + entryToken: "tok-2", + // oldest in-lane → should be first in attention.oldest + laneEnteredAt: daysAgo(5), + }); + yield* insertTicket({ + ticketId: "m-waiting", + boardId: "b-metrics", + title: "Waiting", + lane: "review", + status: "waiting_on_user", + createdAt: daysAgo(2), + entryToken: "tok-3", + laneEnteredAt: daysAgo(1), + }); + // A QUEUED ticket (no entry token, no laneEnteredAt) that is older than + // m-blocked — must appear in attention.oldest aged by queued_at, and rank + // above m-blocked because it has been waiting longer (7 days vs 5 days). + yield* insertTicket({ + ticketId: "m-wip-queued-oldest", + boardId: "b-metrics", + title: "Queued Oldest", + lane: "implement", + status: "queued", + createdAt: daysAgo(8), + queuedAt: daysAgo(7), + }); + // A ticket in another board must never leak in. + yield* insertTicket({ + ticketId: "m-other-board", + boardId: "b-other", + title: "Other", + lane: "implement", + status: "running", + createdAt: daysAgo(1), + entryToken: "tok-x", + laneEnteredAt: daysAgo(1), + }); + + // ── pipeline runs + step runs (lane-aware grouping) ── + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, ticket_id, lane_key, lane_entry_token, status, started_at + ) + VALUES + ('m-pr-impl', 'm-wip-admitted', 'implement', 'tok-1', 'running', ${daysAgo(1)}), + ('m-pr-review', 'm-blocked', 'review', 'tok-2', 'running', ${daysAgo(1)}) + `; + const stepStart = daysAgo(1); + const stepEnd = plusMinutesIso(stepStart, 2); + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, pipeline_run_id, ticket_id, step_key, step_type, status, + attempt, total_tokens, started_at, finished_at + ) + VALUES + ('m-sr-1', 'm-pr-impl', 'm-wip-admitted', 'build', 'agent', 'completed', 2, 100, ${stepStart}, ${stepEnd}), + ('m-sr-2', 'm-pr-impl', 'm-wip-admitted', 'build', 'agent', 'failed', 1, 50, ${stepStart}, ${stepEnd}), + ('m-sr-3', 'm-pr-review', 'm-blocked', 'review', 'agent', 'completed', 1, 25, ${stepStart}, ${stepEnd}) + `; + + // ── route + manual-move events ── + const insertEvent = (input: { + readonly eventId: string; + readonly ticketId: string; + readonly streamVersion: number; + readonly eventType: string; + readonly occurredAt: string; + readonly payload: unknown; + }) => sql` + INSERT INTO workflow_events ( + event_id, ticket_id, stream_version, event_type, occurred_at, payload_json + ) + VALUES ( + ${input.eventId}, ${input.ticketId}, ${input.streamVersion}, ${input.eventType}, + ${input.occurredAt}, ${JSON.stringify(input.payload)} + ) + `; + yield* insertEvent({ + eventId: "m-ev-route-1", + ticketId: "m-wip-admitted", + streamVersion: 0, + eventType: "TicketRouteDecided", + occurredAt: daysAgo(1), + payload: { + fromLane: "implement", + toLane: "review", + source: "lane_transition", + contextSnapshot: { pipeline: { result: "success" } }, + }, + }); + yield* insertEvent({ + eventId: "m-ev-route-2", + ticketId: "m-blocked", + streamVersion: 0, + eventType: "TicketRouteDecided", + occurredAt: daysAgo(1), + // work_source has contextSnapshot=null → result should be 'n/a' + payload: { + fromLane: null, + toLane: "implement", + source: "work_source", + contextSnapshot: null, + }, + }); + // An old route event outside the 7-day window must be excluded. + yield* insertEvent({ + eventId: "m-ev-route-old", + ticketId: "m-wip-admitted", + streamVersion: 1, + eventType: "TicketRouteDecided", + occurredAt: daysAgo(40), + payload: { + fromLane: "implement", + toLane: "review", + source: "lane_transition", + contextSnapshot: { pipeline: { result: "success" } }, + }, + }); + // Manual moves: one counts (reason=manual), one does not (reason=routed). + yield* insertEvent({ + eventId: "m-ev-move-manual", + ticketId: "m-wip-admitted", + streamVersion: 2, + eventType: "TicketMovedToLane", + occurredAt: daysAgo(1), + payload: { toLane: "implement", reason: "manual" }, + }); + yield* insertEvent({ + eventId: "m-ev-move-routed", + ticketId: "m-wip-admitted", + streamVersion: 3, + eventType: "TicketMovedToLane", + occurredAt: daysAgo(1), + payload: { toLane: "review", reason: "routed" }, + }); + + const metrics = yield* read.getBoardMetrics("b-metrics" as never, 7); + + assert.equal(metrics.windowDays, 7); + assert.isString(metrics.generatedAt); + + // throughput: 9 created within board (5 shipped + 4 wip), all within window. + assert.equal(metrics.throughput.created, 9); + assert.equal(metrics.throughput.shipped, 5); + + // cycleTime: durations 10..50 minutes → p50=30m, p90=50m, avg=30m. + // The julianday(...) * 86400000 idiom + CAST AS INTEGER can truncate a + // sub-ms float (e.g. 2999999 for 50m), so allow ±1ms tolerance. + assert.equal(metrics.cycleTime.count, 5); + assert.closeTo(metrics.cycleTime.p50Ms, 30 * minute, 1); + assert.closeTo(metrics.cycleTime.p90Ms, 50 * minute, 1); + assert.closeTo(metrics.cycleTime.avgMs, 30 * minute, 1); + + // wipByLane: terminal tickets excluded; admitted vs queued split. + // implement: 1 admitted (m-wip-admitted) + 2 queued (m-wip-queued + m-wip-queued-oldest). + const wipByLane = Object.fromEntries(metrics.wipByLane.map((w) => [w.laneKey, w])); + assert.equal(wipByLane["implement"]?.admitted, 1); + assert.equal(wipByLane["implement"]?.queued, 2); + assert.equal(wipByLane["review"]?.admitted, 2); // blocked + waiting both have tokens + assert.equal(wipByLane["review"]?.queued, 0); + assert.isUndefined(wipByLane["done"], "terminal-only lane must not appear in WIP"); + + // statusBreakdown: 5 terminal tickets bucket as 'done'. + assert.equal(metrics.statusBreakdown["done"], 5); + assert.equal(metrics.statusBreakdown["running"], 1); + // 2 queued: m-wip-queued (daysAgo(1)) + m-wip-queued-oldest (daysAgo(8)). + assert.equal(metrics.statusBreakdown["queued"], 2); + assert.equal(metrics.statusBreakdown["blocked"], 1); + assert.equal(metrics.statusBreakdown["waiting_on_user"], 1); + + // attention: blocked/waitingOnUser counts + oldest order (desc by age), cap 5. + assert.equal(metrics.attention.blocked, 1); + assert.equal(metrics.attention.waitingOnUser, 1); + assert.isAtMost(metrics.attention.oldest.length, 5); + // Queued ticket (7 days via queued_at) must appear and rank above the + // admitted m-blocked ticket (5 days via current_lane_entered_at). + assert.ok( + metrics.attention.oldest.some((o) => o.ticketId === "m-wip-queued-oldest"), + "queued ticket must appear in attention.oldest", + ); + assert.equal( + metrics.attention.oldest[0]?.ticketId, + "m-wip-queued-oldest", + "queued ticket (7 d) ranks above admitted m-blocked (5 d)", + ); + assert.ok((metrics.attention.oldest[0]?.ageMs ?? 0) > 0); + // No terminal ticket should appear in oldest. + assert.ok( + !metrics.attention.oldest.some((o) => o.ticketId.startsWith("m-ship-")), + "terminal tickets excluded from attention.oldest", + ); + + // routeOutcomes: two grouped rows; work_source → result 'n/a'. + const work = metrics.routeOutcomes.find((r) => r.source === "work_source"); + assert.equal(work?.result, "n/a"); + assert.equal(work?.count, 1); + const laneTransition = metrics.routeOutcomes.find((r) => r.source === "lane_transition"); + assert.equal(laneTransition?.result, "success"); + assert.equal(laneTransition?.count, 1); // old one excluded by window + + // manualMoveCount: only reason=manual within window. + assert.equal(metrics.manualMoveCount, 1); + + // stepStats: lane-aware grouping; retries from attempt>1; tokens; avg present. + const impl = metrics.stepStats.find((s) => s.laneKey === "implement"); + assert.equal(impl?.succeeded, 1); + assert.equal(impl?.failed, 1); + assert.equal(impl?.retries, 1); // m-sr-1 has attempt=2 + assert.equal(impl?.totalTokens, 150); + assert.closeTo(impl?.avgDurationMs ?? 0, 2 * minute, 1); + const review = metrics.stepStats.find((s) => s.laneKey === "review"); + assert.equal(review?.succeeded, 1); + assert.equal(review?.failed, 0); + assert.equal(review?.totalTokens, 25); + + // Other board never leaks. + assert.ok(!metrics.wipByLane.some((w) => w.laneKey === "implement" && w.admitted > 1)); + + // Silence unused warnings for nowMs/nowIso when not asserted directly. + void nowMs; + void nowIso; + }), + ); + + it.effect("getBoardMetrics returns zeros for an empty board and clamps windowDays", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const metrics = yield* read.getBoardMetrics("b-empty" as never, 99); + // 99 is not in {1,7,30} → defaults to 7. + assert.equal(metrics.windowDays, 7); + assert.equal(metrics.throughput.created, 0); + assert.equal(metrics.throughput.shipped, 0); + assert.equal(metrics.cycleTime.count, 0); + assert.equal(metrics.cycleTime.p50Ms, 0); + assert.equal(metrics.cycleTime.p90Ms, 0); + assert.equal(metrics.cycleTime.avgMs, 0); + assert.deepEqual([...metrics.wipByLane], []); + assert.deepEqual(metrics.statusBreakdown, {}); + assert.equal(metrics.attention.blocked, 0); + assert.equal(metrics.attention.waitingOnUser, 0); + assert.deepEqual([...metrics.attention.oldest], []); + assert.deepEqual([...metrics.routeOutcomes], []); + assert.equal(metrics.manualMoveCount, 0); + assert.deepEqual([...metrics.stepStats], []); + + const clamped30 = yield* read.getBoardMetrics("b-empty" as never, 30); + assert.equal(clamped30.windowDays, 30); + const clamped1 = yield* read.getBoardMetrics("b-empty" as never, 1); + assert.equal(clamped1.windowDays, 1); + }), + ); + + it.effect("deleteBoardTicketState cascades workflow_board_proposal rows", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-14T00:00:00.000Z"; + + // Register the board via the proper API + yield* read.registerBoard({ + boardId: "b-proposal-cascade" as never, + projectId: "proj-proposal" as never, + name: "Proposal Board", + workflowFilePath: ".t3/boards/proposal.json", + workflowVersionHash: "hash-proposal", + maxConcurrentTickets: 5, + }); + + // Insert a proposal row scoped to the board + yield* sql` + INSERT INTO workflow_board_proposal ( + proposal_id, board_id, base_version_hash, base_def_json, agent_json, + proposed_def_json, rationale, validation_json, status, created_at + ) + VALUES ( + 'prop-1', 'b-proposal-cascade', 'vhash-abc', '{"lanes":[]}', '{"model":"sonnet"}', + '{"lanes":["inbox"]}', 'Add inbox lane', '{"valid":true}', 'pending', ${now} + ) + `; + + // Insert a proposal for a different board (must survive the cascade) + yield* sql` + INSERT INTO workflow_board_proposal ( + proposal_id, board_id, base_version_hash, base_def_json, agent_json, + proposed_def_json, rationale, validation_json, status, created_at + ) + VALUES ( + 'prop-2', 'b-other', 'vhash-xyz', '{"lanes":[]}', '{"model":"sonnet"}', + '{"lanes":["inbox"]}', 'Other board proposal', '{"valid":true}', 'pending', ${now} + ) + `; + + yield* read.deleteBoardTicketState("b-proposal-cascade" as never); + + const deletedCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_board_proposal WHERE board_id = 'b-proposal-cascade' + `; + assert.equal( + deletedCount[0]?.count, + 0, + "workflow_board_proposal rows for board should be deleted", + ); + + const keptCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_board_proposal WHERE board_id = 'b-other' + `; + assert.equal( + keptCount[0]?.count, + 1, + "workflow_board_proposal rows for other boards must NOT be cascaded", + ); + }), + ); + + it.effect("listBoardProposals returns proposals pending-first, computed outdated flag", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-14T10:00:00.000Z"; + const oldTime = "2026-06-13T08:00:00.000Z"; + + // Register a board so we can get its current versionHash. + yield* read.registerBoard({ + boardId: "b-list-proposals" as never, + projectId: "proj-list-proposals" as never, + name: "List Proposals Board", + workflowFilePath: ".t3/boards/list-proposals.json", + workflowVersionHash: "current-hash-abc", + maxConcurrentTickets: 5, + }); + + const agentJson = encodeUnknownJsonString({ instance: "claude", model: "sonnet" }); + const baseDefJson = encodeUnknownJsonString({ name: "Board", lanes: [] }); + const proposedDefJson = encodeUnknownJsonString({ name: "Board", lanes: [{ key: "inbox" }] }); + const validationJson = encodeUnknownJsonString({ + preservationOk: true, + lintOk: true, + dryRunOk: true, + laneDiffCount: 1, + lintErrors: [], + dryRunRegressions: [], + messages: [], + }); + + // Proposal 1: pending, base_version_hash == current versionHash → outdated: false + yield* sql` + INSERT INTO workflow_board_proposal ( + proposal_id, board_id, base_version_hash, base_def_json, agent_json, + proposed_def_json, rationale, validation_json, status, created_at + ) + VALUES ( + 'prop-list-1', 'b-list-proposals', 'current-hash-abc', ${baseDefJson}, ${agentJson}, + ${proposedDefJson}, 'Add inbox lane', ${validationJson}, 'pending', ${now} + ) + `; + + // Proposal 2: pending, base_version_hash is stale → outdated: true + yield* sql` + INSERT INTO workflow_board_proposal ( + proposal_id, board_id, base_version_hash, base_def_json, agent_json, + proposed_def_json, rationale, validation_json, status, created_at + ) + VALUES ( + 'prop-list-2', 'b-list-proposals', 'old-hash-xyz', ${baseDefJson}, ${agentJson}, + ${proposedDefJson}, 'Old proposal', ${validationJson}, 'pending', ${oldTime} + ) + `; + + // Proposal 3: approved, applied_version_hash set, resolved_at set + yield* sql` + INSERT INTO workflow_board_proposal ( + proposal_id, board_id, base_version_hash, base_def_json, agent_json, + proposed_def_json, rationale, validation_json, status, applied_version_hash, created_at, resolved_at + ) + VALUES ( + 'prop-list-3', 'b-list-proposals', 'current-hash-abc', ${baseDefJson}, ${agentJson}, + ${proposedDefJson}, 'Approved one', ${validationJson}, 'approved', 'applied-v1', ${oldTime}, ${now} + ) + `; + + const proposals = yield* read.listBoardProposals("b-list-proposals" as never); + + // Should return all 3 + assert.equal(proposals.length, 3, "should return all 3 proposals"); + + // pending proposals come first, then non-pending (approved). Within pending, newest first. + const pendingProposals = proposals.filter((p) => p.status === "pending"); + const nonPendingProposals = proposals.filter((p) => p.status !== "pending"); + assert.equal(pendingProposals.length, 2); + assert.equal(nonPendingProposals.length, 1); + + // Pending ones come before non-pending + const firstPendingIndex = proposals.findIndex((p) => p.status === "pending"); + const firstNonPendingIndex = proposals.findIndex((p) => p.status !== "pending"); + assert.ok( + firstPendingIndex < firstNonPendingIndex, + "pending proposals appear before non-pending", + ); + + // prop-list-1 (newer pending) should appear before prop-list-2 (older pending) + const idx1 = proposals.findIndex((p) => p.proposalId === "prop-list-1"); + const idx2 = proposals.findIndex((p) => p.proposalId === "prop-list-2"); + assert.ok(idx1 < idx2, "newer pending proposal should come before older pending proposal"); + + // outdated: false for proposal with current hash + const p1 = proposals.find((p) => p.proposalId === "prop-list-1"); + assert.equal(p1?.outdated, false, "proposal with current versionHash should not be outdated"); + assert.equal(p1?.status, "pending"); + assert.equal(p1?.boardId, "b-list-proposals"); + assert.equal(p1?.rationale, "Add inbox lane"); + assert.deepEqual(p1?.agent, { instance: "claude", model: "sonnet" }); + assert.equal(p1?.appliedVersionHash, null); + assert.equal(p1?.resolvedAt, null); + + // outdated: true for proposal with stale hash + const p2 = proposals.find((p) => p.proposalId === "prop-list-2"); + assert.equal(p2?.outdated, true, "proposal with stale versionHash should be outdated"); + + // approved proposal: not outdated (base hash matches), has appliedVersionHash and resolvedAt + const p3 = proposals.find((p) => p.proposalId === "prop-list-3"); + assert.equal(p3?.status, "approved"); + assert.equal(p3?.appliedVersionHash, "applied-v1"); + assert.equal(p3?.resolvedAt, now); + // outdated based on base_version_hash vs current versionHash + assert.equal(p3?.outdated, false); + }), + ); + + it.effect("listBoardProposals returns empty array when board has no proposals", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + yield* read.registerBoard({ + boardId: "b-no-proposals" as never, + projectId: "proj-no-proposals" as never, + name: "No Proposals Board", + workflowFilePath: ".t3/boards/no-proposals.json", + workflowVersionHash: "hash-no-props", + maxConcurrentTickets: 3, + }); + const proposals = yield* read.listBoardProposals("b-no-proposals" as never); + assert.equal(proposals.length, 0); + }), + ); + + it.effect("getBoardProposal returns view + both encoded defs", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-14T12:00:00.000Z"; + + yield* read.registerBoard({ + boardId: "b-get-proposal" as never, + projectId: "proj-get-proposal" as never, + name: "Get Proposal Board", + workflowFilePath: ".t3/boards/get-proposal.json", + workflowVersionHash: "get-proposal-hash", + maxConcurrentTickets: 3, + }); + + const agentJson = encodeUnknownJsonString({ instance: "claude", model: "opus" }); + const baseDefJson = encodeUnknownJsonString({ + name: "Board", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }], + }); + const proposedDefJson = encodeUnknownJsonString({ + name: "Board", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "inbox", name: "Inbox", entry: "manual" }, + ], + }); + const validationJson = encodeUnknownJsonString({ + preservationOk: true, + lintOk: true, + dryRunOk: true, + laneDiffCount: 1, + lintErrors: [], + dryRunRegressions: [], + messages: [], + }); + + yield* sql` + INSERT INTO workflow_board_proposal ( + proposal_id, board_id, base_version_hash, base_def_json, agent_json, + proposed_def_json, rationale, validation_json, status, created_at + ) + VALUES ( + 'prop-get-1', 'b-get-proposal', 'get-proposal-hash', ${baseDefJson}, ${agentJson}, + ${proposedDefJson}, 'Fetch me', ${validationJson}, 'pending', ${now} + ) + `; + + const result = yield* read.getBoardProposal("prop-get-1"); + assert.ok(result !== null, "expected proposal to be found"); + const { view, proposedDefinition, baseDefinition } = result; + + assert.equal(view.proposalId, "prop-get-1"); + assert.equal(view.boardId, "b-get-proposal"); + assert.equal(view.status, "pending"); + assert.equal(view.rationale, "Fetch me"); + assert.equal(view.outdated, false, "base hash matches current → not outdated"); + assert.equal(view.baseVersionHash, "get-proposal-hash"); + assert.equal(view.appliedVersionHash, null); + assert.equal(view.resolvedAt, null); + assert.deepEqual(view.agent, { instance: "claude", model: "opus" }); + assert.deepEqual(view.validation, { + preservationOk: true, + lintOk: true, + dryRunOk: true, + laneDiffCount: 1, + lintErrors: [], + dryRunRegressions: [], + messages: [], + }); + + // Encoded defs should be parseable objects (raw JSON → object) + assert.ok( + typeof proposedDefinition === "object" && proposedDefinition !== null, + "proposedDefinition is an object", + ); + assert.ok( + typeof baseDefinition === "object" && baseDefinition !== null, + "baseDefinition is an object", + ); + }), + ); + + it.effect("getBoardProposal computes outdated=true when base hash is stale", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-14T12:00:00.000Z"; + + yield* read.registerBoard({ + boardId: "b-stale-proposal" as never, + projectId: "proj-stale-proposal" as never, + name: "Stale Proposal Board", + workflowFilePath: ".t3/boards/stale-proposal.json", + workflowVersionHash: "stale-current-hash", + maxConcurrentTickets: 3, + }); + + const agentJson = encodeUnknownJsonString({ instance: "claude", model: "sonnet" }); + const baseDefJson = encodeUnknownJsonString({ name: "Board", lanes: [] }); + const proposedDefJson = encodeUnknownJsonString({ name: "Board", lanes: [] }); + const validationJson = encodeUnknownJsonString({ + preservationOk: false, + lintOk: false, + dryRunOk: false, + laneDiffCount: 0, + lintErrors: [], + dryRunRegressions: [], + messages: ["failed"], + }); + + yield* sql` + INSERT INTO workflow_board_proposal ( + proposal_id, board_id, base_version_hash, base_def_json, agent_json, + proposed_def_json, rationale, validation_json, status, created_at + ) + VALUES ( + 'prop-stale-1', 'b-stale-proposal', 'old-hash-does-not-match', ${baseDefJson}, ${agentJson}, + ${proposedDefJson}, 'Old proposal', ${validationJson}, 'invalid', ${now} + ) + `; + + const result = yield* read.getBoardProposal("prop-stale-1"); + assert.ok(result !== null, "expected proposal to be found"); + assert.equal(result.view.outdated, true, "base hash mismatch → outdated=true"); + }), + ); + + it.effect("getBoardProposal returns null/fails cleanly when not found", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const result = yield* read.getBoardProposal("does-not-exist"); + // The interface contract: getBoardProposal returns null when not found + assert.isNull(result, "not-found returns null"); + }), + ); + + it.effect( + "listLiveOccupiedLanes flags admitted, queued, and running lanes (not idle/terminal)", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const registry = yield* BoardRegistry; + const boardId = "b-live" as never; + // A board with a terminal `done` lane so a moved-to-done ticket goes terminal. + yield* registry.register(boardId, { + name: "Live", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "admit", name: "Admit", entry: "manual" }, + { key: "queue", name: "Queue", entry: "manual" }, + { key: "run", name: "Run", entry: "manual" }, + { key: "idle", name: "Idle", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const base = { occurredAt: "2026-06-07T00:00:00.000Z" as never }; + + // (1) admitted ticket in `admit` (entry token set, non-terminal). + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "live-a0" as never, + ticketId: "t-admit" as never, + streamVersion: 0, + payload: { boardId, title: "A" as never, laneKey: "admit" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMovedToLane", + eventId: "live-a1" as never, + ticketId: "t-admit" as never, + streamVersion: 1, + payload: { + toLane: "admit" as never, + laneEntryToken: "tok-a" as never, + reason: "initial", + }, + }); + + // (2) queued ticket in `queue` (entry token NULL, status=queued). + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "live-q0" as never, + ticketId: "t-queue" as never, + streamVersion: 0, + payload: { boardId, title: "Q" as never, laneKey: "queue" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketQueued", + eventId: "live-q1" as never, + ticketId: "t-queue" as never, + streamVersion: 1, + payload: { lane: "queue" as never }, + }); + + // (3) running pipeline in `run`. + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "live-r0" as never, + ticketId: "t-run" as never, + streamVersion: 0, + payload: { boardId, title: "R" as never, laneKey: "run" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMovedToLane", + eventId: "live-r1" as never, + ticketId: "t-run" as never, + streamVersion: 1, + payload: { toLane: "run" as never, laneEntryToken: "tok-r" as never, reason: "initial" }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "live-r2" as never, + ticketId: "t-run" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-run" as never, + laneKey: "run" as never, + laneEntryToken: "tok-r" as never, + }, + }); + + // (4) created-but-not-admitted ticket in `idle` (no token, not queued) → NOT occupied. + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "live-i0" as never, + ticketId: "t-idle" as never, + streamVersion: 0, + payload: { boardId, title: "I" as never, laneKey: "idle" as never }, + }); + + // (5) terminal ticket in `done` (admitted then moved to terminal lane) → NOT occupied. + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "live-d0" as never, + ticketId: "t-done" as never, + streamVersion: 0, + payload: { boardId, title: "D" as never, laneKey: "backlog" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMovedToLane", + eventId: "live-d1" as never, + ticketId: "t-done" as never, + streamVersion: 1, + payload: { toLane: "done" as never, laneEntryToken: "tok-d" as never, reason: "routed" }, + }); + + const lanes = yield* read.listLiveOccupiedLanes(boardId); + const set = new Set(lanes); + assert.isTrue(set.has("admit"), "admitted lane is live"); + assert.isTrue(set.has("queue"), "queued lane is live (3b fix)"); + assert.isTrue(set.has("run"), "running-pipeline lane is live"); + assert.isFalse(set.has("idle"), "created-but-not-admitted lane is NOT live"); + assert.isFalse(set.has("done"), "terminal lane is NOT live"); + assert.isFalse(set.has("backlog"), "empty lane is NOT live"); + }), + ); + + it.effect( + "listWorkSourceMappingsForBoard returns provider/source/external/ticket/lane per mapping", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-16T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES + ('t1', 'b1', 'Ticket one', 'triage', 'idle', ${now}, ${now}), + ('t2', 'b2', 'Ticket two', 'backlog', 'idle', ${now}, ${now}) + `; + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, created_at, last_synced_at + ) + VALUES + ('mapping-1', 'b1', 's1', 'github', '82', 't1', + 'hash-1', 'open', 'active', ${now}, ${now}), + ('mapping-2', 'b2', 's2', 'asana', '99', 't2', + 'hash-2', 'open', 'active', ${now}, ${now}) + `; + + const rows = yield* read.listWorkSourceMappingsForBoard("b1" as never); + const r = rows.find((x) => x.externalId === "82"); + assert.equal(r?.provider, "github"); + assert.equal(r?.sourceId, "s1"); + assert.equal(r?.ticketId, "t1"); + assert.equal(r?.currentLaneKey, "triage"); + + // The board_id WHERE clause must scope results to b1 only — the b2 + // mapping (external_id 99) must never leak into a b1 query. + assert.equal(rows.length, 1); + assert.isUndefined(rows.find((x) => x.externalId === "99")); + }), + ); +}); + +describe("percentileNearestRank", () => { + it("computes nearest-rank percentiles", () => { + assert.equal(percentileNearestRank([10, 20, 30, 40, 50], 50), 30); + assert.equal(percentileNearestRank([10, 20, 30, 40, 50], 90), 50); + assert.equal(percentileNearestRank([10, 20, 30, 40, 50], 0), 10); + assert.equal(percentileNearestRank([10, 20, 30, 40, 50], 100), 50); + }); + + it("returns 0 for an empty array", () => { + assert.equal(percentileNearestRank([], 50), 0); + assert.equal(percentileNearestRank([], 90), 0); + }); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.ts new file mode 100644 index 00000000000..24eee55c27a --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.ts @@ -0,0 +1,1637 @@ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; +import { + AgentSelection, + TicketAttachment, + WorkflowDefinition, + WorkflowProposalValidation, + type WorkflowBoardMetrics, + type WorkflowBoardProposalView, + type WorkflowDefinitionEncoded, +} from "@t3tools/contracts"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowReadModel, + type BoardListRow, + type BoardRow, + type PipelineStepRunRow, + type StepRunRow, + type RouteDecisionStepSnapshot, + type TicketMessageRow, + type TicketRouteDecisionRow, + type TicketPrStateRow, + type TicketPrView, + type TicketRow, + type WorkflowCurrentLaneRow, + type WorkflowLaneActionRow, + type WorkflowNeedsAttentionTicketRow, + type WorkflowReadModelShape, + type WorkSourceMappingRow, +} from "../Services/WorkflowReadModel.ts"; + +const toReadModelError = (cause: unknown) => + new WorkflowEventStoreError({ message: "read failed", cause }); + +// Nearest-rank percentile over an ascending-sorted array. `p` is 0..100. +// Empty input → 0 so cycle-time metrics never produce NaN on an empty board. +export const percentileNearestRank = (sortedAscMs: ReadonlyArray<number>, p: number): number => { + const n = sortedAscMs.length; + if (n === 0) { + return 0; + } + const rank = Math.ceil((p / 100) * n) - 1; + const index = Math.min(n - 1, Math.max(0, rank)); + return sortedAscMs[index] ?? 0; +}; + +// Cycle-time windows the metrics dashboard exposes. windowDays is clamped to +// one of these defensively (default 7) so a malformed RPC arg cannot widen the +// scan unboundedly. +const ALLOWED_WINDOW_DAYS = new Set([1, 7, 30]); +const clampWindowDays = (windowDays: number): number => + ALLOWED_WINDOW_DAYS.has(windowDays) ? windowDays : 7; + +const METRICS_OLDEST_CAP = 5; + +const wrap = <A>(effect: Effect.Effect<A, SqlError>) => + effect.pipe(Effect.mapError(toReadModelError)); + +interface StepRunSqlRow extends Omit<StepRunRow, "output"> { + readonly outputJson: string | null; +} + +interface PipelineStepRunSqlRow extends Omit<PipelineStepRunRow, "output"> { + readonly outputJson: string | null; +} + +interface TicketMessageSqlRow extends Omit<TicketMessageRow, "attachments"> { + readonly attachmentsJson: string; +} + +// ─── Board proposal JSON codecs (module-level — safe outside Effect generators) + +const decodeProposalValidationJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(WorkflowProposalValidation), +); +const decodeProposalAgentJson = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentSelection)); +const decodeProposalDefinitionJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(WorkflowDefinition), +); +const encodeProposalDefinition = Schema.encodeSync(WorkflowDefinition); + +interface ProposalSqlRow { + readonly proposalId: string; + readonly boardId: string; + readonly status: string; + readonly rationale: string; + readonly validationJson: string; + readonly agentJson: string; + readonly baseVersionHash: string; + readonly appliedVersionHash: string | null; + readonly createdAt: string; + readonly resolvedAt: string | null; +} + +interface ProposalFullSqlRow extends ProposalSqlRow { + readonly proposedDefJson: string; + readonly baseDefJson: string; +} + +// ─── End board proposal codec block ────────────────────────────────────────── + +const decodeOutputJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); +const decodeTicketAttachmentsJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Array(TicketAttachment)), +); + +const parseStepOutput = (outputJson: string | null) => + outputJson === null + ? Effect.succeed(null) + : decodeOutputJson(outputJson).pipe(Effect.mapError(toReadModelError)); + +const toStepRunRow = (row: StepRunSqlRow) => + Effect.gen(function* () { + const { outputJson, ...step } = row; + const output = yield* parseStepOutput(outputJson); + return { ...step, output } satisfies StepRunRow; + }); + +const toPipelineStepRunRow = (row: PipelineStepRunSqlRow) => + Effect.gen(function* () { + const { outputJson, ...step } = row; + const output = yield* parseStepOutput(outputJson); + return { ...step, output } satisfies PipelineStepRunRow; + }); + +const toTicketMessageRow = (row: TicketMessageSqlRow) => + decodeTicketAttachmentsJson(row.attachmentsJson).pipe( + Effect.mapError(toReadModelError), + Effect.map((attachments) => { + const { attachmentsJson: _attachmentsJson, ...message } = row; + return { ...message, attachments } satisfies TicketMessageRow; + }), + ); + +const asRecord = (value: unknown): Record<string, unknown> | null => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record<string, unknown>) + : null; + +const ROUTE_SOURCES = [ + "step_on", + "lane_transition", + "lane_on", + "external_event", + "work_source", +] as const; +const PIPELINE_RESULTS = ["success", "failure", "blocked"] as const; + +// Route history is for explaining recent movement, not replaying a ticket's +// whole life — bound the event scan so detail polling stays cheap. +const ROUTE_DECISION_EVENT_CAP = 100; + +// Snapshots can embed arbitrarily large captured outputs; route history only +// ever shows the verdict, so lift that one bounded string and drop the rest. +const ROUTE_VERDICT_MAX_LENGTH = 200; + +const liftVerdict = (output: unknown): string | null => { + const record = asRecord(output); + const verdict = record?.["verdict"]; + return typeof verdict === "string" ? verdict.slice(0, ROUTE_VERDICT_MAX_LENGTH) : null; +}; + +const snapshotSteps = ( + value: unknown, +): Readonly<Record<string, RouteDecisionStepSnapshot>> | null => { + const record = asRecord(value); + if (record === null) { + return null; + } + const steps: Record<string, RouteDecisionStepSnapshot> = {}; + for (const [stepKey, raw] of Object.entries(record)) { + const step = asRecord(raw); + if (step === null || typeof step["status"] !== "string") { + continue; + } + steps[stepKey] = { + status: step["status"], + exitCode: typeof step["exitCode"] === "number" ? step["exitCode"] : null, + verdict: liftVerdict(step["output"]), + }; + } + return Object.keys(steps).length > 0 ? steps : null; +}; + +/** + * Map a routing event to a history row. The contextSnapshot is stored as + * opaque JSON, so highlights are lifted defensively — a missing or malformed + * snapshot degrades to just the lane movement. Returns null for events that + * are not history entries (routed TicketMovedToLane rows duplicate their + * TicketRouteDecided twin; initial placement is not a decision). + */ +const toRouteDecisionRow = ( + eventType: string, + occurredAt: string, + payload: unknown, +): TicketRouteDecisionRow | null => { + const record = asRecord(payload); + if (record === null || typeof record["toLane"] !== "string") { + return null; + } + if (eventType === "TicketMovedToLane") { + // routed/external moves duplicate their TicketRouteDecided twin. + return record["reason"] === "manual" + ? { + occurredAt, + fromLane: null, + toLane: record["toLane"], + source: "manual", + matchedTransitionIndex: null, + eventName: null, + pipelineResult: null, + laneRunCount: null, + steps: null, + } + : null; + } + const source = ROUTE_SOURCES.find((candidate) => candidate === record["source"]); + if (source === undefined) { + return null; + } + const snapshot = asRecord(record["contextSnapshot"]); + const pipeline = asRecord(snapshot?.["pipeline"]); + const lane = asRecord(snapshot?.["lane"]); + const runCount = lane?.["runCount"]; + const eventRecord = asRecord(snapshot?.["event"]); + const eventName = typeof eventRecord?.["name"] === "string" ? eventRecord["name"] : null; + return { + occurredAt, + fromLane: typeof record["fromLane"] === "string" ? record["fromLane"] : null, + toLane: record["toLane"], + source, + matchedTransitionIndex: + typeof record["matchedTransitionIndex"] === "number" + ? record["matchedTransitionIndex"] + : null, + eventName, + pipelineResult: + PIPELINE_RESULTS.find((candidate) => candidate === pipeline?.["result"]) ?? null, + laneRunCount: typeof runCount === "number" && Number.isInteger(runCount) ? runCount : null, + steps: snapshotSteps(snapshot?.["steps"]), + }; +}; + +const PR_STATES = ["open", "merged", "closed"] as const; +const CI_STATES = ["pending", "success", "failure"] as const; + +const toPrView = ( + prNumber: number | null, + prUrl: string | null, + prState: string | null, + lastCiState: string | null, +): TicketPrView | undefined => { + if (prNumber === null || prUrl === null || prState === null) { + return undefined; + } + const state = PR_STATES.find((s) => s === prState); + if (state === undefined) { + return undefined; + } + const ciState = CI_STATES.find((s) => s === lastCiState); + const view: TicketPrView = { number: prNumber, url: prUrl, state }; + if (ciState !== undefined) { + return { ...view, ciState }; + } + return view; +}; + +interface TicketDependencySqlRow extends TicketRow { + readonly dependsOnJson?: string | null; + // PR columns from the workflow_pr_state LEFT JOIN — null when no row exists. + readonly prNumber?: number | null; + readonly prUrl?: string | null; + readonly prState?: string | null; + readonly prCiState?: string | null; + // Work-source columns from the work_source_mapping LEFT JOIN — null when no mapping row exists. + readonly sourceMetadataJson?: string | null; +} + +// pr_state is NOT NULL DEFAULT 'open' and only our code writes it, so an +// unrecognized value is an invariant violation — surface it once per query +// instead of silently dropping the pr view without a trace. +const warnUnrecognizedPrStates = (rows: ReadonlyArray<TicketDependencySqlRow>) => { + const ticketIds = rows + .filter( + (row) => typeof row.prState === "string" && !PR_STATES.some((state) => state === row.prState), + ) + .map((row) => row.ticketId); + return ticketIds.length === 0 + ? Effect.void + : Effect.logWarning("workflow ticket pr_state unrecognized", { ticketIds }); +}; + +function withDependencyFields(row: TicketDependencySqlRow): TicketRow; +function withDependencyFields(row: TicketDependencySqlRow | null): TicketRow | null; +function withDependencyFields(row: TicketDependencySqlRow | null): TicketRow | null { + if (row === null) { + return null; + } + const { + dependsOnJson, + prNumber, + prUrl, + prState, + prCiState, + sourceMetadataJson: _sm, + ...ticket + } = row; + let dependsOn: ReadonlyArray<string> = []; + if (typeof dependsOnJson === "string" && dependsOnJson.length > 0) { + try { + const parsed: unknown = JSON.parse(dependsOnJson); + if (Array.isArray(parsed)) { + dependsOn = parsed.filter((value): value is string => typeof value === "string"); + } + } catch { + // Malformed aggregate degrades to "no dependencies" rather than failing + // the whole board read. + } + } + const pr = toPrView(prNumber ?? null, prUrl ?? null, prState ?? null, prCiState ?? null); + return { + ...ticket, + dependsOn, + unresolvedDependencyCount: ticket.unresolvedDependencyCount ?? 0, + ...(pr !== undefined ? { pr } : {}), + }; +} + +/** + * Parse `source_metadata_json` from a `work_source_mapping` row into the + * `syncedSource` shape expected by `TicketDetail` / `WorkflowTicketDetailView`. + * + * Returns `undefined` when: + * - the column is null/undefined (no mapping row) + * - JSON is malformed + * - the parsed object lacks the required `provider` or `url` fields + */ +function parseSyncedSource(raw: string | null | undefined): + | { + provider: "github" | "asana" | "jira"; + url: string; + assignees?: ReadonlyArray<string>; + labels?: ReadonlyArray<string>; + } + | undefined { + if (raw == null) return undefined; + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return undefined; + const obj = parsed as Record<string, unknown>; + const provider = obj["provider"]; + const url = obj["url"]; + if ( + (provider !== "github" && provider !== "asana" && provider !== "jira") || + typeof url !== "string" || + url === "" + ) + return undefined; + const result: { + provider: "github" | "asana" | "jira"; + url: string; + assignees?: ReadonlyArray<string>; + labels?: ReadonlyArray<string>; + } = { provider, url }; + if (Array.isArray(obj["assignees"])) { + result.assignees = obj["assignees"].filter((v): v is string => typeof v === "string"); + } + if (Array.isArray(obj["labels"])) { + result.labels = obj["labels"].filter((v): v is string => typeof v === "string"); + } + return result; + } catch { + return undefined; + } +} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const boardRegistry = yield* BoardRegistry; + + // Resolve the ticket's current lane (name + human actions) from the board + // definition. A board with no registered definition (e.g. a stale or + // unregistered board) degrades to a key-only lane with no actions rather + // than failing the detail read. + const resolveCurrentLane = ( + boardId: string, + currentLaneKey: string, + ): Effect.Effect<WorkflowCurrentLaneRow> => + Effect.gen(function* () { + const definition = yield* boardRegistry.getDefinition(boardId as never); + const lane = definition?.lanes.find((candidate) => candidate.key === currentLaneKey); + if (lane === undefined) { + yield* Effect.logDebug("workflow current lane definition unresolved", { + boardId, + currentLaneKey, + }); + return { key: currentLaneKey, name: currentLaneKey, actions: [] }; + } + const actions: ReadonlyArray<WorkflowLaneActionRow> = (lane.actions ?? []).map((action) => ({ + label: action.label, + to: action.to as string, + ...(action.hint === undefined ? {} : { hint: action.hint }), + })); + return { key: lane.key as string, name: lane.name, actions }; + }); + + const registerBoard: WorkflowReadModelShape["registerBoard"] = (board) => + wrap(sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + ${board.boardId}, + ${board.projectId}, + ${board.name}, + ${board.workflowFilePath}, + ${board.workflowVersionHash}, + ${board.maxConcurrentTickets} + ) + ON CONFLICT(board_id) DO UPDATE SET + project_id = excluded.project_id, + name = excluded.name, + workflow_file_path = excluded.workflow_file_path, + workflow_version_hash = excluded.workflow_version_hash, + max_concurrent_tickets = excluded.max_concurrent_tickets + `).pipe(Effect.asVoid); + + const getBoard: WorkflowReadModelShape["getBoard"] = (boardId) => + wrap(sql<BoardRow>` + SELECT + board_id AS "boardId", + project_id AS "projectId", + name, + workflow_file_path AS "workflowFilePath", + workflow_version_hash AS "workflowVersionHash", + max_concurrent_tickets AS "maxConcurrentTickets" + FROM projection_board + WHERE board_id = ${boardId} + `).pipe(Effect.map((rows) => rows[0] ?? null)); + + const deleteBoard: WorkflowReadModelShape["deleteBoard"] = (boardId) => + wrap(sql` + DELETE FROM projection_board + WHERE board_id = ${boardId} + `).pipe(Effect.asVoid); + + const deleteBoardTicketState: WorkflowReadModelShape["deleteBoardTicketState"] = (boardId) => + wrap(sql` + DELETE FROM workflow_dispatch_outbox + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `).pipe( + Effect.andThen( + wrap(sql` + DELETE FROM workflow_setup_run + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_script_run + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_step_run + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_pipeline_run + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_ticket_message + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_ticket_dependency + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + OR depends_on_ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_pr_observation + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_pr_state + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_notification_outbox + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM work_source_mapping + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM work_source_state + WHERE board_id = ${boardId} + `), + ), + Effect.andThen( + // Outbound deliveries are board-scoped — cascade them. Outbound + // CONNECTIONS are global (not board-scoped), so they are intentionally + // NOT deleted here; a dangling connection ref is allowed. + wrap(sql` + DELETE FROM workflow_outbound_delivery + WHERE board_id = ${boardId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_board_proposal + WHERE board_id = ${boardId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_ticket + WHERE board_id = ${boardId} + `), + ), + Effect.asVoid, + ); + + const deleteTicketState: WorkflowReadModelShape["deleteTicketState"] = (ticketId) => + wrap(sql` + DELETE FROM workflow_dispatch_outbox + WHERE ticket_id = ${ticketId} + `).pipe( + Effect.andThen( + wrap(sql` + DELETE FROM workflow_setup_run + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_script_run + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_step_run + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_pipeline_run + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_ticket_message + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_ticket_dependency + WHERE ticket_id = ${ticketId} + OR depends_on_ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_pr_observation + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_pr_state + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_notification_outbox + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM work_source_mapping + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_ticket + WHERE ticket_id = ${ticketId} + `), + ), + Effect.asVoid, + ); + + const listBoardsForProject: WorkflowReadModelShape["listBoardsForProject"] = (projectId) => + wrap(sql<BoardListRow>` + SELECT + board_id AS "boardId", + name, + workflow_file_path AS "filePath" + FROM projection_board + WHERE project_id = ${projectId} + ORDER BY name COLLATE NOCASE ASC, board_id ASC + `); + + const listTickets: WorkflowReadModelShape["listTickets"] = (boardId) => + wrap(sql<TicketDependencySqlRow>` + SELECT + projection_ticket.ticket_id AS "ticketId", + board_id AS "boardId", + title, + description, + current_lane_key AS "currentLaneKey", + current_lane_entry_token AS "currentLaneEntryToken", + queued_at AS "queuedAt", + token_budget AS "tokenBudget", + projection_ticket.updated_at AS "updatedAt", + ( + SELECT SUM(step.total_tokens) + FROM projection_step_run AS step + WHERE step.ticket_id = projection_ticket.ticket_id + ) AS "totalTokens", + ( + SELECT CAST( + SUM((julianday(step.finished_at) - julianday(step.started_at)) * 86400000.0) + AS INTEGER + ) + FROM projection_step_run AS step + WHERE step.ticket_id = projection_ticket.ticket_id + AND step.started_at IS NOT NULL + AND step.finished_at IS NOT NULL + ) AS "totalDurationMs", + ( + SELECT COUNT(*) + FROM projection_ticket_dependency AS dep + LEFT JOIN projection_ticket AS dep_ticket + ON dep_ticket.ticket_id = dep.depends_on_ticket_id + WHERE dep.ticket_id = projection_ticket.ticket_id + AND dep_ticket.ticket_id IS NOT NULL + AND dep_ticket.terminal_at IS NULL + ) AS "unresolvedDependencyCount", + ( + SELECT json_group_array(dep.depends_on_ticket_id) + FROM projection_ticket_dependency AS dep + WHERE dep.ticket_id = projection_ticket.ticket_id + ) AS "dependsOnJson", + pr.pr_number AS "prNumber", + pr.pr_url AS "prUrl", + pr.pr_state AS "prState", + pr.last_ci_state AS "prCiState", + status + FROM projection_ticket + LEFT JOIN workflow_pr_state AS pr + ON pr.ticket_id = projection_ticket.ticket_id + WHERE board_id = ${boardId} + ORDER BY created_at ASC + `).pipe( + Effect.tap(warnUnrecognizedPrStates), + Effect.map((rows) => rows.map((row) => withDependencyFields(row))), + ); + + const countAdmittedInLane: WorkflowReadModelShape["countAdmittedInLane"] = (boardId, laneKey) => + wrap(sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM projection_ticket + WHERE board_id = ${boardId} + AND current_lane_key = ${laneKey} + AND current_lane_entry_token IS NOT NULL + `).pipe(Effect.map((rows) => rows[0]?.count ?? 0)); + + const oldestQueuedForLane: WorkflowReadModelShape["oldestQueuedForLane"] = (boardId, laneKey) => + wrap(sql<TicketRow>` + SELECT + ticket_id AS "ticketId", + board_id AS "boardId", + title, + description, + current_lane_key AS "currentLaneKey", + current_lane_entry_token AS "currentLaneEntryToken", + queued_at AS "queuedAt", + token_budget AS "tokenBudget", + updated_at AS "updatedAt", + ( + SELECT SUM(step.total_tokens) + FROM projection_step_run AS step + WHERE step.ticket_id = projection_ticket.ticket_id + ) AS "totalTokens", + ( + SELECT CAST( + SUM((julianday(step.finished_at) - julianday(step.started_at)) * 86400000.0) + AS INTEGER + ) + FROM projection_step_run AS step + WHERE step.ticket_id = projection_ticket.ticket_id + AND step.started_at IS NOT NULL + AND step.finished_at IS NOT NULL + ) AS "totalDurationMs", + ( + SELECT COUNT(*) + FROM projection_ticket_dependency AS dep + LEFT JOIN projection_ticket AS dep_ticket + ON dep_ticket.ticket_id = dep.depends_on_ticket_id + WHERE dep.ticket_id = projection_ticket.ticket_id + AND dep_ticket.ticket_id IS NOT NULL + AND dep_ticket.terminal_at IS NULL + ) AS "unresolvedDependencyCount", + ( + SELECT json_group_array(dep.depends_on_ticket_id) + FROM projection_ticket_dependency AS dep + WHERE dep.ticket_id = projection_ticket.ticket_id + ) AS "dependsOnJson", + status + FROM projection_ticket + WHERE board_id = ${boardId} + AND current_lane_key = ${laneKey} + AND queued_at IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM projection_ticket_dependency AS dep + INNER JOIN projection_ticket AS dep_ticket + ON dep_ticket.ticket_id = dep.depends_on_ticket_id + WHERE dep.ticket_id = projection_ticket.ticket_id + AND dep_ticket.terminal_at IS NULL + ) + ORDER BY queued_at ASC, ticket_id ASC + LIMIT 1 + `).pipe(Effect.map((rows) => withDependencyFields(rows[0] ?? null))); + + const getTicketDetail: WorkflowReadModelShape["getTicketDetail"] = (ticketId) => + Effect.gen(function* () { + const ticketRows = yield* wrap(sql<TicketDependencySqlRow>` + SELECT + projection_ticket.ticket_id AS "ticketId", + projection_ticket.board_id AS "boardId", + title, + description, + current_lane_key AS "currentLaneKey", + current_lane_entry_token AS "currentLaneEntryToken", + queued_at AS "queuedAt", + token_budget AS "tokenBudget", + projection_ticket.updated_at AS "updatedAt", + ( + SELECT SUM(step.total_tokens) + FROM projection_step_run AS step + WHERE step.ticket_id = projection_ticket.ticket_id + ) AS "totalTokens", + ( + SELECT CAST( + SUM((julianday(step.finished_at) - julianday(step.started_at)) * 86400000.0) + AS INTEGER + ) + FROM projection_step_run AS step + WHERE step.ticket_id = projection_ticket.ticket_id + AND step.started_at IS NOT NULL + AND step.finished_at IS NOT NULL + ) AS "totalDurationMs", + ( + SELECT COUNT(*) + FROM projection_ticket_dependency AS dep + LEFT JOIN projection_ticket AS dep_ticket + ON dep_ticket.ticket_id = dep.depends_on_ticket_id + WHERE dep.ticket_id = projection_ticket.ticket_id + AND dep_ticket.ticket_id IS NOT NULL + AND dep_ticket.terminal_at IS NULL + ) AS "unresolvedDependencyCount", + ( + SELECT json_group_array(dep.depends_on_ticket_id) + FROM projection_ticket_dependency AS dep + WHERE dep.ticket_id = projection_ticket.ticket_id + ) AS "dependsOnJson", + pr.pr_number AS "prNumber", + pr.pr_url AS "prUrl", + pr.pr_state AS "prState", + pr.last_ci_state AS "prCiState", + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason", + wsm.source_metadata_json AS "sourceMetadataJson" + FROM projection_ticket + LEFT JOIN workflow_pr_state AS pr + ON pr.ticket_id = projection_ticket.ticket_id + LEFT JOIN work_source_mapping AS wsm + ON wsm.ticket_id = projection_ticket.ticket_id + WHERE projection_ticket.ticket_id = ${ticketId} + `); + const rawTicket = ticketRows[0]; + if (!rawTicket) { + return null; + } + yield* warnUnrecognizedPrStates(ticketRows); + const currentLane = yield* resolveCurrentLane(rawTicket.boardId, rawTicket.currentLaneKey); + const ticket: TicketRow = { ...withDependencyFields(rawTicket), currentLane }; + + const syncedSource = parseSyncedSource(rawTicket.sourceMetadataJson); + + const stepRows = yield* wrap(sql<StepRunSqlRow>` + SELECT + step.step_run_id AS "stepRunId", + step.step_key AS "stepKey", + step.step_type AS "stepType", + step.attempt, + step.status, + step.waiting_reason AS "waitingReason", + step.provider_response_kind AS "providerResponseKind", + CASE + WHEN step.status = 'blocked' THEN step.error + ELSE NULL + END AS "blockedReason", + script.script_thread_id AS "scriptThreadId", + script.terminal_id AS "terminalId", + script.status AS "scriptStatus", + script.exit_code AS "exitCode", + script.signal, + step.output_json AS "outputJson", + step.started_at AS "startedAt", + step.finished_at AS "finishedAt", + ( + SELECT outbox.thread_id + FROM workflow_dispatch_outbox AS outbox + WHERE outbox.step_run_id = step.step_run_id + ORDER BY outbox.rowid DESC + LIMIT 1 + ) AS "providerThreadId", + step.input_tokens AS "inputTokens", + step.cached_input_tokens AS "cachedInputTokens", + step.output_tokens AS "outputTokens", + step.total_tokens AS "totalTokens" + FROM projection_step_run AS step + LEFT JOIN workflow_script_run AS script + ON script.step_run_id = step.step_run_id + WHERE step.ticket_id = ${ticketId} + ORDER BY step.started_at ASC, step.rowid ASC + `); + const steps = yield* Effect.forEach(stepRows, toStepRunRow); + const messages = yield* listTicketMessages(ticketId); + return { ticket, steps, messages, ...(syncedSource !== undefined ? { syncedSource } : {}) }; + }); + + const listTicketMessages: WorkflowReadModelShape["listTicketMessages"] = (ticketId) => + Effect.gen(function* () { + const rows = yield* wrap(sql<TicketMessageSqlRow>` + SELECT + message_id AS "messageId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId", + author, + body, + attachments_json AS "attachmentsJson", + created_at AS "createdAt", + edited_at AS "editedAt" + FROM projection_ticket_message + WHERE ticket_id = ${ticketId} + ORDER BY created_at ASC, message_id ASC + `); + return yield* Effect.forEach(rows, toTicketMessageRow); + }); + + const listTicketDiscussion: WorkflowReadModelShape["listTicketDiscussion"] = (ticketId, limit) => + Effect.gen(function* () { + const rows = yield* wrap(sql<{ + readonly author: "agent" | "user"; + readonly body: string; + readonly createdAt: string; + readonly attachmentCount: number; + }>` + SELECT + author, + body, + created_at AS "createdAt", + json_array_length(attachments_json) AS "attachmentCount" + FROM projection_ticket_message + WHERE ticket_id = ${ticketId} + ORDER BY created_at DESC, message_id DESC + LIMIT ${limit} + `); + return [...rows].toReversed(); + }); + + const listReleasableDependents: WorkflowReadModelShape["listReleasableDependents"] = (ticketId) => + wrap(sql<{ readonly ticketId: string; readonly boardId: string; readonly laneKey: string }>` + SELECT + dependent.ticket_id AS "ticketId", + dependent.board_id AS "boardId", + dependent.current_lane_key AS "laneKey" + FROM projection_ticket_dependency AS dep + INNER JOIN projection_ticket AS dependent + ON dependent.ticket_id = dep.ticket_id + WHERE dep.depends_on_ticket_id = ${ticketId} + AND dependent.queued_at IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM projection_ticket_dependency AS other + INNER JOIN projection_ticket AS other_ticket + ON other_ticket.ticket_id = other.depends_on_ticket_id + WHERE other.ticket_id = dependent.ticket_id + AND other_ticket.terminal_at IS NULL + ) + ORDER BY dependent.queued_at ASC, dependent.ticket_id ASC + `); + + const listDependentTicketIds: WorkflowReadModelShape["listDependentTicketIds"] = (ticketId) => + wrap(sql<{ readonly ticketId: string }>` + SELECT ticket_id AS "ticketId" + FROM projection_ticket_dependency + WHERE depends_on_ticket_id = ${ticketId} + ORDER BY ticket_id ASC + `).pipe(Effect.map((rows) => rows.map((row) => row.ticketId))); + + const getBoardDigest: WorkflowReadModelShape["getBoardDigest"] = (boardId, windowHours) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + const sinceIso = DateTime.formatIso(DateTime.subtract(now, { hours: windowHours })); + const counts = yield* wrap(sql<{ + readonly createdCount: number; + readonly shippedCount: number; + }>` + SELECT + SUM(CASE WHEN created_at >= ${sinceIso} THEN 1 ELSE 0 END) AS "createdCount", + SUM(CASE WHEN terminal_at IS NOT NULL AND terminal_at >= ${sinceIso} THEN 1 ELSE 0 END) AS "shippedCount" + FROM projection_ticket + WHERE board_id = ${boardId} + `); + const usage = yield* wrap(sql<{ + readonly totalTokens: number | null; + readonly totalDurationMs: number | null; + }>` + SELECT + SUM(step.total_tokens) AS "totalTokens", + CAST( + SUM((julianday(step.finished_at) - julianday(step.started_at)) * 86400000.0) + AS INTEGER + ) AS "totalDurationMs" + FROM projection_step_run AS step + INNER JOIN projection_ticket AS ticket ON ticket.ticket_id = step.ticket_id + WHERE ticket.board_id = ${boardId} + AND step.finished_at IS NOT NULL + AND step.finished_at >= ${sinceIso} + `); + const attention = yield* wrap(sql<{ + readonly ticketId: string; + readonly title: string; + readonly status: string; + readonly laneKey: string; + readonly updatedAt: string; + }>` + SELECT + ticket_id AS "ticketId", + title, + status, + current_lane_key AS "laneKey", + updated_at AS "updatedAt" + FROM projection_ticket + WHERE board_id = ${boardId} + AND status IN ('waiting_on_user', 'blocked') + ORDER BY updated_at ASC + LIMIT 20 + `); + return { + windowHours, + createdCount: counts[0]?.createdCount ?? 0, + shippedCount: counts[0]?.shippedCount ?? 0, + totalTokens: usage[0]?.totalTokens ?? 0, + totalDurationMs: usage[0]?.totalDurationMs ?? 0, + needsAttention: attention.map((row) => ({ + ticketId: row.ticketId, + title: row.title, + status: row.status, + laneKey: row.laneKey, + sinceMs: Math.max(0, nowMs - Date.parse(row.updatedAt)), + })), + }; + }); + + const getBoardMetrics: WorkflowReadModelShape["getBoardMetrics"] = (boardId, windowDaysRaw) => + Effect.gen(function* () { + const windowDays = clampWindowDays(windowDaysRaw); + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + const generatedAt = DateTime.formatIso(now); + const sinceIso = DateTime.formatIso(DateTime.subtract(now, { days: windowDays })); + + // 1. throughput — board-scoped counts within the window. + const throughput = yield* wrap(sql<{ + readonly created: number; + readonly shipped: number; + }>` + SELECT + SUM(CASE WHEN created_at >= ${sinceIso} THEN 1 ELSE 0 END) AS "created", + SUM(CASE WHEN terminal_at IS NOT NULL AND terminal_at >= ${sinceIso} THEN 1 ELSE 0 END) AS "shipped" + FROM projection_ticket + WHERE board_id = ${boardId} + `); + + // 2. cycleTime — fetch raw durations and percentile in TS (no SQLite + // PERCENTILE_CONT). Only fully-shipped tickets within the window. + const cycleRows = yield* wrap(sql<{ readonly durationMs: number }>` + SELECT + CAST((julianday(terminal_at) - julianday(created_at)) * 86400000.0 AS INTEGER) AS "durationMs" + FROM projection_ticket + WHERE board_id = ${boardId} + AND terminal_at IS NOT NULL + AND terminal_at >= ${sinceIso} + AND created_at IS NOT NULL + `); + const durations = cycleRows.map((row) => row.durationMs).sort((a, b) => a - b); + const cycleCount = durations.length; + const cycleTime = + cycleCount === 0 + ? { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 } + : { + count: cycleCount, + p50Ms: percentileNearestRank(durations, 50), + p90Ms: percentileNearestRank(durations, 90), + avgMs: Math.round(durations.reduce((sum, d) => sum + d, 0) / cycleCount), + }; + + // 3. wipByLane — non-terminal tickets only; admitted (has entry token) vs + // queued (queued but not yet admitted). + const wipRows = yield* wrap(sql<{ + readonly laneKey: string; + readonly admitted: number; + readonly queued: number; + }>` + SELECT + current_lane_key AS "laneKey", + SUM(CASE WHEN current_lane_entry_token IS NOT NULL THEN 1 ELSE 0 END) AS "admitted", + SUM(CASE WHEN queued_at IS NOT NULL AND current_lane_entry_token IS NULL THEN 1 ELSE 0 END) AS "queued" + FROM projection_ticket + WHERE board_id = ${boardId} + AND terminal_at IS NULL + GROUP BY current_lane_key + ORDER BY current_lane_key ASC + `); + const wipByLane = wipRows.map((row) => ({ + laneKey: row.laneKey, + admitted: row.admitted, + queued: row.queued, + })); + + // 4. statusBreakdown — terminal tickets bucket as 'done' regardless of + // their raw status (which stays 'idle' after a terminal move). + const statusRows = yield* wrap(sql<{ + readonly eff: string; + readonly count: number; + }>` + SELECT + CASE WHEN terminal_at IS NOT NULL THEN 'done' ELSE status END AS "eff", + COUNT(*) AS "count" + FROM projection_ticket + WHERE board_id = ${boardId} + GROUP BY eff + `); + const statusBreakdown: Record<string, number> = {}; + for (const row of statusRows) { + statusBreakdown[row.eff] = row.count; + } + + // 5. attention — blocked / waiting_on_user counts (non-terminal) plus the + // oldest tickets by time in current lane. + const attentionCounts = yield* wrap(sql<{ + readonly blocked: number; + readonly waitingOnUser: number; + }>` + SELECT + SUM(CASE WHEN status = 'blocked' AND terminal_at IS NULL THEN 1 ELSE 0 END) AS "blocked", + SUM(CASE WHEN status = 'waiting_on_user' AND terminal_at IS NULL THEN 1 ELSE 0 END) AS "waitingOnUser" + FROM projection_ticket + WHERE board_id = ${boardId} + `); + const oldestRows = yield* wrap(sql<{ + readonly ticketId: string; + readonly title: string; + readonly laneKey: string | null; + readonly enteredAt: string; + }>` + SELECT + ticket_id AS "ticketId", + title, + current_lane_key AS "laneKey", + COALESCE(current_lane_entered_at, queued_at) AS "enteredAt" + FROM projection_ticket + WHERE board_id = ${boardId} + AND terminal_at IS NULL + AND COALESCE(current_lane_entered_at, queued_at) IS NOT NULL + `); + const oldest = oldestRows + .map((row) => ({ + ticketId: row.ticketId, + title: row.title, + laneKey: row.laneKey, + ageMs: Math.max(0, nowMs - Date.parse(row.enteredAt)), + })) + .sort((a, b) => b.ageMs - a.ageMs) + .slice(0, METRICS_OLDEST_CAP); + const attention = { + blocked: attentionCounts[0]?.blocked ?? 0, + waitingOnUser: attentionCounts[0]?.waitingOnUser ?? 0, + oldest, + }; + + // 6. routeOutcomes — grouped TicketRouteDecided within window. The + // pipeline verdict lives at contextSnapshot.pipeline.result; sources + // without one (work_source / external_event) get 'n/a'. + const routeRows = yield* wrap(sql<{ + readonly fromLane: string | null; + readonly toLane: string | null; + readonly source: string; + readonly result: string | null; + readonly count: number; + }>` + SELECT + json_extract(payload_json, '$.fromLane') AS "fromLane", + json_extract(payload_json, '$.toLane') AS "toLane", + json_extract(payload_json, '$.source') AS "source", + json_extract(payload_json, '$.contextSnapshot.pipeline.result') AS "result", + COUNT(*) AS "count" + FROM workflow_events + WHERE event_type = 'TicketRouteDecided' + AND occurred_at >= ${sinceIso} + AND ticket_id IN (SELECT ticket_id FROM projection_ticket WHERE board_id = ${boardId}) + GROUP BY "fromLane", "toLane", "source", "result" + `); + const routeOutcomes = routeRows.map((row) => ({ + fromLane: row.fromLane, + toLane: row.toLane, + source: row.source, + result: row.result ?? "n/a", + count: row.count, + })); + + // 7. manualMoveCount — TicketMovedToLane with reason=manual in window. + const manualMove = yield* wrap(sql<{ readonly count: number }>` + SELECT COUNT(*) AS "count" + FROM workflow_events + WHERE event_type = 'TicketMovedToLane' + AND occurred_at >= ${sinceIso} + AND json_extract(payload_json, '$.reason') = 'manual' + AND ticket_id IN (SELECT ticket_id FROM projection_ticket WHERE board_id = ${boardId}) + `); + + // 8. stepStats — lane-aware grouping over finished step runs within + // window. The projection writes 'completed' for success and 'failed' for + // failure (confirmed in WorkflowProjectionPipeline). AVG over no rows is + // NULL → coalesced to 0 by the row-level ?? below. + const stepRows = yield* wrap(sql<{ + readonly laneKey: string; + readonly stepKey: string; + readonly stepType: string; + readonly succeeded: number; + readonly failed: number; + readonly retries: number; + readonly totalTokens: number; + readonly avgDurationMs: number | null; + }>` + SELECT + pr.lane_key AS "laneKey", + sr.step_key AS "stepKey", + sr.step_type AS "stepType", + SUM(CASE WHEN sr.status = 'completed' THEN 1 ELSE 0 END) AS "succeeded", + SUM(CASE WHEN sr.status = 'failed' THEN 1 ELSE 0 END) AS "failed", + SUM(CASE WHEN COALESCE(sr.attempt, 1) > 1 THEN 1 ELSE 0 END) AS "retries", + SUM(COALESCE(sr.total_tokens, 0)) AS "totalTokens", + CAST(AVG((julianday(sr.finished_at) - julianday(sr.started_at)) * 86400000.0) AS INTEGER) AS "avgDurationMs" + FROM projection_step_run AS sr + INNER JOIN projection_pipeline_run AS pr ON pr.pipeline_run_id = sr.pipeline_run_id + WHERE sr.ticket_id IN (SELECT ticket_id FROM projection_ticket WHERE board_id = ${boardId}) + AND sr.finished_at IS NOT NULL + AND sr.finished_at >= ${sinceIso} + GROUP BY pr.lane_key, sr.step_key, sr.step_type + `); + const stepStats = stepRows.map((row) => ({ + laneKey: row.laneKey, + stepKey: row.stepKey, + stepType: row.stepType, + succeeded: row.succeeded, + failed: row.failed, + retries: row.retries, + totalTokens: row.totalTokens, + avgDurationMs: row.avgDurationMs ?? 0, + })); + + return { + windowDays, + generatedAt, + throughput: { + created: throughput[0]?.created ?? 0, + shipped: throughput[0]?.shipped ?? 0, + }, + cycleTime, + wipByLane, + statusBreakdown, + attention, + routeOutcomes, + manualMoveCount: manualMove[0]?.count ?? 0, + stepStats, + } satisfies WorkflowBoardMetrics; + }); + + // No environment filter is needed: each t3 server process owns exactly one + // SQLite database file, and that file is already environment-scoped at the + // process / WebSocket-connection level. There is no multi-environment-per-DB + // path (projection_board carries project_id, not an environment_id, and the + // server never shares one DB across environments). Confirmed in T8 Part C. + const listNeedsAttentionTickets: WorkflowReadModelShape["listNeedsAttentionTickets"] = () => + wrap(sql<WorkflowNeedsAttentionTicketRow>` + SELECT + pt.ticket_id AS "ticketId", + pt.board_id AS "boardId", + pb.name AS "boardName", + pt.title, + pt.status, + pt.current_lane_key AS "currentLaneKey", + pt.attention_kind AS "attentionKind", + pt.attention_reason AS "attentionReason", + pt.updated_at AS "updatedAt" + FROM projection_ticket AS pt + INNER JOIN projection_board AS pb + ON pb.board_id = pt.board_id + WHERE pt.status IN ('waiting_on_user', 'blocked') + ORDER BY pt.updated_at ASC + `); + + const listTicketRouteDecisions: WorkflowReadModelShape["listTicketRouteDecisions"] = (ticketId) => + Effect.gen(function* () { + // Newest events first with a hard cap — looping tickets accumulate + // routing events forever and detail is polled while steps run. + const rows = yield* wrap(sql<{ + readonly eventType: string; + readonly occurredAt: string; + readonly payloadJson: string; + }>` + SELECT "eventType", "occurredAt", "payloadJson" + FROM ( + SELECT + sequence, + event_type AS "eventType", + occurred_at AS "occurredAt", + payload_json AS "payloadJson" + FROM workflow_events + WHERE ticket_id = ${ticketId} + AND event_type IN ('TicketRouteDecided', 'TicketMovedToLane') + ORDER BY sequence DESC + LIMIT ${ROUTE_DECISION_EVENT_CAP} + ) + ORDER BY sequence ASC + `); + const decisions: TicketRouteDecisionRow[] = []; + for (const row of rows) { + const payload = yield* decodeOutputJson(row.payloadJson).pipe( + Effect.mapError(toReadModelError), + ); + const decision = toRouteDecisionRow(row.eventType, row.occurredAt, payload); + if (decision !== null) { + decisions.push(decision); + } + } + return decisions; + }); + + // Counts the CURRENT streak of pipeline runs in the lane, not all-time + // visits: a pipeline run in another lane or a manual move resets the count, + // so a human pulling a ticket back into a looping lane gets a fresh budget. + // Computed over the totally-ordered event log (sequence) so same-instant + // timestamps cannot blur the reset boundary. + const countLanePipelineRuns: WorkflowReadModelShape["countLanePipelineRuns"] = (pipelineRunId) => + wrap(sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events AS started + INNER JOIN projection_pipeline_run AS current + ON current.pipeline_run_id = ${pipelineRunId} + WHERE started.ticket_id = current.ticket_id + AND started.event_type = 'PipelineStarted' + AND json_extract(started.payload_json, '$.laneKey') = current.lane_key + AND started.sequence > COALESCE( + ( + SELECT MAX(reset.sequence) + FROM workflow_events AS reset + WHERE reset.ticket_id = current.ticket_id + AND ( + ( + reset.event_type = 'TicketMovedToLane' + AND json_extract(reset.payload_json, '$.reason') = 'manual' + ) + OR ( + reset.event_type = 'PipelineStarted' + AND json_extract(reset.payload_json, '$.laneKey') != current.lane_key + ) + ) + ), + 0 + ) + `).pipe(Effect.map((rows) => rows[0]?.count ?? 0)); + + const listStepRunsForPipeline: WorkflowReadModelShape["listStepRunsForPipeline"] = ( + pipelineRunId, + ) => + Effect.gen(function* () { + const stepRows = yield* wrap(sql<PipelineStepRunSqlRow>` + SELECT + step.step_key AS "stepKey", + step.step_type AS "stepType", + step.status, + script.exit_code AS "exitCode", + step.output_json AS "outputJson" + FROM projection_step_run AS step + LEFT JOIN workflow_script_run AS script + ON script.step_run_id = step.step_run_id + WHERE step.pipeline_run_id = ${pipelineRunId} + ORDER BY step.started_at ASC, step.rowid ASC + `); + return yield* Effect.forEach(stepRows, toPipelineStepRunRow); + }); + + const getTicketPrState: WorkflowReadModelShape["getTicketPrState"] = (ticketId) => + wrap(sql<TicketPrStateRow>` + SELECT + pr_number AS "prNumber", + pr_url AS "prUrl", + branch, + remote_name AS "remoteName", + repo, + pr_state AS "prState", + last_head_sha AS "lastHeadSha", + last_ci_state AS "lastCiState", + last_review_decision AS "lastReviewDecision", + last_comment_cursor AS "lastCommentCursor" + FROM workflow_pr_state + WHERE ticket_id = ${ticketId} + `).pipe(Effect.map((rows) => rows[0] ?? null)); + + const recordBoardProposal: WorkflowReadModelShape["recordBoardProposal"] = (proposal) => + wrap(sql` + INSERT INTO workflow_board_proposal ( + proposal_id, + board_id, + base_version_hash, + base_def_json, + agent_json, + proposed_def_json, + rationale, + validation_json, + status, + created_at + ) + VALUES ( + ${proposal.proposalId}, + ${proposal.boardId}, + ${proposal.baseVersionHash}, + ${proposal.baseDefJson}, + ${proposal.agentJson}, + ${proposal.proposedDefJson}, + ${proposal.rationale}, + ${proposal.validationJson}, + ${proposal.status}, + ${proposal.createdAt} + ) + `).pipe(Effect.asVoid); + + // ─── board proposal read helpers ───────────────────────────────────────── + + // Map a raw DB row to a WorkflowBoardProposalView. `currentVersionHash` is + // the board's current workflow_version_hash (used to derive `outdated`). + const toProposalView = (row: ProposalSqlRow, currentVersionHash: string) => + Effect.gen(function* () { + const validation = yield* decodeProposalValidationJson(row.validationJson); + const agent = yield* decodeProposalAgentJson(row.agentJson); + const view: WorkflowBoardProposalView = { + proposalId: row.proposalId, + boardId: row.boardId as never, + status: row.status as WorkflowBoardProposalView["status"], + rationale: row.rationale, + validation, + baseVersionHash: row.baseVersionHash, + appliedVersionHash: row.appliedVersionHash, + outdated: row.baseVersionHash !== currentVersionHash, + agent, + createdAt: row.createdAt, + resolvedAt: row.resolvedAt, + }; + return view; + }).pipe(Effect.mapError(toReadModelError)); + + const listBoardProposals: WorkflowReadModelShape["listBoardProposals"] = (boardId) => + Effect.gen(function* () { + // Get the board's current versionHash so we can compute `outdated`. + const board = yield* getBoard(boardId); + const currentVersionHash = board?.workflowVersionHash ?? ""; + + const rows = yield* wrap(sql<ProposalSqlRow>` + SELECT + proposal_id AS "proposalId", + board_id AS "boardId", + status, + rationale, + validation_json AS "validationJson", + agent_json AS "agentJson", + base_version_hash AS "baseVersionHash", + applied_version_hash AS "appliedVersionHash", + created_at AS "createdAt", + resolved_at AS "resolvedAt" + FROM workflow_board_proposal + WHERE board_id = ${boardId} + ORDER BY + CASE WHEN status = 'pending' THEN 0 ELSE 1 END ASC, + created_at DESC + `); + + return yield* Effect.forEach(rows, (row) => toProposalView(row, currentVersionHash)); + }); + + const getBoardProposal: WorkflowReadModelShape["getBoardProposal"] = (proposalId) => + Effect.gen(function* () { + const rows = yield* wrap(sql<ProposalFullSqlRow>` + SELECT + proposal_id AS "proposalId", + board_id AS "boardId", + status, + rationale, + validation_json AS "validationJson", + agent_json AS "agentJson", + base_version_hash AS "baseVersionHash", + applied_version_hash AS "appliedVersionHash", + created_at AS "createdAt", + resolved_at AS "resolvedAt", + proposed_def_json AS "proposedDefJson", + base_def_json AS "baseDefJson" + FROM workflow_board_proposal + WHERE proposal_id = ${proposalId} + `); + + const row = rows[0]; + if (!row) { + return null; + } + + // Get the board's current versionHash for the outdated computation. + const board = yield* getBoard(row.boardId as never); + const currentVersionHash = board?.workflowVersionHash ?? ""; + + const view = yield* toProposalView(row, currentVersionHash); + + // Decode both defs as encoded (round-trip through decode→encode to get + // the canonical WorkflowDefinitionEncoded shape). + const proposedDef = yield* decodeProposalDefinitionJson(row.proposedDefJson).pipe( + Effect.mapError(toReadModelError), + ); + const baseDef = yield* decodeProposalDefinitionJson(row.baseDefJson).pipe( + Effect.mapError(toReadModelError), + ); + + return { + view, + proposedDefinition: encodeProposalDefinition(proposedDef) as WorkflowDefinitionEncoded, + baseDefinition: encodeProposalDefinition(baseDef) as WorkflowDefinitionEncoded, + }; + }); + + // Lanes holding live work. Three arms: + // 1. admitted, non-terminal tickets (entry token set) + // 2. queued, non-terminal tickets (TicketQueued nulls the entry token + sets + // status='queued'; the admitted arm misses them, but applying a proposal + // that restructures the lane — flips entry to manual, or drops/changes the + // wipLimit — would admit them under rules they were never gated against, or + // strand them in the queue, so they must block an apply) + // 3. lanes with a running pipeline + const listLiveOccupiedLanes: WorkflowReadModelShape["listLiveOccupiedLanes"] = (boardId) => + wrap(sql<{ readonly laneKey: string }>` + SELECT current_lane_key AS "laneKey" + FROM projection_ticket + WHERE board_id = ${boardId} + AND terminal_at IS NULL + AND current_lane_entry_token IS NOT NULL + UNION + SELECT current_lane_key AS "laneKey" + FROM projection_ticket + WHERE board_id = ${boardId} + AND status = 'queued' + AND queued_at IS NOT NULL + AND terminal_at IS NULL + UNION + SELECT run.lane_key AS "laneKey" + FROM projection_pipeline_run AS run + INNER JOIN projection_ticket AS t ON t.ticket_id = run.ticket_id + WHERE t.board_id = ${boardId} + AND run.status = 'running' + `).pipe(Effect.map((rows) => rows.map((row) => row.laneKey))); + + const resolveBoardProposalStatus: WorkflowReadModelShape["resolveBoardProposalStatus"] = ( + input, + ) => + sql + .withTransaction( + sql<{ readonly proposalId: string }>` + UPDATE workflow_board_proposal + SET + status = ${input.status}, + resolved_at = ${input.resolvedAt}, + applied_version_hash = ${input.appliedVersionHash ?? null} + WHERE proposal_id = ${input.proposalId} + ${input.fromStatus === undefined ? sql`` : sql`AND status = ${input.fromStatus}`} + RETURNING proposal_id AS "proposalId" + `, + ) + .pipe( + Effect.map((rows) => rows.length), + Effect.mapError(toReadModelError), + ); + + const listWorkSourceMappingsForBoard: WorkflowReadModelShape["listWorkSourceMappingsForBoard"] = ( + boardId, + ) => + wrap(sql<WorkSourceMappingRow>` + SELECT m.provider AS "provider", + m.source_id AS "sourceId", + m.external_id AS "externalId", + m.ticket_id AS "ticketId", + t.current_lane_key AS "currentLaneKey" + FROM work_source_mapping m + JOIN projection_ticket t ON t.ticket_id = m.ticket_id + WHERE m.board_id = ${boardId} + `); + + return { + registerBoard, + getBoard, + deleteBoard, + deleteBoardTicketState, + deleteTicketState, + listBoardsForProject, + listTickets, + countAdmittedInLane, + oldestQueuedForLane, + getTicketDetail, + countLanePipelineRuns, + listTicketMessages, + listTicketDiscussion, + listTicketRouteDecisions, + listReleasableDependents, + listDependentTicketIds, + getBoardDigest, + getBoardMetrics, + listNeedsAttentionTickets, + listStepRunsForPipeline, + getTicketPrState, + recordBoardProposal, + listBoardProposals, + getBoardProposal, + listLiveOccupiedLanes, + resolveBoardProposalStatus, + listWorkSourceMappingsForBoard, + } satisfies WorkflowReadModelShape; +}); + +export const WorkflowReadModelLive = Layer.effect(WorkflowReadModel, make); diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts new file mode 100644 index 00000000000..05bff9554f2 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts @@ -0,0 +1,3116 @@ +// @effect-diagnostics globalTimers:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { WorkflowRpcError } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { DurableApprovalResumeLive } from "./DurableApprovalResume.ts"; +import { ProviderDispatchOutboxLive } from "./ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { + ProviderResponsePort, + type ProviderResponseInput, +} from "../Services/ProviderResponsePort.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { + WorkflowEventCommitter, + type WorkflowEventCommitterShape, +} from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowFileLoader } from "../Services/WorkflowFileLoader.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowRecovery } from "../Services/WorkflowRecovery.ts"; +import { WorktreeLeaseServiceLive } from "./WorktreeLeaseService.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { GitHubPort, type GitHubPortShape } from "../Services/GitHubPort.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRecoveryLive } from "./WorkflowRecovery.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const completedRecoveredSteps: Array<{ + readonly stepRunId: string; + readonly result: unknown; + readonly captureTurn?: unknown; +}> = []; +let recoveryEventId = 0; +const loadedRecoveryBoards: string[] = []; +let recoveryStepExecutions = 0; +let delayedPipelineStartRelease: Deferred.Deferred<void> | null = null; +let delayedPipelineStartAttempts = 0; + +// Mutable GitHub port double for PR recovery tests. Reset per test. +const gitHubPortScript: { + findPrForBranch: { number: number; url: string } | null; + prDetailState: "open" | "merged" | "closed"; + findPrForBranchCalls: number; +} = { + findPrForBranch: null, + prDetailState: "open", + findPrForBranchCalls: 0, +}; + +const RecoveryGitHubPortLayer = Layer.succeed(GitHubPort, { + resolveRemote: () => Effect.succeed({ remoteName: "origin", repo: "acme/widgets" }), + findPrForBranch: () => + Effect.sync(() => { + gitHubPortScript.findPrForBranchCalls += 1; + return gitHubPortScript.findPrForBranch; + }), + prDetail: (input: { prNumber: number }) => + Effect.succeed({ + number: input.prNumber, + url: `https://github.com/acme/widgets/pull/${input.prNumber}`, + state: gitHubPortScript.prDetailState, + headSha: null, + reviewDecision: "none" as const, + ciState: "success" as const, + }), +} as unknown as GitHubPortShape); + +const recoveryPreloadFileSystem = FileSystem.layerNoop({ + exists: () => Effect.succeed(true), +}); + +const recoveryPreloadSupport = Layer.mergeAll( + WorkflowFoundationLive, + NodeServices.layer, + recoveryPreloadFileSystem, +); + +const layer = it.layer( + WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge( + Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => Effect.succeed(input.boardId), + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused createTicket"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused moveTicket"), + createTicketAndEnterUnlocked: () => Effect.die("unused createTicketAndEnterUnlocked"), + closeTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + cancellableProviderTurnsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + supersedeProviderWorkForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + terminalAgentSessionThreadsForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + stopAgentSessionsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + editTicketFieldsUnlocked: () => Effect.die("unused editTicketFieldsUnlocked"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.die("unused runLane"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused resolveApproval"), + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.die("unused cancelStep"), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: (stepRunId, result, captureTurn) => + Effect.sync(() => { + completedRecoveredSteps.push({ + stepRunId, + result, + ...(captureTurn === undefined ? {} : { captureTurn }), + }); + }), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowIds, { + ticketId: () => Effect.succeed("ticket-unused" as never), + pipelineRunId: () => Effect.succeed("pipeline-unused" as never), + scriptRunId: () => Effect.succeed("script-unused" as never), + stepRunId: () => Effect.succeed("step-unused" as never), + messageId: () => Effect.succeed("message-unused" as never), + eventId: () => + Effect.sync(() => { + recoveryEventId += 1; + return `evt-recovery-${recoveryEventId}` as never; + }), + token: () => Effect.succeed("token-unused" as never), + mappingId: () => Effect.succeed("mapping-unused" as never), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(RecoveryGitHubPortLayer), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const recoveryWipDefinition = { + name: "recovery wip", + lanes: [ + { + key: "queue", + name: "Queue", + entry: "auto", + wipLimit: 1, + pipeline: [ + { + key: "queue-step", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "recover queued", + }, + ], + }, + { + key: "stranded", + name: "Stranded", + entry: "auto", + wipLimit: 1, + pipeline: [ + { + key: "stranded-step", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "recover stranded", + }, + ], + }, + ], +}; +const recoveryDefinitions = new Map<string, typeof recoveryWipDefinition>(); + +const recoveryWipExecutor = Layer.succeed(StepExecutor, { + execute: () => + Effect.sync(() => { + recoveryStepExecutions += 1; + return { _tag: "failed" as const, error: "recovered pipeline holds its slot" }; + }), +} satisfies StepExecutorShape); + +const recoveryBoardRegistry = Layer.succeed(BoardRegistry, { + register: (boardId, definition) => + Effect.sync(() => { + recoveryDefinitions.set(boardId as string, definition as typeof recoveryWipDefinition); + return definition as never; + }), + unregister: (boardId) => + Effect.sync(() => { + recoveryDefinitions.delete(boardId as string); + }), + getDefinition: (boardId) => + Effect.succeed((recoveryDefinitions.get(boardId as string) ?? null) as never), + listDefinitions: () => + Effect.succeed( + Array.from(recoveryDefinitions.entries(), ([boardId, definition]) => ({ + boardId: boardId as never, + definition: definition as never, + })), + ), + getLane: (boardId, laneKey) => + Effect.succeed( + (recoveryDefinitions.get(boardId as string)?.lanes.find((lane) => lane.key === laneKey) ?? + null) as never, + ), +}); + +const recoveryWipFileLoader = Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.sync(() => { + loadedRecoveryBoards.push(input.boardId as string); + recoveryDefinitions.set(input.boardId as string, recoveryWipDefinition); + return input.boardId; + }), +}); + +const isWorkflowEventStoreError = Schema.is(WorkflowEventStoreError); +const toDelayedCommitterError = (cause: unknown) => + isWorkflowEventStoreError(cause) + ? cause + : new WorkflowEventStoreError({ message: "delayed workflow commit transaction failed", cause }); + +const delayedPipelineStartCommitter = Layer.effect( + WorkflowEventCommitter, + Effect.gen(function* () { + const release = yield* Deferred.make<void>(); + const store = yield* WorkflowEventStore; + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + delayedPipelineStartRelease = release; + delayedPipelineStartAttempts = 0; + + const appendAndProject = (event: Parameters<WorkflowEventCommitterShape["commit"]>[0]) => + Effect.gen(function* () { + if (event.type === "PipelineStarted") { + delayedPipelineStartAttempts += 1; + yield* Deferred.await(release); + } + const persisted = yield* store.append(event); + yield* pipeline.projectEvent(persisted); + return persisted; + }); + + return { + commit: (event) => appendAndProject(event).pipe(Effect.asVoid), + commitMany: (events) => + sql + .withTransaction(Effect.forEach(events, appendAndProject, { concurrency: 1 })) + .pipe(Effect.mapError(toDelayedCommitterError), Effect.asVoid), + appendManyUnlocked: (events) => + Effect.forEach(events, appendAndProject, { concurrency: 1 }).pipe( + Effect.mapError(toDelayedCommitterError), + ), + publishTicketView: () => Effect.void, + } satisfies WorkflowEventCommitterShape; + }), +); + +const recoveryWipLayer = it.layer( + WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEngineLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(recoveryWipExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(recoveryBoardRegistry), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(recoveryWipFileLoader), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const delayedPipelineStartRecoveryLayer = it.layer( + WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEngineLayer), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(recoveryWipExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(recoveryBoardRegistry), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(recoveryWipFileLoader), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge(delayedPipelineStartCommitter), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const waitForRecoveryCondition = <E>( + condition: Effect.Effect<boolean, E>, + label: string, +): Effect.Effect<void, E> => + Effect.gen(function* () { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (yield* condition) { + return; + } + yield* Effect.promise<void>(() => new Promise((resolve) => setTimeout(resolve, 10))); + yield* Effect.yieldNow; + } + assert.fail(`Timed out waiting for ${label}`); + }); + +const workflowEventCount = (sql: SqlClient.SqlClient, ticketId: string, eventType: string) => + sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = ${ticketId} + AND event_type = ${eventType} + `.pipe(Effect.map((rows) => rows[0]?.count ?? 0)); + +const pipelineStartsForToken = ( + sql: SqlClient.SqlClient, + ticketId: string, + laneEntryToken: string, +) => + sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = ${ticketId} + AND event_type = 'PipelineStarted' + AND json_extract(payload_json, '$.laneEntryToken') = ${laneEntryToken} + `.pipe(Effect.map((rows) => rows[0]?.count ?? 0)); + +const decodeAwaitingPayloadJson = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + providerRequestId: Schema.optional(Schema.String), + providerQuestionId: Schema.optional(Schema.String), + }), + ), +); + +it.effect("recovers provider user-input waits with a fresh request before accepting answers", () => + Effect.gen(function* () { + const providerStarts = yield* Ref.make<ReadonlyArray<string>>([]); + const responses = yield* Ref.make<ReadonlyArray<ProviderResponseInput>>([]); + const providerTestLayer = Layer.mergeAll( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: (request) => + Ref.update(providerStarts, (starts) => [...starts, request.dispatchId as string]).pipe( + Effect.as({ turnId: "turn-live" as never }), + ), + }), + Layer.succeed(TurnStateReader, { + read: (threadId) => + Ref.get(responses).pipe( + Effect.map((calls) => + calls.length > 0 + ? ({ _tag: "completed" } as const) + : ({ + _tag: "awaiting_user", + waitingReason: "Live provider question", + providerThreadId: threadId, + providerRequestId: "request-live" as never, + providerResponseKind: "user-input" as const, + providerQuestionId: "question-live", + } as const), + ), + ), + }), + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(responses, (calls) => [...calls, input]), + }), + ); + const workflowTestLayer = Layer.mergeAll( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + Layer.succeed(StepExecutor, { execute: () => Effect.die("unused") }), + Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => Effect.succeed(input.boardId), + }), + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ); + const recoveryLayer = WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge(providerTestLayer), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEngineLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(workflowTestLayer), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const recovery = yield* WorkflowRecovery; + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + + yield* registry.register("board-live-wait" as never, { + name: "Live Wait", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "manual", + pipeline: [ + { + key: "ask", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "ask", + }, + ], + }, + ], + }); + yield* read.registerBoard({ + boardId: "board-live-wait" as never, + projectId: "project-live-wait" as never, + name: "Live Wait", + workflowFilePath: ".t3/boards/live-wait.json", + workflowVersionHash: "hash-live-wait", + maxConcurrentTickets: 1, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-live-wait-created" as never, + ticketId: "ticket-live-wait" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "board-live-wait" as never, + title: "Live wait", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-live-wait-moved" as never, + ticketId: "ticket-live-wait" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "token-live-wait" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-live-wait-pipeline" as never, + ticketId: "ticket-live-wait" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-live-wait" as never, + laneKey: "impl" as never, + laneEntryToken: "token-live-wait" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-live-wait-step" as never, + ticketId: "ticket-live-wait" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-live-wait" as never, + stepRunId: "step-live-wait" as never, + stepKey: "ask" as never, + stepType: "agent", + }, + } as never); + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-live-wait-stale-await" as never, + ticketId: "ticket-live-wait" as never, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + stepRunId: "step-live-wait" as never, + waitingReason: "Stale provider question", + providerThreadId: "thread-live-wait" as never, + providerRequestId: "request-stale" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-stale", + }, + } as never); + 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-live-wait', + 'turn-stale', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:04.000Z', + '2026-06-07T00:00:04.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-live-wait', + 'ticket-live-wait', + 'step-live-wait', + 'thread-live-wait', + 'codex', + 'gpt-5.5', + 'ask', + '/tmp/live-wait', + 'started', + 'turn-stale', + '2026-06-07T00:00:04.000Z', + '2026-06-07T00:00:04.000Z' + ) + `; + + yield* recovery.recover(); + + assert.deepEqual(yield* Ref.get(providerStarts), ["dispatch-live-wait"]); + const waitRows = yield* sql<{ readonly payloadJson: string }>` + SELECT payload_json AS "payloadJson" + FROM workflow_events + WHERE ticket_id = 'ticket-live-wait' + AND event_type = 'StepAwaitingUser' + ORDER BY sequence ASC + `; + const latestPayload = yield* decodeAwaitingPayloadJson(waitRows.at(-1)?.payloadJson ?? "{}"); + assert.equal(latestPayload.providerRequestId, "request-live"); + assert.equal(latestPayload.providerQuestionId, "question-live"); + + yield* engine.answerTicketStep({ + stepRunId: "step-live-wait" as never, + text: "Use the live answer.", + }); + + assert.deepEqual( + (yield* Ref.get(responses)).map((response) => ({ + requestId: response.requestId as string, + questionId: response.questionId, + text: response.text, + })), + [ + { + requestId: "request-live", + questionId: "question-live", + text: "Use the live answer.", + }, + ], + ); + }).pipe(Effect.provide(recoveryLayer)); + }), +); + +it.effect("starts recovered provider waits once when the fresh turn is still running", () => + Effect.gen(function* () { + const providerStarts = yield* Ref.make<ReadonlyArray<string>>([]); + const runningTurnLayer = WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: (request) => + Ref.modify(providerStarts, (starts) => [ + { turnId: `turn-live-${starts.length + 1}` as never }, + [...starts, request.dispatchId as string], + ]), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "running" as const }), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => Effect.succeed(input.boardId), + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused createTicket"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused moveTicket"), + createTicketAndEnterUnlocked: () => Effect.die("unused createTicketAndEnterUnlocked"), + closeTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + cancellableProviderTurnsForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + supersedeProviderWorkForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + terminalAgentSessionThreadsForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + stopAgentSessionsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + editTicketFieldsUnlocked: () => Effect.die("unused editTicketFieldsUnlocked"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.die("unused runLane"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused resolveApproval"), + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.die("unused cancelStep"), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowIds, { + ticketId: () => Effect.succeed("ticket-unused" as never), + pipelineRunId: () => Effect.succeed("pipeline-unused" as never), + scriptRunId: () => Effect.succeed("script-unused" as never), + stepRunId: () => Effect.succeed("step-unused" as never), + messageId: () => Effect.succeed("message-unused" as never), + eventId: () => + Effect.sync(() => { + recoveryEventId += 1; + return `evt-running-recovery-${recoveryEventId}` as never; + }), + token: () => Effect.succeed("token-unused" as never), + mappingId: () => Effect.succeed("mapping-unused" as never), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const recovery = yield* WorkflowRecovery; + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-running-wait', + 'project-running-wait', + 'Running Wait', + '.t3/boards/running-wait.json', + 'hash-running-wait', + 1 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-running-wait', + 'board-running-wait', + 'Running wait', + 'impl', + 'waiting_on_user', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:04.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'evt-running-wait-stale', + 'ticket-running-wait', + 0, + 'StepAwaitingUser', + '2026-06-07T00:00:04.000Z', + '{"stepRunId":"step-running-wait","waitingReason":"Stale provider question","providerThreadId":"thread-running-wait","providerRequestId":"request-stale","providerResponseKind":"user-input","providerQuestionId":"question-stale"}' + ) + `; + 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-running-wait', + 'turn-stale', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-running-wait', + 'ticket-running-wait', + 'step-running-wait', + 'thread-running-wait', + 'codex', + 'gpt-5.5', + 'ask', + '/tmp/running-wait', + 'started', + 'turn-stale', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z' + ) + `; + + yield* recovery.recover(); + + assert.deepEqual(yield* Ref.get(providerStarts), ["dispatch-running-wait"]); + const dispatchRows = yield* sql<{ + readonly status: string; + readonly turnId: string | null; + }>` + SELECT + status, + turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE dispatch_id = 'dispatch-running-wait' + `; + assert.deepEqual(dispatchRows[0], { status: "started", turnId: "turn-live-1" }); + }).pipe(Effect.provide(runningTurnLayer)); + }), +); + +it.effect("recommits recovered provider approval requests after stale dispatch cleanup", () => + Effect.gen(function* () { + const providerStarts = yield* Ref.make<ReadonlyArray<string>>([]); + const requestRecoveryLayer = WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: (request) => + Ref.update(providerStarts, (starts) => [...starts, request.dispatchId as string]).pipe( + Effect.as({ turnId: "turn-request-live" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: (threadId) => + Ref.get(providerStarts).pipe( + Effect.map((starts) => + starts.length === 0 + ? ({ _tag: "running" } as const) + : ({ + _tag: "awaiting_user" as const, + waitingReason: "Approve the recovered command?", + providerThreadId: threadId, + providerRequestId: "request-approval-live" as never, + providerResponseKind: "request" as const, + } as const), + ), + ), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => Effect.succeed(input.boardId), + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused createTicket"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused moveTicket"), + createTicketAndEnterUnlocked: () => Effect.die("unused createTicketAndEnterUnlocked"), + closeTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + cancellableProviderTurnsForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + supersedeProviderWorkForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + terminalAgentSessionThreadsForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + stopAgentSessionsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + editTicketFieldsUnlocked: () => Effect.die("unused editTicketFieldsUnlocked"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.die("unused runLane"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused resolveApproval"), + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.die("unused cancelStep"), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowIds, { + ticketId: () => Effect.succeed("ticket-unused" as never), + pipelineRunId: () => Effect.succeed("pipeline-unused" as never), + scriptRunId: () => Effect.succeed("script-unused" as never), + stepRunId: () => Effect.succeed("step-unused" as never), + messageId: () => Effect.succeed("message-unused" as never), + eventId: () => + Effect.sync(() => { + recoveryEventId += 1; + return `evt-request-recovery-${recoveryEventId}` as never; + }), + token: () => Effect.succeed("token-unused" as never), + mappingId: () => Effect.succeed("mapping-unused" as never), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const recovery = yield* WorkflowRecovery; + const registry = yield* BoardRegistry; + const sql = yield* SqlClient.SqlClient; + + yield* registry.register("board-request-wait" as never, { + name: "Request Wait", + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }); + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-request-wait', + 'project-request-wait', + 'Request Wait', + '.t3/boards/request-wait.json', + 'hash-request-wait', + 1 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-request-wait', + 'board-request-wait', + 'Request wait', + 'impl', + 'waiting_on_user', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:04.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'evt-request-wait-stale', + 'ticket-request-wait', + 0, + 'StepAwaitingUser', + '2026-06-07T00:00:04.000Z', + '{"stepRunId":"step-request-wait","waitingReason":"Stale approval","providerThreadId":"thread-request-wait","providerRequestId":"request-approval-stale","providerResponseKind":"request"}' + ) + `; + 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-request-wait', + 'turn-request-stale', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-request-wait', + 'ticket-request-wait', + 'step-request-wait', + 'thread-request-wait', + 'codex', + 'gpt-5.5', + 'approve', + '/tmp/request-wait', + 'started', + 'turn-request-stale', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z' + ) + `; + + yield* recovery.recover(); + assert.deepEqual(yield* Ref.get(providerStarts), ["dispatch-request-wait"]); + const dispatchRows = yield* sql<{ + readonly status: string; + readonly turnId: string | null; + }>` + SELECT + status, + turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE dispatch_id = 'dispatch-request-wait' + `; + assert.deepEqual(dispatchRows[0], { + status: "started", + turnId: "turn-request-live", + }); + yield* waitForRecoveryCondition( + workflowEventCount(sql, "ticket-request-wait", "StepAwaitingUser").pipe( + Effect.map((count) => count === 2), + ), + "recovered provider approval wait", + ); + + const waitRows = yield* sql<{ readonly payloadJson: string }>` + SELECT payload_json AS "payloadJson" + FROM workflow_events + WHERE ticket_id = 'ticket-request-wait' + AND event_type = 'StepAwaitingUser' + ORDER BY sequence ASC + `; + const latestPayload = yield* decodeAwaitingPayloadJson(waitRows.at(-1)?.payloadJson ?? "{}"); + assert.equal(latestPayload.providerRequestId, "request-approval-live"); + }).pipe(Effect.provide(requestRecoveryLayer)); + }), +); + +it.effect("fails an interrupted panel step even when only one member row is still started", () => + Effect.gen(function* () { + const recovered = yield* Ref.make< + ReadonlyArray<{ readonly stepRunId: string; readonly result: unknown }> + >([]); + const providerStarts = yield* Ref.make<ReadonlyArray<string>>([]); + const panelRecoveryLayer = WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + // A dead panel member must never be re-dispatched by recovery. + ensureTurnStarted: (request) => + Ref.update(providerStarts, (starts) => [...starts, request.dispatchId as string]).pipe( + Effect.as({ turnId: "turn-panel-live" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + // Member 2 of panel A crashed mid-turn (projected 'running'); + // member 2 of panel B reached a terminal turn before the crash. + // Neither may decide its panel single-handedly. + read: (threadId) => + Effect.succeed( + (threadId as string) === "thread-panel-b-member-2" + ? ({ _tag: "completed" } as const) + : ({ _tag: "running" } as const), + ), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => Effect.succeed(input.boardId), + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge( + Layer.effect( + WorkflowEngine, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return { + createTicket: () => Effect.die("unused createTicket"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused moveTicket"), + createTicketAndEnterUnlocked: () => Effect.die("unused createTicketAndEnterUnlocked"), + closeTicketFromSourceUnlocked: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + reopenTicketFromSourceUnlocked: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + cancellableProviderTurnsForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + supersedeProviderWorkForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + terminalAgentSessionThreadsForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + stopAgentSessionsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + editTicketFieldsUnlocked: () => Effect.die("unused editTicketFieldsUnlocked"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.die("unused runLane"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused resolveApproval"), + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.die("unused cancelStep"), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + // Record the call and settle the projection like the real + // engine would, so later recovery stages see the step done. + completeRecoveredStep: (stepRunId, result) => + Ref.update(recovered, (calls) => [ + ...calls, + { stepRunId: stepRunId as string, result }, + ]).pipe( + Effect.andThen( + sql` + UPDATE projection_step_run + SET status = 'failed' + WHERE step_run_id = ${stepRunId as string} + `.pipe(Effect.orDie), + ), + Effect.asVoid, + ), + }; + }), + ), + ), + Layer.provideMerge( + Layer.succeed(WorkflowIds, { + ticketId: () => Effect.succeed("ticket-unused" as never), + pipelineRunId: () => Effect.succeed("pipeline-unused" as never), + scriptRunId: () => Effect.succeed("script-unused" as never), + stepRunId: () => Effect.succeed("step-unused" as never), + messageId: () => Effect.succeed("message-unused" as never), + eventId: () => + Effect.sync(() => { + recoveryEventId += 1; + return `evt-panel-recovery-${recoveryEventId}` as never; + }), + token: () => Effect.succeed("token-unused" as never), + mappingId: () => Effect.succeed("mapping-unused" as never), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const recovery = yield* WorkflowRecovery; + const registry = yield* BoardRegistry; + const sql = yield* SqlClient.SqlClient; + + yield* registry.register("board-panel-recovery" as never, { + name: "Panel Recovery", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + pipeline: [ + { + key: "panel-review", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "review the work", + captureOutput: true, + panel: 3, + }, + ], + }, + ], + }); + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-panel-recovery', + 'project-panel-recovery', + 'Panel Recovery', + '.t3/boards/panel-recovery.json', + 'hash-panel-recovery', + 1 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES + ( + 'ticket-panel-recovery', + 'board-panel-recovery', + 'Panel recovery', + 'review', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:02.000Z' + ), + ( + 'ticket-panel-recovery-b', + 'board-panel-recovery', + 'Panel recovery B', + 'review', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:02.000Z' + ) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES + ( + 'step-panel-recovery', + 'pipeline-panel-recovery', + 'ticket-panel-recovery', + 'panel-review', + 'agent', + 'running', + '2026-06-07T00:00:00.000Z' + ), + ( + 'step-panel-recovery-b', + 'pipeline-panel-recovery-b', + 'ticket-panel-recovery-b', + 'panel-review', + 'agent', + 'running', + '2026-06-07T00:00:00.000Z' + ) + `; + // A 3-member sequential panel crashed mid-member-2: member 1 already + // confirmed, member 3 was never dispatched. Only member 2 is 'started'. + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at, + confirmed_at + ) + VALUES + ( + 'dispatch-panel-member-1', + 'ticket-panel-recovery', + 'step-panel-recovery', + 'thread-panel-member-1', + 'codex', + 'gpt-5.5', + 'review the work', + '/tmp/panel-recovery', + 'confirmed', + 'turn-panel-member-1', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z' + ), + ( + 'dispatch-panel-member-2', + 'ticket-panel-recovery', + 'step-panel-recovery', + 'thread-panel-member-2', + 'codex', + 'gpt-5.5', + 'review the work', + '/tmp/panel-recovery', + 'started', + 'turn-panel-member-2', + '2026-06-07T00:00:01.000Z', + '2026-06-07T00:00:01.000Z', + NULL + ), + ( + 'dispatch-panel-b-member-1', + 'ticket-panel-recovery-b', + 'step-panel-recovery-b', + 'thread-panel-b-member-1', + 'codex', + 'gpt-5.5', + 'review the work', + '/tmp/panel-recovery-b', + 'confirmed', + 'turn-panel-b-member-1', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z' + ), + ( + 'dispatch-panel-b-member-2', + 'ticket-panel-recovery-b', + 'step-panel-recovery-b', + 'thread-panel-b-member-2', + 'codex', + 'gpt-5.5', + 'review the work', + '/tmp/panel-recovery-b', + 'started', + 'turn-panel-b-member-2', + '2026-06-07T00:00:01.000Z', + '2026-06-07T00:00:01.000Z', + NULL + ) + `; + + yield* recovery.recover(); + + // No panel member may be re-dispatched: recovery must settle the + // panels without starting fresh provider turns for dead members. + assert.deepEqual(yield* Ref.get(providerStarts), []); + const recoveredCalls = [...(yield* Ref.get(recovered))].sort((a, b) => + a.stepRunId.localeCompare(b.stepRunId), + ); + assert.deepEqual(recoveredCalls, [ + { + stepRunId: "step-panel-recovery", + result: { + _tag: "failed", + error: "review panel interrupted by restart", + retryable: true, + }, + }, + { + stepRunId: "step-panel-recovery-b", + result: { + _tag: "failed", + error: "review panel interrupted by restart", + retryable: true, + }, + }, + ]); + const outboxRows = yield* sql<{ readonly status: string }>` + SELECT status + FROM workflow_dispatch_outbox + WHERE step_run_id IN ('step-panel-recovery', 'step-panel-recovery-b') + ORDER BY dispatch_id ASC + `; + assert.deepEqual( + outboxRows.map((row) => row.status), + ["confirmed", "confirmed", "confirmed", "confirmed"], + ); + }).pipe(Effect.provide(panelRecoveryLayer)); + }), +); + +recoveryWipLayer("WorkflowRecovery WIP admission", (it) => { + it.effect( + "preloads persisted boards, admits queued tickets, and restarts stranded auto tickets", + () => + Effect.gen(function* () { + loadedRecoveryBoards.length = 0; + recoveryStepExecutions = 0; + const recovery = yield* WorkflowRecovery; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + + yield* registry.register("b-recovery-wip" as never, recoveryWipDefinition); + yield* read.registerBoard({ + boardId: "b-recovery-wip" as never, + projectId: "p-recovery-wip" as never, + name: "Recovery WIP", + workflowFilePath: ".t3/boards/recovery-wip.json", + workflowVersionHash: "hash-recovery-wip", + maxConcurrentTickets: 3, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-recovery-queued-created" as never, + ticketId: "ticket-recovery-queued" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-recovery-wip" as never, + title: "Queued recovery", + laneKey: "queue" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketQueued", + eventId: "evt-recovery-queued" as never, + ticketId: "ticket-recovery-queued" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { lane: "queue" as never }, + } as never); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-recovery-stranded-created" as never, + ticketId: "ticket-recovery-stranded" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + boardId: "b-recovery-wip" as never, + title: "Stranded recovery", + laneKey: "stranded" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-recovery-stranded-admitted" as never, + ticketId: "ticket-recovery-stranded" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + toLane: "stranded" as never, + laneEntryToken: "tok-recovery-stranded" as never, + reason: "initial", + }, + } as never); + + yield* recovery.recover(); + + assert.deepEqual(loadedRecoveryBoards, ["b-recovery-wip"]); + yield* waitForRecoveryCondition( + Effect.gen(function* () { + const queued = yield* read.getTicketDetail("ticket-recovery-queued" as never); + return ( + queued !== null && + queued.ticket.currentLaneEntryToken !== null && + queued.ticket.queuedAt === null + ); + }), + "queued ticket admission", + ); + yield* waitForRecoveryCondition( + Effect.gen(function* () { + const queuedStarts = yield* workflowEventCount( + sql, + "ticket-recovery-queued", + "PipelineStarted", + ); + const strandedStarts = yield* workflowEventCount( + sql, + "ticket-recovery-stranded", + "PipelineStarted", + ); + return queuedStarts === 1 && strandedStarts === 1; + }), + "recovered auto pipeline starts", + ); + assert.equal(yield* workflowEventCount(sql, "ticket-recovery-queued", "TicketAdmitted"), 1); + + yield* recovery.recover(); + yield* waitForRecoveryCondition( + Effect.gen(function* () { + const queuedAdmits = yield* workflowEventCount( + sql, + "ticket-recovery-queued", + "TicketAdmitted", + ); + const queuedStarts = yield* workflowEventCount( + sql, + "ticket-recovery-queued", + "PipelineStarted", + ); + const strandedStarts = yield* workflowEventCount( + sql, + "ticket-recovery-stranded", + "PipelineStarted", + ); + return queuedAdmits === 1 && queuedStarts === 1 && strandedStarts === 1; + }), + "idempotent WIP recovery", + ); + assert.equal(recoveryStepExecutions, 2); + }), + ); +}); + +delayedPipelineStartRecoveryLayer("WorkflowEngine delayed start idempotency", (it) => { + it.effect("skips duplicate runLane starts for the same token while allowing a new token", () => + Effect.gen(function* () { + delayedPipelineStartAttempts = 0; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + + yield* registry.register("b-runlane-idempotent" as never, recoveryWipDefinition); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-runlane-idempotent-created" as never, + ticketId: "ticket-runlane-idempotent" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-runlane-idempotent" as never, + title: "Run lane idempotent", + laneKey: "queue" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-runlane-idempotent-admitted" as never, + ticketId: "ticket-runlane-idempotent" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "queue" as never, + laneEntryToken: "tok-runlane-idempotent" as never, + reason: "initial", + }, + } as never); + + yield* engine.runLane("ticket-runlane-idempotent" as never); + yield* waitForRecoveryCondition( + Effect.sync(() => delayedPipelineStartAttempts === 1), + "first delayed runLane start", + ); + + yield* engine.runLane("ticket-runlane-idempotent" as never); + yield* Effect.yieldNow; + assert.equal(delayedPipelineStartAttempts, 1); + assert.equal( + yield* pipelineStartsForToken(sql, "ticket-runlane-idempotent", "tok-runlane-idempotent"), + 0, + ); + + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-runlane-idempotent-new-token" as never, + ticketId: "ticket-runlane-idempotent" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + toLane: "queue" as never, + laneEntryToken: "tok-runlane-idempotent-new" as never, + reason: "manual", + }, + } as never); + yield* engine.runLane("ticket-runlane-idempotent" as never); + yield* waitForRecoveryCondition( + Effect.sync(() => delayedPipelineStartAttempts === 2), + "new-token delayed runLane start", + ); + + const release = delayedPipelineStartRelease; + assert.isNotNull(release); + if (release === null) { + assert.fail("expected delayed pipeline start release gate"); + } + yield* Deferred.succeed(release, undefined); + yield* waitForRecoveryCondition( + Effect.gen(function* () { + const originalStarts = yield* pipelineStartsForToken( + sql, + "ticket-runlane-idempotent", + "tok-runlane-idempotent", + ); + const newStarts = yield* pipelineStartsForToken( + sql, + "ticket-runlane-idempotent", + "tok-runlane-idempotent-new", + ); + return originalStarts === 1 && newStarts === 1; + }), + "original and new-token pipeline starts", + ); + assert.equal( + yield* pipelineStartsForToken(sql, "ticket-runlane-idempotent", "tok-runlane-idempotent"), + 1, + ); + assert.equal( + yield* pipelineStartsForToken( + sql, + "ticket-runlane-idempotent", + "tok-runlane-idempotent-new", + ), + 1, + ); + }), + ); +}); + +delayedPipelineStartRecoveryLayer("WorkflowRecovery delayed WIP start", (it) => { + it.effect("starts recovered auto tickets once across two in-flight recoveries", () => + Effect.gen(function* () { + loadedRecoveryBoards.length = 0; + recoveryStepExecutions = 0; + const recovery = yield* WorkflowRecovery; + const read = yield* WorkflowReadModel; + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + + yield* read.registerBoard({ + boardId: "b-recovery-delayed-start" as never, + projectId: "p-recovery-delayed-start" as never, + name: "Recovery delayed start", + workflowFilePath: ".t3/boards/recovery-delayed-start.json", + workflowVersionHash: "hash-recovery-delayed-start", + maxConcurrentTickets: 3, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-recovery-delayed-created" as never, + ticketId: "ticket-recovery-delayed" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-recovery-delayed-start" as never, + title: "Queued delayed recovery", + laneKey: "queue" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketQueued", + eventId: "evt-recovery-delayed-queued" as never, + ticketId: "ticket-recovery-delayed" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { lane: "queue" as never }, + } as never); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-recovery-delayed-stranded-created" as never, + ticketId: "ticket-recovery-delayed-stranded" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + boardId: "b-recovery-delayed-start" as never, + title: "Stranded delayed recovery", + laneKey: "stranded" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-recovery-delayed-stranded-admitted" as never, + ticketId: "ticket-recovery-delayed-stranded" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + toLane: "stranded" as never, + laneEntryToken: "tok-recovery-delayed-stranded" as never, + reason: "initial", + }, + } as never); + + const recoveryFiber = yield* recovery.recover().pipe(Effect.forkScoped); + yield* waitForRecoveryCondition( + Effect.sync(() => delayedPipelineStartAttempts === 2), + "delayed pipeline start attempts", + ); + yield* Fiber.join(recoveryFiber); + + const admitted = yield* read.getTicketDetail("ticket-recovery-delayed" as never); + const laneEntryToken = admitted?.ticket.currentLaneEntryToken; + assert.isNotNull(laneEntryToken ?? null); + if (laneEntryToken === null || laneEntryToken === undefined) { + assert.fail("expected recovery admission to assign a token"); + } + + yield* recovery.recover(); + yield* Effect.yieldNow; + assert.equal(delayedPipelineStartAttempts, 2); + assert.equal( + yield* pipelineStartsForToken(sql, "ticket-recovery-delayed", laneEntryToken), + 0, + ); + assert.equal( + yield* pipelineStartsForToken( + sql, + "ticket-recovery-delayed-stranded", + "tok-recovery-delayed-stranded", + ), + 0, + ); + + const release = delayedPipelineStartRelease; + assert.isNotNull(release); + if (release === null) { + assert.fail("expected delayed pipeline start release gate"); + } + yield* Deferred.succeed(release, undefined); + yield* waitForRecoveryCondition( + Effect.gen(function* () { + const queuedStarts = yield* pipelineStartsForToken( + sql, + "ticket-recovery-delayed", + laneEntryToken, + ); + const strandedStarts = yield* pipelineStartsForToken( + sql, + "ticket-recovery-delayed-stranded", + "tok-recovery-delayed-stranded", + ); + return queuedStarts === 1 && strandedStarts === 1; + }), + "single delayed pipeline starts", + ); + assert.equal( + yield* pipelineStartsForToken(sql, "ticket-recovery-delayed", laneEntryToken), + 1, + ); + assert.equal( + yield* pipelineStartsForToken( + sql, + "ticket-recovery-delayed-stranded", + "tok-recovery-delayed-stranded", + ), + 1, + ); + + yield* recovery.recover(); + assert.equal( + yield* pipelineStartsForToken(sql, "ticket-recovery-delayed", laneEntryToken), + 1, + ); + assert.equal( + yield* pipelineStartsForToken( + sql, + "ticket-recovery-delayed-stranded", + "tok-recovery-delayed-stranded", + ), + 1, + ); + }), + ); +}); + +it.effect("cascades persisted boards whose workflow file is missing during preload", () => + Effect.gen(function* () { + const cancelledBoards = yield* Ref.make<ReadonlyArray<string>>([]); + const unregisteredBoards = yield* Ref.make<ReadonlyArray<string>>([]); + const missingFileLayer = WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => + Effect.fail( + new WorkflowRpcError({ + message: "workflow file read failed", + cause: { reason: { _tag: "NotFound" } } as never, + }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge( + Layer.succeed(BoardRegistry, { + register: () => Effect.die("unused"), + unregister: (boardId) => + Ref.update(unregisteredBoards, (boards) => [...boards, boardId as string]), + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused createTicket"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused moveTicket"), + createTicketAndEnterUnlocked: () => Effect.die("unused createTicketAndEnterUnlocked"), + closeTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + cancellableProviderTurnsForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + supersedeProviderWorkForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + terminalAgentSessionThreadsForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + stopAgentSessionsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + editTicketFieldsUnlocked: () => Effect.die("unused editTicketFieldsUnlocked"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.die("unused runLane"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused resolveApproval"), + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.die("unused cancelStep"), + cancelBoardPipelines: (boardId) => + Ref.update(cancelledBoards, (boards) => [...boards, boardId as string]), + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.die("stale board must not recover wip"), + completeRecoveredStep: () => Effect.die("unused completeRecoveredStep"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowIds, { + ticketId: () => Effect.succeed("ticket-unused" as never), + pipelineRunId: () => Effect.succeed("pipeline-unused" as never), + scriptRunId: () => Effect.succeed("script-unused" as never), + stepRunId: () => Effect.succeed("step-unused" as never), + messageId: () => Effect.succeed("message-unused" as never), + eventId: () => Effect.succeed("event-unused" as never), + token: () => Effect.succeed("token-unused" as never), + mappingId: () => Effect.succeed("mapping-unused" as never), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-stale-file', + 'project-stale-file', + 'Stale File', + '.t3/boards/stale-file.json', + 'hash-stale-file', + 3 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-stale-file', + 'board-stale-file', + 'Stale ticket', + 'impl', + 'running', + ${now}, + ${now} + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'event-stale-file', + 'ticket-stale-file', + 0, + 'TicketCreated', + ${now}, + '{"boardId":"board-stale-file","title":"Stale ticket","laneKey":"impl"}' + ) + `; + yield* sql` + INSERT INTO workflow_board_version ( + board_id, + version_hash, + content_json, + source, + created_at + ) + VALUES ( + 'board-stale-file', + 'hash-stale-file-version', + '{"name":"Stale File"}', + 'save', + ${now} + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-stale-file', + 'ticket-stale-file', + 'step-stale-file', + 'thread-stale-file', + 'codex', + 'gpt-5.5', + 'stale dispatch', + '/tmp/stale-file', + 'pending', + ${now} + ) + `; + yield* sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES ( + 'setup-stale-file', + 'ticket-stale-file', + 'worktree-stale-file', + 'running', + ${now} + ) + `; + + yield* recovery.recover(); + + assert.deepEqual(yield* Ref.get(cancelledBoards), ["board-stale-file"]); + assert.deepEqual(yield* Ref.get(unregisteredBoards), ["board-stale-file"]); + const counts = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_board' AS tableName, COUNT(*) AS count + FROM projection_board + WHERE board_id = 'board-stale-file' + UNION ALL + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE board_id = 'board-stale-file' + UNION ALL + SELECT 'workflow_events' AS tableName, COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = 'ticket-stale-file' + UNION ALL + SELECT 'workflow_board_version' AS tableName, COUNT(*) AS count + FROM workflow_board_version + WHERE board_id = 'board-stale-file' + UNION ALL + SELECT 'workflow_dispatch_outbox' AS tableName, COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE ticket_id = 'ticket-stale-file' + UNION ALL + SELECT 'workflow_setup_run' AS tableName, COUNT(*) AS count + FROM workflow_setup_run + WHERE ticket_id = 'ticket-stale-file' + `; + assert.deepEqual( + counts.map((row) => [row.tableName, row.count]), + [ + ["projection_board", 0], + ["projection_ticket", 0], + ["workflow_events", 0], + ["workflow_board_version", 0], + ["workflow_dispatch_outbox", 0], + ["workflow_setup_run", 0], + ], + ); + }).pipe(Effect.provide(missingFileLayer)); + }), +); + +it.effect("preload does not resurrect a board deleted while its save lock is held", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-workflow-recovery-preload-delete-", + }); + const boardsDir = path.join(workspaceRoot, ".t3/boards"); + const boardPath = path.join(boardsDir, "preload-delete.json"); + const boardId = "board-preload-delete" as never; + const projectId = "project-preload-delete" as never; + const finishLoad = yield* Deferred.make<void>(); + const deleteLockHeld = yield* Deferred.make<void>(); + const finishDelete = yield* Deferred.make<void>(); + const loadedBoards = yield* Ref.make<ReadonlyArray<string>>([]); + const recoveredBoards = yield* Ref.make<ReadonlyArray<string>>([]); + yield* fs.makeDirectory(boardsDir, { recursive: true }); + yield* fs.writeFileString( + boardPath, + '{"name":"Preload Delete","lanes":[{"key":"impl","name":"Impl","entry":"manual"}]}', + ); + + const preloadDeleteLayer = WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.effect( + WorkflowFileLoader, + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + return { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.gen(function* () { + yield* Ref.update(loadedBoards, (boards) => [ + ...boards, + input.boardId as string, + ]); + yield* Deferred.await(finishLoad); + yield* registry + .register(input.boardId, { + name: "Preload Delete", + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }) + .pipe( + Effect.mapError( + (cause) => + new WorkflowRpcError({ + message: "test board registration failed", + cause, + }), + ), + ); + yield* read + .registerBoard({ + boardId: input.boardId, + projectId: input.projectId, + name: "Preload Delete", + workflowFilePath: input.relativePath, + workflowVersionHash: "hash-preload-delete-resurrected", + maxConcurrentTickets: 1, + }) + .pipe( + Effect.mapError( + (cause) => + new WorkflowRpcError({ + message: "test board projection registration failed", + cause, + }), + ), + ); + return input.boardId; + }), + }; + }), + ), + ), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed(workspaceRoot), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused createTicket"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused moveTicket"), + createTicketAndEnterUnlocked: () => Effect.die("unused createTicketAndEnterUnlocked"), + closeTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + reopenTicketFromSourceUnlocked: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + cancellableProviderTurnsForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + supersedeProviderWorkForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + terminalAgentSessionThreadsForTicket: () => + Effect.die("unused closeTicketFromSourceUnlocked"), + stopAgentSessionsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + editTicketFieldsUnlocked: () => Effect.die("unused editTicketFieldsUnlocked"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.die("unused runLane"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused resolveApproval"), + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.die("unused cancelStep"), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: (recoveredBoardId) => + Ref.update(recoveredBoards, (boards) => [...boards, recoveredBoardId as string]), + completeRecoveredStep: () => Effect.die("unused completeRecoveredStep"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowIds, { + ticketId: () => Effect.succeed("ticket-unused" as never), + pipelineRunId: () => Effect.succeed("pipeline-unused" as never), + scriptRunId: () => Effect.succeed("script-unused" as never), + stepRunId: () => Effect.succeed("step-unused" as never), + messageId: () => Effect.succeed("message-unused" as never), + eventId: () => Effect.succeed("event-unused" as never), + token: () => Effect.succeed("token-unused" as never), + mappingId: () => Effect.succeed("mapping-unused" as never), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const recovery = yield* WorkflowRecovery; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const saveLocks = yield* WorkflowBoardSaveLocks; + + yield* registry.register(boardId, { + name: "Preload Delete", + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }); + yield* read.registerBoard({ + boardId, + projectId, + name: "Preload Delete", + workflowFilePath: ".t3/boards/preload-delete.json", + workflowVersionHash: "hash-preload-delete", + maxConcurrentTickets: 1, + }); + + const deleteFiber = yield* saveLocks + .withSaveLock( + boardId, + Effect.gen(function* () { + yield* Deferred.succeed(deleteLockHeld, undefined); + yield* Deferred.await(finishDelete); + yield* fs.remove(boardPath); + yield* registry.unregister(boardId); + yield* read.deleteBoard(boardId); + }), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(deleteLockHeld); + + const recoveryFiber = yield* recovery.recover().pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + const loaderEnteredWhileDeleteHeld = (yield* Ref.get(loadedBoards)).length > 0; + + yield* Deferred.succeed(finishDelete, undefined); + yield* Fiber.join(deleteFiber); + yield* Deferred.succeed(finishLoad, undefined).pipe(Effect.ignore); + yield* Fiber.join(recoveryFiber).pipe(Effect.timeout("1 second")); + + assert.isFalse(loaderEnteredWhileDeleteHeld); + assert.deepEqual(yield* Ref.get(loadedBoards), []); + assert.deepEqual(yield* Ref.get(recoveredBoards), []); + assert.isNull(yield* registry.getDefinition(boardId)); + assert.isNull(yield* read.getBoard(boardId)); + }).pipe(Effect.provide(preloadDeleteLayer)); + }).pipe(Effect.provide(NodeServices.layer)), + ), +); + +layer("WorkflowRecovery", (it) => { + it.effect("confirms recovered dispatches and completes terminal steps", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + completedRecoveredSteps.length = 0; + + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-1', + 'project-1', + 'Recovery Board', + '.t3/boards/recovery.json', + 'hash-recovery', + 3 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-1', + 'board-1', + 'Recover dispatch', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-1', + 'ticket-1', + 'step-run-1', + 'thread-1', + 'codex', + 'gpt-5.5', + 'finish the step', + '/tmp/wt-ticket-1', + 'pending', + '2026-06-07T00:00:00.000Z' + ) + `; + + yield* recovery.recover(); + + const rows = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = 'dispatch-1' + `; + assert.equal(rows[0]?.status, "confirmed"); + + assert.deepEqual(completedRecoveredSteps, [ + { + stepRunId: "step-run-1", + result: { _tag: "completed" }, + captureTurn: { threadId: "thread-1", turnId: "turn-1" }, + }, + ]); + }), + ); + + it.effect("releases worktree leases for steps that ended blocked", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'evt-step-blocked', + 'ticket-blocked', + 0, + 'StepBlocked', + '2026-06-07T00:00:00.000Z', + '{"stepRunId":"step-run-blocked","reason":"Project not trusted to run scripts"}' + ) + `; + yield* sql` + INSERT INTO worktree_lease ( + worktree_ref, + owner_kind, + owner_id, + fence_token, + acquired_at, + expires_at + ) + VALUES ( + 'wt-blocked', + 'step', + 'step-run-blocked', + 7, + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:30:00.000Z' + ) + `; + + yield* recovery.recover(); + + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-blocked' + `; + assert.equal(rows[0]?.ownerKind, "released"); + }), + ); + + it.effect("fails running script runs after restart and releases their step lease", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + const registry = yield* BoardRegistry; + const store = yield* WorkflowEventStore; + + yield* registry.register("board-script-recovery" as never, { + name: "Script recovery", + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }); + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-script-recovery', + 'project-script-recovery', + 'Script Recovery', + '.t3/boards/script-recovery.json', + 'hash-script-recovery', + 3 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-script-recovery', + 'board-script-recovery', + 'Recover script', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES + ( + 'evt-script-started', + 'ticket-script-recovery', + 0, + 'StepStarted', + '2026-06-07T00:00:00.000Z', + '{"pipelineRunId":"pipeline-script-recovery","stepRunId":"step-run-script-recovery","stepKey":"tests","stepType":"script"}' + ), + ( + 'evt-script-run-started', + 'ticket-script-recovery', + 1, + 'ScriptStepStarted', + '2026-06-07T00:00:01.000Z', + '{"scriptRunId":"script-run-recovery","stepRunId":"step-run-script-recovery","scriptThreadId":"workflow-script:script-run-recovery","terminalId":"script-script-run-recovery"}' + ) + `; + yield* sql` + INSERT INTO workflow_script_run ( + script_run_id, + step_run_id, + ticket_id, + script_thread_id, + terminal_id, + status, + started_at + ) + VALUES ( + 'script-run-recovery', + 'step-run-script-recovery', + 'ticket-script-recovery', + 'workflow-script:script-run-recovery', + 'script-script-run-recovery', + 'running', + '2026-06-07T00:00:01.000Z' + ) + `; + yield* sql` + INSERT INTO worktree_lease ( + worktree_ref, + owner_kind, + owner_id, + fence_token, + acquired_at, + expires_at + ) + VALUES ( + 'wt-script-recovery', + 'step', + 'step-run-script-recovery', + 11, + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:30:00.000Z' + ) + `; + + yield* recovery.recover(); + + const scriptRows = yield* sql<{ readonly status: string }>` + SELECT status + FROM workflow_script_run + WHERE script_run_id = 'script-run-recovery' + `; + const leaseRows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-script-recovery' + `; + const events = yield* Stream.runCollect( + store.readByTicket("ticket-script-recovery" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + + assert.equal(scriptRows[0]?.status, "cancelled"); + assert.isTrue( + events.some( + (event) => + event.type === "ScriptStepExited" && + event.payload.scriptRunId === "script-run-recovery" && + event.payload.outcome === "cancelled", + ), + ); + assert.isTrue( + events.some( + (event) => + event.type === "StepFailed" && + event.payload.stepRunId === "step-run-script-recovery" && + event.payload.error === "script interrupted by server restart", + ), + ); + assert.deepEqual(completedRecoveredSteps, [ + { + stepRunId: "step-run-script-recovery", + result: { _tag: "failed", error: "script interrupted by server restart" }, + }, + ]); + assert.equal(leaseRows[0]?.ownerKind, "released"); + }), + ); + + it.effect("recovers an already-terminal merge step with its stored outcome", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + + // Crash window: the StepCompleted event was appended but the crash hit + // before the projection update, so the step run still says 'running'. + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES ( + 'step-run-merge-terminal', + 'pipeline-merge-terminal', + 'ticket-merge-terminal', + 'land', + 'merge', + 'running', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'evt-merge-terminal-completed', + 'ticket-merge-terminal', + 0, + 'StepCompleted', + '2026-06-07T00:00:01.000Z', + '{"stepRunId":"step-run-merge-terminal","output":{"merged":true}}' + ) + `; + + yield* recovery.recover(); + + assert.deepEqual(completedRecoveredSteps, [ + { + stepRunId: "step-run-merge-terminal", + result: { _tag: "completed", output: { merged: true } }, + }, + ]); + }), + ); + + // --- pullRequest step recovery --------------------------------------------- + + const prBoardDefinition = { + name: "pr recovery", + lanes: [ + { + key: "ship", + name: "Ship", + entry: "auto", + pipeline: [ + { key: "open-pr", type: "pullRequest", action: "open" }, + { key: "land-pr", type: "pullRequest", action: "land" }, + ], + }, + ], + }; + + const seedPrStep = (input: { + readonly boardId: string; + readonly ticketId: string; + readonly stepRunId: string; + readonly stepKey: string; + }) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const registry = yield* BoardRegistry; + yield* registry.register(input.boardId as never, prBoardDefinition); + yield* sql` + INSERT INTO projection_board ( + board_id, project_id, name, workflow_file_path, + workflow_version_hash, max_concurrent_tickets + ) + VALUES ( + ${input.boardId}, ${`${input.boardId}-project`}, 'PR recovery', + '.t3/boards/pr.json', ${`hash-${input.boardId}`}, 1 + ) + `; + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at + ) + VALUES ( + ${`${input.boardId}-project`}, 'PR repo', '/tmp/pr-repo', '{}', + '2026-06-07T00:00:00.000Z', '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ( + ${input.ticketId}, ${input.boardId}, 'PR ticket', 'ship', 'running', + '2026-06-07T00:00:00.000Z', '2026-06-07T00:00:01.000Z' + ) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, pipeline_run_id, ticket_id, step_key, step_type, status, started_at + ) + VALUES ( + ${input.stepRunId}, ${`${input.stepRunId}-pipeline`}, ${input.ticketId}, + ${input.stepKey}, 'pullRequest', 'running', '2026-06-07T00:00:00.000Z' + ) + `; + }); + + const seedPrStateRow = (ticketId: string, prNumber: number) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, pr_state, updated_at + ) + VALUES ( + ${ticketId}, ${prNumber}, ${`https://github.com/acme/widgets/pull/${prNumber}`}, + ${`workflow/${ticketId}`}, 'origin', 'acme/widgets', 'open', '2026-06-07T00:00:02.000Z' + ) + `; + }); + + it.effect("recovers an open PR step from recorded PR state without adopting", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + gitHubPortScript.findPrForBranch = null; + gitHubPortScript.findPrForBranchCalls = 0; + const recovery = yield* WorkflowRecovery; + + yield* seedPrStep({ + boardId: "board-pr-open-recorded", + ticketId: "ticket-pr-open-recorded", + stepRunId: "step-run-pr-open-recorded", + stepKey: "open-pr", + }); + yield* seedPrStateRow("ticket-pr-open-recorded", 42); + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter( + (c) => c.stepRunId === "step-run-pr-open-recorded", + ); + assert.deepEqual(calls, [ + { + stepRunId: "step-run-pr-open-recorded", + result: { + _tag: "completed", + output: { prNumber: 42, url: "https://github.com/acme/widgets/pull/42" }, + }, + }, + ]); + // PR already recorded → no branch lookup, no extra TicketPrOpened. + assert.equal(gitHubPortScript.findPrForBranchCalls, 0); + const events = yield* Stream.runCollect( + (yield* WorkflowEventStore).readByTicket("ticket-pr-open-recorded" as never), + ); + assert.equal(Array.from(events).filter((e) => e.type === "TicketPrOpened").length, 0); + }), + ); + + it.effect("adopts a created-but-unrecorded PR and commits TicketPrOpened", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + gitHubPortScript.findPrForBranch = { + number: 77, + url: "https://github.com/acme/widgets/pull/77", + }; + gitHubPortScript.findPrForBranchCalls = 0; + const recovery = yield* WorkflowRecovery; + + yield* seedPrStep({ + boardId: "board-pr-open-adopt", + ticketId: "ticket-pr-open-adopt", + stepRunId: "step-run-pr-open-adopt", + stepKey: "open-pr", + }); + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter((c) => c.stepRunId === "step-run-pr-open-adopt"); + assert.deepEqual(calls, [ + { + stepRunId: "step-run-pr-open-adopt", + result: { + _tag: "completed", + output: { prNumber: 77, url: "https://github.com/acme/widgets/pull/77" }, + }, + }, + ]); + assert.isAtLeast(gitHubPortScript.findPrForBranchCalls, 1); + const events = yield* Stream.runCollect( + (yield* WorkflowEventStore).readByTicket("ticket-pr-open-adopt" as never), + ); + const opened = Array.from(events).filter((e) => e.type === "TicketPrOpened"); + assert.equal(opened.length, 1); + assert.equal((opened[0] as { payload: { prNumber: number } }).payload.prNumber, 77); + }), + ); + + it.effect("fails an open PR step when no PR exists on the remote", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + gitHubPortScript.findPrForBranch = null; + const recovery = yield* WorkflowRecovery; + + yield* seedPrStep({ + boardId: "board-pr-open-none", + ticketId: "ticket-pr-open-none", + stepRunId: "step-run-pr-open-none", + stepKey: "open-pr", + }); + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter((c) => c.stepRunId === "step-run-pr-open-none"); + assert.deepEqual(calls, [ + { + stepRunId: "step-run-pr-open-none", + result: { _tag: "failed", error: "PR open interrupted by restart" }, + }, + ]); + }), + ); + + it.effect("completes a land PR step when prDetail reports merged", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + gitHubPortScript.prDetailState = "merged"; + const recovery = yield* WorkflowRecovery; + + yield* seedPrStep({ + boardId: "board-pr-land-merged", + ticketId: "ticket-pr-land-merged", + stepRunId: "step-run-pr-land-merged", + stepKey: "land-pr", + }); + yield* seedPrStateRow("ticket-pr-land-merged", 55); + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter( + (c) => c.stepRunId === "step-run-pr-land-merged", + ); + assert.deepEqual(calls, [ + { stepRunId: "step-run-pr-land-merged", result: { _tag: "completed" } }, + ]); + }), + ); + + it.effect("fails a land PR step when prDetail reports not merged", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + gitHubPortScript.prDetailState = "open"; + const recovery = yield* WorkflowRecovery; + + yield* seedPrStep({ + boardId: "board-pr-land-open", + ticketId: "ticket-pr-land-open", + stepRunId: "step-run-pr-land-open", + stepKey: "land-pr", + }); + yield* seedPrStateRow("ticket-pr-land-open", 56); + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter((c) => c.stepRunId === "step-run-pr-land-open"); + assert.deepEqual(calls, [ + { + stepRunId: "step-run-pr-land-open", + result: { _tag: "failed", error: "land interrupted by restart" }, + }, + ]); + }), + ); + + it.effect("fails a land PR step when no PR state is recorded", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + const recovery = yield* WorkflowRecovery; + + yield* seedPrStep({ + boardId: "board-pr-land-norow", + ticketId: "ticket-pr-land-norow", + stepRunId: "step-run-pr-land-norow", + stepKey: "land-pr", + }); + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter((c) => c.stepRunId === "step-run-pr-land-norow"); + assert.deepEqual(calls, [ + { + stepRunId: "step-run-pr-land-norow", + result: { _tag: "failed", error: "land interrupted by restart" }, + }, + ]); + }), + ); + + it.effect("fails a running step whose outbox rows were confirmed before the terminal event", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + + // Crash window: awaitTerminal confirmed the dispatch row (e.g. on its + // 30-minute timeout) but the process died before the engine committed + // the step's terminal event. No dispatch stage looks at confirmed rows + // and the projection still says 'running' with no terminal event. + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-confirmed-crash', + 'project-confirmed-crash', + 'Confirmed Crash', + '.t3/boards/confirmed-crash.json', + 'hash-confirmed-crash', + 1 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-confirmed-crash', + 'board-confirmed-crash', + 'Confirmed crash', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z' + ) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES ( + 'step-confirmed-crash', + 'pipeline-confirmed-crash', + 'ticket-confirmed-crash', + 'implement', + 'agent', + 'running', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at, + confirmed_at + ) + VALUES ( + 'dispatch-confirmed-crash', + 'ticket-confirmed-crash', + 'step-confirmed-crash', + 'thread-confirmed-crash', + 'codex', + 'gpt-5.5', + 'implement the step', + '/tmp/confirmed-crash', + 'confirmed', + 'turn-confirmed-crash', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z' + ) + `; + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter( + (call) => call.stepRunId === "step-confirmed-crash", + ); + assert.deepEqual(calls, [ + { + stepRunId: "step-confirmed-crash", + result: { _tag: "failed", error: "step interrupted by server restart" }, + }, + ]); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.ts new file mode 100644 index 00000000000..5f832323b9d --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.ts @@ -0,0 +1,1161 @@ +import type { + BoardId, + MessageId, + ProjectId, + ScriptRunId, + StepRunId, + ThreadId, + TicketId, + TurnId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { DurableApprovalResume } from "../Services/DurableApprovalResume.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ProviderDispatchOutbox, + type ProviderDispatchTerminalResult, +} from "../Services/ProviderDispatchOutbox.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardVersionStore } from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowFileLoader } from "../Services/WorkflowFileLoader.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import type { PersistedWorkflowEvent, WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowWebhook } from "../Services/WorkflowWebhook.ts"; +import { WorkflowRecovery, type WorkflowRecoveryShape } from "../Services/WorkflowRecovery.ts"; +import { MergeGitPort } from "../Services/TicketMergeService.ts"; +import { GitHubPort } from "../Services/GitHubPort.ts"; +import type { RecoveredStepResult } from "../Services/WorkflowEngine.ts"; +import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; +import { WorkflowWorktreeJanitor } from "../Services/WorkflowWorktreeJanitor.ts"; +import { WorkflowAgentSessionStore } from "../Services/WorkflowAgentSessionStore.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { deleteWorkflowBoardOwnedState } from "../boardDeletion.ts"; +import { truncateTicketMessageBody } from "../ticketMessageBody.ts"; + +interface DispatchRecoveryRow { + readonly dispatchId: string; + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly threadId: string; + readonly turnId: string | null; + readonly status: "pending" | "started" | "confirmed"; +} + +interface LeaseRecoveryRow { + readonly worktreeRef: string; + readonly ownerId: string; + readonly fenceToken: number; +} + +interface ScriptRecoveryRow { + readonly scriptRunId: ScriptRunId; + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; +} + +interface PersistedBoardRecoveryRow { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly workflowFilePath: string; +} + +const SCRIPT_RESTART_ERROR = "script interrupted by server restart"; +const MERGE_RESTART_ERROR = "merge interrupted by server restart"; +const STEP_RESTART_ERROR = "step interrupted by server restart"; +const PR_OPEN_RESTART_ERROR = "PR open interrupted by restart"; +const PR_LAND_RESTART_ERROR = "land interrupted by restart"; + +interface MergeRecoveryRow { + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly repoRoot: string | null; +} + +interface StrandedPipelineRow { + readonly stepRunId: StepRunId; + readonly status: "completed" | "failed" | "blocked"; + readonly error: string | null; + readonly retryable: number | null; + readonly outputJson: string | null; +} + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const toRecoveryError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = <A>(effect: Effect.Effect<A, SqlError>) => + effect.pipe(Effect.mapError(toRecoveryError("workflow recovery sql failed"))); + +const hasNotFoundReason = (cause: unknown): boolean => { + if (typeof cause !== "object" || cause === null) { + return false; + } + if ("reason" in cause) { + const reason = (cause as { readonly reason?: unknown }).reason; + if ( + typeof reason === "object" && + reason !== null && + "_tag" in reason && + (reason as { readonly _tag?: unknown })._tag === "NotFound" + ) { + return true; + } + } + if ("cause" in cause) { + return hasNotFoundReason((cause as { readonly cause?: unknown }).cause); + } + return false; +}; + +const isMissingWorkflowFileError = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "message" in cause && + String((cause as { readonly message?: unknown }).message).includes("workflow file read failed") && + hasNotFoundReason(cause); + +type TerminalStepEvent = Extract< + PersistedWorkflowEvent, + { readonly type: "StepCompleted" | "StepFailed" | "StepBlocked" } +>; + +const isTerminalStepEvent = (event: PersistedWorkflowEvent): event is TerminalStepEvent => + event.type === "StepCompleted" || event.type === "StepFailed" || event.type === "StepBlocked"; + +// Shared mapping from a step's stored terminal outcome to the recovered +// result: a step that already reached a terminal state must be recovered +// with that state, never a synthesized restart failure. +const toRecoveredStepResult = (terminal: { + readonly status: "completed" | "failed" | "blocked"; + readonly error: string | null; + readonly retryable: boolean; + readonly output: unknown; +}): RecoveredStepResult => + terminal.status === "completed" + ? { _tag: "completed", ...(terminal.output === undefined ? {} : { output: terminal.output }) } + : terminal.status === "blocked" + ? { _tag: "blocked", reason: terminal.error ?? "step blocked" } + : { + _tag: "failed", + error: terminal.error ?? "step failed", + ...(terminal.retryable ? {} : { retryable: false }), + }; + +const recoveredResultFromTerminalEvent = (event: TerminalStepEvent): RecoveredStepResult => + event.type === "StepCompleted" + ? toRecoveredStepResult({ + status: "completed", + error: null, + retryable: true, + output: event.payload.output, + }) + : event.type === "StepBlocked" + ? toRecoveredStepResult({ + status: "blocked", + error: event.payload.reason, + retryable: true, + output: undefined, + }) + : toRecoveredStepResult({ + status: "failed", + error: event.payload.error, + retryable: event.payload.retryable !== false, + output: undefined, + }); + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; + const outbox = yield* ProviderDispatchOutbox; + const turns = yield* TurnStateReader; + const approvals = yield* DurableApprovalResume; + const committer = yield* WorkflowEventCommitter; + const engine = yield* WorkflowEngine; + const ids = yield* WorkflowIds; + const store = yield* WorkflowEventStore; + const leases = yield* WorktreeLeaseService; + const boardRegistry = yield* BoardRegistry; + const readModel = yield* WorkflowReadModel; + const saveLocks = yield* WorkflowBoardSaveLocks; + const versionStore = yield* WorkflowBoardVersionStore; + const worktreeJanitor = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<WorkflowWorktreeJanitor>, + WorkflowWorktreeJanitor, + ); + const mergeGit = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<MergeGitPort>, + MergeGitPort, + ); + // PR recovery inspects external state through gh. Trimmed test layers without + // a GitHubPort fall back to "not found" → failed, never crash recovery. + const gitHub = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<GitHubPort>, + GitHubPort, + ); + const webhook = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<WorkflowWebhook>, + WorkflowWebhook, + ); + const agentSessions = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<WorkflowAgentSessionStore>, + WorkflowAgentSessionStore, + ); + const providerService = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<ProviderService>, + ProviderService, + ); + // Spread into both board-deletion cascade calls so a missing-file board's + // per-agent sessions are torn down (A8). + const agentSessionDeletionDeps = { + ...(Option.isSome(agentSessions) ? { agentSessions: agentSessions.value } : {}), + ...(Option.isSome(providerService) ? { provider: providerService.value } : {}), + }; + + const getOptionalBoardLoaders = Effect.context<never>().pipe( + Effect.map((context) => ({ + fileLoader: Context.getOption( + context as Context.Context<WorkflowFileLoader>, + WorkflowFileLoader, + ), + projectWorkspaceResolver: Context.getOption( + context as Context.Context<ProjectWorkspaceResolver>, + ProjectWorkspaceResolver, + ), + })), + ); + + const ticketEvents = (ticketId: TicketId) => + Stream.runCollect(store.readByTicket(ticketId)).pipe(Effect.map((chunk) => Array.from(chunk))); + + const hasTerminalStepEvent = ( + events: ReadonlyArray<PersistedWorkflowEvent>, + stepRunId: StepRunId, + ) => events.some((event) => isTerminalStepEvent(event) && event.payload.stepRunId === stepRunId); + + const latestTerminalStepEvent = ( + events: ReadonlyArray<PersistedWorkflowEvent>, + stepRunId: StepRunId, + ): TerminalStepEvent | null => + events.reduce<TerminalStepEvent | null>( + (latest, event) => + isTerminalStepEvent(event) && event.payload.stepRunId === stepRunId ? event : latest, + null, + ); + + const latestAwaitingStepEvent = ( + events: ReadonlyArray<PersistedWorkflowEvent>, + stepRunId: StepRunId, + ) => + events.reduce<Extract<PersistedWorkflowEvent, { readonly type: "StepAwaitingUser" }> | null>( + (latest, event) => { + if (event.type === "StepAwaitingUser" && event.payload.stepRunId === stepRunId) { + return event; + } + if (event.type === "StepUserResolved" && event.payload.stepRunId === stepRunId) { + return null; + } + return latest; + }, + null, + ); + + const hasScriptExitedEvent = ( + events: ReadonlyArray<PersistedWorkflowEvent>, + scriptRunId: ScriptRunId, + ) => + events.some( + (event) => event.type === "ScriptStepExited" && event.payload.scriptRunId === scriptRunId, + ); + + const commitAwaitingTerminalStep = ( + row: DispatchRecoveryRow, + result: Extract<ProviderDispatchTerminalResult, { readonly awaitingUser: true }>, + ) => + Effect.gen(function* () { + const events = yield* ticketEvents(row.ticketId); + if (hasTerminalStepEvent(events, row.stepRunId)) { + return; + } + const latestAwait = latestAwaitingStepEvent(events, row.stepRunId); + if ( + latestAwait !== null && + latestAwait.payload.waitingReason === result.waitingReason && + latestAwait.payload.providerThreadId === result.providerThreadId && + latestAwait.payload.providerRequestId === result.providerRequestId && + latestAwait.payload.providerResponseKind === result.providerResponseKind && + latestAwait.payload.providerQuestionId === result.providerQuestionId + ) { + return; + } + + const eventId = yield* ids.eventId(); + const occurredAt = yield* nowIso; + const awaitEvent = { + type: "StepAwaitingUser", + eventId, + ticketId: row.ticketId, + occurredAt, + payload: { + stepRunId: row.stepRunId, + waitingReason: result.waitingReason, + providerThreadId: result.providerThreadId, + providerRequestId: result.providerRequestId, + providerResponseKind: result.providerResponseKind, + ...(result.providerQuestionId === undefined + ? {} + : { providerQuestionId: result.providerQuestionId }), + }, + } satisfies WorkflowEventInput; + if (result.providerResponseKind !== "user-input") { + yield* committer.commit(awaitEvent); + return; + } + yield* committer.commitMany([ + awaitEvent, + { + type: "TicketMessagePosted", + eventId: yield* ids.eventId(), + ticketId: row.ticketId, + occurredAt, + payload: { + messageId: (yield* ids.messageId()) as MessageId, + stepRunId: row.stepRunId, + author: "agent", + body: truncateTicketMessageBody(result.waitingReason), + attachments: [], + createdAt: occurredAt, + }, + } satisfies WorkflowEventInput, + ]); + }); + + const completeTerminalPipeline = ( + row: DispatchRecoveryRow, + result: ProviderDispatchTerminalResult, + ) => + "awaitingUser" in result + ? Effect.void + : engine.completeRecoveredStep( + row.stepRunId, + result.ok + ? { _tag: "completed" } + : { _tag: "failed", error: result.error ?? "turn failed" }, + row.turnId === null + ? undefined + : { threadId: row.threadId as ThreadId, turnId: row.turnId as TurnId }, + ); + + const interruptProjectedTurn = (row: DispatchRecoveryRow) => + row.turnId === null + ? Effect.void + : nowIso.pipe( + Effect.flatMap((interruptedAt) => + wrapSql(sql` + UPDATE projection_turns + SET state = 'interrupted', + completed_at = ${interruptedAt} + WHERE thread_id = ${row.threadId} + AND turn_id = ${row.turnId} + AND state IN ('pending', 'running') + `), + ), + ); + + const deleteOrphanDispatches = wrapSql(sql` + DELETE FROM workflow_dispatch_outbox + WHERE NOT EXISTS ( + SELECT 1 + FROM projection_ticket AS ticket + INNER JOIN projection_board AS board + ON board.board_id = ticket.board_id + WHERE ticket.ticket_id = workflow_dispatch_outbox.ticket_id + ) + `).pipe(Effect.asVoid); + + const recoverTerminalDispatches = Effect.gen(function* () { + yield* deleteOrphanDispatches; + const rows = yield* wrapSql(sql<DispatchRecoveryRow>` + SELECT + dispatch_id AS "dispatchId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId", + thread_id AS "threadId", + turn_id AS "turnId", + status + FROM workflow_dispatch_outbox + WHERE status != 'confirmed' + `); + + for (const row of rows) { + if (row.status === "pending") { + continue; + } + // Interrupted panels are settled by settleInterruptedPanelDispatches + // before this stage runs; any panel row still unconfirmed here must + // not be recovered single-dispatch (one member's terminal turn would + // decide the whole panel) nor reset for re-dispatch. + if (yield* isPanelStep(row.stepRunId as string)) { + continue; + } + const state = yield* turns.read(row.threadId as never); + if (state._tag === "running") { + if (row.status === "started") { + yield* interruptProjectedTurn(row); + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'pending', + started_at = NULL, + turn_id = NULL + WHERE dispatch_id = ${row.dispatchId} + AND status = 'started' + `); + } + continue; + } + const result = yield* outbox.awaitTerminal(row.dispatchId as never, row.threadId as never); + if ("awaitingUser" in result) { + yield* commitAwaitingTerminalStep(row, result); + } + yield* completeTerminalPipeline(row, result); + } + }); + + const releaseTerminalStepLeases = Effect.gen(function* () { + const rows = yield* wrapSql(sql<LeaseRecoveryRow>` + SELECT + leases.worktree_ref AS "worktreeRef", + leases.owner_id AS "ownerId", + leases.fence_token AS "fenceToken" + FROM worktree_lease AS leases + WHERE leases.owner_kind = 'step' + AND EXISTS ( + SELECT 1 + FROM workflow_events AS events + WHERE events.event_type IN ('StepCompleted', 'StepFailed', 'StepBlocked') + AND json_extract(events.payload_json, '$.stepRunId') = leases.owner_id + ) + `); + for (const row of rows) { + yield* leases.release(row.worktreeRef, row.fenceToken); + } + }); + + const recoverRunningScriptRuns = Effect.gen(function* () { + const rows = yield* wrapSql(sql<ScriptRecoveryRow>` + SELECT + script_run_id AS "scriptRunId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId" + FROM workflow_script_run + WHERE status = 'running' + `); + + for (const row of rows) { + const events = yield* ticketEvents(row.ticketId); + if (!hasScriptExitedEvent(events, row.scriptRunId)) { + yield* committer.commit({ + type: "ScriptStepExited", + eventId: yield* ids.eventId(), + ticketId: row.ticketId, + occurredAt: yield* nowIso, + payload: { + scriptRunId: row.scriptRunId, + exitCode: null, + signal: null, + outcome: "cancelled", + }, + } satisfies WorkflowEventInput); + } + // Same crash window as merge recovery: a stored terminal event means + // the step already finished — recover its outcome, don't fail it. + const terminal = latestTerminalStepEvent(events, row.stepRunId); + if (terminal !== null) { + yield* engine.completeRecoveredStep( + row.stepRunId, + recoveredResultFromTerminalEvent(terminal), + ); + continue; + } + yield* committer.commit({ + type: "StepFailed", + eventId: yield* ids.eventId(), + ticketId: row.ticketId, + occurredAt: yield* nowIso, + payload: { + stepRunId: row.stepRunId, + error: SCRIPT_RESTART_ERROR, + }, + } satisfies WorkflowEventInput); + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "failed", + error: SCRIPT_RESTART_ERROR, + }); + } + }); + + // Decide what a crash mid-merge actually did to the repo: the merge may + // have landed (commit created before the event commit), be sitting half + // done with MERGE_HEAD set, or never have started. Without git access we + // conservatively report failure. + const inspectInterruptedMerge = ( + repoRoot: string | null, + ticketId: TicketId, + ): Effect.Effect<RecoveredStepResult> => + Effect.gen(function* () { + const failed: RecoveredStepResult = { _tag: "failed", error: MERGE_RESTART_ERROR }; + if (repoRoot === null || Option.isNone(mergeGit)) { + return failed; + } + const git = mergeGit.value; + const worktreeRef = `workflow/${ticketId}`; + + const mergeHead = yield* git.run({ + cwd: repoRoot, + args: ["rev-parse", "-q", "--verify", "MERGE_HEAD"], + allowNonZeroExit: true, + }); + if (mergeHead.exitCode === 0) { + const refTip = yield* git.run({ + cwd: repoRoot, + args: ["rev-parse", "-q", "--verify", `refs/heads/${worktreeRef}`], + allowNonZeroExit: true, + }); + if (refTip.exitCode === 0 && refTip.stdout.trim() === mergeHead.stdout.trim()) { + // The half-finished merge is ours: clean the repo up and let a + // human re-run the lane. + yield* git + .run({ cwd: repoRoot, args: ["merge", "--abort"], allowNonZeroExit: true }) + .pipe(Effect.ignore); + return { + _tag: "blocked", + reason: "Merge interrupted by server restart; the in-progress merge was aborted.", + } satisfies RecoveredStepResult; + } + // Someone else's merge — leave the repo alone. + return { + _tag: "blocked", + reason: + "Merge interrupted by server restart and the repo has an unrelated in-progress merge.", + } satisfies RecoveredStepResult; + } + + const ancestor = yield* git.run({ + cwd: repoRoot, + args: ["merge-base", "--is-ancestor", worktreeRef, "HEAD"], + allowNonZeroExit: true, + }); + if (ancestor.exitCode === 0) { + // The ticket branch is fully contained in HEAD: the merge landed + // before the crash (or there was nothing to merge). + return { _tag: "completed" } satisfies RecoveredStepResult; + } + return failed; + }).pipe( + Effect.orElseSucceed( + (): RecoveredStepResult => ({ _tag: "failed", error: MERGE_RESTART_ERROR }), + ), + ); + + const recoverRunningMergeSteps = Effect.gen(function* () { + const rows = yield* wrapSql(sql<MergeRecoveryRow>` + SELECT + step.ticket_id AS "ticketId", + step.step_run_id AS "stepRunId", + ( + SELECT projects.workspace_root + FROM projection_ticket AS ticket + INNER JOIN projection_board AS board + ON board.board_id = ticket.board_id + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE ticket.ticket_id = step.ticket_id + ) AS "repoRoot" + FROM projection_step_run AS step + WHERE step.step_type = 'merge' + AND step.status IN ('running', 'dispatch_requested') + `); + + for (const row of rows) { + const events = yield* ticketEvents(row.ticketId); + // A crash between the terminal event append and its projection leaves + // the step 'running' even though it already finished — recover the + // stored outcome instead of synthesizing a failure. + const terminal = latestTerminalStepEvent(events, row.stepRunId); + if (terminal !== null) { + yield* engine.completeRecoveredStep( + row.stepRunId, + recoveredResultFromTerminalEvent(terminal), + ); + continue; + } + const result = yield* inspectInterruptedMerge(row.repoRoot, row.ticketId); + yield* engine.completeRecoveredStep(row.stepRunId, result); + } + }); + + // Resolve a pullRequest step's `action` ("open" | "land") from its board + // definition. Returns null when the step def is no longer resolvable (board + // edited/unloaded) — the caller then fails the step honestly. + const resolvePullRequestAction = (boardId: BoardId, stepKey: string) => + Effect.gen(function* () { + const definition = yield* boardRegistry.getDefinition(boardId); + const step = definition?.lanes + .flatMap((lane) => lane.pipeline ?? []) + .find((candidate) => candidate.key === stepKey); + return step !== undefined && step.type === "pullRequest" ? step.action : null; + }); + + // Recovery is by inspection (no retry budget), mirroring merge recovery. A PR + // step left 'running' after a crash is settled by checking external state: + // - open : getTicketPrState is the authority. A recorded row means + // TicketPrOpened already committed → completed. No row means the open + // never committed; if a PR was nonetheless created on the remote + // (crash-after-create-before-commit) findPrForBranch adopts it, committing + // the missing TicketPrOpened. No PR found → failed. + // - land : prDetail on the recorded PR. state "merged" → completed; anything + // else (or no recorded PR) → failed (a land cannot have succeeded without a + // recorded PR to merge). + const recoverRunningPullRequestSteps = Effect.gen(function* () { + const rows = yield* wrapSql(sql<{ + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly stepKey: string; + readonly boardId: BoardId; + readonly repoRoot: string | null; + }>` + SELECT + step.ticket_id AS "ticketId", + step.step_run_id AS "stepRunId", + step.step_key AS "stepKey", + ticket.board_id AS "boardId", + ( + SELECT projects.workspace_root + FROM projection_board AS board + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE board.board_id = ticket.board_id + ) AS "repoRoot" + FROM projection_step_run AS step + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = step.ticket_id + WHERE step.step_type = 'pullRequest' + AND step.status IN ('running', 'dispatch_requested') + `); + + for (const row of rows) { + const events = yield* ticketEvents(row.ticketId); + // Same crash window as merge recovery: a stored terminal event means the + // step already finished — recover its outcome. + const terminal = latestTerminalStepEvent(events, row.stepRunId); + if (terminal !== null) { + yield* engine.completeRecoveredStep( + row.stepRunId, + recoveredResultFromTerminalEvent(terminal), + ); + continue; + } + + const action = yield* resolvePullRequestAction(row.boardId, row.stepKey); + const prState = yield* readModel.getTicketPrState(row.ticketId); + + if (action === "land") { + // A land cannot have landed without a recorded PR to merge. + if (prState === null || Option.isNone(gitHub) || row.repoRoot === null) { + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "failed", + error: PR_LAND_RESTART_ERROR, + }); + continue; + } + // A transient prDetail failure (network/rate-limit/auth blip during + // startup) must NOT be conflated with a confirmed not-merged state: the + // PR may have actually merged before the crash. Swallowing the error to + // null and synthesizing a retryable failure would re-run `land`, whose + // mergePr on the already-merged PR returns not-ok and blocks the ticket. + // So: only the success channel decides merged-vs-failed (and a confirmed + // not-merged is retryable — re-running land legitimately retries the + // merge that never happened). On the error channel we cannot confirm + // merge, so fail NON-retryably and leave it for honest manual recovery + // rather than auto-driving an already-merged PR into 'blocked'. + const recovered = yield* gitHub.value + .prDetail({ cwd: row.repoRoot, prNumber: prState.prNumber }) + .pipe( + Effect.matchEffect({ + onSuccess: (detail) => + Effect.succeed( + detail.state === "merged" + ? ({ _tag: "completed" } satisfies RecoveredStepResult) + : ({ + _tag: "failed", + error: PR_LAND_RESTART_ERROR, + } satisfies RecoveredStepResult), + ), + onFailure: (cause) => + Effect.logWarning("workflow.recovery.land-pr-detail-failed", { + ticketId: row.ticketId, + stepRunId: row.stepRunId, + prNumber: prState.prNumber, + cause, + }).pipe( + Effect.as({ + _tag: "failed", + error: PR_LAND_RESTART_ERROR, + retryable: false, + } satisfies RecoveredStepResult), + ), + }), + ); + yield* engine.completeRecoveredStep(row.stepRunId, recovered); + continue; + } + + // action === "open" (or an unresolvable step def — treat as open). + if (prState !== null) { + // TicketPrOpened already committed: the PR exists and was recorded. + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "completed", + output: { prNumber: prState.prNumber, url: prState.prUrl }, + }); + continue; + } + + // No recorded PR. A PR may still have been created on the remote before + // the crash; adopt it by branch, committing the missing TicketPrOpened. + // Without a gh port or a repo root there is no way to look one up. + if (Option.isNone(gitHub) || row.repoRoot === null) { + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "failed", + error: PR_OPEN_RESTART_ERROR, + }); + continue; + } + const github = gitHub.value; + const repoRoot = row.repoRoot; + const branch = `workflow/${row.ticketId}`; + const found = yield* github + .findPrForBranch({ cwd: repoRoot, branch }) + .pipe(Effect.orElseSucceed(() => null)); + if (found === null) { + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "failed", + error: PR_OPEN_RESTART_ERROR, + }); + continue; + } + + // The remote PR is real but unrecorded: commit TicketPrOpened so the + // PR-state projection is consistent, then complete the step. resolveRemote + // backfills the remote/repo metadata the open action would have recorded. + const remote = yield* github.resolveRemote(repoRoot).pipe(Effect.orElseSucceed(() => null)); + yield* committer.commit({ + type: "TicketPrOpened", + eventId: yield* ids.eventId(), + ticketId: row.ticketId, + occurredAt: yield* nowIso, + payload: { + stepRunId: row.stepRunId, + prNumber: found.number, + url: found.url, + branch, + remoteName: remote?.remoteName ?? "origin", + repo: remote?.repo ?? "", + }, + } as WorkflowEventInput); + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "completed", + output: { prNumber: found.number, url: found.url }, + }); + } + }); + + // A crash between a step's terminal event and the next step (or the + // PipelineCompleted commit) leaves the pipeline run 'running' with no live + // fiber: nothing would ever route the ticket or release its WIP slot. + // Resume those pipelines from their latest terminal step. Pipelines with a + // pending/started dispatch are owned by the outbox monitors, and pipelines + // whose ticket has already moved lanes are excluded by the token match. + const resumeStrandedPipelines = Effect.gen(function* () { + const rows = yield* wrapSql(sql<StrandedPipelineRow>` + SELECT + step.step_run_id AS "stepRunId", + step.status, + step.error, + step.retryable, + step.output_json AS "outputJson" + FROM projection_pipeline_run AS pipeline + INNER JOIN projection_step_run AS step + ON step.rowid = ( + SELECT candidate.rowid + FROM projection_step_run AS candidate + WHERE candidate.pipeline_run_id = pipeline.pipeline_run_id + ORDER BY candidate.started_at DESC, candidate.rowid DESC + LIMIT 1 + ) + WHERE pipeline.status = 'running' + AND step.status IN ('completed', 'failed', 'blocked') + AND pipeline.lane_entry_token = ( + SELECT ticket.current_lane_entry_token + FROM projection_ticket AS ticket + WHERE ticket.ticket_id = pipeline.ticket_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM workflow_dispatch_outbox AS outbox + WHERE outbox.ticket_id = pipeline.ticket_id + AND outbox.status IN ('pending', 'started') + ) + `); + + const parseOutput = (outputJson: string | null): unknown => { + if (outputJson === null) { + return undefined; + } + try { + return JSON.parse(outputJson) as unknown; + } catch { + return undefined; + } + }; + + for (const row of rows) { + yield* engine.completeRecoveredStep( + row.stepRunId, + toRecoveredStepResult({ + status: row.status, + error: row.error, + retryable: row.retryable !== 0, + output: parseOutput(row.outputJson), + }), + ); + } + }); + + // Panel detection must not depend on outbox row status: a sequential panel + // crashed mid-member leaves earlier members 'confirmed' (and later members + // not yet dispatched), so the started-row group can shrink to a single row + // even though the step is a panel. Resolve the step definition instead. + const isPanelStep = (stepRunId: string) => + Effect.gen(function* () { + const stepRows = yield* wrapSql(sql<{ + readonly stepKey: string; + readonly boardId: BoardId; + }>` + SELECT + step.step_key AS "stepKey", + ticket.board_id AS "boardId" + FROM projection_step_run AS step + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = step.ticket_id + WHERE step.step_run_id = ${stepRunId} + `); + const stepRow = stepRows[0]; + if (stepRow !== undefined) { + const definition = yield* boardRegistry.getDefinition(stepRow.boardId); + const step = definition?.lanes + .flatMap((lane) => lane.pipeline ?? []) + .find((candidate) => candidate.key === stepRow.stepKey); + if (step !== undefined) { + // Mirrors the executor's panel gate (RealStepExecutor): a panel + // only fans out when captureOutput is set. + return step.type === "agent" && (step.panel ?? 0) >= 2 && step.captureOutput === true; + } + } + // The step definition is not resolvable (board edited or unloaded); + // fall back to counting every outbox row for the step regardless of + // status — a panel fans out several dispatches under one stepRunId. + const counts = yield* wrapSql(sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE step_run_id = ${stepRunId} + `); + return (counts[0]?.count ?? 0) > 1; + }); + + // An interrupted panel cannot be resumed member-by-member: settle every + // member row and fail the step honestly (retryable) instead. + const settleInterruptedPanel = (stepRunId: string) => + Effect.gen(function* () { + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE step_run_id = ${stepRunId} + `); + yield* engine.completeRecoveredStep(stepRunId as never, { + _tag: "failed", + error: "review panel interrupted by restart", + retryable: true, + }); + }).pipe(Effect.ignoreCause({ log: true })); + + // Panel rows must be settled before any single-dispatch stage touches the + // outbox: recoverTerminalDispatches would complete the whole panel from one + // member's terminal turn, and its row reset would let recoverPending start + // a fresh provider turn for a dead panel member that nothing ever stops. + const settleInterruptedPanelDispatches = Effect.gen(function* () { + yield* deleteOrphanDispatches; + const rows = yield* wrapSql(sql<{ readonly stepRunId: StepRunId }>` + SELECT DISTINCT step_run_id AS "stepRunId" + FROM workflow_dispatch_outbox + WHERE status != 'confirmed' + `); + for (const row of rows) { + if (yield* isPanelStep(row.stepRunId as string)) { + yield* settleInterruptedPanel(row.stepRunId as string); + } + } + }); + + // Crash window: awaitTerminal (or the panel settlement) confirmed a step's + // outbox rows but the process died before the engine committed the step's + // terminal event. Every dispatch stage keys off non-confirmed rows and + // resumeStrandedPipelines keys off projection-terminal steps, so nothing + // else would ever settle the step — the ticket would stick 'running' + // forever. Steps awaiting user input are excluded twice over: their + // projection status is 'awaiting_user' and their dispatch row stays + // 'started' until the wait resolves. + // + // No provider-session cleanup happens here (or anywhere in recovery): + // recovery runs once at server startup, when every adapter session + // registry is empty — interruptTurn/stopSession would only fail with + // session-not-found. An agent child process orphaned by a hard-killed + // server is unreachable through the provider API entirely; reining those + // in would take OS-level lifecycle tracking, not a recovery-time call. + const recoverConfirmedRunningSteps = Effect.gen(function* () { + const rows = yield* wrapSql(sql<{ + readonly stepRunId: StepRunId; + readonly ticketId: TicketId; + }>` + SELECT + step.step_run_id AS "stepRunId", + step.ticket_id AS "ticketId" + FROM projection_step_run AS step + WHERE step.status = 'running' + AND EXISTS ( + SELECT 1 + FROM workflow_dispatch_outbox AS outbox + WHERE outbox.step_run_id = step.step_run_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM workflow_dispatch_outbox AS outbox + WHERE outbox.step_run_id = step.step_run_id + AND outbox.status != 'confirmed' + ) + `); + for (const row of rows) { + const events = yield* ticketEvents(row.ticketId); + const terminal = latestTerminalStepEvent(events, row.stepRunId); + yield* engine.completeRecoveredStep( + row.stepRunId, + terminal !== null + ? recoveredResultFromTerminalEvent(terminal) + : { _tag: "failed", error: STEP_RESTART_ERROR }, + ); + } + }); + + const monitorStartedDispatches = Effect.gen(function* () { + yield* deleteOrphanDispatches; + const allRows = yield* wrapSql(sql<DispatchRecoveryRow>` + SELECT + dispatch_id AS "dispatchId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId", + thread_id AS "threadId", + turn_id AS "turnId", + status + FROM workflow_dispatch_outbox + WHERE status = 'started' + `); + + // Review-panel steps fan out several dispatches under one stepRunId. + // Single-dispatch recovery would let the first member's terminal state + // complete the whole step without a majority, so an interrupted panel + // fails honestly (retryable) and its member rows are settled instead. + const rowsByStep = new Map<string, DispatchRecoveryRow[]>(); + for (const row of allRows) { + const group = rowsByStep.get(row.stepRunId as string) ?? []; + group.push(row); + rowsByStep.set(row.stepRunId as string, group); + } + const rows: DispatchRecoveryRow[] = []; + for (const [stepRunId, group] of rowsByStep) { + if (group.length === 1 && group[0] !== undefined && !(yield* isPanelStep(stepRunId))) { + rows.push(group[0]); + continue; + } + yield* settleInterruptedPanel(stepRunId); + } + + yield* Effect.forEach( + rows, + (row) => + Effect.gen(function* () { + const result = yield* outbox.awaitTerminal( + row.dispatchId as never, + row.threadId as never, + ); + if ("awaitingUser" in result) { + yield* commitAwaitingTerminalStep(row, result); + } + yield* completeTerminalPipeline(row, result); + yield* releaseTerminalStepLeases; + }).pipe( + // Recovery monitors must not block startup. These continuations are not + // registered as live pipeline fibers, so manual moves cannot interrupt + // this narrow restart window. + Effect.ignoreCause({ log: true }), + Effect.forkDetach({ startImmediately: true }), + Effect.asVoid, + ), + { discard: true }, + ); + }); + + const preloadPersistedBoards = Effect.gen(function* () { + const rows = yield* wrapSql(sql<PersistedBoardRecoveryRow>` + SELECT + board_id AS "boardId", + project_id AS "projectId", + workflow_file_path AS "workflowFilePath" + FROM projection_board + ORDER BY board_id ASC + `); + + const { fileLoader, projectWorkspaceResolver } = yield* getOptionalBoardLoaders; + const staleBoardIds = new Set<string>(); + if (Option.isSome(fileLoader) && Option.isSome(projectWorkspaceResolver)) { + for (const row of rows) { + yield* saveLocks.withSaveLock( + row.boardId, + Effect.gen(function* () { + const currentBoard = yield* readModel.getBoard(row.boardId); + if (currentBoard === null) { + staleBoardIds.add(row.boardId as string); + return; + } + + const workspaceRoot = yield* projectWorkspaceResolver.value + .resolve(currentBoard.projectId as ProjectId) + .pipe(Effect.mapError(toRecoveryError("workflow recovery project resolve failed"))); + const workflowFilePath = currentBoard.workflowFilePath; + const fileExists = yield* fileSystem + .exists(path.resolve(workspaceRoot, workflowFilePath)) + .pipe(Effect.mapError(toRecoveryError("workflow recovery board file check failed"))); + + if (!fileExists) { + yield* deleteWorkflowBoardOwnedState( + { + boardRegistry, + engine, + eventStore: store, + readModel, + versionStore, + sql, + ...(Option.isSome(worktreeJanitor) + ? { worktreeJanitor: worktreeJanitor.value } + : {}), + ...(Option.isSome(webhook) ? { webhook: webhook.value } : {}), + ...agentSessionDeletionDeps, + }, + row.boardId, + ).pipe(Effect.mapError(toRecoveryError("workflow recovery board cascade failed"))); + staleBoardIds.add(row.boardId as string); + return; + } + + yield* fileLoader.value + .loadAndRegister({ + boardId: row.boardId, + projectId: currentBoard.projectId as ProjectId, + workspaceRoot, + relativePath: workflowFilePath, + }) + .pipe( + Effect.catch((cause) => + isMissingWorkflowFileError(cause) + ? deleteWorkflowBoardOwnedState( + { + boardRegistry, + engine, + eventStore: store, + readModel, + versionStore, + sql, + ...(Option.isSome(worktreeJanitor) + ? { worktreeJanitor: worktreeJanitor.value } + : {}), + ...(Option.isSome(webhook) ? { webhook: webhook.value } : {}), + ...agentSessionDeletionDeps, + }, + row.boardId, + ).pipe( + Effect.tap(() => + Effect.sync(() => staleBoardIds.add(row.boardId as string)), + ), + Effect.mapError(toRecoveryError("workflow recovery board cascade failed")), + ) + : Effect.fail(toRecoveryError("workflow recovery board preload failed")(cause)), + ), + ); + }), + ); + } + } + + return rows + .filter((row) => !staleBoardIds.has(row.boardId as string)) + .map((row) => row.boardId); + }); + + const recoverWorkflowWip = Effect.gen(function* () { + const boardIds = yield* preloadPersistedBoards; + for (const boardId of boardIds) { + yield* engine.recoverBoardWip(boardId); + } + }); + + const recover: WorkflowRecoveryShape["recover"] = () => + Effect.gen(function* () { + yield* recoverWorkflowWip; + yield* approvals.resume(); + yield* settleInterruptedPanelDispatches; + yield* recoverTerminalDispatches; + yield* recoverRunningScriptRuns; + yield* recoverRunningMergeSteps; + yield* recoverRunningPullRequestSteps; + // Must run before recoverPending: tombstoneStaleDispatches also + // confirms rows, and those superseded steps are not this sweep's + // target (completeRecoveredStep's token guard handles them anyway). + yield* recoverConfirmedRunningSteps; + yield* outbox.recoverPending(); + yield* monitorStartedDispatches; + yield* resumeStrandedPipelines; + yield* releaseTerminalStepLeases; + }); + + return { recover } satisfies WorkflowRecoveryShape; +}); + +export const WorkflowRecoveryLive = Layer.effect(WorkflowRecovery, make); diff --git a/apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.test.ts b/apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.test.ts new file mode 100644 index 00000000000..ba6fddccdfa --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.test.ts @@ -0,0 +1,156 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowRoutingContextBuilder } from "../Services/WorkflowRoutingContextBuilder.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowProjectionPipelineLive } from "./WorkflowProjectionPipeline.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const layer = it.layer( + WorkflowRoutingContextBuilderLive.pipe( + Layer.provideMerge(WorkflowProjectionPipelineLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowRoutingContextBuilder", (it) => { + it.effect("builds routing context from the pipeline-scoped read model", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const builder = yield* WorkflowRoutingContextBuilder; + const base = { + ticketId: "t-routing-context" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "routing-context-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Routing context" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "routing-context-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-routing-context" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-routing-context" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "routing-context-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-routing-context" as never, + stepRunId: "sr-routing-tests" as never, + stepKey: "tests" as never, + stepType: "script", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepStarted", + eventId: "routing-context-d" as never, + streamVersion: 3, + payload: { + scriptRunId: "script-routing-context" as never, + stepRunId: "sr-routing-tests" as never, + scriptThreadId: "workflow-script:script-routing-context" as never, + terminalId: "script-routing-context" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepExited", + eventId: "routing-context-e" as never, + streamVersion: 4, + payload: { + scriptRunId: "script-routing-context" as never, + exitCode: 1, + signal: null, + outcome: "exited", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepCompleted", + eventId: "routing-context-f" as never, + streamVersion: 5, + payload: { stepRunId: "sr-routing-tests" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "routing-context-g" as never, + streamVersion: 6, + payload: { + pipelineRunId: "pr-routing-context" as never, + stepRunId: "sr-routing-review" as never, + stepKey: "review" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepCompleted", + eventId: "routing-context-h" as never, + streamVersion: 7, + payload: { + stepRunId: "sr-routing-review" as never, + output: { verdict: "block" }, + }, + } as never); + + // lane.runCount is computed over the ordered event log; mirror the + // projected PipelineStarted there. + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO workflow_events + (event_id, ticket_id, stream_version, event_type, occurred_at, payload_json) + VALUES ( + 'routing-context-pipeline-started', + 't-routing-context', + 100, + 'PipelineStarted', + '2026-06-07T00:00:00.000Z', + '{"pipelineRunId":"pr-routing-context","laneKey":"implement","laneEntryToken":"tok-routing-context"}' + ) + `; + + const context = yield* builder.build({ + ticketId: "t-routing-context" as never, + pipelineRunId: "pr-routing-context" as never, + result: "failure", + }); + + assert.deepEqual(context, { + pipeline: { result: "failure" }, + lane: { runCount: 1 }, + status: "running", + steps: { + tests: { exitCode: 1, status: "completed", output: null }, + review: { exitCode: null, status: "completed", output: { verdict: "block" } }, + }, + }); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.ts b/apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.ts new file mode 100644 index 00000000000..babf42583d6 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.ts @@ -0,0 +1,52 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { + WorkflowRoutingContextBuilder, + type WorkflowRoutingContextBuilderShape, +} from "../Services/WorkflowRoutingContextBuilder.ts"; + +const make = Effect.gen(function* () { + const readModel = yield* WorkflowReadModel; + + const build: WorkflowRoutingContextBuilderShape["build"] = (input) => + Effect.gen(function* () { + const detail = yield* readModel.getTicketDetail(input.ticketId); + if (!detail) { + return yield* new WorkflowEventStoreError({ + message: `ticket not found while building routing context: ${input.ticketId}`, + }); + } + + const laneRunCount = yield* readModel.countLanePipelineRuns(input.pipelineRunId); + const rows = yield* readModel.listStepRunsForPipeline(input.pipelineRunId); + // A retried step has one projection_step_run row per attempt sharing the + // same stepKey. listStepRunsForPipeline orders by started_at ASC, rowid + // ASC, so fromEntries deliberately keeps the LAST (most recent) attempt: + // routing predicates evaluate against the final attempt's outcome, not + // any earlier failed attempt. Predicates cannot observe prior attempts. + const steps = Object.fromEntries( + rows.map((row) => [ + row.stepKey, + { + exitCode: row.exitCode, + status: row.status, + output: row.output, + }, + ]), + ); + + return { + pipeline: { result: input.result }, + lane: { runCount: laneRunCount }, + status: detail.ticket.status, + steps, + }; + }); + + return { build } satisfies WorkflowRoutingContextBuilderShape; +}); + +export const WorkflowRoutingContextBuilderLive = Layer.effect(WorkflowRoutingContextBuilder, make); diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts new file mode 100644 index 00000000000..d6c3ca24d4a --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts @@ -0,0 +1,8656 @@ +import { createHash } from "node:crypto"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + type BoardListEntry, + BoardId, + LaneKey, + type ProjectId, + StepKey, + StepRunId, + TicketId, + WORKFLOW_WS_METHODS, + WorkflowDefinition, + type WorkflowDefinition as WorkflowDefinitionType, + type WorkflowDefinitionEncoded, + WorkflowRpcError, + TextGenerationError, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { + proposeBoardImprovement, + listBoardProposals, + getBoardProposal, + resolveBoardProposal, + revertBoardProposal, + validateAndCreateBoard, + createWorkflowBoard, + generateWorkflowDraft, + workflowRpcHandlers, +} from "./WorkflowRpcHandlers.ts"; +import { BOARD_TEMPLATES } from "../boardTemplates.ts"; +import { makeWorkflowBoardSaveLocks } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardVersionStoreLive } from "./WorkflowBoardVersionStore.ts"; +import { defaultBoardDefinition } from "../defaultBoard.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import type { ProjectScriptTrustShape } from "../Services/ProjectScriptTrust.ts"; +import type { WorkSourceConnectionStoreShape } from "../Services/WorkSourceConnectionStore.ts"; +import { WorkSourceAuthError } from "../Services/WorkSourceProvider.ts"; +import type { + WorkflowBoardVersionRecordInput, + WorkflowBoardVersionSource, + WorkflowBoardVersionStoreShape, +} from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowBoardVersionStore } from "../Services/WorkflowBoardVersionStore.ts"; +import type { WorkflowReadModelShape } from "../Services/WorkflowReadModel.ts"; +import { + encodeWorkflowDefinitionJson, + lintWorkflowDefinition, + type LintError, +} from "../workflowFile.ts"; +import { MAX_PREDICATE_DEPTH } from "../jsonLogicRule.ts"; + +const noopProjectScriptTrust = { + isTrusted: () => Effect.succeed(false), + setTrusted: () => Effect.void, +} satisfies ProjectScriptTrustShape; + +const noopConnectionStore = { + getToken: (connectionRef: string, _expectedProvider) => + Effect.fail(new WorkSourceAuthError({ connectionRef })), + getConnectionAuth: (connectionRef: string, _expectedProvider) => + Effect.fail(new WorkSourceAuthError({ connectionRef })), + create: () => Effect.die("noopConnectionStore.create not implemented"), + list: () => Effect.succeed([]), + remove: () => Effect.void, +} satisfies WorkSourceConnectionStoreShape; + +const noopVersionStore = { + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, +} satisfies WorkflowBoardVersionStoreShape; + +const noopReadModel = { + registerBoard: () => Effect.void, + getBoard: () => Effect.succeed(null), + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + listTickets: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + getTicketDetail: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listTicketDiscussion: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + listDependentTicketIds: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + getBoardMetrics: () => + Effect.succeed({ + windowDays: 7, + generatedAt: "2026-06-07T00:00:00.000Z", + throughput: { created: 0, shipped: 0 }, + cycleTime: { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }, + wipByLane: [], + statusBreakdown: {}, + attention: { blocked: 0, waitingOnUser: 0, oldest: [] }, + routeOutcomes: [], + manualMoveCount: 0, + stepStats: [], + }), + listNeedsAttentionTickets: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listStepRunsForPipeline: () => Effect.succeed([]), + getTicketPrState: () => Effect.succeed(null), + recordBoardProposal: () => Effect.void, + listBoardProposals: () => Effect.succeed([]), + getBoardProposal: () => Effect.succeed(null), + listLiveOccupiedLanes: () => Effect.succeed([]), + resolveBoardProposalStatus: () => Effect.succeed(1), + listWorkSourceMappingsForBoard: () => Effect.succeed([]), +} satisfies WorkflowReadModelShape; + +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); +const encodeWorkflowDefinition = Schema.encodeSync(WorkflowDefinition); +const sha256Hex = (value: string) => createHash("sha256").update(value).digest("hex"); + +const versionRoundTripLayer = it.layer( + WorkflowBoardVersionStoreLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const invokeWorkflowHandler = <A>( + handlers: ReturnType<typeof workflowRpcHandlers>, + method: string, + input: unknown, +): Effect.Effect<A, WorkflowRpcError> => { + const handler = ( + handlers as unknown as Record<string, (input: unknown) => Effect.Effect<A, WorkflowRpcError>> + )[method]; + return handler + ? handler(input) + : Effect.fail(new WorkflowRpcError({ message: `${method} handler is not registered` })); +}; + +it.effect("workflowRpcHandlers maps createTicket and subscribeBoard", () => + Effect.gen(function* () { + const boardId = BoardId.make("board-1"); + const backlog = LaneKey.make("backlog"); + const review = LaneKey.make("review"); + const definition = { + name: "Delivery", + lanes: [ + { key: backlog, name: "Backlog", entry: "manual" }, + { + key: review, + name: "Review", + entry: "manual", + wipLimit: 2, + pipeline: [{ key: StepKey.make("approve"), type: "approval", prompt: "Approve?" }], + }, + ], + } satisfies WorkflowDefinitionType; + let editedTicket: unknown = null; + let answeredStep: unknown = null; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.succeed(TicketId.make("ticket-created")), + editTicket: (input) => + Effect.sync(() => { + editedTicket = input; + }), + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: (input) => + Effect.sync(() => { + answeredStep = input; + }), + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-1", + name: "Delivery", + workflowFilePath: ".t3/boards/delivery.json", + workflowVersionHash: "hash", + maxConcurrentTickets: 2, + }), + listTickets: () => + Effect.succeed([ + { + ticketId: "ticket-1", + boardId, + title: "Existing", + description: null, + currentLaneKey: "backlog", + currentLaneEntryToken: null, + queuedAt: "2026-06-07T00:00:00.000Z", + totalTokens: null, + totalDurationMs: null, + status: "idle", + }, + ]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(definition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.succeed(boardId), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/project"), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const created = yield* handlers[WORKFLOW_WS_METHODS.createTicket]({ + boardId, + title: "New ticket", + initialLane: backlog, + }); + yield* handlers[WORKFLOW_WS_METHODS.editTicket]({ + ticketId: TicketId.make("ticket-1"), + title: "Updated", + description: "", + }); + yield* handlers[WORKFLOW_WS_METHODS.answerTicketStep]({ + stepRunId: StepRunId.make("step-1"), + text: "Use sandbox.", + attachments: [], + }); + const streamItems = Array.from( + yield* handlers[WORKFLOW_WS_METHODS.subscribeBoard]({ boardId }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ); + + assert.deepEqual(created, { ticketId: "ticket-created" }); + assert.deepEqual(editedTicket, { + ticketId: TicketId.make("ticket-1"), + title: "Updated", + description: "", + }); + assert.deepEqual(answeredStep, { + stepRunId: StepRunId.make("step-1"), + text: "Use sandbox.", + attachments: [], + }); + assert.equal(streamItems[0]?.kind, "snapshot"); + if (streamItems[0]?.kind === "snapshot") { + assert.equal(streamItems[0].snapshot.board.name, "Delivery"); + assert.equal(streamItems[0].snapshot.board.lanes[0]?.pipelineStepCount, 0); + assert.equal(streamItems[0].snapshot.board.lanes[1]?.pipelineStepCount, 1); + assert.equal(streamItems[0].snapshot.board.lanes[1]?.wipLimit, 2); + assert.equal(streamItems[0].snapshot.tickets[0]?.title, "Existing"); + assert.equal(streamItems[0].snapshot.tickets[0]?.queuedAt, "2026-06-07T00:00:00.000Z"); + } + }), +); + +it.effect("workflowRpcHandlers lists and creates boards without a client path", () => + Effect.gen(function* () { + const projectId = "project-rpc" as ProjectId; + const projectRoot = "/tmp/project-rpc-root"; + const rows = new Map< + string, + { + readonly boardId: string; + readonly projectId: string; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; + } + >(); + const definitions = new Map<string, WorkflowDefinitionType>(); + const entries: BoardListEntry[] = []; + const writes: Array<{ + readonly projectRoot: string; + readonly relativePath: string; + readonly contents: string; + }> = []; + const versionRecords: WorkflowBoardVersionRecordInput[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: (boardId) => Effect.succeed(rows.get(boardId as string) ?? null), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: (boardId) => Effect.succeed(definitions.get(boardId as string) ?? null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.sync(() => { + const content = writes.find( + (write) => write.relativePath === input.relativePath, + )?.contents; + const definition = defaultBoardDefinition({ + name: input.relativePath.includes("-2") ? "Workflow Board" : "Workflow Board", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }); + rows.set(input.boardId as string, { + boardId: input.boardId, + projectId: input.projectId, + name: definition.name, + workflowFilePath: input.relativePath, + workflowVersionHash: sha256Hex(content ?? ""), + maxConcurrentTickets: 3, + }); + definitions.set(input.boardId as string, definition); + entries.push({ + boardId: input.boardId, + name: definition.name, + filePath: input.relativePath, + error: null, + }); + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: (input) => + Effect.sync(() => { + versionRecords.push(input); + }), + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed(entries), + list: () => Effect.succeed(entries), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(projectRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("writeFile must not be used"), + createFileExclusive: (input) => + Effect.sync(() => { + writes.push(input); + return { relativePath: input.relativePath }; + }), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const overlongCreate = yield* Effect.exit( + handlers[WORKFLOW_WS_METHODS.createBoard]({ + projectId, + name: "A".repeat(129), + agent: { instance: "codex_main", model: "gpt-5.5" }, + }), + ); + assert.strictEqual(overlongCreate._tag, "Failure"); + assert.deepEqual(writes, []); + + assert.deepEqual(yield* handlers[WORKFLOW_WS_METHODS.listBoards]({ projectId }), []); + + const first = yield* handlers[WORKFLOW_WS_METHODS.createBoard]({ + projectId, + name: "Workflow Board", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }); + const second = yield* handlers[WORKFLOW_WS_METHODS.createBoard]({ + projectId, + name: "Workflow Board", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }); + + assert.equal(first.boardId, `${projectId}__workflow-board`); + assert.equal(first.snapshot.projectId, projectId); + assert.equal(second.boardId, `${projectId}__workflow-board-2`); + assert.deepEqual( + writes.map((write) => ({ + projectRoot: write.projectRoot, + relativePath: write.relativePath, + })), + [ + { projectRoot, relativePath: ".t3/boards/workflow-board.json" }, + { projectRoot, relativePath: ".t3/boards/workflow-board-2.json" }, + ], + ); + assert.deepEqual( + versionRecords.map((record) => ({ + boardId: record.boardId, + versionHash: record.versionHash, + contentJson: record.contentJson, + source: record.source, + })), + [ + { + boardId: first.boardId, + versionHash: sha256Hex(writes[0]!.contents), + contentJson: writes[0]!.contents, + source: "create", + }, + { + boardId: second.boardId, + versionHash: sha256Hex(writes[1]!.contents), + contentJson: writes[1]!.contents, + source: "create", + }, + ], + ); + assert.deepEqual( + (yield* handlers[WORKFLOW_WS_METHODS.listBoards]({ projectId })).map( + (entry) => entry.boardId, + ), + [`${projectId}__workflow-board`, `${projectId}__workflow-board-2`], + ); + }), +); + +it.effect( + "workflowRpcHandlers deletes the board file before clearing registration and history", + () => + Effect.gen(function* () { + const boardId = BoardId.make("project-rpc__delete-me"); + const projectId = "project-rpc" as ProjectId; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-delete-board-", + }); + const boardFilePath = path.join(workspaceRoot, ".t3/boards/delete-me.json"); + yield* fileSystem.makeDirectory(path.join(workspaceRoot, ".t3/boards"), { recursive: true }); + yield* fileSystem.writeFileString(boardFilePath, "{}\n"); + const operations: string[] = []; + const fileDeletes: Array<{ readonly cwd: string; readonly relativePath: string }> = []; + const registryUnregistered: BoardId[] = []; + const readModelDeleted: BoardId[] = []; + const versionsDeleted: BoardId[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId + ? { + boardId, + projectId, + name: "Delete Me", + workflowFilePath: ".t3/boards/delete-me.json", + workflowVersionHash: "hash-delete-me", + maxConcurrentTickets: 3, + } + : null, + ), + deleteBoard: (inputBoardId) => + Effect.sync(() => { + operations.push("delete-projection"); + readModelDeleted.push(inputBoardId); + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: (inputBoardId) => + Effect.sync(() => { + operations.push("unregister"); + registryUnregistered.push(inputBoardId); + }), + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: (inputBoardId) => + Effect.sync(() => { + operations.push("delete-versions"); + versionsDeleted.push(inputBoardId); + }), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: (input) => + Effect.gen(function* () { + operations.push("delete-file"); + fileDeletes.push(input); + yield* fileSystem + .remove(path.join(input.cwd, input.relativePath), { force: true }) + .pipe(Effect.orDie); + }), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + yield* invokeWorkflowHandler<void>(handlers, WORKFLOW_WS_METHODS.deleteBoard, { + boardId, + relativePath: "../client-supplied-escape.json", + }); + + const deletedStat = yield* fileSystem + .stat(boardFilePath) + .pipe(Effect.orElseSucceed(() => null)); + assert.isNull(deletedStat); + assert.deepEqual(fileDeletes, [ + { cwd: workspaceRoot, relativePath: ".t3/boards/delete-me.json" }, + ]); + // The DB cascade (versions → projection) runs inside the deletion + // transaction; the in-memory unregister happens AFTER it commits, so a + // rollback leaves the board consistently registered. + assert.deepEqual(operations, [ + "delete-file", + "delete-versions", + "delete-projection", + "unregister", + ]); + assert.deepEqual(registryUnregistered, [boardId]); + assert.deepEqual(readModelDeleted, [boardId]); + assert.deepEqual(versionsDeleted, [boardId]); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "workflowRpcHandlers cascades board-owned state before deleting the board projection", + () => + Effect.gen(function* () { + const boardId = BoardId.make("project-rpc__cascade-delete"); + const projectId = "project-rpc" as ProjectId; + const operations: string[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: (inputBoardId: BoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("cancel-pipelines"); + }), + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId + ? { + boardId, + projectId, + name: "Cascade Delete", + workflowFilePath: ".t3/boards/cascade-delete.json", + workflowVersionHash: "hash-cascade-delete", + maxConcurrentTickets: 3, + } + : null, + ), + listTickets: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId + ? [ + { + ticketId: "ticket-cascade-a", + boardId, + title: "A", + description: null, + currentLaneKey: "backlog", + currentLaneEntryToken: null, + queuedAt: null, + totalTokens: null, + totalDurationMs: null, + status: "idle", + }, + { + ticketId: "ticket-cascade-b", + boardId, + title: "B", + description: null, + currentLaneKey: "backlog", + currentLaneEntryToken: null, + queuedAt: null, + totalTokens: null, + totalDurationMs: null, + status: "idle", + }, + ] + : [], + ), + deleteBoardTicketState: (inputBoardId: BoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("delete-ticket-state"); + }), + deleteBoard: (inputBoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("delete-board"); + }), + }, + eventStore: { + deleteForBoard: (inputBoardId: BoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("delete-events"); + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: (inputBoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("unregister"); + }), + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: (inputBoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("delete-versions"); + }), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/workspace/project-rpc"), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => + Effect.sync(() => { + operations.push("delete-file"); + }), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + yield* invokeWorkflowHandler<void>(handlers, WORKFLOW_WS_METHODS.deleteBoard, { boardId }); + + // The board-owned DB cascade (versions → events → ticket-state → board) + // runs inside the deletion transaction; the in-memory unregister happens + // AFTER it commits (so a rollback leaves the board consistently registered). + assert.deepEqual(operations, [ + "delete-file", + "cancel-pipelines", + "delete-versions", + "delete-events", + "delete-ticket-state", + "delete-board", + "unregister", + ]); + }), +); + +it.effect("workflowRpcHandlers completes deleteBoard retry after a mid-cascade failure", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-rpc__retry-delete"); + const projectId = "project-rpc" as ProjectId; + let boardProjectionPresent = true; + let versionRows = 1; + let ticketRows = 1; + let eventRows = 1; + let outboxRows = 1; + let setupRows = 1; + let failProjectionDeleteOnce = true; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId && boardProjectionPresent + ? { + boardId, + projectId, + name: "Retry Delete", + workflowFilePath: ".t3/boards/retry-delete.json", + workflowVersionHash: "hash-retry-delete", + maxConcurrentTickets: 3, + } + : null, + ), + listTickets: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId && ticketRows > 0 + ? [ + { + ticketId: "ticket-retry-delete", + boardId, + title: "Retry ticket", + description: null, + currentLaneKey: "backlog", + currentLaneEntryToken: null, + queuedAt: null, + totalTokens: null, + totalDurationMs: null, + status: "idle", + }, + ] + : [], + ), + deleteBoardTicketState: () => + Effect.sync(() => { + ticketRows = 0; + outboxRows = 0; + setupRows = 0; + }), + deleteBoard: () => + Effect.sync(() => { + boardProjectionPresent = false; + }).pipe( + Effect.andThen( + failProjectionDeleteOnce + ? Effect.sync(() => { + failProjectionDeleteOnce = false; + }).pipe( + Effect.andThen( + Effect.fail( + new WorkflowEventStoreError({ + message: "simulated post-projection failure", + }), + ), + ), + ) + : Effect.void, + ), + ), + }, + eventStore: { + deleteForBoard: () => + Effect.sync(() => { + eventRows = 0; + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => + Effect.sync(() => { + versionRows = 0; + }), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/workspace/project-rpc"), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.void, + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + let firstAttemptFailed = false; + yield* invokeWorkflowHandler<void>(handlers, WORKFLOW_WS_METHODS.deleteBoard, { + boardId, + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + firstAttemptFailed = error.message === "Failed to delete workflow board state"; + }), + ), + ); + assert.isTrue(firstAttemptFailed); + assert.isFalse(boardProjectionPresent); + assert.equal(versionRows, 0); + + versionRows = 1; + ticketRows = 1; + eventRows = 1; + outboxRows = 1; + setupRows = 1; + + yield* invokeWorkflowHandler<void>(handlers, WORKFLOW_WS_METHODS.deleteBoard, { boardId }); + + assert.deepEqual( + { + boardProjectionPresent, + versionRows, + ticketRows, + eventRows, + outboxRows, + setupRows, + }, + { + boardProjectionPresent: false, + versionRows: 0, + ticketRows: 0, + eventRows: 0, + outboxRows: 0, + setupRows: 0, + }, + ); + }), +); + +it.effect("workflowRpcHandlers rejects deleteBoard whose derived path is not a board file", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-rpc__unsafe-delete"); + const sideEffects: string[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-rpc", + name: "Unsafe Delete", + workflowFilePath: ".t3/boards/../escape.json", + workflowVersionHash: "hash-unsafe-delete", + maxConcurrentTickets: 3, + }), + deleteBoard: () => + Effect.sync(() => { + sideEffects.push("delete-projection"); + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => + Effect.sync(() => { + sideEffects.push("unregister"); + }), + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => + Effect.sync(() => { + sideEffects.push("delete-versions"); + }), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.die("resolve must not run for unsafe delete paths"), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => + Effect.sync(() => { + sideEffects.push("delete-file"); + }), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const result = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.deleteBoard, { boardId }), + ); + + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(result.cause.toString().includes("not a deletable workflow board file")); + } + assert.deepEqual(sideEffects, []); + }), +); + +it.effect("workflowRpcHandlers includes route history in ticket detail", () => + Effect.gen(function* () { + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getTicketDetail: () => + Effect.succeed({ + ticket: { + ticketId: "ticket-route-rpc", + boardId: "board-route-rpc", + title: "Routed", + description: null, + currentLaneKey: "review", + currentLaneEntryToken: null, + queuedAt: null, + totalTokens: null, + totalDurationMs: null, + status: "idle", + }, + steps: [], + messages: [], + } as never), + listTicketRouteDecisions: () => + Effect.succeed([ + { + occurredAt: "2026-06-07T00:00:01.000Z", + fromLane: "implement", + toLane: "review", + source: "lane_transition" as const, + matchedTransitionIndex: 1, + eventName: null, + pipelineResult: "success" as const, + laneRunCount: 2, + steps: { + verdict: { status: "completed", exitCode: 0, verdict: "approve" }, + }, + }, + { + occurredAt: "2026-06-07T00:00:02.000Z", + fromLane: null, + toLane: "implement", + source: "manual" as const, + matchedTransitionIndex: null, + eventName: null, + pipelineResult: null, + laneRunCount: null, + steps: null, + }, + ]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/project"), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const detail = yield* handlers[WORKFLOW_WS_METHODS.getTicketDetail]({ + ticketId: TicketId.make("ticket-route-rpc"), + }); + + assert.equal(detail.routeHistory?.length, 2); + const first = detail.routeHistory?.[0]; + assert.equal(first?.fromLane, "implement"); + assert.equal(first?.source, "lane_transition"); + assert.equal(first?.matchedTransitionIndex, 1); + assert.equal(first?.pipelineResult, "success"); + assert.equal(first?.laneRunCount, 2); + assert.deepEqual(first?.steps?.["verdict"], { + status: "completed", + exitCode: 0, + verdict: "approve", + }); + const second = detail.routeHistory?.[1]; + assert.equal(second?.source, "manual"); + assert.equal(second?.fromLane, undefined); + assert.equal(second?.matchedTransitionIndex, undefined); + assert.equal(second?.steps, undefined); + }), +); + +it.effect("workflowRpcHandlers delegates project script trust updates", () => + Effect.gen(function* () { + const projectId = "project-trust-rpc" as ProjectId; + const updates: Array<{ readonly projectId: ProjectId; readonly trusted: boolean }> = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: noopReadModel, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/project"), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + projectScriptTrust: { + isTrusted: () => Effect.die("unused"), + setTrusted: (inputProjectId, trusted) => + Effect.sync(() => { + updates.push({ projectId: inputProjectId, trusted }); + }), + }, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + yield* handlers[WORKFLOW_WS_METHODS.setProjectScriptTrust]({ + projectId, + trusted: true, + }); + + assert.deepEqual(updates, [{ projectId, trusted: true }]); + }), +); + +it.effect("workflowRpcHandlers delegates cooperative step cancellation", () => + Effect.gen(function* () { + const stepRunId = StepRunId.make("step-run-cancel-rpc"); + const cancelled: StepRunId[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + completeRecoveredStep: () => Effect.void, + recoverBoardWip: () => Effect.void, + cancelStep: (inputStepRunId) => + Effect.sync(() => { + cancelled.push(inputStepRunId); + }), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + }, + readModel: noopReadModel, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/project"), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + yield* handlers[WORKFLOW_WS_METHODS.cancelStep]({ stepRunId }); + + assert.deepEqual(cancelled, [stepRunId]); + }), +); + +it.effect("workflowRpcHandlers gets and saves encoded board definitions", () => + Effect.gen(function* () { + const projectId = "project-editor-rpc" as ProjectId; + const boardId = BoardId.make("project-editor-rpc__delivery"); + const workspaceRoot = "/tmp/editor-rpc-project"; + const workflowFilePath = ".t3/boards/delivery.json"; + const originalDefinition = yield* decodeWorkflowDefinition({ + name: "Delivery", + lanes: [ + { + key: "run", + name: "Run", + entry: "auto", + pipeline: [{ key: "smoke", type: "script", run: "pnpm test", timeout: "5 minutes" }], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const editedDefinition = yield* decodeWorkflowDefinition({ + name: "Delivery Edited", + lanes: [ + { key: "queue", name: "Queue", entry: "manual", wipLimit: 2 }, + { + key: "run", + name: "Run", + entry: "auto", + pipeline: [{ key: "smoke", type: "script", run: "pnpm test", timeout: "5 minutes" }], + transitions: [{ when: { var: "pipeline.result" }, to: "done" }], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const editedDefinitionEncoded = encodeWorkflowDefinition(editedDefinition); + const originalRaw = `${encodeWorkflowDefinitionJson(originalDefinition)}\n`; + const originalHash = sha256Hex(originalRaw); + let fileContents = originalRaw; + let registryDefinition = originalDefinition; + let boardRow = { + boardId, + projectId, + name: originalDefinition.name, + workflowFilePath, + workflowVersionHash: originalHash, + maxConcurrentTickets: 3, + }; + const writes: Array<{ + readonly cwd: string; + readonly relativePath: string; + readonly contents: string; + }> = []; + const versionRecords: WorkflowBoardVersionRecordInput[] = []; + let failNextVersionRecord = false; + let failedVersionRecordAttempts = 0; + const lintedDefinitions: WorkflowDefinitionType[] = []; + const loadedBoards: Array<{ + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly relativePath: string; + }> = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => Effect.succeed(boardRow), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: (input) => + Effect.sync(() => { + lintedDefinitions.push(input.definition); + return []; + }), + loadAndRegister: (input) => + Effect.sync(() => { + loadedBoards.push(input); + registryDefinition = editedDefinition; + boardRow = { + ...boardRow, + name: editedDefinition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: (input) => + failNextVersionRecord + ? Effect.sync(() => { + failNextVersionRecord = false; + failedVersionRecordAttempts += 1; + }).pipe( + Effect.andThen( + Effect.fail( + new WorkflowEventStoreError({ message: "version record unavailable" }), + ), + ), + ) + : Effect.sync(() => { + versionRecords.push(input); + }), + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot, relativePath: workflowFilePath }); + return fileContents; + }), + writeFile: (input) => + Effect.sync(() => { + writes.push(input); + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const loaded = yield* invokeWorkflowHandler<{ + readonly definition: unknown; + readonly versionHash: string; + }>(handlers, WORKFLOW_WS_METHODS.getBoardDefinition, { boardId }); + assert.equal(loaded.versionHash, originalHash); + const loadedStep = ( + (loaded.definition as { readonly lanes: readonly unknown[] }).lanes[0] as { + readonly pipeline?: readonly unknown[]; + } + ).pipeline?.[0] as { readonly timeout?: unknown } | undefined; + assert.isDefined(loadedStep); + assert.isString(loadedStep.timeout); + + const saved = yield* invokeWorkflowHandler< + | { + readonly ok: true; + readonly definition: unknown; + readonly versionHash: string; + readonly snapshot: { readonly board: { readonly name: string } }; + } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: editedDefinitionEncoded, + expectedVersionHash: originalHash, + workflowFilePath: ".t3/boards/client-supplied.json", + }); + + assert.equal(saved.ok, true); + if (saved.ok !== true) { + assert.fail("expected successful save"); + } + assert.equal(saved.versionHash, sha256Hex(writes[0]!.contents)); + assert.equal(saved.snapshot.board.name, "Delivery Edited"); + assert.equal(lintedDefinitions[0]?.name, "Delivery Edited"); + assert.deepEqual( + versionRecords.map((record) => ({ + boardId: record.boardId, + versionHash: record.versionHash, + contentJson: record.contentJson, + source: record.source, + })), + [ + { + boardId, + versionHash: sha256Hex(writes[0]!.contents), + contentJson: writes[0]!.contents, + source: "save", + }, + ], + ); + assert.deepEqual( + writes.map((write) => ({ + cwd: write.cwd, + relativePath: write.relativePath, + })), + [{ cwd: workspaceRoot, relativePath: workflowFilePath }], + ); + const writtenDefinition = yield* decodeWorkflowDefinitionJson(writes[0]!.contents); + assert.equal(writtenDefinition.name, "Delivery Edited"); + const writtenStep = writtenDefinition.lanes[1]?.pipeline?.[0]; + assert.isDefined(writtenStep); + assert.equal(writtenStep.type, "script"); + assert.deepEqual(loadedBoards, [ + { boardId, projectId, workspaceRoot, relativePath: workflowFilePath }, + ]); + const savedStep = ( + (saved.definition as { readonly lanes: readonly unknown[] }).lanes[1] as { + readonly pipeline?: readonly unknown[]; + } + ).pipeline?.[0] as { readonly timeout?: unknown } | undefined; + assert.isDefined(savedStep); + assert.isString(savedStep.timeout); + + const revertedDefinition = yield* decodeWorkflowDefinition({ + name: "Delivery Reverted", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const reverted = yield* invokeWorkflowHandler< + | { + readonly ok: true; + readonly definition: unknown; + readonly versionHash: string; + } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(revertedDefinition), + expectedVersionHash: saved.versionHash, + source: "revert", + }); + assert.equal(reverted.ok, true); + if (reverted.ok !== true) { + assert.fail("expected successful revert save"); + } + assert.equal(versionRecords.at(-1)?.source, "revert"); + assert.equal(versionRecords.at(-1)?.contentJson, writes.at(-1)?.contents); + + const afterBestEffortFailureDefinition = yield* decodeWorkflowDefinition({ + name: "Delivery After History Failure", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + failNextVersionRecord = true; + const savedDespiteHistoryFailure = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(afterBestEffortFailureDefinition), + expectedVersionHash: reverted.versionHash, + }); + assert.equal(savedDespiteHistoryFailure.ok, true); + assert.equal(failedVersionRecordAttempts, 1); + }), +); + +it.effect( + "workflowRpcHandlers renames a board display name in file, projection, registry, and history", + () => + Effect.gen(function* () { + const projectId = "project-rename-rpc" as ProjectId; + const boardId = BoardId.make("project-rename-rpc__delivery"); + const workspaceRoot = "/tmp/rename-rpc-project"; + const workflowFilePath = ".t3/boards/delivery.json"; + const originalDefinition = yield* decodeWorkflowDefinition({ + name: "Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + let fileContents = `${encodeWorkflowDefinitionJson(originalDefinition)}\n`; + let registryDefinition = originalDefinition; + let boardRow = { + boardId, + projectId, + name: originalDefinition.name, + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + const writes: Array<{ + readonly cwd: string; + readonly relativePath: string; + readonly contents: string; + }> = []; + const versionRecords: WorkflowBoardVersionRecordInput[] = []; + const lintedDefinitions: WorkflowDefinitionType[] = []; + const loadedBoards: Array<{ + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly relativePath: string; + }> = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => Effect.succeed(boardRow), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: (input) => + Effect.sync(() => { + lintedDefinitions.push(input.definition); + return []; + }), + loadAndRegister: (input) => + Effect.gen(function* () { + loadedBoards.push(input); + const definition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + registryDefinition = definition; + boardRow = { + ...boardRow, + name: definition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: (input) => + Effect.sync(() => { + versionRecords.push(input); + }), + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot, relativePath: workflowFilePath }); + return fileContents; + }), + writeFile: (input) => + Effect.sync(() => { + writes.push(input); + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + saveLocks: yield* makeWorkflowBoardSaveLocks, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + yield* invokeWorkflowHandler<void>(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: "Delivery Renamed", + }); + + assert.equal(boardRow.name, "Delivery Renamed"); + assert.equal(registryDefinition.name, "Delivery Renamed"); + assert.equal(lintedDefinitions[0]?.name, "Delivery Renamed"); + assert.deepEqual( + writes.map((write) => ({ + cwd: write.cwd, + relativePath: write.relativePath, + })), + [{ cwd: workspaceRoot, relativePath: workflowFilePath }], + ); + const writtenDefinition = yield* decodeWorkflowDefinitionJson(writes[0]!.contents); + assert.equal(writtenDefinition.name, "Delivery Renamed"); + assert.deepEqual(loadedBoards, [ + { boardId, projectId, workspaceRoot, relativePath: workflowFilePath }, + ]); + assert.deepEqual( + versionRecords.map((record) => ({ + boardId: record.boardId, + versionHash: record.versionHash, + contentJson: record.contentJson, + source: record.source, + })), + [ + { + boardId, + versionHash: sha256Hex(writes[0]!.contents), + contentJson: writes[0]!.contents, + source: "rename", + }, + ], + ); + }), +); + +it.effect( + "workflowRpcHandlers rolls the board file back when registration fails post-write, then a retry succeeds", + () => + Effect.gen(function* () { + const projectId = "project-rename-rpc" as ProjectId; + const boardId = BoardId.make("project-rename-rpc__retry"); + const workspaceRoot = "/tmp/rename-rpc-retry"; + const workflowFilePath = ".t3/boards/retry.json"; + const originalDefinition = yield* decodeWorkflowDefinition({ + name: "Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + let fileContents = `${encodeWorkflowDefinitionJson(originalDefinition)}\n`; + let registryDefinition = originalDefinition; + let boardRow = { + boardId, + projectId, + name: originalDefinition.name, + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + let failNextRegistration = true; + const writes: Array<{ + readonly cwd: string; + readonly relativePath: string; + readonly contents: string; + }> = []; + const loadedBoards: Array<{ + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly relativePath: string; + }> = []; + const versionRecords: WorkflowBoardVersionRecordInput[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => Effect.succeed(boardRow), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.gen(function* () { + loadedBoards.push(input); + if (failNextRegistration) { + failNextRegistration = false; + return yield* new WorkflowRpcError({ message: "registration unavailable" }); + } + + const definition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + registryDefinition = definition; + boardRow = { + ...boardRow, + name: definition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: (input) => + Effect.sync(() => { + versionRecords.push(input); + }), + list: () => + Effect.succeed( + versionRecords.map((record, index) => ({ + versionId: versionRecords.length - index, + versionHash: record.versionHash, + source: record.source, + createdAt: `2026-06-08T00:00:0${index}.000Z`, + })), + ), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: (input) => + Effect.sync(() => { + writes.push(input); + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + saveLocks: yield* makeWorkflowBoardSaveLocks, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const failed = yield* Effect.exit( + invokeWorkflowHandler<void>(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: "Delivery Renamed", + }), + ); + assert.strictEqual(failed._tag, "Failure"); + assert.equal(boardRow.name, "Delivery"); + assert.equal(registryDefinition.name, "Delivery"); + // Attempt 1 wrote the rename, then rolled the durable file back to the + // original when registration failed (saves are all-or-nothing). + const failedWrite = yield* decodeWorkflowDefinitionJson(writes[0]!.contents); + assert.equal(failedWrite.name, "Delivery Renamed"); + assert.equal(writes.length, 2); + const rolledBack = yield* decodeWorkflowDefinitionJson(writes[1]!.contents); + assert.equal(rolledBack.name, "Delivery"); + + yield* invokeWorkflowHandler<void>(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: "Delivery Renamed", + }); + + assert.equal(boardRow.name, "Delivery Renamed"); + assert.equal(registryDefinition.name, "Delivery Renamed"); + // 3 writes total: rename (attempt 1) → rollback restore → rename (retry). + assert.deepEqual( + writes.map((write) => write.relativePath), + [workflowFilePath, workflowFilePath, workflowFilePath], + ); + assert.deepEqual( + loadedBoards.map((loaded) => loaded.relativePath), + [workflowFilePath, workflowFilePath], + ); + assert.deepEqual( + versionRecords.map((record) => ({ + boardId: record.boardId, + versionHash: record.versionHash, + contentJson: record.contentJson, + source: record.source, + })), + [ + { + boardId, + versionHash: sha256Hex(fileContents), + contentJson: fileContents, + source: "rename", + }, + ], + ); + }), +); + +it.effect("workflowRpcHandlers rejects blank board rename names before touching the file", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-rename-rpc__blank"); + const sideEffects: string[] = []; + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => + Effect.sync(() => { + sideEffects.push("get-board"); + return null; + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => + Effect.sync(() => { + sideEffects.push("lint"); + return []; + }), + loadAndRegister: () => + Effect.sync(() => { + sideEffects.push("load"); + return boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => + Effect.sync(() => { + sideEffects.push("resolve"); + return "/tmp/blank-rename"; + }), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => + Effect.sync(() => { + sideEffects.push("read"); + return "{}"; + }), + writeFile: () => + Effect.sync(() => { + sideEffects.push("write"); + return { relativePath: ".t3/boards/blank.json" }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const blank = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: " ", + }), + ); + + assert.strictEqual(blank._tag, "Failure"); + assert.deepEqual(sideEffects, []); + + const overlong = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: "A".repeat(129), + }), + ); + assert.strictEqual(overlong._tag, "Failure"); + assert.deepEqual(sideEffects, []); + }), +); + +it.effect("workflowRpcHandlers treats unchanged board rename names as a no-op", () => + Effect.gen(function* () { + const projectId = "project-rename-rpc" as ProjectId; + const boardId = BoardId.make("project-rename-rpc__unchanged"); + const workspaceRoot = "/tmp/rename-rpc-unchanged"; + const workflowFilePath = ".t3/boards/unchanged.json"; + const definition = yield* decodeWorkflowDefinition({ + name: "Delivery", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const fileContents = `${encodeWorkflowDefinitionJson(definition)}\n`; + const sideEffects: string[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId, + name: "Delivery", + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(definition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => + Effect.sync(() => { + sideEffects.push("lint"); + return []; + }), + loadAndRegister: () => + Effect.sync(() => { + sideEffects.push("load"); + return boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: () => + Effect.sync(() => { + sideEffects.push("version"); + }), + list: () => + Effect.succeed([ + { + versionId: 1, + versionHash: sha256Hex(fileContents), + source: "rename", + createdAt: "2026-06-08T00:00:00.000Z", + }, + ]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: () => + Effect.sync(() => { + sideEffects.push("write"); + return { relativePath: workflowFilePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + saveLocks: yield* makeWorkflowBoardSaveLocks, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + yield* invokeWorkflowHandler<void>(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: "Delivery", + }); + + assert.deepEqual(sideEffects, []); + }), +); + +it.effect("workflowRpcHandlers reports missing boards during rename without writing", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-rename-rpc__missing"); + const sideEffects: string[] = []; + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: noopReadModel, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => + Effect.sync(() => { + sideEffects.push("lint"); + return []; + }), + loadAndRegister: () => + Effect.sync(() => { + sideEffects.push("load"); + return boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => + Effect.sync(() => { + sideEffects.push("resolve"); + return "/tmp/missing-rename"; + }), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => + Effect.sync(() => { + sideEffects.push("read"); + return "{}"; + }), + writeFile: () => + Effect.sync(() => { + sideEffects.push("write"); + return { relativePath: ".t3/boards/missing.json" }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + saveLocks: yield* makeWorkflowBoardSaveLocks, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const result = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: "Missing renamed", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(result.cause.toString().includes(`Workflow board ${boardId} was not found`)); + } + assert.deepEqual(sideEffects, []); + }), +); + +it.effect( + "workflowRpcHandlers serializes rename racing delete without resurrecting board state", + () => + Effect.gen(function* () { + const projectId = "project-rename-rpc" as ProjectId; + const boardId = BoardId.make("project-rename-rpc__race-delete"); + const workspaceRoot = "/tmp/rename-rpc-race-delete"; + const workflowFilePath = ".t3/boards/race-delete.json"; + const originalDefinition = yield* decodeWorkflowDefinition({ + name: "Race Delete", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + let filePresent = true; + let fileContents = `${encodeWorkflowDefinitionJson(originalDefinition)}\n`; + let registryDefinition: WorkflowDefinitionType | null = originalDefinition; + let boardProjectionPresent = true; + let boardRow = { + boardId, + projectId, + name: originalDefinition.name, + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + const versionRecords: WorkflowBoardVersionRecordInput[] = []; + const renameWriteStarted = yield* Deferred.make<void>(); + const allowRenameWrite = yield* Deferred.make<void>(); + const saveLocks = yield* makeWorkflowBoardSaveLocks; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => Effect.succeed(boardProjectionPresent ? boardRow : null), + deleteBoard: () => + Effect.sync(() => { + boardProjectionPresent = false; + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => + Effect.sync(() => { + registryDefinition = null; + }), + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + registryDefinition = definition; + boardRow = { + ...boardRow, + name: definition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + boardProjectionPresent = true; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: (input) => + Effect.sync(() => { + versionRecords.push(input); + }), + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => + Effect.sync(() => { + versionRecords.splice(0, versionRecords.length); + }), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: (input) => + Effect.gen(function* () { + yield* Deferred.succeed(renameWriteStarted, undefined); + yield* Deferred.await(allowRenameWrite); + filePresent = true; + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => + Effect.sync(() => { + filePresent = false; + }), + }, + saveLocks, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const renameFiber = yield* invokeWorkflowHandler<void>( + handlers, + WORKFLOW_WS_METHODS.renameBoard, + { + boardId, + name: "Race Delete Renamed", + }, + ).pipe(Effect.forkChild); + yield* Deferred.await(renameWriteStarted); + const deleteFiber = yield* invokeWorkflowHandler<void>( + handlers, + WORKFLOW_WS_METHODS.deleteBoard, + { + boardId, + }, + ).pipe(Effect.forkChild); + yield* Deferred.succeed(allowRenameWrite, undefined); + + yield* Fiber.join(renameFiber); + yield* Fiber.join(deleteFiber); + + assert.isFalse(filePresent); + assert.isFalse(boardProjectionPresent); + assert.isNull(registryDefinition); + assert.deepEqual(versionRecords, []); + }), +); + +it.effect("workflowRpcHandlers lists board versions and lazy-imports missing history", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-version-rpc__delivery"); + const otherBoardId = BoardId.make("project-version-rpc__other"); + const projectId = "project-version-rpc" as ProjectId; + const workspaceRoot = "/tmp/project-version-rpc-root"; + const workflowFilePath = ".t3/boards/delivery.json"; + const importedDefinition = yield* decodeWorkflowDefinition({ + name: "Imported Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const savedDefinition = yield* decodeWorkflowDefinition({ + name: "Saved Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "review", name: "Review", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const importedRaw = `${encodeWorkflowDefinitionJson(importedDefinition)}\n`; + const savedRaw = `${encodeWorkflowDefinitionJson(savedDefinition)}\n`; + const importedHash = sha256Hex(importedRaw); + const savedHash = sha256Hex(savedRaw); + const recorded: WorkflowBoardVersionRecordInput[] = []; + const versions: Array<{ + readonly boardId: BoardId; + readonly versionId: number; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; + readonly createdAt: string; + }> = []; + let nextVersionId = 1; + + const addVersion = (input: WorkflowBoardVersionRecordInput, createdAt: string) => { + versions.push({ + boardId: input.boardId, + versionId: nextVersionId, + versionHash: input.versionHash, + contentJson: input.contentJson, + source: input.source, + createdAt, + }); + nextVersionId += 1; + }; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId + ? { + boardId, + projectId, + name: "Imported Delivery", + workflowFilePath, + workflowVersionHash: importedHash, + maxConcurrentTickets: 3, + } + : null, + ), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.die("unused"), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.die("unused"), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: (input) => + Effect.sync(() => { + recorded.push(input); + addVersion(input, "2026-06-08T12:00:00.000Z"); + }), + list: (inputBoardId) => + Effect.succeed( + versions + .filter((version) => version.boardId === inputBoardId) + .toSorted((left, right) => right.versionId - left.versionId) + .map(({ contentJson: _contentJson, boardId: _boardId, ...summary }) => summary), + ), + get: (inputBoardId, versionId) => + Effect.succeed( + versions.find( + (version) => version.boardId === inputBoardId && version.versionId === versionId, + ) ?? null, + ), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: (inputProjectId) => + Effect.sync(() => { + assert.equal(inputProjectId, projectId); + return workspaceRoot; + }), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot, relativePath: workflowFilePath }); + return importedRaw; + }), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const importedVersions = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly versionId: number; + readonly versionHash: string; + readonly source: string; + readonly createdAt: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + + assert.deepEqual(recorded, [ + { + boardId, + versionHash: importedHash, + contentJson: importedRaw, + source: "import", + }, + ]); + assert.deepEqual(importedVersions, [ + { + versionId: 1, + versionHash: importedHash, + source: "import", + createdAt: "2026-06-08T12:00:00.000Z", + isCurrent: true, + }, + ]); + assert.equal("contentJson" in importedVersions[0]!, false); + + addVersion( + { + boardId, + versionHash: savedHash, + contentJson: savedRaw, + source: "save", + }, + "2026-06-08T12:05:00.000Z", + ); + const listedVersions = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly versionId: number; + readonly versionHash: string; + readonly source: string; + readonly createdAt: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + assert.deepEqual(listedVersions, [ + { + versionId: 2, + versionHash: savedHash, + source: "save", + createdAt: "2026-06-08T12:05:00.000Z", + isCurrent: true, + }, + { + versionId: 1, + versionHash: importedHash, + source: "import", + createdAt: "2026-06-08T12:00:00.000Z", + isCurrent: false, + }, + ]); + + const importedVersion = yield* invokeWorkflowHandler<{ + readonly versionId: number; + readonly definition: unknown; + readonly versionHash: string; + readonly source: string; + readonly createdAt: string; + }>(handlers, WORKFLOW_WS_METHODS.getBoardVersion, { boardId, versionId: 1 }); + assert.equal(importedVersion.versionId, 1); + assert.equal( + (importedVersion.definition as { readonly name: string }).name, + "Imported Delivery", + ); + assert.equal(importedVersion.versionHash, importedHash); + assert.equal(importedVersion.source, "import"); + assert.equal(importedVersion.createdAt, "2026-06-08T12:00:00.000Z"); + + const missingVersion = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.getBoardVersion, { + boardId, + versionId: 999, + }), + ); + assert.strictEqual(missingVersion._tag, "Failure"); + + const wrongBoardVersion = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.getBoardVersion, { + boardId: otherBoardId, + versionId: 1, + }), + ); + assert.strictEqual(wrongBoardVersion._tag, "Failure"); + }), +); + +it.effect("workflowRpcHandlers records only one lazy import for concurrent history opens", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-version-rpc__concurrent-import"); + const projectId = "project-version-rpc" as ProjectId; + const workspaceRoot = "/tmp/project-version-rpc-root"; + const workflowFilePath = ".t3/boards/concurrent-import.json"; + const importedDefinition = yield* decodeWorkflowDefinition({ + name: "Concurrent Import", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const importedRaw = `${encodeWorkflowDefinitionJson(importedDefinition)}\n`; + const importedHash = sha256Hex(importedRaw); + const recorded: WorkflowBoardVersionRecordInput[] = []; + const versions: Array<{ + readonly boardId: BoardId; + readonly versionId: number; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; + readonly createdAt: string; + }> = []; + let nextVersionId = 1; + let initialListCalls = 0; + const initialListsEntered = yield* Deferred.make<void>(); + const saveLocks = yield* makeWorkflowBoardSaveLocks; + + const addVersion = (input: WorkflowBoardVersionRecordInput) => { + versions.push({ + boardId: input.boardId, + versionId: nextVersionId, + versionHash: input.versionHash, + contentJson: input.contentJson, + source: input.source, + createdAt: "2026-06-08T12:00:00.000Z", + }); + nextVersionId += 1; + }; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId, + name: importedDefinition.name, + workflowFilePath, + workflowVersionHash: importedHash, + maxConcurrentTickets: 3, + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.die("unused"), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + saveLocks, + fileLoader: { + lintDefinition: () => Effect.die("unused"), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: (input) => + Effect.sync(() => { + recorded.push(input); + addVersion(input); + }), + list: (inputBoardId) => + Effect.gen(function* () { + const snapshot = versions + .filter((version) => version.boardId === inputBoardId) + .toSorted((left, right) => right.versionId - left.versionId) + .map(({ contentJson: _contentJson, boardId: _boardId, ...summary }) => summary); + if (initialListCalls < 2) { + initialListCalls += 1; + if (initialListCalls === 2) { + yield* Deferred.succeed(initialListsEntered, undefined); + } else { + yield* Deferred.await(initialListsEntered); + } + } + return snapshot; + }), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(importedRaw), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const listVersions = invokeWorkflowHandler< + ReadonlyArray<{ + readonly source: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + + const first = yield* listVersions.pipe(Effect.forkChild); + const second = yield* listVersions.pipe(Effect.forkChild); + const results = [yield* Fiber.join(first), yield* Fiber.join(second)]; + + assert.deepEqual(recorded, [ + { + boardId, + versionHash: importedHash, + contentJson: importedRaw, + source: "import", + }, + ]); + assert.deepEqual( + results.map((result) => result.map((version) => version.source)), + [["import"], ["import"]], + ); + }), +); + +it.effect("workflowRpcHandlers serializes createBoard against lazy history import", () => + Effect.gen(function* () { + const projectId = "project-create-import-race" as ProjectId; + const boardId = BoardId.make(`${projectId}__race-board`); + const workspaceRoot = "/tmp/project-create-import-race-root"; + const saveLocks = yield* makeWorkflowBoardSaveLocks; + const createdBoardRegistered = yield* Deferred.make<void>(); + const versions: Array<{ + readonly boardId: BoardId; + readonly versionId: number; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; + readonly createdAt: string; + }> = []; + let nextVersionId = 1; + let fileContents = ""; + let registryDefinition: WorkflowDefinitionType | null = null; + let boardRow: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; + } | null = null; + + const versionSummaries = (inputBoardId: BoardId) => + versions + .filter((version) => version.boardId === inputBoardId) + .toSorted((left, right) => right.versionId - left.versionId) + .map(({ contentJson: _contentJson, boardId: _boardId, ...summary }) => summary); + + const recordVersion = (input: WorkflowBoardVersionRecordInput) => { + const newest = versionSummaries(input.boardId)[0]; + if (newest?.versionHash === input.versionHash) { + return; + } + versions.push({ + boardId: input.boardId, + versionId: nextVersionId, + versionHash: input.versionHash, + contentJson: input.contentJson, + source: input.source, + createdAt: "2026-06-08T12:00:00.000Z", + }); + nextVersionId += 1; + }; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: (inputBoardId) => Effect.succeed(inputBoardId === boardId ? boardRow : null), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + saveLocks, + fileLoader: { + lintDefinition: () => Effect.die("unused"), + loadAndRegister: (input) => + Effect.gen(function* () { + registryDefinition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + boardRow = { + boardId: input.boardId, + projectId: input.projectId, + name: registryDefinition.name, + workflowFilePath: input.relativePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + yield* Deferred.succeed(createdBoardRegistered, undefined); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: (input) => Effect.sync(() => recordVersion(input)), + list: (inputBoardId) => Effect.sync(() => versionSummaries(inputBoardId)), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: () => Effect.die("unused"), + createFileExclusive: (input) => + Effect.sync(() => { + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const createFiber = yield* invokeWorkflowHandler<{ + readonly boardId: BoardId; + }>(handlers, WORKFLOW_WS_METHODS.createBoard, { + projectId, + name: "Race Board", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }).pipe(Effect.forkChild); + + yield* Deferred.await(createdBoardRegistered); + const listFiber = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly source: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }).pipe(Effect.forkChild); + + const created = yield* Fiber.join(createFiber); + const listed = yield* Fiber.join(listFiber); + + assert.equal(created.boardId, boardId); + assert.deepEqual( + versions.map((version) => version.source), + ["create"], + ); + assert.deepEqual( + listed.map((version) => ({ source: version.source, isCurrent: version.isCurrent })), + [{ source: "create", isCurrent: true }], + ); + }), +); + +it.effect("workflowRpcHandlers skips lazy import when history appears after an empty read", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-version-rpc__history-populated"); + const projectId = "project-version-rpc" as ProjectId; + const workspaceRoot = "/tmp/project-version-rpc-root"; + const workflowFilePath = ".t3/boards/history-populated.json"; + const importedDefinition = yield* decodeWorkflowDefinition({ + name: "Imported Before Existing Save", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const savedDefinition = yield* decodeWorkflowDefinition({ + name: "Existing Save", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const importedRaw = `${encodeWorkflowDefinitionJson(importedDefinition)}\n`; + const savedRaw = `${encodeWorkflowDefinitionJson(savedDefinition)}\n`; + const importedHash = sha256Hex(importedRaw); + const savedHash = sha256Hex(savedRaw); + const recorded: WorkflowBoardVersionRecordInput[] = []; + const versions: Array<{ + readonly boardId: BoardId; + readonly versionId: number; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; + readonly createdAt: string; + }> = [ + { + boardId, + versionId: 1, + versionHash: savedHash, + contentJson: savedRaw, + source: "save", + createdAt: "2026-06-08T12:05:00.000Z", + }, + ]; + let nextVersionId = 2; + let listCalls = 0; + const saveLocks = yield* makeWorkflowBoardSaveLocks; + + const addVersion = (input: WorkflowBoardVersionRecordInput) => { + versions.push({ + boardId: input.boardId, + versionId: nextVersionId, + versionHash: input.versionHash, + contentJson: input.contentJson, + source: input.source, + createdAt: "2026-06-08T12:00:00.000Z", + }); + nextVersionId += 1; + }; + + const versionSummaries = (inputBoardId: BoardId) => + versions + .filter((version) => version.boardId === inputBoardId) + .toSorted((left, right) => right.versionId - left.versionId) + .map(({ contentJson: _contentJson, boardId: _boardId, ...summary }) => summary); + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId, + name: importedDefinition.name, + workflowFilePath, + workflowVersionHash: importedHash, + maxConcurrentTickets: 3, + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(importedDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + saveLocks, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: (input) => + Effect.sync(() => { + recorded.push(input); + addVersion(input); + }), + list: (inputBoardId) => + Effect.sync(() => { + listCalls += 1; + return listCalls === 1 ? [] : versionSummaries(inputBoardId); + }), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(importedRaw), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const listedVersions = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly source: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + assert.deepEqual(recorded, []); + assert.deepEqual( + listedVersions.map((version) => ({ + source: version.source, + isCurrent: version.isCurrent, + })), + [{ source: "save", isCurrent: true }], + ); + }), +); + +versionRoundTripLayer("workflowRpcHandlers version history round trip", (it) => { + it.effect("imports, saves, loads, and re-saves a reverted board version", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const boardId = BoardId.make("project-version-round-trip__delivery"); + const projectId = "project-version-round-trip" as ProjectId; + const workspaceRoot = "/tmp/project-version-round-trip-root"; + const workflowFilePath = ".t3/boards/delivery.json"; + const importedDefinition = yield* decodeWorkflowDefinition({ + name: "Imported Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const savedDefinition = yield* decodeWorkflowDefinition({ + name: "Saved Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "review", name: "Review", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const currentDefinition = yield* decodeWorkflowDefinition({ + name: "Current Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "review", name: "Review", entry: "auto" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + let fileContents = `${encodeWorkflowDefinitionJson(importedDefinition)}\n`; + let registryDefinition = importedDefinition; + let boardRow = { + boardId, + projectId, + name: importedDefinition.name, + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: (inputBoardId) => Effect.succeed(inputBoardId === boardId ? boardRow : null), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.mapError( + (cause) => + new WorkflowRpcError({ + message: "round-trip workflow definition decode failed", + cause, + }), + ), + ); + registryDefinition = definition; + boardRow = { + ...boardRow, + name: definition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: (input) => + Effect.sync(() => { + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const importedVersions = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly versionId: number; + readonly source: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + assert.deepEqual( + importedVersions.map((version) => ({ + source: version.source, + isCurrent: version.isCurrent, + })), + [{ source: "import", isCurrent: true }], + ); + + const firstSave = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(savedDefinition), + expectedVersionHash: boardRow.workflowVersionHash, + }); + assert.equal(firstSave.ok, true); + if (firstSave.ok !== true) { + assert.fail("expected first save to succeed"); + } + + // PR review: the save path enforces the import DoS caps. An oversized + // definition (>MAX_IMPORT_LANES) is rejected with lintErrors and never + // written — and leaves the version hash unchanged so the chain continues. + const contentAfterFirstSave = fileContents; + const oversizedDefinition = yield* decodeWorkflowDefinition({ + name: "Too Many Lanes", + lanes: [ + ...Array.from({ length: 1001 }, (_, index) => ({ + key: `overflow-${index}`, + name: `Overflow ${index}`, + entry: "manual", + })), + { key: "done-overflow", name: "Done", entry: "manual", terminal: true }, + ], + }); + const oversizedSave = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(oversizedDefinition), + expectedVersionHash: firstSave.versionHash, + }); + assert.equal(oversizedSave.ok, false); + if (oversizedSave.ok !== false) { + assert.fail("expected oversized save to be rejected by the size cap"); + } + assert.ok(oversizedSave.lintErrors.length > 0); + assert.equal(fileContents, contentAfterFirstSave); // no write on rejection + + const secondSave = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(currentDefinition), + expectedVersionHash: firstSave.versionHash, + }); + assert.equal(secondSave.ok, true); + if (secondSave.ok !== true) { + assert.fail("expected second save to succeed"); + } + + const versionsBeforeRevert = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly versionId: number; + readonly versionHash: string; + readonly source: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + assert.deepEqual( + versionsBeforeRevert.map((version) => ({ + source: version.source, + isCurrent: version.isCurrent, + })), + [ + { source: "save", isCurrent: true }, + { source: "save", isCurrent: false }, + { source: "import", isCurrent: false }, + ], + ); + + const importVersion = versionsBeforeRevert.at(-1); + assert.isDefined(importVersion); + const loadedImport = yield* invokeWorkflowHandler<{ + readonly versionId: number; + readonly definition: WorkflowDefinitionEncoded; + readonly versionHash: string; + readonly source: string; + }>(handlers, WORKFLOW_WS_METHODS.getBoardVersion, { + boardId, + versionId: importVersion.versionId, + }); + assert.equal(loadedImport.source, "import"); + assert.equal(loadedImport.definition.name, "Imported Delivery"); + + const revertSave = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: loadedImport.definition, + expectedVersionHash: secondSave.versionHash, + source: "revert", + }); + assert.equal(revertSave.ok, true); + + const versionsAfterRevert = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly versionHash: string; + readonly source: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + assert.deepEqual( + versionsAfterRevert.map((version) => ({ + versionHash: version.versionHash, + source: version.source, + isCurrent: version.isCurrent, + })), + [ + { + versionHash: loadedImport.versionHash, + source: "revert", + isCurrent: true, + }, + { + versionHash: secondSave.versionHash, + source: "save", + isCurrent: false, + }, + { + versionHash: firstSave.versionHash, + source: "save", + isCurrent: false, + }, + { + versionHash: loadedImport.versionHash, + source: "import", + isCurrent: false, + }, + ], + ); + }), + ); +}); + +it.effect("workflowRpcHandlers rejects lint-invalid board saves without writing", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-editor-rpc__invalid"); + const definition = yield* decodeWorkflowDefinition({ + name: "Invalid", + lanes: [{ key: "queue", name: "Queue", entry: "manual", wipLimit: 0 }], + }); + const definitionEncoded = encodeWorkflowDefinition(definition); + const currentRaw = `${encodeWorkflowDefinitionJson(definition)}\n`; + const currentHash = sha256Hex(currentRaw); + let writeCount = 0; + const lintErrors: ReadonlyArray<LintError> = [ + { + code: "invalid_wip_limit", + message: "Lane queue wipLimit must be at least 1", + laneKey: "queue", + }, + ]; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-editor-rpc", + name: "Invalid", + workflowFilePath: ".t3/boards/invalid.json", + workflowVersionHash: currentHash, + maxConcurrentTickets: 3, + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(definition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed(lintErrors), + loadAndRegister: () => Effect.die("loadAndRegister must not run after lint failure"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/editor-rpc-project"), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(currentRaw), + writeFile: () => + Effect.sync(() => { + writeCount += 1; + return { relativePath: ".t3/boards/invalid.json" }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const saved = yield* invokeWorkflowHandler<{ + readonly ok: false; + readonly lintErrors: ReadonlyArray<{ + readonly code: string; + readonly message: string; + readonly laneKey?: string; + }>; + }>(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: definitionEncoded, + expectedVersionHash: currentHash, + }); + + assert.equal(saved.ok, false); + assert.deepEqual(saved.lintErrors, lintErrors); + assert.equal(writeCount, 0); + }), +); + +it.effect("workflowRpcHandlers rejects stale board saves without writing", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-editor-rpc__stale"); + const definition = yield* decodeWorkflowDefinition({ + name: "Stale", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const definitionEncoded = encodeWorkflowDefinition(definition); + const currentRaw = `${encodeWorkflowDefinitionJson(definition)}\n`; + const currentHash = sha256Hex(currentRaw); + const workspaceRoot = "/tmp/editor-rpc-project"; + let writeCount = 0; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-editor-rpc", + name: "Stale", + workflowFilePath: ".t3/boards/stale.json", + workflowVersionHash: currentHash, + maxConcurrentTickets: 3, + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(definition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.die("lintDefinition must not run after version conflict"), + loadAndRegister: () => Effect.die("loadAndRegister must not run after version conflict"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: (input) => + Effect.sync(() => { + assert.deepEqual(input, { + cwd: workspaceRoot, + relativePath: ".t3/boards/stale.json", + }); + return currentRaw; + }), + writeFile: () => + Effect.sync(() => { + writeCount += 1; + return { relativePath: ".t3/boards/stale.json" }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const saved = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + | { readonly ok: false; readonly conflict: true; readonly currentVersionHash: string } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: definitionEncoded, + expectedVersionHash: "hash-stale", + }); + + assert.deepEqual(saved, { + ok: false, + conflict: true, + currentVersionHash: currentHash, + }); + assert.equal(writeCount, 0); + }), +); + +it.effect("workflowRpcHandlers rejects saves when the board file changed on disk", () => + Effect.gen(function* () { + const projectId = "project-editor-rpc" as ProjectId; + const boardId = BoardId.make("project-editor-rpc__external-edit"); + const workspaceRoot = "/tmp/editor-rpc-project"; + const workflowFilePath = ".t3/boards/external-edit.json"; + const originalDefinition = yield* decodeWorkflowDefinition({ + name: "External Edit", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const editedDefinition = yield* decodeWorkflowDefinition({ + name: "External Edit Saved", + lanes: [{ key: "queue", name: "Queue Saved", entry: "manual" }], + }); + const externalDefinition = yield* decodeWorkflowDefinition({ + name: "External Edit Hand Edited", + lanes: [{ key: "queue", name: "Queue Hand Edited", entry: "manual" }], + }); + const originalRaw = `${encodeWorkflowDefinitionJson(originalDefinition)}\n`; + const externalRaw = `${encodeWorkflowDefinitionJson(externalDefinition)}\n`; + const originalHash = sha256Hex(originalRaw); + const externalHash = sha256Hex(externalRaw); + let fileContents = originalRaw; + let writeCount = 0; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId, + name: "External Edit", + workflowFilePath, + workflowVersionHash: originalHash, + maxConcurrentTickets: 3, + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(originalDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("loadAndRegister must not run after on-disk conflict"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot, relativePath: workflowFilePath }); + return fileContents; + }), + writeFile: (input) => + Effect.sync(() => { + writeCount += 1; + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const loaded = yield* invokeWorkflowHandler<{ + readonly versionHash: string; + }>(handlers, WORKFLOW_WS_METHODS.getBoardDefinition, { boardId }); + fileContents = externalRaw; + + const saved = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + | { readonly ok: false; readonly conflict: true; readonly currentVersionHash: string } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(editedDefinition), + expectedVersionHash: loaded.versionHash, + }); + + assert.deepEqual(saved, { + ok: false, + conflict: true, + currentVersionHash: externalHash, + }); + assert.equal(writeCount, 0); + assert.equal(fileContents, externalRaw); + }), +); + +it.effect("workflowRpcHandlers serializes same-base board saves so only one succeeds", () => + Effect.gen(function* () { + const projectId = "project-editor-rpc" as ProjectId; + const boardId = BoardId.make("project-editor-rpc__concurrent"); + const workspaceRoot = "/tmp/editor-rpc-project"; + const workflowFilePath = ".t3/boards/concurrent.json"; + const baseDefinition = yield* decodeWorkflowDefinition({ + name: "Concurrent", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const firstDefinition = yield* decodeWorkflowDefinition({ + name: "Concurrent First", + lanes: [{ key: "queue", name: "Queue First", entry: "manual" }], + }); + const secondDefinition = yield* decodeWorkflowDefinition({ + name: "Concurrent Second", + lanes: [{ key: "queue", name: "Queue Second", entry: "manual" }], + }); + const baseRaw = `${encodeWorkflowDefinitionJson(baseDefinition)}\n`; + const baseHash = sha256Hex(baseRaw); + let fileContents = baseRaw; + let registryDefinition = baseDefinition; + let boardRow = { + boardId, + projectId, + name: baseDefinition.name, + workflowFilePath, + workflowVersionHash: baseHash, + maxConcurrentTickets: 3, + }; + let writeCount = 0; + const firstWriteEntered = yield* Deferred.make<void>(); + const saveLocks = yield* makeWorkflowBoardSaveLocks; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => Effect.succeed(boardRow), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + saveLocks, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.gen(function* () { + registryDefinition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + boardRow = { + ...boardRow, + name: registryDefinition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: (input) => + Effect.gen(function* () { + writeCount += 1; + if (writeCount === 1) { + yield* Deferred.succeed(firstWriteEntered, undefined); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + } + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const save = (definition: WorkflowDefinitionType) => + invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + | { readonly ok: false; readonly conflict: true; readonly currentVersionHash: string } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(definition), + expectedVersionHash: baseHash, + }); + + const first = yield* save(firstDefinition).pipe(Effect.forkChild); + yield* Deferred.await(firstWriteEntered); + const second = yield* save(secondDefinition).pipe(Effect.forkChild); + + const results = [yield* Fiber.join(first), yield* Fiber.join(second)]; + assert.equal(results.filter((result) => result.ok === true).length, 1); + const conflict = results.find((result) => result.ok === false && "conflict" in result); + assert.deepEqual(conflict, { + ok: false, + conflict: true, + currentVersionHash: sha256Hex(fileContents), + }); + assert.equal(writeCount, 1); + }), +); + +it.effect("workflowRpcHandlers serializes deleteBoard with an in-flight save", () => + Effect.gen(function* () { + const projectId = "project-editor-rpc" as ProjectId; + const boardId = BoardId.make("project-editor-rpc__delete-save-race"); + const workspaceRoot = "/tmp/editor-rpc-project"; + const workflowFilePath = ".t3/boards/delete-save-race.json"; + const baseDefinition = yield* decodeWorkflowDefinition({ + name: "Delete Save Race", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const savedDefinition = yield* decodeWorkflowDefinition({ + name: "Delete Save Race Saved", + lanes: [{ key: "queue", name: "Queue Saved", entry: "manual" }], + }); + const baseRaw = `${encodeWorkflowDefinitionJson(baseDefinition)}\n`; + const baseHash = sha256Hex(baseRaw); + let fileContents = baseRaw; + let registryDefinition: WorkflowDefinitionType | null = baseDefinition; + let boardRow: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; + } | null = { + boardId, + projectId, + name: baseDefinition.name, + workflowFilePath, + workflowVersionHash: baseHash, + maxConcurrentTickets: 3, + }; + const versions: WorkflowBoardVersionRecordInput[] = []; + const saveWriteEntered = yield* Deferred.make<void>(); + const saveLocks = yield* makeWorkflowBoardSaveLocks; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: (inputBoardId) => Effect.succeed(inputBoardId === boardId ? boardRow : null), + deleteBoard: (inputBoardId) => + Effect.sync(() => { + if (inputBoardId === boardId) { + boardRow = null; + } + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: (inputBoardId) => + Effect.sync(() => { + if (inputBoardId === boardId) { + registryDefinition = null; + } + }), + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + saveLocks, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.gen(function* () { + registryDefinition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + boardRow = { + boardId: input.boardId, + projectId: input.projectId, + name: registryDefinition.name, + workflowFilePath: input.relativePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: (input) => + Effect.sync(() => { + versions.push(input); + }), + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: (inputBoardId) => + Effect.sync(() => { + for (let index = versions.length - 1; index >= 0; index -= 1) { + if (versions[index]?.boardId === inputBoardId) { + versions.splice(index, 1); + } + } + }), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: (input) => + Effect.gen(function* () { + fileContents = input.contents; + yield* Deferred.succeed(saveWriteEntered, undefined); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot, relativePath: workflowFilePath }); + fileContents = ""; + }), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const saveFiber = yield* invokeWorkflowHandler<{ + readonly ok: true; + readonly versionHash: string; + }>(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(savedDefinition), + expectedVersionHash: baseHash, + }).pipe(Effect.forkChild); + + yield* Deferred.await(saveWriteEntered); + const deleteFiber = yield* invokeWorkflowHandler<void>( + handlers, + WORKFLOW_WS_METHODS.deleteBoard, + { boardId }, + ).pipe(Effect.forkChild); + + const saved = yield* Fiber.join(saveFiber); + yield* Fiber.join(deleteFiber); + + assert.equal(saved.ok, true); + assert.equal(boardRow, null); + assert.equal(registryDefinition, null); + assert.deepEqual(versions, []); + }), +); + +it.effect("workflowRpcHandlers rejects unsafe instruction paths without writing", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-editor-rpc__unsafe-instruction"); + const definition = yield* decodeWorkflowDefinition({ + name: "Unsafe Instruction", + lanes: [ + { + key: "run", + name: "Run", + entry: "auto", + pipeline: [ + { + key: "agent", + type: "agent", + agent: { instance: "codex_main", model: "gpt-5.5" }, + instruction: { file: "../escape.md" }, + }, + ], + }, + ], + }); + const definitionEncoded = encodeWorkflowDefinition(definition); + const currentRaw = `${encodeWorkflowDefinitionJson(definition)}\n`; + const currentHash = sha256Hex(currentRaw); + let writeCount = 0; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-editor-rpc", + name: "Unsafe Instruction", + workflowFilePath: ".t3/boards/unsafe-instruction.json", + workflowVersionHash: currentHash, + maxConcurrentTickets: 3, + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(definition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: (input) => + Effect.succeed( + lintWorkflowDefinition(input.definition, { + providerInstanceExists: () => true, + instructionFileExists: () => true, + }), + ), + loadAndRegister: () => Effect.die("loadAndRegister must not run after lint failure"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/editor-rpc-project"), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(currentRaw), + writeFile: () => + Effect.sync(() => { + writeCount += 1; + return { relativePath: ".t3/boards/unsafe-instruction.json" }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const saved = yield* invokeWorkflowHandler<{ + readonly ok: false; + readonly lintErrors: ReadonlyArray<{ readonly code: string; readonly message: string }>; + }>(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: definitionEncoded, + expectedVersionHash: currentHash, + }); + + assert.equal(saved.ok, false); + assert.deepEqual( + saved.lintErrors.map((error) => error.code), + ["unsafe_instruction_path"], + ); + assert.equal(writeCount, 0); + }), +); + +it.effect("workflowRpcHandlers rejects board saves whose derived path is not a board file", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-editor-rpc__unsafe"); + const definition = yield* decodeWorkflowDefinition({ + name: "Unsafe", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const definitionEncoded = encodeWorkflowDefinition(definition); + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-editor-rpc", + name: "Unsafe", + workflowFilePath: ".t3/boards/../unsafe.json", + workflowVersionHash: "hash-before", + maxConcurrentTickets: 3, + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(definition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.die("lintDefinition must not run for unsafe path"), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/editor-rpc-project"), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("readFileString must not run for unsafe path"), + writeFile: () => Effect.die("writeFile must not run for unsafe path"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const result = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: definitionEncoded, + expectedVersionHash: "hash-before", + }), + ); + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(result.cause.toString().includes("not a writable workflow board file")); + } + }), +); + +it.effect( + "workflowRpcHandlers round-trips saved board definitions and preserves invalid files", + () => + Effect.gen(function* () { + const projectId = "project-editor-roundtrip" as ProjectId; + const boardId = BoardId.make("project-editor-roundtrip__delivery"); + const workspaceRoot = "/tmp/editor-roundtrip-project"; + const workflowFilePath = ".t3/boards/delivery.json"; + const initialDefinition = yield* decodeWorkflowDefinition({ + name: "Round Trip", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + let fileContents = `${encodeWorkflowDefinitionJson(initialDefinition)}\n`; + const initialHash = sha256Hex(fileContents); + let registryDefinition = initialDefinition; + let boardRow = { + boardId, + projectId, + name: registryDefinition.name, + workflowFilePath, + workflowVersionHash: initialHash, + maxConcurrentTickets: 3, + }; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getBoard: () => Effect.succeed(boardRow), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: (input) => + Effect.sync(() => + input.definition.lanes.some( + (lane) => lane.wipLimit !== undefined && lane.wipLimit < 1, + ) + ? [ + { + code: "invalid_wip_limit" as const, + message: "wipLimit must be at least 1", + laneKey: "queue", + }, + ] + : [], + ), + loadAndRegister: (input) => + Effect.gen(function* () { + registryDefinition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + boardRow = { + ...boardRow, + name: registryDefinition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot, relativePath: workflowFilePath }); + return fileContents; + }), + writeFile: (input) => + Effect.sync(() => { + assert.equal(input.cwd, workspaceRoot); + assert.equal(input.relativePath, workflowFilePath); + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const loadedBefore = yield* invokeWorkflowHandler<{ + readonly definition: { readonly name: string }; + readonly versionHash: string; + }>(handlers, WORKFLOW_WS_METHODS.getBoardDefinition, { boardId }); + assert.equal(loadedBefore.definition.name, "Round Trip"); + assert.equal(loadedBefore.versionHash, initialHash); + + const editedDefinition = yield* decodeWorkflowDefinition({ + name: "Round Trip Edited", + lanes: [ + { key: "queue", name: "Queue Updated", entry: "manual", wipLimit: 2 }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const saved = yield* invokeWorkflowHandler< + | { + readonly ok: true; + readonly definition: { readonly name: string }; + readonly versionHash: string; + } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(editedDefinition), + expectedVersionHash: initialHash, + }); + assert.equal(saved.ok, true); + if (saved.ok !== true) { + assert.fail("expected successful save"); + } + assert.equal(saved.versionHash, sha256Hex(fileContents)); + + const loadedAfter = yield* invokeWorkflowHandler<{ + readonly definition: { + readonly name: string; + readonly lanes: ReadonlyArray<{ readonly name: string }>; + }; + readonly versionHash: string; + }>(handlers, WORKFLOW_WS_METHODS.getBoardDefinition, { boardId }); + assert.equal(loadedAfter.definition.name, "Round Trip Edited"); + assert.equal(loadedAfter.definition.lanes[0]?.name, "Queue Updated"); + assert.equal(loadedAfter.versionHash, saved.versionHash); + + const fileContentsAfterValidSave = fileContents; + const invalidDefinition = yield* decodeWorkflowDefinition({ + name: "Round Trip Invalid", + lanes: [ + { key: "queue", name: "Queue", entry: "manual", wipLimit: 0 }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const rejected = yield* invokeWorkflowHandler<{ + readonly ok: false; + readonly lintErrors: ReadonlyArray<{ readonly code: string }>; + }>(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(invalidDefinition), + expectedVersionHash: saved.versionHash, + }); + assert.equal(rejected.ok, false); + assert.equal(rejected.lintErrors[0]?.code, "invalid_wip_limit"); + assert.equal(fileContents, fileContentsAfterValidSave); + }), +); + +it.effect( + "workflowRpcHandlers listNeedsAttentionTickets returns real query rows (not the placeholder [])", + () => + Effect.gen(function* () { + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + listNeedsAttentionTickets: () => + Effect.succeed([ + { + ticketId: "ticket-attention-1", + boardId: "board-attention-1", + boardName: "Delivery Board", + title: "Deploy hotfix", + status: "waiting_on_user", + currentLaneKey: "review", + attentionKind: "waiting_for_input" as const, + attentionReason: "Please confirm the deploy target", + updatedAt: "2026-06-13T10:00:00.000Z", + }, + // A second ticket with status "running" — should NOT appear because the + // read model filters; we verify the handler passes through exactly what + // the read model returns (the model already filters), so we give it only + // the attention row. + ]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/project"), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const rows = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly ticketId: string; + readonly boardName: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }> + >(handlers, WORKFLOW_WS_METHODS.listNeedsAttentionTickets, {}); + + assert.equal(rows.length, 1, "should return the one attention row, not an empty placeholder"); + assert.equal(rows[0]?.ticketId, "ticket-attention-1"); + assert.equal(rows[0]?.boardName, "Delivery Board"); + assert.equal(rows[0]?.attentionKind, "waiting_for_input"); + assert.equal(rows[0]?.attentionReason, "Please confirm the deploy target"); + }), +); + +it.effect( + "workflowRpcHandlers getTicketDetail surfaces attentionKind, attentionReason, and currentLane.actions", + () => + Effect.gen(function* () { + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + ...noopReadModel, + getTicketDetail: () => + Effect.succeed({ + ticket: { + ticketId: "ticket-detail-attention", + boardId: "board-detail-1", + title: "Review PR", + description: null, + currentLaneKey: "review", + currentLaneEntryToken: null, + queuedAt: null, + totalTokens: null, + totalDurationMs: null, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "Awaiting human review", + currentLane: { + key: "review", + name: "Review", + actions: [{ label: "Approve", to: "done", hint: "Looks good" }], + }, + }, + steps: [], + messages: [], + } as never), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/project"), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const detail = yield* handlers[WORKFLOW_WS_METHODS.getTicketDetail]({ + ticketId: TicketId.make("ticket-detail-attention"), + }); + + assert.equal( + detail.ticket.attentionKind, + "waiting_for_input", + "attentionKind must pass through from read-model row", + ); + assert.equal( + detail.ticket.attentionReason, + "Awaiting human review", + "attentionReason must pass through from read-model row", + ); + assert.isDefined(detail.ticket.currentLane, "currentLane must be present in detail view"); + assert.equal(detail.ticket.currentLane?.key, "review"); + assert.equal(detail.ticket.currentLane?.name, "Review"); + assert.equal(detail.ticket.currentLane?.actions.length, 1); + assert.equal(detail.ticket.currentLane?.actions[0]?.label, "Approve"); + assert.equal(detail.ticket.currentLane?.actions[0]?.to, "done"); + assert.equal(detail.ticket.currentLane?.actions[0]?.hint, "Looks good"); + }), +); + +const importNoopEngine = { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId: unknown, effect: unknown) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, +} as never; + +interface ImportHarnessOptions { + /** Lint errors returned by fileLoader.lintDefinition (strict import lint). */ + readonly lintErrors?: ReadonlyArray<LintError>; +} + +/** + * Builds a workflow handler set wired with state-tracking fakes for exercising + * importBoard end-to-end (caps → decode → lint partition → create-from-def). + * + * The loadAndRegister fake mimics the real seam: in "strict" mode it throws on + * env-bound codes, in "skip" mode (what importBoard uses) it registers without + * re-linting. File writes go through createFileExclusive and are tracked so we + * can assert no orphan file is left on a blocking-lint rejection. + */ +const makeImportHarness = (projectId: ProjectId, options: ImportHarnessOptions = {}) => { + const projectRoot = "/tmp/import-project-root"; + const rows = new Map< + string, + { + readonly boardId: string; + readonly projectId: string; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; + } + >(); + const definitions = new Map<string, WorkflowDefinitionType>(); + const entries: BoardListEntry[] = []; + const writes: Array<{ + readonly projectRoot: string; + readonly relativePath: string; + readonly contents: string; + }> = []; + const deletes: Array<{ readonly cwd: string; readonly relativePath: string }> = []; + const versionRecords: WorkflowBoardVersionRecordInput[] = []; + const registerCalls: Array<{ readonly boardId: string; readonly lintMode?: string }> = []; + + const deps = { + engine: importNoopEngine, + readModel: { + ...noopReadModel, + getBoard: (boardId) => Effect.succeed(rows.get(boardId as string) ?? null), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: (boardId) => Effect.succeed(definitions.get(boardId as string) ?? null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { getTicketDiff: () => Effect.die("unused") }, + ticketWorktrees: { resolveForTicket: () => Effect.die("unused") }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed(options.lintErrors ?? []), + loadAndRegister: (input) => + Effect.gen(function* () { + // Mirror the real loadAndRegister: strict mode would throw on the + // env-bound codes; skip mode registers regardless. + if (input.lintMode !== "skip") { + const offending = (options.lintErrors ?? []).filter( + (error) => + error.code === "unknown_provider_instance" || + error.code === "missing_instruction_file", + ); + if (offending.length > 0) { + return yield* new WorkflowRpcError({ + message: `Workflow lint failed: ${offending.map((error) => error.code).join(", ")}`, + }); + } + } + registerCalls.push({ + boardId: input.boardId as string, + ...(input.lintMode === undefined ? {} : { lintMode: input.lintMode }), + }); + const content = writes.find( + (write) => write.relativePath === input.relativePath, + )?.contents; + const definition = defaultBoardDefinition({ + name: "Imported", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }); + rows.set(input.boardId as string, { + boardId: input.boardId, + projectId: input.projectId, + name: definition.name, + workflowFilePath: input.relativePath, + workflowVersionHash: sha256Hex(content ?? ""), + maxConcurrentTickets: 3, + }); + definitions.set(input.boardId as string, definition); + entries.push({ + boardId: input.boardId, + name: definition.name, + filePath: input.relativePath, + error: null, + }); + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: { + record: (input) => + Effect.sync(() => { + versionRecords.push(input); + }), + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed(entries), + list: () => Effect.succeed(entries), + }, + projectWorkspaceResolver: { resolve: () => Effect.succeed(projectRoot) }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("writeFile must not be used"), + createFileExclusive: (input) => + Effect.sync(() => { + writes.push(input); + return { relativePath: input.relativePath }; + }), + deleteFile: (input) => + Effect.sync(() => { + deletes.push(input); + }), + }, + // The createWorkflowBoard dead-end dry-run gate (definition path only) needs + // a predicate evaluator; the always-false stub means transitions never fire. + predicates: stubPredicates, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + } satisfies Parameters<typeof workflowRpcHandlers>[0]; + + const handlers = workflowRpcHandlers(deps); + + return { handlers, deps, projectRoot, writes, deletes, versionRecords, registerCalls }; +}; + +const manualImportDefinition = (name: string): WorkflowDefinitionEncoded => + encodeWorkflowDefinition({ + name, + lanes: [ + { key: LaneKey.make("backlog"), name: "Backlog", entry: "manual" }, + { key: LaneKey.make("done"), name: "Done", entry: "manual" }, + ], + } satisfies WorkflowDefinitionType); + +it.effect("importBoard creates a board from a valid manual definition", () => + Effect.gen(function* () { + const projectId = "import-valid" as ProjectId; + const harness = makeImportHarness(projectId); + + const result = yield* invokeWorkflowHandler<{ + readonly ok: boolean; + readonly boardId: string; + readonly warnings: ReadonlyArray<string>; + }>(harness.handlers, WORKFLOW_WS_METHODS.importBoard, { + projectId, + definition: manualImportDefinition("Imported Flow"), + }); + + assert.equal(result.ok, true); + assert.equal(result.boardId, `${projectId}__imported-flow`); + assert.deepEqual(result.warnings, []); + // Board file was written and registered (permissive, lintMode skip). + assert.equal(harness.writes.length, 1); + assert.equal(harness.writes[0]?.relativePath, ".t3/boards/imported-flow.json"); + assert.deepEqual(harness.registerCalls, [ + { boardId: `${projectId}__imported-flow`, lintMode: "skip" }, + ]); + // "import" version recorded. + assert.equal(harness.versionRecords.length, 1); + assert.equal(harness.versionRecords[0]?.source, "import"); + // No orphan cleanup on success. + assert.deepEqual(harness.deletes, []); + }), +); + +it.effect("importBoard treats an unknown agent instance as a warning, not a blocker", () => + Effect.gen(function* () { + const projectId = "import-envbound" as ProjectId; + const harness = makeImportHarness(projectId, { + lintErrors: [ + { + code: "unknown_provider_instance", + message: 'Agent instance "ghost" does not exist', + laneKey: "backlog", + }, + ], + }); + + const result = yield* invokeWorkflowHandler<{ + readonly ok: boolean; + readonly boardId: string; + readonly warnings: ReadonlyArray<string>; + }>(harness.handlers, WORKFLOW_WS_METHODS.importBoard, { + projectId, + definition: manualImportDefinition("Env Bound"), + }); + + assert.equal(result.ok, true, "env-bound lint must not block the import"); + assert.equal(result.boardId, `${projectId}__env-bound`); + assert.deepEqual(result.warnings, ['Agent instance "ghost" does not exist']); + // Board WAS created despite the env-bound finding. + assert.equal(harness.writes.length, 1); + assert.deepEqual(harness.registerCalls, [ + { boardId: `${projectId}__env-bound`, lintMode: "skip" }, + ]); + }), +); + +it.effect("importBoard blocks a structural lint error and leaves no orphan file", () => + Effect.gen(function* () { + const projectId = "import-structural" as ProjectId; + const harness = makeImportHarness(projectId, { + lintErrors: [ + { + code: "duplicate_lane_key", + message: 'Duplicate lane key "backlog"', + laneKey: "backlog", + }, + ], + }); + + const result = yield* invokeWorkflowHandler<{ + readonly ok: boolean; + readonly lintErrors: ReadonlyArray<{ readonly code: string }>; + }>(harness.handlers, WORKFLOW_WS_METHODS.importBoard, { + projectId, + definition: manualImportDefinition("Structural"), + }); + + assert.equal(result.ok, false, "structural lint must block the import"); + assert.equal(result.lintErrors.length, 1); + assert.equal(result.lintErrors[0]?.code, "duplicate_lane_key"); + // No file written → no orphan, and nothing to delete. + assert.deepEqual(harness.writes, []); + assert.deepEqual(harness.deletes, []); + assert.deepEqual(harness.registerCalls, []); + }), +); + +it.effect( + "importBoard rejects an oversized definition with renderable lintErrors (not RpcError)", + () => + Effect.gen(function* () { + const projectId = "import-oversized" as ProjectId; + const harness = makeImportHarness(projectId); + // Exceed the NEW generous DoS ceiling (MAX_IMPORT_LANES = 1000). + const oversized = { + name: "Too Big", + lanes: Array.from({ length: 1001 }, (_unused, index) => ({ + key: LaneKey.make(`lane-${index}`), + name: `Lane ${index}`, + entry: "manual" as const, + })), + } satisfies WorkflowDefinitionType; + + // A cap violation is a user-input problem → renderable {ok:false, lintErrors}, + // NOT a transport WorkflowRpcError. The handler must resolve, not fail. + const result = yield* invokeWorkflowHandler<{ + readonly ok: boolean; + readonly lintErrors: ReadonlyArray<{ readonly code: string; readonly message: string }>; + }>(harness.handlers, WORKFLOW_WS_METHODS.importBoard, { + projectId, + definition: encodeWorkflowDefinition(oversized), + }); + + assert.equal(result.ok, false, "oversized import must return {ok:false}"); + assert.isTrue(result.lintErrors.length > 0, "oversized import must surface lintErrors"); + assert.equal(result.lintErrors[0]?.code, "invalid_step"); + assert.match(result.lintErrors[0]?.message ?? "", /too large/i); + assert.deepEqual(harness.writes, []); + }), +); + +it.effect("importBoard imports a large-but-valid board the OLD caps wrongly rejected", () => + Effect.gen(function* () { + // 250 lanes exceeds the OLD MAX_DRY_RUN_LANES (200) but is well within the new + // generous MAX_IMPORT_LANES (1000). A saved board this size must round-trip. + const projectId = "import-large-valid" as ProjectId; + const harness = makeImportHarness(projectId); + const large = { + name: "Large Valid", + lanes: Array.from({ length: 250 }, (_unused, index) => ({ + key: LaneKey.make(`lane-${index}`), + name: `Lane ${index}`, + entry: "manual" as const, + })), + } satisfies WorkflowDefinitionType; + + const result = yield* invokeWorkflowHandler<{ + readonly ok: boolean; + readonly boardId: string; + readonly warnings: ReadonlyArray<string>; + }>(harness.handlers, WORKFLOW_WS_METHODS.importBoard, { + projectId, + definition: encodeWorkflowDefinition(large), + }); + + assert.equal(result.ok, true, "a 250-lane board must import under the new caps"); + assert.equal(result.boardId, `${projectId}__large-valid`); + assert.equal(harness.writes.length, 1); + }), +); + +it.effect("importBoard does not die (defect) when given a deeply-nested predicate definition", () => + Effect.gen(function* () { + // This test verifies two fixes together: + // Fix 1: the depth guard in inspectNode catches too-deep predicates as a + // blocking lint error (invalid_json_logic) rather than stack-overflowing. + // Fix 2: importBoard's JSON.stringify size probe is wrapped so a pathologically + // deep object cannot produce an unhandled RangeError defect. + // + // We use a harness whose lintDefinition calls through to the real + // lintWorkflowDefinition so the depth guard fires in an integrated path. + const projectId = "import-deep-predicate" as ProjectId; + + // Build a predicate nested one level beyond the guard (depth > MAX_PREDICATE_DEPTH). + let deepPredicate: unknown = { var: "pipeline.result" }; + for (let i = 0; i < MAX_PREDICATE_DEPTH + 1; i++) { + deepPredicate = { "!": deepPredicate }; + } + + const definition = { + name: "Deep Predicate Board", + lanes: [ + { + key: LaneKey.make("impl"), + name: "Impl", + entry: "auto" as const, + pipeline: [{ key: StepKey.make("s"), type: "script" as const, run: "echo ok" }], + transitions: [{ when: deepPredicate, to: LaneKey.make("done") }], + on: { success: LaneKey.make("done"), failure: LaneKey.make("done") }, + }, + { key: LaneKey.make("done"), name: "Done", entry: "manual" as const, terminal: true }, + ], + } as unknown as WorkflowDefinitionType; + + // Override lintDefinition to call through to the real lint (default harness + // always returns [], which would miss the depth guard test). + const harness = makeImportHarness(projectId, { + lintErrors: lintWorkflowDefinition(definition, { + providerInstanceExists: () => true, + instructionFileExists: () => true, + }), + }); + + const encoded = encodeWorkflowDefinition(definition); + + const exit = yield* Effect.exit( + invokeWorkflowHandler(harness.handlers, WORKFLOW_WS_METHODS.importBoard, { + projectId, + definition: encoded, + }), + ); + + // Must never be a defect (Die / unhandled RangeError). + if (exit._tag === "Failure") { + const cause = exit.cause; + assert.isFalse( + Cause.hasDies(cause), + `importBoard must not die on a too-deep predicate; got Die defect`, + ); + // A clean WorkflowRpcError Fail is also acceptable. + } else { + // Resolved means lint blocked it with {ok:false, lintErrors:[...]}. + const result = exit.value as { ok: boolean; lintErrors?: unknown[] }; + assert.isFalse( + result.ok, + "importBoard must return ok:false when lint blocks a too-deep predicate", + ); + assert.isTrue( + Array.isArray(result.lintErrors) && result.lintErrors.length > 0, + "importBoard must surface lint errors for a too-deep predicate", + ); + } + + // No file written (import was blocked before the write step). + assert.deepEqual(harness.writes, []); + }), +); + +it.effect("importBoard dedupes the slug for a duplicate board name", () => + Effect.gen(function* () { + const projectId = "import-dupe" as ProjectId; + const harness = makeImportHarness(projectId); + + const first = yield* invokeWorkflowHandler<{ readonly boardId: string }>( + harness.handlers, + WORKFLOW_WS_METHODS.importBoard, + { projectId, definition: manualImportDefinition("Same Name") }, + ); + const second = yield* invokeWorkflowHandler<{ readonly boardId: string }>( + harness.handlers, + WORKFLOW_WS_METHODS.importBoard, + { projectId, definition: manualImportDefinition("Same Name") }, + ); + + assert.equal(first.boardId, `${projectId}__same-name`); + assert.equal(second.boardId, `${projectId}__same-name-2`); + }), +); + +// ─── validateAndCreateBoard create-mode (vs import-mode) ──────────────────── +// +// Task 2 extracts the shared validate-and-create pipeline. import-mode keeps the +// env-bound→warning downgrade; create-mode blocks on EVERY lint error. These +// tests call the shared helper directly with the same harness deps importBoard +// uses, exercising both modes against identical inputs. + +it.effect( + "validateAndCreateBoard create-mode blocks an env-bound lint code that import-mode warns on", + () => + Effect.gen(function* () { + const projectId = "create-envbound" as ProjectId; + const harness = makeImportHarness(projectId, { + lintErrors: [ + { + code: "unknown_provider_instance", + message: 'Agent instance "ghost" does not exist', + laneKey: "backlog", + }, + ], + }); + const encodedDefinition = manualImportDefinition("Env Bound Create"); + + // import-mode: env-bound finding becomes a warning, board IS created. + const imported = yield* validateAndCreateBoard(harness.deps, { + projectId, + encodedDefinition, + mode: "import", + }); + assert.equal(imported.ok, true, "import-mode must downgrade env-bound to a warning"); + assert.isTrue( + imported.ok && imported.warnings.length > 0, + "import-mode must surface the env-bound finding as a warning", + ); + + // create-mode: same env-bound finding now BLOCKS — no board written/registered. + const created = yield* validateAndCreateBoard(harness.deps, { + projectId, + encodedDefinition, + mode: "create", + }); + assert.equal(created.ok, false, "create-mode must block on the env-bound finding"); + assert.isTrue( + !created.ok && created.lintErrors.length > 0, + "create-mode must surface the env-bound finding as a blocking lintError", + ); + assert.equal(!created.ok && created.lintErrors[0]?.code, "unknown_provider_instance"); + // create-mode wrote NO board file and registered nothing — only import's + // single create remains on the harness. + assert.equal(harness.writes.length, 1, "only the import-mode create wrote a file"); + assert.deepEqual(harness.registerCalls, [ + { boardId: `${projectId}__env-bound-create`, lintMode: "skip" }, + ]); + }), +); + +it.effect("validateAndCreateBoard blocks a non-env-bound authoring error in BOTH modes", () => + Effect.gen(function* () { + const projectId = "create-structural" as ProjectId; + const harness = makeImportHarness(projectId, { + lintErrors: [ + { + code: "missing_lane_ref", + message: 'Transition target lane "ghost" does not exist', + laneKey: "backlog", + }, + ], + }); + const encodedDefinition = manualImportDefinition("Structural Both"); + + const imported = yield* validateAndCreateBoard(harness.deps, { + projectId, + encodedDefinition, + mode: "import", + }); + assert.equal(imported.ok, false, "non-env-bound error must block import-mode"); + assert.equal(imported.ok ? undefined : imported.lintErrors[0]?.code, "missing_lane_ref"); + + const created = yield* validateAndCreateBoard(harness.deps, { + projectId, + encodedDefinition, + mode: "create", + }); + assert.equal(created.ok, false, "non-env-bound error must block create-mode"); + assert.equal(created.ok ? undefined : created.lintErrors[0]?.code, "missing_lane_ref"); + + // Neither mode wrote a board file. + assert.deepEqual(harness.writes, []); + assert.deepEqual(harness.registerCalls, []); + }), +); + +it.effect("validateAndCreateBoard rejects an oversized definition in BOTH modes", () => + Effect.gen(function* () { + const projectId = "create-oversized" as ProjectId; + const harness = makeImportHarness(projectId); + const oversized = { + name: "Too Big", + lanes: Array.from({ length: 1001 }, (_unused, index) => ({ + key: LaneKey.make(`lane-${index}`), + name: `Lane ${index}`, + entry: "manual" as const, + })), + } satisfies WorkflowDefinitionType; + const encodedDefinition = encodeWorkflowDefinition(oversized); + + const imported = yield* validateAndCreateBoard(harness.deps, { + projectId, + encodedDefinition, + mode: "import", + }); + assert.equal(imported.ok, false, "oversized import must return {ok:false}"); + assert.equal(imported.ok ? undefined : imported.lintErrors[0]?.code, "invalid_step"); + + const created = yield* validateAndCreateBoard(harness.deps, { + projectId, + encodedDefinition, + mode: "create", + }); + assert.equal(created.ok, false, "oversized create must return {ok:false}"); + assert.equal(created.ok ? undefined : created.lintErrors[0]?.code, "invalid_step"); + + assert.deepEqual(harness.writes, []); + }), +); + +// ─── createWorkflowBoard (Create Workflow Wizard, Task 5) ─────────────────── +// +// The wizard handler builds an encoded definition per choice.kind and routes it +// through the SAME create-mode validateAndCreateBoard pipeline. Empty/template +// build a definition locally; definition passes the raw client payload straight +// to the helper (which re-validates). Result shape is {ok:true, boardId} (NO +// warnings) | {ok:false, lintErrors, message?}. + +const wizardAgent = { instance: "codex_main", model: "gpt-5.5" } as const; + +it.effect("createWorkflowBoard creates an empty board from emptyBoardDefinition", () => + Effect.gen(function* () { + const projectId = "wizard-empty" as ProjectId; + const harness = makeImportHarness(projectId); + + const result = yield* createWorkflowBoard(harness.deps, { + projectId, + name: "Empty Wizard" as never, + choice: { kind: "empty" }, + }); + + assert.equal(result.ok, true); + assert.equal(result.ok ? result.boardId : undefined, `${projectId}__empty-wizard`); + // Success shape carries NO warnings key (contract result has no warnings). + assert.isFalse("warnings" in result, "create result must not carry warnings"); + // Board file written + registered via create-mode (lintMode skip after lint). + assert.equal(harness.writes.length, 1); + assert.equal(harness.writes[0]?.relativePath, ".t3/boards/empty-wizard.json"); + // The written definition is the empty board (3 manual lanes). + const written = yield* decodeWorkflowDefinitionJson(harness.writes[0]!.contents); + assert.equal(written.lanes.length, 3); + assert.deepEqual( + written.lanes.map((lane) => lane.key), + ["to-do", "in-progress", "done"], + ); + // "create" version recorded. + assert.equal(harness.versionRecords.length, 1); + assert.equal(harness.versionRecords[0]?.source, "create"); + assert.deepEqual(harness.deletes, []); + }), +); + +it.effect("createWorkflowBoard creates a template board with the agent threaded", () => + Effect.gen(function* () { + const projectId = "wizard-template" as ProjectId; + const harness = makeImportHarness(projectId); + + const result = yield* createWorkflowBoard(harness.deps, { + projectId, + name: "Lite Loop" as never, + choice: { kind: "template", templateId: "lite-agent-loop", agent: wizardAgent }, + }); + + assert.equal(result.ok, true); + assert.equal(result.ok ? result.boardId : undefined, `${projectId}__lite-loop`); + assert.equal(harness.writes.length, 1); + // The lite-agent-loop template threads the agent into its pipeline steps. + const written = yield* decodeWorkflowDefinitionJson(harness.writes[0]!.contents); + const inProgress = written.lanes.find((lane) => lane.key === "in-progress"); + assert.isDefined(inProgress?.pipeline); + const implement = inProgress?.pipeline?.find((step) => step.key === "implement"); + assert.deepEqual(implement?.type === "agent" ? implement.agent : undefined, { + instance: wizardAgent.instance, + model: wizardAgent.model, + }); + assert.equal(harness.versionRecords[0]?.source, "create"); + }), +); + +it.effect("createWorkflowBoard rejects a requiresAgent template with no agent (no board)", () => + Effect.gen(function* () { + const projectId = "wizard-template-noagent" as ProjectId; + const harness = makeImportHarness(projectId); + + const result = yield* createWorkflowBoard(harness.deps, { + projectId, + name: "Needs Agent" as never, + choice: { kind: "template", templateId: "full-sdlc" }, + }); + + assert.equal(result.ok, false); + assert.isTrue(!result.ok && typeof result.message === "string" && result.message.length > 0); + assert.deepEqual(!result.ok ? result.lintErrors : [null], []); + // No board written/registered: the agent check is BEFORE any build/create. + assert.deepEqual(harness.writes, []); + assert.deepEqual(harness.registerCalls, []); + assert.deepEqual(harness.versionRecords, []); + }), +); + +it.effect("createWorkflowBoard rejects an unknown templateId (no board)", () => + Effect.gen(function* () { + const projectId = "wizard-template-unknown" as ProjectId; + const harness = makeImportHarness(projectId); + + const result = yield* createWorkflowBoard(harness.deps, { + projectId, + name: "Ghost Template" as never, + choice: { kind: "template", templateId: "does-not-exist", agent: wizardAgent }, + }); + + assert.equal(result.ok, false); + assert.isTrue( + !result.ok && typeof result.message === "string" && /unknown template/i.test(result.message), + ); + assert.deepEqual(!result.ok ? result.lintErrors : [null], []); + assert.deepEqual(harness.writes, []); + assert.deepEqual(harness.registerCalls, []); + }), +); + +it.effect("createWorkflowBoard creates a board from a valid client definition", () => + Effect.gen(function* () { + const projectId = "wizard-definition" as ProjectId; + const harness = makeImportHarness(projectId); + + const result = yield* createWorkflowBoard(harness.deps, { + projectId, + name: "From Def" as never, + choice: { + kind: "definition", + definition: manualImportDefinition("Authored Board"), + }, + }); + + assert.equal(result.ok, true); + // Slug derives from the DEFINITION name (validateAndCreateBoard uses the + // decoded definition's name), not the wizard input.name. + assert.equal(result.ok ? result.boardId : undefined, `${projectId}__authored-board`); + assert.isFalse("warnings" in result, "create result must not carry warnings"); + assert.equal(harness.writes.length, 1); + assert.equal(harness.versionRecords[0]?.source, "create"); + }), +); + +it.effect("createWorkflowBoard blocks a definition that fails strict lint (no board)", () => + Effect.gen(function* () { + const projectId = "wizard-definition-lint" as ProjectId; + // A transition target referencing a missing lane → blocking lint in create-mode. + const harness = makeImportHarness(projectId, { + lintErrors: [ + { + code: "missing_lane_ref", + message: 'Transition target lane "ghost" does not exist', + laneKey: "backlog", + }, + ], + }); + + const result = yield* createWorkflowBoard(harness.deps, { + projectId, + name: "Bad Def" as never, + choice: { + kind: "definition", + definition: manualImportDefinition("Bad Authored Board"), + }, + }); + + assert.equal(result.ok, false); + assert.equal(!result.ok ? result.lintErrors[0]?.code : undefined, "missing_lane_ref"); + assert.deepEqual(harness.writes, []); + assert.deepEqual(harness.registerCalls, []); + }), +); + +it.effect("createWorkflowBoard rejects an oversized client definition (no board)", () => + Effect.gen(function* () { + const projectId = "wizard-definition-oversized" as ProjectId; + const harness = makeImportHarness(projectId); + const oversized = { + name: "Too Big", + lanes: Array.from({ length: 1001 }, (_unused, index) => ({ + key: LaneKey.make(`lane-${index}`), + name: `Lane ${index}`, + entry: "manual" as const, + })), + } satisfies WorkflowDefinitionType; + + const result = yield* createWorkflowBoard(harness.deps, { + projectId, + name: "Oversized" as never, + choice: { kind: "definition", definition: encodeWorkflowDefinition(oversized) }, + }); + + assert.equal(result.ok, false); + assert.equal(!result.ok ? result.lintErrors[0]?.code : undefined, "invalid_step"); + assert.deepEqual(harness.writes, []); + }), +); + +it.effect( + "createWorkflowBoard rejects a stranding client definition via the dry-run gate (no board)", + () => + Effect.gen(function* () { + const projectId = "wizard-definition-strands" as ProjectId; + const harness = makeImportHarness(projectId); + // `build` is an auto lane whose only step has no step.on routing and the + // lane has no transitions / lane.on → dry run ends in no_route (strands + // tickets). A terminal `done` lane exists so lint would pass; only the + // dead-end dry-run gate (definition path) catches it — and BEFORE any write. + const strandingDef = encodeWorkflowDefinition({ + name: "Stranding Authored Board", + lanes: [ + { key: LaneKey.make("backlog"), name: "Backlog", entry: "manual" }, + { + key: LaneKey.make("build"), + name: "Build", + entry: "auto", + pipeline: [ + { + key: StepKey.make("implement"), + type: "agent", + agent: { instance: "codex_main", model: "gpt-5.5" }, + instruction: "do work", + }, + ], + }, + { key: LaneKey.make("done"), name: "Done", entry: "auto", terminal: true }, + ], + } satisfies WorkflowDefinitionType); + + const result = yield* createWorkflowBoard(harness.deps, { + projectId, + name: "Strands" as never, + choice: { kind: "definition", definition: strandingDef }, + }); + + assert.equal(result.ok, false); + if (result.ok !== false) assert.fail("expected ok:false"); + // The dead-end dry-run now runs as validateAndCreateBoard's afterLint hook + // (AFTER caps + decode + lint), so the stranding message renders through a + // single "invalid_step" lintError (the only failure shape the import helper + // can return), not via a separate top-level `message`. + assert.equal(result.lintErrors.length, 1); + assert.equal(result.lintErrors[0]?.code, "invalid_step"); + assert.include(result.lintErrors[0]?.message ?? "", "strands tickets"); + assert.include(result.lintErrors[0]?.message ?? "", "build"); + // Nothing persisted: the afterLint gate returns ok:false BEFORE persist. + assert.deepEqual(harness.writes, []); + assert.deepEqual(harness.registerCalls, []); + assert.deepEqual(harness.versionRecords, []); + }), +); + +it.effect( + "createWorkflowBoard rejects an oversized definition WITHOUT running the dead-end dry-run (caps before dry-run)", + () => + Effect.gen(function* () { + const projectId = "wizard-definition-oversized-ordering" as ProjectId; + const harness = makeImportHarness(projectId); + // Spy predicate evaluator: the dead-end dry-run is the ONLY caller of + // `evaluate` on this path. An oversized def must be rejected by the caps in + // validateAndCreateBoard BEFORE the (bounded) afterLint dry-run could run, + // so `evaluate` must be invoked ZERO times. + let evaluateCalls = 0; + const spyPredicates = { + evaluate: () => { + evaluateCalls += 1; + return Effect.succeed({ result: false, matchedPaths: [] }); + }, + }; + const deps = { ...harness.deps, predicates: spyPredicates }; + // > MAX_IMPORT_LANES (1000) AND every lane is an auto lane with no pipeline, + // so a dead-end dry-run (if it ran first) WOULD strand every lane + // (`no_route`). The fix guarantees the caps reject the def FIRST → the + // rejection is the "too large" caps message, never the stranding message. + const oversized = { + name: "Too Big", + lanes: Array.from({ length: 1001 }, (_unused, index) => ({ + key: LaneKey.make(`lane-${index}`), + name: `Lane ${index}`, + entry: "auto" as const, + })), + } satisfies WorkflowDefinitionType; + + const result = yield* createWorkflowBoard(deps, { + projectId, + name: "Oversized" as never, + choice: { kind: "definition", definition: encodeWorkflowDefinition(oversized) }, + }); + + assert.equal(result.ok, false); + if (result.ok !== false) assert.fail("expected ok:false"); + // Rejected by the caps (the "too large" lint message), NOT the stranding msg. + assert.equal(result.lintErrors[0]?.code, "invalid_step"); + assert.match(result.lintErrors[0]?.message ?? "", /too large/i); + assert.notInclude(result.lintErrors[0]?.message ?? "", "strands tickets"); + // The dead-end dry-run never ran: caps fired first. + assert.equal(evaluateCalls, 0, "dead-end dry-run must NOT run on an oversized def"); + assert.deepEqual(harness.writes, []); + assert.deepEqual(harness.registerCalls, []); + }), +); + +// ─── proposeBoardImprovement (self-improve E4) ────────────────────────────── + +const proposalBoardId = BoardId.make("board-propose"); +const proposalAgent = { instance: "claude_main", model: "sonnet" } as const; + +// backlog (manual) → work (auto; step.on success → done) → done (terminal) +const proposalBaseDefinition = { + name: "Self-improve board", + sources: [], + outbound: [], + lanes: [ + { key: LaneKey.make("backlog"), name: "Backlog", entry: "manual" }, + { + key: LaneKey.make("work"), + name: "Work", + entry: "auto", + pipeline: [ + { + key: StepKey.make("code"), + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do the work", + on: { + success: LaneKey.make("done"), + failure: LaneKey.make("done"), + blocked: LaneKey.make("done"), + }, + }, + ], + }, + { key: LaneKey.make("done"), name: "Done", entry: "auto", terminal: true }, + ], +} satisfies WorkflowDefinitionType; + +const proposalMetrics = { + windowDays: 30, + generatedAt: "2026-06-14T00:00:00.000Z", + throughput: { created: 3, shipped: 2 }, + cycleTime: { count: 2, p50Ms: 100, p90Ms: 200, avgMs: 150 }, + wipByLane: [], + statusBreakdown: {}, + attention: { blocked: 0, waitingOnUser: 0, oldest: [] }, + routeOutcomes: [], + manualMoveCount: 0, + stepStats: [], +} as const; + +const stubPredicates = { + evaluate: () => Effect.succeed({ result: false, matchedPaths: [] }), +}; + +interface ProposalGenResult { + readonly proposedDefinition: unknown; + readonly rationale: string; +} + +const makeProposeDeps = (args: { + readonly gen: () => Effect.Effect<ProposalGenResult, TextGenerationError>; + readonly lint?: () => Effect.Effect<ReadonlyArray<LintError>, WorkflowRpcError>; + readonly recorded: Array<unknown>; +}) => ({ + readModel: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId: proposalBoardId, + projectId: "project-1", + name: "Self-improve board", + workflowFilePath: ".t3/boards/self-improve.json", + workflowVersionHash: "base-hash-123", + maxConcurrentTickets: 2, + }), + getBoardMetrics: () => Effect.succeed(proposalMetrics), + recordBoardProposal: (proposal: unknown) => + Effect.sync(() => { + args.recorded.push(proposal); + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(proposalBaseDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + fileLoader: { + lintDefinition: args.lint ?? (() => Effect.succeed([])), + loadAndRegister: () => Effect.die("unused"), + }, + projectWorkspaceResolver: { resolve: () => Effect.succeed("/tmp/project") }, + predicates: stubPredicates, + textGeneration: { generateBoardProposal: args.gen }, +}); + +it.effect("proposeBoardImprovement stores a pending proposal when all gates pass", () => + Effect.gen(function* () { + const recorded: Array<unknown> = []; + // A clean targeted change: rename a lane only. + const proposed = { + ...proposalBaseDefinition, + lanes: proposalBaseDefinition.lanes.map((lane) => + (lane.key as string) === "work" ? { ...lane, name: "Work (revised)" } : lane, + ), + }; + const deps = makeProposeDeps({ + recorded, + gen: () => + Effect.succeed({ proposedDefinition: proposed, rationale: "renamed the work lane" }), + }); + + const { proposal } = yield* proposeBoardImprovement(deps, { + boardId: proposalBoardId, + agent: proposalAgent, + }); + + assert.equal(proposal.status, "pending"); + assert.isTrue(proposal.validation.preservationOk); + assert.isTrue(proposal.validation.lintOk); + assert.isTrue(proposal.validation.dryRunOk); + assert.equal(proposal.baseVersionHash, "base-hash-123"); + assert.isFalse(proposal.outdated); + assert.equal(proposal.appliedVersionHash, null); + assert.equal(recorded.length, 1); + const row = recorded[0] as { + readonly status: string; + readonly baseVersionHash: string; + readonly baseDefJson: string; + readonly agentJson: string; + }; + assert.equal(row.status, "pending"); + assert.equal(row.baseVersionHash, "base-hash-123"); + assert.include(row.baseDefJson, "Self-improve board"); + assert.include(row.agentJson, "claude_main"); + }), +); + +it.effect( + "proposeBoardImprovement → invalid when the proposal changes sources (preservation)", + () => + Effect.gen(function* () { + const recorded: Array<unknown> = []; + const proposed = { + ...proposalBaseDefinition, + sources: [ + { + id: "src-1", + provider: "github", + connectionRef: "conn-1", + selector: {}, + destinationLane: LaneKey.make("backlog"), + closedLane: LaneKey.make("done"), + enabled: true, + }, + ], + }; + const deps = makeProposeDeps({ + recorded, + gen: () => Effect.succeed({ proposedDefinition: proposed, rationale: "add a source" }), + }); + + const { proposal } = yield* proposeBoardImprovement(deps, { + boardId: proposalBoardId, + agent: proposalAgent, + }); + + assert.equal(proposal.status, "invalid"); + assert.isFalse(proposal.validation.preservationOk); + assert.isTrue(proposal.validation.messages.some((m) => m.includes("sources"))); + assert.equal((recorded[0] as { readonly status: string }).status, "invalid"); + }), +); + +it.effect("proposeBoardImprovement → invalid when the proposal REMOVES a lane (preservation)", () => + Effect.gen(function* () { + const recorded: Array<unknown> = []; + // Drop the `work` lane entirely. Routing INTO it would be caught by dry-run, + // but the removed lane's OWN startLane combos vanish from proposedResults — + // the preservation lane-key superset gate is what closes this. + const proposed = { + ...proposalBaseDefinition, + lanes: proposalBaseDefinition.lanes.filter((lane) => (lane.key as string) !== "work"), + }; + const deps = makeProposeDeps({ + recorded, + gen: () => Effect.succeed({ proposedDefinition: proposed, rationale: "drop a lane" }), + }); + + const { proposal } = yield* proposeBoardImprovement(deps, { + boardId: proposalBoardId, + agent: proposalAgent, + }); + + assert.equal(proposal.status, "invalid"); + assert.isFalse(proposal.validation.preservationOk); + assert.isTrue( + proposal.validation.messages.some((m) => m.includes("removes/renames") && m.includes("work")), + ); + // No saveBoardDefinition path exists in deps; the only write is the proposal row. + assert.equal(recorded.length, 1); + assert.equal((recorded[0] as { readonly status: string }).status, "invalid"); + }), +); + +it.effect("proposeBoardImprovement → invalid when the proposed definition fails strict lint", () => + Effect.gen(function* () { + const recorded: Array<unknown> = []; + const proposed = { + ...proposalBaseDefinition, + lanes: proposalBaseDefinition.lanes.map((lane) => + (lane.key as string) === "work" ? { ...lane, name: "Work (revised)" } : lane, + ), + }; + const deps = makeProposeDeps({ + recorded, + gen: () => Effect.succeed({ proposedDefinition: proposed, rationale: "tweak" }), + lint: () => + Effect.succeed([ + { code: "missing_lane_ref", message: "lane target missing" } satisfies LintError, + ]), + }); + + const { proposal } = yield* proposeBoardImprovement(deps, { + boardId: proposalBoardId, + agent: proposalAgent, + }); + + assert.equal(proposal.status, "invalid"); + assert.isTrue(proposal.validation.preservationOk); + assert.isFalse(proposal.validation.lintOk); + assert.equal(proposal.validation.lintErrors.length, 1); + }), +); + +it.effect( + "proposeBoardImprovement → invalid when the proposal introduces a new dead end (dry-run)", + () => + Effect.gen(function* () { + const recorded: Array<unknown> = []; + // Drop the step.on routing from `work` so the auto lane ends in no_route. + const proposed = { + ...proposalBaseDefinition, + lanes: proposalBaseDefinition.lanes.map((lane) => + (lane.key as string) === "work" + ? { + key: LaneKey.make("work"), + name: "Work", + entry: "auto", + pipeline: [ + { + key: StepKey.make("code"), + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do the work", + }, + ], + } + : lane, + ), + }; + const deps = makeProposeDeps({ + recorded, + gen: () => Effect.succeed({ proposedDefinition: proposed, rationale: "remove routing" }), + }); + + const { proposal } = yield* proposeBoardImprovement(deps, { + boardId: proposalBoardId, + agent: proposalAgent, + }); + + assert.equal(proposal.status, "invalid"); + assert.isTrue(proposal.validation.preservationOk); + assert.isTrue(proposal.validation.lintOk); + assert.isFalse(proposal.validation.dryRunOk); + assert.isTrue(proposal.validation.dryRunRegressions.length > 0); + }), +); + +it.effect( + "proposeBoardImprovement → invalid (decode) when the proposed definition is malformed", + () => + Effect.gen(function* () { + const recorded: Array<unknown> = []; + const deps = makeProposeDeps({ + recorded, + gen: () => + Effect.succeed({ proposedDefinition: { not: "a workflow def" }, rationale: "broken" }), + }); + + const { proposal } = yield* proposeBoardImprovement(deps, { + boardId: proposalBoardId, + agent: proposalAgent, + }); + + assert.equal(proposal.status, "invalid"); + assert.isTrue(proposal.validation.messages.some((m) => m.includes("decoded"))); + }), +); + +it.effect("proposeBoardImprovement → invalid when generation fails", () => + Effect.gen(function* () { + const recorded: Array<unknown> = []; + const deps = makeProposeDeps({ + recorded, + gen: () => + Effect.fail( + new TextGenerationError({ operation: "generateBoardProposal", detail: "provider down" }), + ), + }); + + const { proposal } = yield* proposeBoardImprovement(deps, { + boardId: proposalBoardId, + agent: proposalAgent, + }); + + assert.equal(proposal.status, "invalid"); + assert.include(proposal.rationale, "generation failed"); + assert.equal((recorded[0] as { readonly status: string }).status, "invalid"); + }), +); + +// ─── listBoardProposals + getBoardProposal (self-improve E5) ───────────────── + +const stubProposalView = ( + overrides?: Partial<{ + proposalId: string; + boardId: BoardId; + status: "pending" | "approved" | "rejected" | "superseded" | "invalid" | "reverted"; + outdated: boolean; + }>, +) => ({ + proposalId: "prop-e5-1", + boardId: proposalBoardId, + status: "pending" as const, + rationale: "stub rationale", + validation: { + preservationOk: true, + lintOk: true, + dryRunOk: true, + laneDiffCount: 1, + lintErrors: [], + dryRunRegressions: [], + messages: [], + }, + baseVersionHash: "base-hash-123", + appliedVersionHash: null, + outdated: false, + agent: proposalAgent, + createdAt: "2026-06-14T10:00:00.000Z", + resolvedAt: null, + ...overrides, +}); + +it.effect("listBoardProposals handler returns proposals from readModel", () => + Effect.gen(function* () { + const p1 = stubProposalView({ proposalId: "prop-1", outdated: false }); + const p2 = stubProposalView({ proposalId: "prop-2", outdated: true }); + const deps = { + readModel: { + ...noopReadModel, + listBoardProposals: (_boardId: BoardId) => Effect.succeed([p1, p2]), + }, + observeRpcEffect: <A, E, R>(_method: string, effect: Effect.Effect<A, E, R>) => effect, + }; + const result = yield* listBoardProposals(deps, { boardId: proposalBoardId }); + assert.equal(result.proposals.length, 2); + assert.equal(result.proposals[0]?.proposalId, "prop-1"); + assert.equal(result.proposals[0]?.outdated, false); + assert.equal(result.proposals[1]?.proposalId, "prop-2"); + assert.equal(result.proposals[1]?.outdated, true); + }), +); + +it.effect("listBoardProposals handler returns empty array when no proposals", () => + Effect.gen(function* () { + const deps = { + readModel: { + ...noopReadModel, + listBoardProposals: (_boardId: BoardId) => Effect.succeed([]), + }, + observeRpcEffect: <A, E, R>(_method: string, effect: Effect.Effect<A, E, R>) => effect, + }; + const result = yield* listBoardProposals(deps, { boardId: proposalBoardId }); + assert.equal(result.proposals.length, 0); + }), +); + +it.effect("getBoardProposal handler returns proposal + both defs", () => + Effect.gen(function* () { + const view = stubProposalView(); + const proposedDef = encodeWorkflowDefinition(proposalBaseDefinition); + const baseDef = encodeWorkflowDefinition(proposalBaseDefinition); + const deps = { + readModel: { + ...noopReadModel, + getBoardProposal: (_proposalId: string) => + Effect.succeed({ + view, + proposedDefinition: proposedDef, + baseDefinition: baseDef, + }), + }, + observeRpcEffect: <A, E, R>(_method: string, effect: Effect.Effect<A, E, R>) => effect, + }; + const result = yield* getBoardProposal(deps, { proposalId: "prop-e5-1" }); + assert.equal(result.proposal.proposalId, "prop-e5-1"); + assert.equal(result.proposal.outdated, false); + assert.deepEqual(result.proposedDefinition, proposedDef); + assert.deepEqual(result.baseDefinition, baseDef); + }), +); + +it.effect("getBoardProposal handler fails with WorkflowRpcError when not found", () => + Effect.gen(function* () { + const deps = { + readModel: { + ...noopReadModel, + getBoardProposal: (_proposalId: string) => Effect.succeed(null), + }, + observeRpcEffect: <A, E, R>(_method: string, effect: Effect.Effect<A, E, R>) => effect, + }; + const exit = yield* getBoardProposal(deps, { proposalId: "does-not-exist" }).pipe(Effect.exit); + assert.strictEqual(exit._tag, "Failure"); + if (exit._tag === "Failure") { + assert.isTrue(exit.cause.toString().includes("was not found")); + } + }), +); + +// ─── resolveBoardProposal (self-improve E6) ───────────────────────────────── + +// backlog (manual) → work (auto; step.on → done) → done (terminal) +const resolveBaseDefinition = { + name: "Self-improve resolve board", + lanes: [ + { key: LaneKey.make("backlog"), name: "Backlog", entry: "manual" }, + { + key: LaneKey.make("work"), + name: "Work", + entry: "auto", + pipeline: [ + { + key: StepKey.make("code"), + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do the work", + on: { + success: LaneKey.make("done"), + failure: LaneKey.make("done"), + blocked: LaneKey.make("done"), + }, + }, + ], + }, + { key: LaneKey.make("done"), name: "Done", entry: "auto", terminal: true }, + ], +} satisfies WorkflowDefinitionType; + +// A proposal that only renames the `backlog` lane — changes the backlog lane def. +const resolveProposedDefinition = { + ...resolveBaseDefinition, + lanes: resolveBaseDefinition.lanes.map((lane) => + (lane.key as string) === "backlog" ? { ...lane, name: "Inbox" } : lane, + ), +} satisfies WorkflowDefinitionType; + +interface ResolveHarness { + readonly recorded: Array<{ + readonly proposalId: string; + readonly status: string; + readonly resolvedAt: string; + readonly appliedVersionHash?: string | null; + readonly fromStatus?: string; + }>; + readonly writes: Array<string>; + // Order trace: "lock" entries record entry/exit of withBoardAdmissionLock, and + // "write" records the board-file write — proves the write happened inside the lock. + readonly trace: Array<string>; + readonly deps: Parameters<typeof resolveBoardProposal>[0]; +} + +const makeResolveHarness = (args: { + readonly versionStore: WorkflowBoardVersionStore["Service"]; + readonly proposalStatus?: "pending" | "approved" | "rejected" | "superseded" | "invalid"; + readonly proposedDefinition?: WorkflowDefinitionType; + readonly baseDefinition?: WorkflowDefinitionType; + readonly outdated?: boolean; + readonly liveOccupiedLanes?: ReadonlyArray<string>; + readonly lint?: () => Effect.Effect<ReadonlyArray<LintError>, never>; + // The board's current on-disk hash drives saveBoardDefinition's optimistic + // concurrency. When it differs from the proposal base hash, save → conflict. + readonly currentFileMatchesBase?: boolean; + // Predicate evaluator for the apply-time dry-run re-validation. Defaults to the + // always-false stub. Omit to skip dry-run (predicates undefined). + readonly predicates?: typeof stubPredicates | null; + // Affected-row count returned by the guarded (fromStatus-bearing) status flip. + // Default 1. Set 0 to simulate a concurrent reject/supersede that raced the + // pending→approved flip — exercises the reconciliation forced write. + readonly guardedStatusAffected?: number; +}): ResolveHarness => { + const boardId = BoardId.make("project-resolve__board"); + const projectId = "project-resolve" as ProjectId; + const workflowFilePath = ".t3/boards/resolve.json"; + const workspaceRoot = "/tmp/project-resolve-root"; + + const baseDef = args.baseDefinition ?? resolveBaseDefinition; + const proposedDef = args.proposedDefinition ?? resolveProposedDefinition; + + // The on-disk file. By default it equals the proposal base def, so its hash + // equals the proposal base hash → save passes the concurrency check. + let fileContents = `${encodeWorkflowDefinitionJson(decodeWorkflowDefinitionSync(baseDef))}\n`; + const baseHash = sha256Hex(fileContents); + if (args.currentFileMatchesBase === false) { + fileContents = `${encodeWorkflowDefinitionJson(decodeWorkflowDefinitionSync({ ...baseDef, name: "Drifted" }))}\n`; + } + let registryDefinition = decodeWorkflowDefinitionSync(baseDef); + let boardRow = { + boardId, + projectId, + name: registryDefinition.name, + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + + const proposalView = stubProposalView({ + proposalId: "prop-e6-1", + boardId, + status: args.proposalStatus ?? "pending", + outdated: args.outdated ?? false, + }); + // baseVersionHash on the view must match the on-disk base hash so a clean + // approve passes saveBoardDefinition's expectedVersionHash check. + const view = { ...proposalView, baseVersionHash: baseHash }; + + const recorded: ResolveHarness["recorded"] = []; + const writes: Array<string> = []; + const trace: Array<string> = []; + + const deps = { + engine: { + withBoardAdmissionLock: <A, E, R>(_boardId: BoardId, effect: Effect.Effect<A, E, R>) => + Effect.gen(function* () { + trace.push("lock:enter"); + const result = yield* effect; + trace.push("lock:exit"); + return result; + }), + }, + predicates: args.predicates === null ? undefined : (args.predicates ?? stubPredicates), + readModel: { + ...noopReadModel, + getBoard: (inputBoardId: BoardId) => + Effect.succeed(inputBoardId === boardId ? boardRow : null), + getBoardProposal: (_proposalId: string) => + Effect.succeed({ + view, + proposedDefinition: encodeWorkflowDefinition(decodeWorkflowDefinitionSync(proposedDef)), + baseDefinition: encodeWorkflowDefinition(decodeWorkflowDefinitionSync(baseDef)), + }), + listLiveOccupiedLanes: (_boardId: BoardId) => Effect.succeed(args.liveOccupiedLanes ?? []), + resolveBoardProposalStatus: (input: { + proposalId: string; + status: string; + resolvedAt: string; + appliedVersionHash?: string | null; + fromStatus?: string; + }) => + Effect.sync(() => { + recorded.push(input); + // Guarded flips (fromStatus present) can be made to "lose" a race by + // returning 0; unguarded (forced reconcile) flips always affect 1. + if (input.fromStatus !== undefined && args.guardedStatusAffected !== undefined) { + return args.guardedStatusAffected; + } + return 1; + }), + listWorkSourceMappingsForBoard: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + fileLoader: { + lintDefinition: args.lint ?? (() => Effect.succeed([])), + loadAndRegister: (input: { readonly boardId: BoardId }) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.mapError( + (cause) => new WorkflowRpcError({ message: "round-trip decode failed", cause }), + ), + ); + registryDefinition = definition; + boardRow = { + ...boardRow, + name: definition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: (input: { readonly relativePath: string; readonly contents: string }) => + Effect.sync(() => { + fileContents = input.contents; + writes.push(input.contents); + trace.push("write"); + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + versionStore: args.versionStore, + } as unknown as Parameters<typeof resolveBoardProposal>[0]; + + return { recorded, writes, trace, deps }; +}; + +const decodeWorkflowDefinitionSync = (input: unknown) => + Schema.decodeUnknownSync(WorkflowDefinition)(input); + +versionRoundTripLayer("resolveBoardProposal (self-improve E6)", (it) => { + it.effect( + "approve a clean pending proposal → save with base hash + self-improve source → approved", + () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeResolveHarness({ versionStore }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + assert.equal(result.ok, true); + if (result.ok !== true) { + assert.fail("expected approve to succeed"); + } + assert.equal(result.proposal.status, "approved"); + assert.isNotNull(result.proposal.appliedVersionHash); + assert.isNotNull(result.proposal.resolvedAt); + // saveBoardDefinition was called exactly once (one write). + assert.equal(h.writes.length, 1); + // status transition stamped approved + applied hash. + const approved = h.recorded.find((r) => r.status === "approved"); + assert.isDefined(approved); + assert.equal(approved?.fromStatus, "pending"); + assert.isDefined(approved?.appliedVersionHash); + assert.equal(approved?.appliedVersionHash, result.proposal.appliedVersionHash); + }), + ); + + it.effect("approve blocked by a modified lane holding live work → live_tickets, no save", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + // The proposal modifies the `backlog` lane; backlog is live-occupied. + const h = makeResolveHarness({ versionStore, liveOccupiedLanes: ["backlog"] }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected live_tickets rejection"); + } + assert.equal(result.reason, "live_tickets"); + assert.include(result.message, "backlog"); + // saveBoardDefinition NOT called; proposal stays pending (no status write). + assert.equal(h.writes.length, 0); + assert.equal(h.recorded.length, 0); + }), + ); + + it.effect("approve NOT blocked when only an UNCHANGED lane holds live work", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + // Proposal modifies `backlog`; `work` (unchanged) is the occupied lane. + const h = makeResolveHarness({ versionStore, liveOccupiedLanes: ["work"] }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + assert.equal(result.ok, true); + if (result.ok !== true) { + assert.fail("expected approve to succeed (idle modified lane)"); + } + assert.equal(result.proposal.status, "approved"); + assert.equal(h.writes.length, 1); + }), + ); + + it.effect("approve blocked by a QUEUED ticket in a changed lane → live_tickets, no save", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + // listLiveOccupiedLanes surfaces queued tickets (3b fix), so a queued + // ticket in the modified `backlog` lane makes it live-occupied → block. + const h = makeResolveHarness({ versionStore, liveOccupiedLanes: ["backlog"] }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected live_tickets rejection for a queued ticket in a changed lane"); + } + assert.equal(result.reason, "live_tickets"); + assert.include(result.message, "backlog"); + assert.equal(h.writes.length, 0); + assert.equal(h.recorded.length, 0); + }), + ); + + it.effect("approve NOT blocked by a QUEUED ticket in an UNCHANGED lane", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + // Queued ticket sits in `work` (unchanged) → does not block the apply. + const h = makeResolveHarness({ versionStore, liveOccupiedLanes: ["work"] }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + assert.equal(result.ok, true); + if (result.ok !== true) { + assert.fail("expected approve to succeed (queued in unchanged lane)"); + } + assert.equal(result.proposal.status, "approved"); + assert.equal(h.writes.length, 1); + }), + ); + + it.effect("approve an outdated proposal → conflict, status superseded, no save", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeResolveHarness({ versionStore, outdated: true }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected conflict"); + } + assert.equal(result.reason, "conflict"); + assert.equal(h.writes.length, 0); + const superseded = h.recorded.find((r) => r.status === "superseded"); + assert.isDefined(superseded); + assert.equal(superseded?.fromStatus, "pending"); + }), + ); + + it.effect( + "approve when saveBoardDefinition returns lintErrors → lint, status stays pending", + () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeResolveHarness({ + versionStore, + lint: () => + Effect.succeed([ + { code: "missing_lane_ref", message: "lane target missing" } satisfies LintError, + ]), + }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected lint rejection"); + } + assert.equal(result.reason, "lint"); + assert.isDefined(result.lintErrors); + assert.equal(result.lintErrors?.length, 1); + // Lint is detected before the write; proposal stays pending (no status write). + assert.equal(h.recorded.length, 0); + }), + ); + + it.effect("approve when the on-disk board drifted from base → conflict, superseded", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + // The proposal is NOT flagged outdated (view.outdated false) but the actual + // file hash no longer matches base → saveBoardDefinition returns conflict. + const h = makeResolveHarness({ versionStore, currentFileMatchesBase: false }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected conflict from save"); + } + assert.equal(result.reason, "conflict"); + assert.equal(h.writes.length, 0); + const superseded = h.recorded.find((r) => r.status === "superseded"); + assert.isDefined(superseded); + }), + ); + + it.effect("reject → status rejected, no save", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeResolveHarness({ versionStore }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "reject", + }); + + assert.equal(result.ok, true); + if (result.ok !== true) { + assert.fail("expected reject ok"); + } + assert.equal(result.proposal.status, "rejected"); + assert.equal(h.writes.length, 0); + const rejected = h.recorded.find((r) => r.status === "rejected"); + assert.isDefined(rejected); + assert.equal(rejected?.fromStatus, "pending"); + }), + ); + + it.effect( + "approving an already-resolved (non-pending) proposal → ok:false invalid, no save", + () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeResolveHarness({ versionStore, proposalStatus: "approved" }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + // Finding #4: a non-pending proposal is NOT actionable; never report ok:true. + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected ok:false for a non-pending approve"); + } + assert.equal(result.reason, "invalid"); + assert.equal(h.writes.length, 0); + assert.equal(h.recorded.length, 0); + }), + ); + + // CRITICAL invariant: resolve-approve is the SOLE saveBoardDefinition caller for + // proposals. propose/list/get must NEVER write a board definition. We wire a + // board-file writer that fails loudly and confirm none of those paths touch it. + it.effect( + "propose / list / get NEVER call saveBoardDefinition (only resolve-approve writes)", + () => + Effect.gen(function* () { + let wrote = false; + const recorded: Array<unknown> = []; + const proposed = { + ...proposalBaseDefinition, + lanes: proposalBaseDefinition.lanes.map((lane) => + (lane.key as string) === "work" ? { ...lane, name: "Work (revised)" } : lane, + ), + }; + // Augment the propose deps with a writer that records any board-file write. + const baseDeps = makeProposeDeps({ + recorded, + gen: () => + Effect.succeed({ proposedDefinition: proposed, rationale: "renamed the work lane" }), + }); + const deps = { + ...baseDeps, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed("{}"), + writeFile: () => + Effect.sync(() => { + wrote = true; + return { relativePath: "x" }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + } as unknown as Parameters<typeof proposeBoardImprovement>[0]; + + yield* proposeBoardImprovement(deps, { + boardId: proposalBoardId, + agent: proposalAgent, + }); + assert.isFalse(wrote, "proposeBoardImprovement must not write a board definition"); + + const listDeps = { + readModel: { ...noopReadModel, listBoardProposals: () => Effect.succeed([]) }, + observeRpcEffect: <A, E, R>(_m: string, e: Effect.Effect<A, E, R>) => e, + }; + yield* listBoardProposals(listDeps, { boardId: proposalBoardId }); + + const view = stubProposalView(); + const getDeps = { + readModel: { + ...noopReadModel, + getBoardProposal: () => + Effect.succeed({ + view, + proposedDefinition: encodeWorkflowDefinition(proposalBaseDefinition), + baseDefinition: encodeWorkflowDefinition(proposalBaseDefinition), + }), + }, + observeRpcEffect: <A, E, R>(_m: string, e: Effect.Effect<A, E, R>) => e, + }; + yield* getBoardProposal(getDeps, { proposalId: "prop-e5-1" }); + + assert.isFalse(wrote, "no proposal read/propose path may write a board definition"); + }), + ); + + // ── Finding #1 — admission lock wraps the live-gate + save (TOCTOU) ───────── + it.effect("approve runs the live-gate + save INSIDE the board admission lock", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeResolveHarness({ versionStore }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + assert.equal(result.ok, true); + // The board write must be bracketed by the admission lock — proving no + // ticket can enter a changed lane between the gate and the write. + assert.deepEqual(h.trace, ["lock:enter", "write", "lock:exit"]); + }), + ); + + it.effect("reject also takes the admission lock (cannot flip a proposal mid-apply)", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeResolveHarness({ versionStore }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "reject", + }); + + assert.equal(result.ok, true); + // Reject takes the lock (and writes nothing). + assert.deepEqual(h.trace, ["lock:enter", "lock:exit"]); + assert.equal(h.writes.length, 0); + }), + ); + + // ── Finding #2 — apply-state durability: reconcile to approved on lost race ── + it.effect("approve reconciles to 'approved' even if the guarded flip is raced (affected 0)", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + // The pending→approved guarded flip "loses" (a concurrent reject/supersede + // slipped the row out of pending after the save); the forced reconcile wins. + const h = makeResolveHarness({ versionStore, guardedStatusAffected: 0 }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + assert.equal(result.ok, true); + if (result.ok !== true) { + assert.fail("expected approve ok after reconcile"); + } + assert.equal(result.proposal.status, "approved"); + // The board WAS written, so the invariant (hash==proposed ⇒ approved) holds. + assert.equal(h.writes.length, 1); + // Two status writes: the guarded (affected 0) + the forced reconcile. + const approvedWrites = h.recorded.filter((r) => r.status === "approved"); + assert.equal(approvedWrites.length, 2); + const guarded = approvedWrites.find((r) => r.fromStatus === "pending"); + const forced = approvedWrites.find((r) => r.fromStatus === undefined); + assert.isDefined(guarded, "guarded pending→approved flip attempted"); + assert.isDefined(forced, "forced reconcile (no fromStatus) applied"); + assert.isDefined(forced?.appliedVersionHash); + }), + ); + + // ── Finding #3 — approve re-runs preservation + dry-run with current code ─── + it.effect("approve re-runs PRESERVATION and rejects a now-invalid proposal (no save)", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + // Proposed def changes the lane set in a way preservation forbids: drop the + // `work` lane entirely (lane-key removal is a preservation violation). + const proposedDropsLane = { + ...resolveBaseDefinition, + lanes: resolveBaseDefinition.lanes.filter((lane) => (lane.key as string) !== "work"), + } satisfies WorkflowDefinitionType; + const h = makeResolveHarness({ versionStore, proposedDefinition: proposedDropsLane }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected ok:false from re-validation"); + } + assert.equal(result.reason, "invalid"); + assert.include(result.message, "preservation"); + // No save; proposal marked invalid. + assert.equal(h.writes.length, 0); + const invalidated = h.recorded.find((r) => r.status === "invalid"); + assert.isDefined(invalidated); + assert.equal(invalidated?.fromStatus, "pending"); + }), + ); + + it.effect( + "approve re-runs DRY-RUN and rejects a proposal that now regresses routing (no save)", + () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + // Proposed def drops the `work` lane's step.on routing → its auto lane ends + // in a NEW dead end (no_route) the base did not have → dry-run regression. + const proposedDeadEnd = { + ...resolveBaseDefinition, + lanes: resolveBaseDefinition.lanes.map((lane) => + (lane.key as string) === "work" + ? { + key: LaneKey.make("work"), + name: "Work", + entry: "auto", + pipeline: [ + { + key: StepKey.make("code"), + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do the work", + }, + ], + } + : lane, + ), + } satisfies WorkflowDefinitionType; + const h = makeResolveHarness({ versionStore, proposedDefinition: proposedDeadEnd }); + + const result = yield* resolveBoardProposal(h.deps, { + proposalId: "prop-e6-1", + action: "approve", + }); + + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected ok:false from dry-run re-validation"); + } + assert.equal(result.reason, "invalid"); + assert.include(result.message, "regression"); + assert.equal(h.writes.length, 0); + assert.isDefined(h.recorded.find((r) => r.status === "invalid")); + }), + ); +}); + +// ─── revertBoardProposal (self-improve E7) ────────────────────────────────── + +// For revert tests, "applied" state = board currently matches the proposedDef +// (i.e. the improvement was already applied). base_def_json holds the original. +// The revert restores base_def_json by calling saveBoardDefinition with +// expectedVersionHash = current board hash (= applied_version_hash). + +interface RevertHarness { + readonly recorded: Array<{ + readonly proposalId: string; + readonly status: string; + readonly resolvedAt: string; + readonly appliedVersionHash?: string | null; + readonly fromStatus?: string; + }>; + readonly writes: Array<string>; + readonly trace: Array<string>; + readonly deps: Parameters<typeof revertBoardProposal>[0]; +} + +const makeRevertHarness = (args: { + readonly versionStore: WorkflowBoardVersionStore["Service"]; + readonly proposalStatus?: + | "pending" + | "approved" + | "rejected" + | "superseded" + | "invalid" + | "reverted"; + readonly proposedDefinition?: WorkflowDefinitionType; + readonly baseDefinition?: WorkflowDefinitionType; + // When true (default), the on-disk file matches proposedDef (the improvement is live). + // The board's current hash must equal applied_version_hash for revert to proceed. + readonly currentFileMatchesProposed?: boolean; + readonly liveOccupiedLanes?: ReadonlyArray<string>; + readonly lint?: () => Effect.Effect<ReadonlyArray<LintError>, never>; + // Affected-row count for the guarded (approved→reverted) status flip. Default 1; + // 0 exercises the reconciliation forced write. + readonly guardedStatusAffected?: number; +}): RevertHarness => { + const boardId = BoardId.make("project-revert__board"); + const projectId = "project-revert" as ProjectId; + const workflowFilePath = ".t3/boards/revert.json"; + const workspaceRoot = "/tmp/project-revert-root"; + + const baseDef = args.baseDefinition ?? resolveBaseDefinition; + const proposedDef = args.proposedDefinition ?? resolveProposedDefinition; + + // The on-disk file holds the proposed (applied) definition by default. + const proposedEncoded = decodeWorkflowDefinitionSync(proposedDef); + const proposedFileContents = `${encodeWorkflowDefinitionJson(proposedEncoded)}\n`; + const appliedVersionHash = sha256Hex(proposedFileContents); + + let fileContents = + args.currentFileMatchesProposed === false + ? `${encodeWorkflowDefinitionJson(decodeWorkflowDefinitionSync({ ...proposedDef, name: "Drifted" }))}\n` + : proposedFileContents; + + let registryDefinition = proposedEncoded; + let boardRow = { + boardId, + projectId, + name: registryDefinition.name, + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + + const proposalView = stubProposalView({ + proposalId: "prop-e7-1", + boardId, + status: args.proposalStatus ?? "approved", + // appliedVersionHash must match the current board hash for revert to proceed. + ...(args.proposalStatus !== "pending" && + args.proposalStatus !== "rejected" && + args.proposalStatus !== "superseded" && + args.proposalStatus !== "invalid" && + args.proposalStatus !== "reverted" + ? {} + : {}), + }); + // For a clean revert: appliedVersionHash == appliedVersionHash (the hash of proposed file) + const view = { + ...proposalView, + appliedVersionHash, + }; + + const recorded: RevertHarness["recorded"] = []; + const writes: Array<string> = []; + const trace: Array<string> = []; + + const deps = { + engine: { + withBoardAdmissionLock: <A, E, R>(_boardId: BoardId, effect: Effect.Effect<A, E, R>) => + Effect.gen(function* () { + trace.push("lock:enter"); + const result = yield* effect; + trace.push("lock:exit"); + return result; + }), + }, + readModel: { + ...noopReadModel, + getBoard: (inputBoardId: BoardId) => + Effect.succeed(inputBoardId === boardId ? boardRow : null), + getBoardProposal: (_proposalId: string) => + Effect.succeed({ + view, + proposedDefinition: encodeWorkflowDefinition(decodeWorkflowDefinitionSync(proposedDef)), + baseDefinition: encodeWorkflowDefinition(decodeWorkflowDefinitionSync(baseDef)), + }), + listLiveOccupiedLanes: (_boardId: BoardId) => Effect.succeed(args.liveOccupiedLanes ?? []), + resolveBoardProposalStatus: (input: { + proposalId: string; + status: string; + resolvedAt: string; + appliedVersionHash?: string | null; + fromStatus?: string; + }) => + Effect.sync(() => { + recorded.push(input); + if (input.fromStatus !== undefined && args.guardedStatusAffected !== undefined) { + return args.guardedStatusAffected; + } + return 1; + }), + listWorkSourceMappingsForBoard: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + fileLoader: { + lintDefinition: args.lint ?? (() => Effect.succeed([])), + loadAndRegister: (input: { readonly boardId: BoardId }) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.mapError( + (cause) => new WorkflowRpcError({ message: "round-trip decode failed", cause }), + ), + ); + registryDefinition = definition; + boardRow = { + ...boardRow, + name: definition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: (input: { readonly relativePath: string; readonly contents: string }) => + Effect.sync(() => { + fileContents = input.contents; + writes.push(input.contents); + trace.push("write"); + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + versionStore: args.versionStore, + } as unknown as Parameters<typeof revertBoardProposal>[0]; + + return { recorded, writes, trace, deps }; +}; + +versionRoundTripLayer("revertBoardProposal (self-improve E7)", (it) => { + it.effect( + "revert an approved proposal (board unchanged since apply, no live conflict) → restores base_def + source self-improve-revert → reverted", + () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeRevertHarness({ versionStore }); + + const result = yield* revertBoardProposal(h.deps, { proposalId: "prop-e7-1" }); + + assert.equal(result.ok, true); + if (result.ok !== true) { + assert.fail("expected revert to succeed"); + } + assert.equal(result.proposal.status, "reverted"); + assert.isNotNull(result.proposal.resolvedAt); + // saveBoardDefinition called exactly once. + assert.equal(h.writes.length, 1); + // The written content must decode to the BASE definition (not the proposed). + const writtenDef = yield* decodeWorkflowDefinitionJson(h.writes[0]!).pipe( + Effect.mapError((e) => new WorkflowRpcError({ message: String(e), cause: e })), + ); + assert.equal(writtenDef.lanes[0]?.name, resolveBaseDefinition.lanes[0]?.name); + // Status transition stamped reverted. + const reverted = h.recorded.find((r) => r.status === "reverted"); + assert.isDefined(reverted); + assert.equal(reverted?.fromStatus, "approved"); + }), + ); + + it.effect( + "revert blocked by live-gate: a lane the improvement ADDED holds a ticket → live_tickets, no save", + () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + // proposedDef renames backlog→"Inbox"; reverting = going from proposed→base. + // "backlog" lane is CHANGED between proposed and base (name differs). + // If backlog is live-occupied, revert must be blocked. + const h = makeRevertHarness({ versionStore, liveOccupiedLanes: ["backlog"] }); + + const result = yield* revertBoardProposal(h.deps, { proposalId: "prop-e7-1" }); + + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected live_tickets rejection"); + } + assert.equal(result.reason, "live_tickets"); + assert.include(result.message, "backlog"); + // saveBoardDefinition NOT called. + assert.equal(h.writes.length, 0); + assert.equal(h.recorded.length, 0); + }), + ); + + it.effect( + "revert when board changed since apply (current hash ≠ applied_version_hash) → conflict, no save", + () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + // currentFileMatchesProposed: false → board drifted after apply → conflict. + const h = makeRevertHarness({ versionStore, currentFileMatchesProposed: false }); + + const result = yield* revertBoardProposal(h.deps, { proposalId: "prop-e7-1" }); + + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected conflict"); + } + assert.equal(result.reason, "conflict"); + // saveBoardDefinition NOT called; no status writes. + assert.equal(h.writes.length, 0); + assert.equal(h.recorded.length, 0); + }), + ); + + it.effect("revert a pending proposal → clear invalid result, no save", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeRevertHarness({ versionStore, proposalStatus: "pending" }); + + const result = yield* revertBoardProposal(h.deps, { proposalId: "prop-e7-1" }); + + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected invalid result for non-approved proposal"); + } + assert.equal(result.reason, "invalid"); + assert.equal(h.writes.length, 0); + assert.equal(h.recorded.length, 0); + }), + ); + + it.effect("revert a rejected proposal → clear invalid result, no save", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeRevertHarness({ versionStore, proposalStatus: "rejected" }); + + const result = yield* revertBoardProposal(h.deps, { proposalId: "prop-e7-1" }); + + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected invalid result for rejected proposal"); + } + assert.equal(result.reason, "invalid"); + assert.equal(h.writes.length, 0); + assert.equal(h.recorded.length, 0); + }), + ); + + it.effect("revert is gated: no save on live_tickets path", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeRevertHarness({ versionStore, liveOccupiedLanes: ["backlog"] }); + + const result = yield* revertBoardProposal(h.deps, { proposalId: "prop-e7-1" }); + + // Confirm the false path has 0 writes (gate enforced). + assert.equal(result.ok, false); + assert.equal(h.writes.length, 0); + }), + ); + + it.effect("revert is gated: no save on conflict path", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeRevertHarness({ versionStore, currentFileMatchesProposed: false }); + + const result = yield* revertBoardProposal(h.deps, { proposalId: "prop-e7-1" }); + + assert.equal(result.ok, false); + assert.equal(h.writes.length, 0); + }), + ); + + // ── Finding #1 — admission lock wraps the revert live-gate + save ────────── + it.effect("revert runs the live-gate + save INSIDE the board admission lock", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeRevertHarness({ versionStore }); + + const result = yield* revertBoardProposal(h.deps, { proposalId: "prop-e7-1" }); + + assert.equal(result.ok, true); + assert.deepEqual(h.trace, ["lock:enter", "write", "lock:exit"]); + }), + ); + + // ── Finding #2 — revert reconciles to 'reverted' if the guarded flip is raced ─ + it.effect("revert reconciles to 'reverted' even if the guarded flip is raced (affected 0)", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeRevertHarness({ versionStore, guardedStatusAffected: 0 }); + + const result = yield* revertBoardProposal(h.deps, { proposalId: "prop-e7-1" }); + + assert.equal(result.ok, true); + if (result.ok !== true) { + assert.fail("expected revert ok after reconcile"); + } + assert.equal(result.proposal.status, "reverted"); + assert.equal(h.writes.length, 1); + const revertedWrites = h.recorded.filter((r) => r.status === "reverted"); + assert.equal(revertedWrites.length, 2); + assert.isDefined(revertedWrites.find((r) => r.fromStatus === "approved")); + assert.isDefined(revertedWrites.find((r) => r.fromStatus === undefined)); + }), + ); + + // ── Finding #4 — non-approved revert returns ok:false (covered above for + // pending/rejected; this asserts the superseded case explicitly). ──────── + it.effect("revert a superseded proposal → ok:false invalid, no save", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const h = makeRevertHarness({ versionStore, proposalStatus: "superseded" }); + + const result = yield* revertBoardProposal(h.deps, { proposalId: "prop-e7-1" }); + + assert.equal(result.ok, false); + if (result.ok !== false) { + assert.fail("expected ok:false for a non-approved revert"); + } + assert.equal(result.reason, "invalid"); + assert.equal(h.writes.length, 0); + }), + ); +}); + +// ─── generateWorkflowDraft (create-wizard agent-assisted, Task 6) ──────────── + +const draftProjectId = "project-draft" as ProjectId; +const draftAgent = { instance: "wizard_inst", model: "opus" } as const; + +// A valid agent+approval+manual def. NOTE: the agent step deliberately OMITS +// `agent` and the second emits a DIFFERENT instance — the handler must inject +// the chosen agent into both BEFORE decode. +const draftGeneratedDef = { + name: "Drafted board", + sources: [], + outbound: [], + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "build", + name: "Build", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + instruction: "implement the work", + on: { success: "review", failure: "backlog", blocked: "backlog" }, + }, + ], + }, + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [ + { + key: "approve", + type: "agent", + agent: { instance: "SOMETHING_ELSE", model: "haiku" }, + instruction: "review the work", + retry: { maxAttempts: 3, escalate: { instance: "esc", model: "opus" } }, + on: { success: "done", failure: "backlog", blocked: "backlog" }, + }, + ], + }, + { key: "done", name: "Done", entry: "auto", terminal: true }, + ], +}; + +const makeDraftDeps = (args: { + readonly gen: () => Effect.Effect< + { readonly proposedDefinition: unknown; readonly rationale: string }, + TextGenerationError + >; + readonly lint?: () => Effect.Effect<ReadonlyArray<LintError>, WorkflowRpcError>; + readonly writes: Array<unknown>; +}) => ({ + fileLoader: { + lintDefinition: args.lint ?? (() => Effect.succeed([])), + loadAndRegister: (input: unknown) => + Effect.sync(() => { + args.writes.push(input); + return BoardId.make("board-should-not-be-written"); + }), + }, + projectWorkspaceResolver: { resolve: () => Effect.succeed("/tmp/project") }, + textGeneration: { generateBoardProposal: args.gen }, + // The dead-end dry-run gate needs a predicate evaluator. The always-false stub + // means transitions never fire, so routing falls through to step.on / lane.on. + predicates: stubPredicates, +}); + +it.effect( + "generateWorkflowDraft → ok:true with the chosen agent injected into ALL agent steps; no persist", + () => + Effect.gen(function* () { + const writes: Array<unknown> = []; + const deps = makeDraftDeps({ + writes, + gen: () => + Effect.succeed({ proposedDefinition: draftGeneratedDef, rationale: "drafted it" }), + }); + + const result = yield* generateWorkflowDraft(deps, { + projectId: draftProjectId, + name: "Drafted board" as never, + description: "I build then review" as never, + agent: draftAgent, + }); + + assert.equal(result.ok, true); + if (result.ok !== true) assert.fail("expected ok:true"); + assert.equal(result.rationale, "drafted it"); + // Every agent step carries the injected agent (overwriting / filling in). + for (const lane of result.definition.lanes) { + for (const step of lane.pipeline ?? []) { + if (step.type === "agent") { + assert.equal(step.agent.instance, "wizard_inst"); + assert.equal(step.agent.model, "opus"); + } + } + } + // Nothing was persisted. + assert.equal(writes.length, 0); + }), +); + +it.effect( + "generateWorkflowDraft → ok:false when the generated board contains a forbidden step type; no persist", + () => + Effect.gen(function* () { + const writes: Array<unknown> = []; + const forbiddenDef = { + ...draftGeneratedDef, + lanes: [ + ...draftGeneratedDef.lanes, + { + key: "ship", + name: "Ship", + entry: "auto", + pipeline: [{ key: "merge-it", type: "merge", on: { success: "done" } }], + }, + ], + }; + const deps = makeDraftDeps({ + writes, + gen: () => Effect.succeed({ proposedDefinition: forbiddenDef, rationale: "with a merge" }), + }); + + const result = yield* generateWorkflowDraft(deps, { + projectId: draftProjectId, + name: "B" as never, + description: "d" as never, + agent: draftAgent, + }); + + assert.equal(result.ok, false); + if (result.ok !== false) assert.fail("expected ok:false"); + assert.include(result.message, "forbidden"); + assert.equal(writes.length, 0); + }), +); + +it.effect( + "generateWorkflowDraft → ok:false with lintErrors when the injected def fails strict lint; no persist", + () => + Effect.gen(function* () { + const writes: Array<unknown> = []; + const deps = makeDraftDeps({ + writes, + gen: () => + Effect.succeed({ proposedDefinition: draftGeneratedDef, rationale: "drafted it" }), + lint: () => + Effect.succeed([ + { code: "missing_lane_ref", message: "transition to a missing lane" } as LintError, + ]), + }); + + const result = yield* generateWorkflowDraft(deps, { + projectId: draftProjectId, + name: "B" as never, + description: "d" as never, + agent: draftAgent, + }); + + assert.equal(result.ok, false); + if (result.ok !== false) assert.fail("expected ok:false"); + assert.isDefined(result.lintErrors); + assert.equal(result.lintErrors?.length, 1); + assert.equal(writes.length, 0); + }), +); + +it.effect( + "generateWorkflowDraft → ok:false SURFACES the specific decode reason (not an opaque message); no persist", + () => + Effect.gen(function* () { + const writes: Array<unknown> = []; + // A draft with an out-of-vocabulary `entry` — the most common LLM mistake. + const badEntryDef = { + name: "B", + lanes: [{ key: "a", name: "A", entry: "automatic" }], + }; + const deps = makeDraftDeps({ + writes, + gen: () => Effect.succeed({ proposedDefinition: badEntryDef, rationale: "oops" }), + }); + + const result = yield* generateWorkflowDraft(deps, { + projectId: draftProjectId, + name: "B" as never, + description: "d" as never, + agent: draftAgent, + }); + + assert.equal(result.ok, false); + if (result.ok !== false) assert.fail("expected ok:false"); + // The message must name the actual violation + path, not a generic string. + assert.include(result.message, "structurally invalid"); + assert.include(result.message, "entry"); + assert.include(result.message, "auto"); + assert.equal(writes.length, 0); + }), +); + +it.effect("generateWorkflowDraft → ok:false when generation fails", () => + Effect.gen(function* () { + const writes: Array<unknown> = []; + const deps = makeDraftDeps({ + writes, + gen: () => + Effect.fail( + new TextGenerationError({ operation: "generateBoardProposal", detail: "provider down" }), + ), + }); + + const result = yield* generateWorkflowDraft(deps, { + projectId: draftProjectId, + name: "B" as never, + description: "d" as never, + agent: draftAgent, + }); + + assert.equal(result.ok, false); + if (result.ok !== false) assert.fail("expected ok:false"); + assert.equal(writes.length, 0); + }), +); + +it.effect( + "generateWorkflowDraft → ok:false when the generated board has too many lanes; no persist", + () => + Effect.gen(function* () { + const writes: Array<unknown> = []; + // MAX_DRY_RUN_LANES is 200 (private to the handler module). Build a + // structurally-valid def with 201 manual lanes + 1 terminal lane so it + // decodes cleanly, then trips the >200 lane-count guard BEFORE lint. + const manyLanes = [ + ...Array.from({ length: 201 }, (_, i) => ({ + key: `lane-${i}`, + name: `Lane ${i}`, + entry: "manual" as const, + })), + { key: "done", name: "Done", entry: "auto" as const, terminal: true }, + ]; + const hugeDef = { name: "Huge board", sources: [], outbound: [], lanes: manyLanes }; + const deps = makeDraftDeps({ + writes, + gen: () => Effect.succeed({ proposedDefinition: hugeDef, rationale: "too many lanes" }), + }); + + const result = yield* generateWorkflowDraft(deps, { + projectId: draftProjectId, + name: "B" as never, + description: "d" as never, + agent: draftAgent, + }); + + assert.equal(result.ok, false); + if (result.ok !== false) assert.fail("expected ok:false"); + assert.include(result.message, "too many lanes"); + assert.equal(writes.length, 0); + }), +); + +it.effect( + "generateWorkflowDraft → forces the wizard's name even when the model emits a different one", + () => + Effect.gen(function* () { + const writes: Array<unknown> = []; + // The model emits name "Untitled"; the wizard input.name is "Release Flow". + const deps = makeDraftDeps({ + writes, + gen: () => + Effect.succeed({ + proposedDefinition: { ...draftGeneratedDef, name: "Untitled" }, + rationale: "drafted it", + }), + }); + + const result = yield* generateWorkflowDraft(deps, { + projectId: draftProjectId, + name: "Release Flow" as never, + description: "I build then review" as never, + agent: draftAgent, + }); + + assert.equal(result.ok, true); + if (result.ok !== true) assert.fail("expected ok:true"); + assert.equal(result.definition.name, "Release Flow"); + assert.equal(writes.length, 0); + }), +); + +it.effect( + "generateWorkflowDraft → ok:false when the generated board strands tickets (dead-end auto lane); no persist", + () => + Effect.gen(function* () { + const writes: Array<unknown> = []; + // `build` is an auto lane whose only step has NO step.on routing and the + // lane has no transitions / lane.on → the dry run ends in no_route (a + // dead-end that strands tickets). There IS a terminal `done` lane, so lint + // passes; only the dry-run gate catches this. + const strandingDef = { + name: "Stranding board", + sources: [], + outbound: [], + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "build", + name: "Build", + entry: "auto", + pipeline: [{ key: "implement", type: "agent", instruction: "do work" }], + }, + { key: "done", name: "Done", entry: "auto", terminal: true }, + ], + }; + const deps = makeDraftDeps({ + writes, + gen: () => Effect.succeed({ proposedDefinition: strandingDef, rationale: "no way out" }), + }); + + const result = yield* generateWorkflowDraft(deps, { + projectId: draftProjectId, + name: "B" as never, + description: "d" as never, + agent: draftAgent, + }); + + assert.equal(result.ok, false); + if (result.ok !== false) assert.fail("expected ok:false"); + assert.include(result.message, "strands tickets"); + assert.include(result.message, "build"); + assert.equal(writes.length, 0); + }), +); + +it.effect("generateWorkflowDraft → ok:false when a single lane is too large; no persist", () => + Effect.gen(function* () { + const writes: Array<unknown> = []; + // ONE auto lane with > MAX_IMPORT_PER_LANE (1000) pipeline steps. The + // lane-COUNT guard would never catch this; the per-lane cap does. + const hugeLaneDef = { + name: "Huge lane board", + sources: [], + outbound: [], + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "build", + name: "Build", + entry: "auto", + pipeline: Array.from({ length: 1001 }, (_unused, i) => ({ + key: `step-${i}`, + type: "agent", + instruction: "x", + on: { success: "done", failure: "backlog", blocked: "backlog" }, + })), + }, + { key: "done", name: "Done", entry: "auto", terminal: true }, + ], + }; + const deps = makeDraftDeps({ + writes, + gen: () => Effect.succeed({ proposedDefinition: hugeLaneDef, rationale: "huge lane" }), + }); + + const result = yield* generateWorkflowDraft(deps, { + projectId: draftProjectId, + name: "B" as never, + description: "d" as never, + agent: draftAgent, + }); + + assert.equal(result.ok, false); + if (result.ok !== false) assert.fail("expected ok:false"); + assert.include(result.message, "lane that is too large"); + assert.equal(writes.length, 0); + }), +); + +it.effect( + "generateWorkflowDraft → ok:false when the raw generated board exceeds the byte cap; no persist", + () => + Effect.gen(function* () { + const writes: Array<unknown> = []; + // A single lane whose step instruction is a multi-MB string → the raw + // JSON.stringify length exceeds MAX_IMPORT_DEFINITION_CHARS (2_000_000) + // BEFORE the forbidden-type walk / decode / lint. + const giantInstruction = "x".repeat(2_500_000); + const oversizedDef = { + name: "Oversized board", + sources: [], + outbound: [], + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "build", + name: "Build", + entry: "auto", + pipeline: [{ key: "implement", type: "agent", instruction: giantInstruction }], + }, + { key: "done", name: "Done", entry: "auto", terminal: true }, + ], + }; + const deps = makeDraftDeps({ + writes, + gen: () => Effect.succeed({ proposedDefinition: oversizedDef, rationale: "too big" }), + }); + + const result = yield* generateWorkflowDraft(deps, { + projectId: draftProjectId, + name: "B" as never, + description: "d" as never, + agent: draftAgent, + }); + + assert.equal(result.ok, false); + if (result.ok !== false) assert.fail("expected ok:false"); + assert.include(result.message, "too large"); + assert.equal(writes.length, 0); + }), +); + +it.effect( + "generateWorkflowDraft → ok:false (too large) for a MANY-STEPS raw board, before the inject walk; no persist", + () => + Effect.gen(function* () { + const writes: Array<unknown> = []; + // ONE lane with a huge NUMBER of small steps (not one giant string). The + // raw JSON.stringify length exceeds MAX_IMPORT_DEFINITION_CHARS (2_000_000) + // purely from the step count, so the byte cap MUST fire before the + // injectAgentIntoSteps walk loops every step. ~60k small agent steps at + // ~70 bytes each comfortably clears 2MB. + const manyStepsDef = { + name: "Many steps board", + sources: [], + outbound: [], + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "build", + name: "Build", + entry: "auto", + pipeline: Array.from({ length: 60_000 }, (_unused, i) => ({ + key: `step-${i}`, + type: "agent", + instruction: "do work please", + })), + }, + { key: "done", name: "Done", entry: "auto", terminal: true }, + ], + }; + const deps = makeDraftDeps({ + writes, + gen: () => Effect.succeed({ proposedDefinition: manyStepsDef, rationale: "many steps" }), + }); + + const result = yield* generateWorkflowDraft(deps, { + projectId: draftProjectId, + name: "B" as never, + description: "d" as never, + agent: draftAgent, + }); + + assert.equal(result.ok, false); + if (result.ok !== false) assert.fail("expected ok:false"); + assert.include(result.message, "too large"); + assert.equal(writes.length, 0); + }), +); + +// ── listImportableWorkItems (B3) ───────────────────────────────────────────── + +it.effect("listImportableWorkItems annotates mapped items + reports sources", () => + Effect.gen(function* () { + const boardId = BoardId.make("b1"); + const triageLane = LaneKey.make("triage"); + const doneLane = LaneKey.make("done"); + + // A board definition with one github source. + const definition = { + name: "Test Board", + lanes: [ + { key: triageLane, name: "Triage", entry: "manual" }, + { key: doneLane, name: "Done", entry: "auto", terminal: true }, + ], + sources: [ + { + id: "s1" as unknown as import("@t3tools/contracts").SourceId, + provider: "github" as const, + connectionRef: "c", + selector: { owner: "acme", repo: "app" }, + destinationLane: triageLane, + closedLane: doneLane, + enabled: true, + }, + ], + } satisfies WorkflowDefinitionType; + + // Two external work items returned by the provider. + const issue82 = { + provider: "github" as const, + externalId: "82", + url: "https://github.com/acme/app/issues/82", + lifecycle: "open" as const, + version: {}, + fields: { title: "Fix bug 82", assignees: ["dev1"] }, + }; + const issue83 = { + provider: "github" as const, + externalId: "83", + url: "https://github.com/acme/app/issues/83", + lifecycle: "open" as const, + version: {}, + fields: { title: "Add feature 83", assignees: [] }, + }; + + // Stub provider. + const stubProvider = { + provider: "github" as const, + selectorSchema: Schema.Struct({}), + listPage: (_input: unknown) => Effect.succeed({ items: [issue82, issue83] }), + getItem: () => Effect.die("unused"), + viewer: () => Effect.succeed({ id: "octocat", aliases: ["octocat"] }), + toImportableView: (input: { selector: unknown; item: { externalId: string } }) => ({ + displayRef: `#${input.item.externalId}`, + container: "acme/app", + }), + }; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.die("unused"), + moveTicket: () => Effect.die("unused"), + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.die("unused"), + ingestExternalEvent: () => Effect.die("unused"), + resolveApproval: () => Effect.die("unused"), + answerTicketStep: () => Effect.die("unused"), + postTicketMessage: () => Effect.die("unused"), + editTicketMessage: () => Effect.die("unused"), + cancelStep: () => Effect.die("unused"), + cancelBoardPipelines: () => Effect.die("unused"), + cancelTicketPipelines: () => Effect.die("unused"), + recoverBoardWip: () => Effect.die("unused"), + completeRecoveredStep: () => Effect.die("unused"), + }, + readModel: { + ...noopReadModel, + listWorkSourceMappingsForBoard: () => + Effect.succeed([ + { + provider: "github", + sourceId: "s1", + externalId: "82", + ticketId: "ticket-1", + currentLaneKey: "triage", + }, + ]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: (id: BoardId) => Effect.succeed(id === boardId ? definition : null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { getTicketDiff: () => Effect.die("unused") }, + ticketWorktrees: { resolveForTicket: () => Effect.die("unused") }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { discover: () => Effect.succeed([]), list: () => Effect.succeed([]) }, + projectWorkspaceResolver: { resolve: () => Effect.succeed("/tmp/project") }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + workSourceProviders: { + get: (_provider: import("@t3tools/contracts/workSource").WorkSourceProviderName) => + stubProvider as unknown as import("../Services/WorkSourceProvider.ts").WorkSourceProvider, + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const res = yield* invokeWorkflowHandler< + import("@t3tools/contracts/workSource").ListImportableWorkItemsResult + >(handlers, WORKFLOW_WS_METHODS.listImportableWorkItems, { boardId }); + + const i82 = res.items.find((i) => i.externalId === "82"); + assert.equal(i82?.mappedTicketId, "ticket-1"); + assert.equal(i82?.mappedLane, "triage"); + assert.equal(res.items.find((i) => i.externalId === "83")?.mappedTicketId, null); + assert.equal(res.sources.length, 1); + assert.equal(res.sources[0]?.sourceId, "s1"); + }), +); + +it.effect("gates mutating RPCs behind readiness while reads bypass the gate", () => + Effect.gen(function* () { + const projectId = "project-gate" as ProjectId; + let moved = false; + let gateCalls = 0; + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => + Effect.sync(() => { + moved = true; + }), + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + editTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: noopReadModel, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { getTicketDiff: () => Effect.die("unused") }, + ticketWorktrees: { resolveForTicket: () => Effect.die("unused") }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { discover: () => Effect.succeed([]), list: () => Effect.succeed([]) }, + projectWorkspaceResolver: { resolve: () => Effect.succeed("/tmp/project") }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + // Simulate a runtime that is not ready / recovery failed: the gate fails + // without ever running the wrapped effect. + gate: () => { + gateCalls += 1; + return Effect.fail(new WorkflowRpcError({ message: "runtime not ready" })); + }, + }); + + // Mutating RPC is gated: it fails and never reaches the engine. + const moveResult = yield* Effect.exit( + invokeWorkflowHandler<void>(handlers, WORKFLOW_WS_METHODS.moveTicket, { + ticketId: TicketId.make("ticket-1"), + toLane: LaneKey.make("done"), + }), + ); + assert.strictEqual(moveResult._tag, "Failure"); + assert.equal(moved, false); + assert.equal(gateCalls, 1); + + // Read RPC bypasses the gate entirely. + const boards = yield* handlers[WORKFLOW_WS_METHODS.listBoards]({ projectId }); + assert.deepEqual(boards, []); + assert.equal(gateCalls, 1); + }), +); + +it.effect("gates importWorkItems behind readiness; listImportableWorkItems bypasses the gate", () => + Effect.gen(function* () { + let gateCalls = 0; + // A provider/committer that DIE if ever reached: proves the gate blocks the + // import body before any scan/reconcile work runs. + const dyingProvider = { + provider: "github" as const, + selectorSchema: Schema.Struct({}), + listPage: () => Effect.die("import body must not run when gated"), + getItem: () => Effect.die("unused"), + viewer: () => Effect.die("unused"), + toImportableView: () => Effect.die("unused"), + }; + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.die("unused"), + moveTicket: () => Effect.die("unused"), + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.die("unused"), + ingestExternalEvent: () => Effect.die("unused"), + resolveApproval: () => Effect.die("unused"), + answerTicketStep: () => Effect.die("unused"), + postTicketMessage: () => Effect.die("unused"), + editTicketMessage: () => Effect.die("unused"), + cancelStep: () => Effect.die("unused"), + cancelBoardPipelines: () => Effect.die("unused"), + cancelTicketPipelines: () => Effect.die("unused"), + recoverBoardWip: () => Effect.die("unused"), + completeRecoveredStep: () => Effect.die("unused"), + }, + readModel: noopReadModel, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { getTicketDiff: () => Effect.die("unused") }, + ticketWorktrees: { resolveForTicket: () => Effect.die("unused") }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { discover: () => Effect.succeed([]), list: () => Effect.succeed([]) }, + projectWorkspaceResolver: { resolve: () => Effect.succeed("/tmp/project") }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + workSourceProviders: { + get: (_provider: import("@t3tools/contracts/workSource").WorkSourceProviderName) => + dyingProvider as unknown as import("../Services/WorkSourceProvider.ts").WorkSourceProvider, + }, + sourceCommitter: { + reconcileChunk: () => Effect.die("import body must not run when gated"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + // Simulate a runtime that is not ready / recovery failed: the gate fails + // without ever running the wrapped effect. + gate: () => { + gateCalls += 1; + return Effect.fail(new WorkflowRpcError({ message: "runtime not ready" })); + }, + }); + + // importWorkItems is a MUTATING method → gated: it fails with the gate's + // WorkflowRpcError and never reaches the (dying) scan/reconcile body. + const importResult = yield* Effect.exit( + invokeWorkflowHandler<import("@t3tools/contracts/workSource").ImportWorkItemsResult>( + handlers, + WORKFLOW_WS_METHODS.importWorkItems, + { boardId: BoardId.make("b1"), sourceId: "s1", externalIds: ["82"] }, + ), + ); + assert.strictEqual(importResult._tag, "Failure"); + if (importResult._tag === "Failure") { + // The gate's "runtime not ready" WorkflowRpcError — NOT a die from the + // provider/committer, which proves the gate blocked the body. + assert.isTrue(importResult.cause.toString().includes("runtime not ready")); + assert.isFalse(Cause.hasDies(importResult.cause)); + } + assert.equal(gateCalls, 1); + + // listImportableWorkItems is a READ → bypasses the gate. It returns null + // definition → a clean WorkflowRpcError("board not found"), NOT the gate's + // "runtime not ready" failure, and gateCalls stays at 1 (gate never invoked). + const listResult = yield* Effect.exit( + invokeWorkflowHandler<import("@t3tools/contracts/workSource").ListImportableWorkItemsResult>( + handlers, + WORKFLOW_WS_METHODS.listImportableWorkItems, + { boardId: BoardId.make("b1") }, + ), + ); + assert.strictEqual(listResult._tag, "Failure"); + if (listResult._tag === "Failure") { + // Reached the handler body (board not found), proving the gate did not block it. + assert.isTrue(listResult.cause.toString().includes("board not found")); + assert.isFalse(listResult.cause.toString().includes("runtime not ready")); + } + assert.equal(gateCalls, 1); + }), +); + +// ── importWorkItems (B4) ────────────────────────────────────────────────────── + +/** Shared test setup for importWorkItems tests. */ +const makeImportDeps = (opts: { + /** Items the provider scan returns. */ + scanItems: ReadonlyArray<{ + provider: "github"; + externalId: string; + url: string; + lifecycle: "open" | "closed"; + version: Record<string, unknown>; + fields: { title: string; assignees: string[] }; + }>; + /** Mappings present BEFORE reconcileChunk. */ + beforeMappings: ReadonlyArray<{ + provider: string; + sourceId: string; + externalId: string; + ticketId: string; + currentLaneKey: string; + }>; + /** Mappings present AFTER reconcileChunk (simulates projection update). */ + afterMappings: ReadonlyArray<{ + provider: string; + sourceId: string; + externalId: string; + ticketId: string; + currentLaneKey: string; + }>; + reconcileChunkCalls?: Array<{ + boardId: string; + deltas: ReadonlyArray<{ _tag: string; item: { externalId: string } }>; + }>; +}) => { + const boardId = BoardId.make("b1"); + const triageLane = LaneKey.make("triage"); + const doneLane = LaneKey.make("done"); + + const definition = { + name: "Test Board", + lanes: [ + { key: triageLane, name: "Triage", entry: "manual" as const }, + { key: doneLane, name: "Done", entry: "auto" as const, terminal: true }, + ], + sources: [ + { + id: "s1" as unknown as import("@t3tools/contracts").SourceId, + provider: "github" as const, + connectionRef: "c", + selector: { owner: "acme", repo: "app" }, + destinationLane: triageLane, + closedLane: doneLane, + enabled: true, + }, + ], + } satisfies WorkflowDefinitionType; + + const stubProvider = { + provider: "github" as const, + selectorSchema: Schema.Struct({}), + listPage: (_input: unknown) => Effect.succeed({ items: opts.scanItems }), + getItem: () => Effect.die("unused"), + viewer: () => Effect.succeed({ id: "octocat", aliases: ["octocat"] }), + toImportableView: (input: { selector: unknown; item: { externalId: string } }) => ({ + displayRef: `#${input.item.externalId}`, + container: "acme/app", + }), + }; + + // Track before/after calls to simulate projection state after reconcileChunk. + let callCount = 0; + + const capturedReconcileCalls = opts.reconcileChunkCalls ?? []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.die("unused"), + moveTicket: () => Effect.die("unused"), + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + reopenTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + terminalAgentSessionThreadsForTicket: () => Effect.die("unused"), + stopAgentSessionsForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => Effect.die("unused"), + ingestExternalEvent: () => Effect.die("unused"), + resolveApproval: () => Effect.die("unused"), + answerTicketStep: () => Effect.die("unused"), + postTicketMessage: () => Effect.die("unused"), + editTicketMessage: () => Effect.die("unused"), + cancelStep: () => Effect.die("unused"), + cancelBoardPipelines: () => Effect.die("unused"), + cancelTicketPipelines: () => Effect.die("unused"), + recoverBoardWip: () => Effect.die("unused"), + completeRecoveredStep: () => Effect.die("unused"), + }, + readModel: { + ...noopReadModel, + listWorkSourceMappingsForBoard: () => { + callCount += 1; + // First call = before-state; second call = after-state. + return callCount === 1 + ? Effect.succeed(opts.beforeMappings) + : Effect.succeed(opts.afterMappings); + }, + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: (id: BoardId) => Effect.succeed(id === boardId ? definition : null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { getTicketDiff: () => Effect.die("unused") }, + ticketWorktrees: { resolveForTicket: () => Effect.die("unused") }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + boardDiscovery: { discover: () => Effect.succeed([]), list: () => Effect.succeed([]) }, + projectWorkspaceResolver: { resolve: () => Effect.succeed("/tmp/project") }, + workspaceFileSystem: { + readFile: () => Effect.die("unused"), + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + workSourceProviders: { + get: (_provider: import("@t3tools/contracts/workSource").WorkSourceProviderName) => + stubProvider as unknown as import("../Services/WorkSourceProvider.ts").WorkSourceProvider, + }, + sourceCommitter: { + reconcileChunk: (bid, _lanes, deltas) => + Effect.sync(() => { + capturedReconcileCalls.push({ + boardId: String(bid), + deltas: deltas as ReadonlyArray<{ _tag: string; item: { externalId: string } }>, + }); + }), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + return { handlers, boardId, capturedReconcileCalls }; +}; + +it.effect("importWorkItems imports in-scope unmapped ids, skips mapped + out-of-scope", () => + Effect.gen(function* () { + const issue82 = { + provider: "github" as const, + externalId: "82", + url: "https://github.com/acme/app/issues/82", + lifecycle: "open" as const, + version: {}, + fields: { title: "Fix bug 82", assignees: [] }, + }; + const issue83 = { + provider: "github" as const, + externalId: "83", + url: "https://github.com/acme/app/issues/83", + lifecycle: "open" as const, + version: {}, + fields: { title: "Add feature 83", assignees: [] }, + }; + + // Scan returns issues 82 and 83. Issue 83 is already mapped before-state. + // After reconcileChunk, issue 82 is now mapped (after-state). + const { handlers, boardId } = makeImportDeps({ + scanItems: [issue82, issue83], + beforeMappings: [ + { + provider: "github", + sourceId: "s1", + externalId: "83", + ticketId: "ticket-83", + currentLaneKey: "triage", + }, + ], + afterMappings: [ + { + provider: "github", + sourceId: "s1", + externalId: "83", + ticketId: "ticket-83", + currentLaneKey: "triage", + }, + { + provider: "github", + sourceId: "s1", + externalId: "82", + ticketId: "ticket-82", + currentLaneKey: "triage", + }, + ], + }); + + // Client requests: "82" (importable), "83" (already mapped), "99" (not in scan). + const res = yield* invokeWorkflowHandler< + import("@t3tools/contracts/workSource").ImportWorkItemsResult + >(handlers, WORKFLOW_WS_METHODS.importWorkItems, { + boardId, + sourceId: "s1", + externalIds: ["82", "83", "99"], + }); + + assert.deepEqual( + res.imported.map((i) => i.externalId), + ["82"], + ); + assert.equal(res.imported[0]?.ticketId, "ticket-82"); + + const reasons = Object.fromEntries(res.skipped.map((s) => [s.externalId, s.reason])); + assert.equal(reasons["83"], "already on board"); + assert.match(reasons["99"] ?? "", /not in source/i); + }), +); + +it.effect( + "importWorkItems calls reconcileChunk with the correct delta and NOT in a save lock", + () => + Effect.gen(function* () { + const issue82 = { + provider: "github" as const, + externalId: "82", + url: "https://github.com/acme/app/issues/82", + lifecycle: "open" as const, + version: {}, + fields: { title: "Fix bug 82", assignees: [] }, + }; + + const capturedCalls: Array<{ + boardId: string; + deltas: ReadonlyArray<{ _tag: string; item: { externalId: string } }>; + }> = []; + + const { handlers, boardId } = makeImportDeps({ + scanItems: [issue82], + beforeMappings: [], + afterMappings: [ + { + provider: "github", + sourceId: "s1", + externalId: "82", + ticketId: "ticket-82", + currentLaneKey: "triage", + }, + ], + reconcileChunkCalls: capturedCalls, + }); + + yield* invokeWorkflowHandler<import("@t3tools/contracts/workSource").ImportWorkItemsResult>( + handlers, + WORKFLOW_WS_METHODS.importWorkItems, + { boardId, sourceId: "s1", externalIds: ["82"] }, + ); + + // reconcileChunk was called exactly once. + assert.equal(capturedCalls.length, 1); + // The chunk contained exactly the delta for issue 82. + assert.equal(capturedCalls[0]?.deltas.length, 1); + assert.equal(capturedCalls[0]?.deltas[0]?._tag, "new"); + assert.equal(capturedCalls[0]?.deltas[0]?.item.externalId, "82"); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts new file mode 100644 index 00000000000..86c38d18013 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts @@ -0,0 +1,3442 @@ +import type { + BoardListEntry, + BoardSnapshot, + BoardTicketView, + WorkflowIntakeResult, + EnvironmentAuthorizationError, + MessageId, + ProjectId, + StepRunId, + StepRunStatus, + TicketAttachment, + TicketId, + TicketStatus, + WorkflowBoardVersionSummary, + WorkflowCreateBoardInput as WorkflowCreateBoardInputType, + WorkflowGetBoardDefinitionResult, + WorkflowGetBoardVersionResult, + WorkflowImportBoardInput as WorkflowImportBoardInputType, + WorkflowImportBoardResult, + WorkflowLintError, + WorkflowNeedsAttentionTicketView, + WorkflowRenameBoardInput as WorkflowRenameBoardInputType, + WorkflowSaveBoardDefinitionInput, + WorkflowSaveBoardDefinitionResult, + WorkflowStepRunView, + WorkflowTicketDetailView, + WorkflowDefinition as WorkflowDefinitionType, + WorkflowDefinitionEncoded, + WorkflowDryRunScenario, + WorkflowDryRunResult as WorkflowDryRunResultType, + WorkflowBoardProposalView, + WorkflowProposeBoardImprovementInput as WorkflowProposeBoardImprovementInputType, + WorkflowProposeBoardImprovementResult, + WorkflowListBoardProposalsResult, + WorkflowGetBoardProposalResult, + WorkflowListBoardProposalsInput as WorkflowListBoardProposalsInputType, + WorkflowGetBoardProposalInput as WorkflowGetBoardProposalInputType, + WorkflowResolveBoardProposalInput as WorkflowResolveBoardProposalInputType, + WorkflowResolveBoardProposalResult, + WorkflowRevertBoardProposalInput as WorkflowRevertBoardProposalInputType, + WorkflowRevertBoardProposalResult, + WorkflowListBoardTemplatesResult, + WorkflowCreateWorkflowBoardInput as WorkflowCreateWorkflowBoardInputType, + WorkflowCreateWorkflowBoardResult, + WorkflowGenerateWorkflowDraftInput as WorkflowGenerateWorkflowDraftInputType, + WorkflowGenerateWorkflowDraftResult, + ModelSelection as ModelSelectionType, +} from "@t3tools/contracts"; +import type { WorkSourceConnectionView } from "@t3tools/contracts/workSource"; +import type { WorkSourceProviderName } from "@t3tools/contracts/workSource"; +import type { + ListImportableWorkItemsResult, + ImportWorkItemsResult, +} from "@t3tools/contracts/workSource"; +import type { CreateOutboundConnectionInput, OutboundConnectionView } from "@t3tools/contracts"; +import { + AgentSelection, + BoardId, + LaneKey, + StepKey, + WORKFLOW_WS_METHODS, + WorkflowCreateBoardInput, + WorkflowDefinition, + WorkflowProposalValidation, + WorkflowRenameBoardInput, + WorkflowRpcError, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type * as SqlClient from "effect/unstable/sql/SqlClient"; + +import type { WorkspaceFileSystemShape } from "../../workspace/Services/WorkspaceFileSystem.ts"; +import { slugifyBoardName, uniqueBoardSlug } from "../boardSlug.ts"; +import { BOARD_TEMPLATES, listBoardTemplateSummaries } from "../boardTemplates.ts"; +import { defaultBoardDefinition } from "../defaultBoard.ts"; +import { + MAX_IMPORT_DEFINITION_CHARS, + MAX_IMPORT_LANES, + MAX_IMPORT_PER_LANE, + definitionLaneCapViolation, + exceedsDefinitionCharCap, +} from "../definitionCaps.ts"; +import { emptyBoardDefinition } from "../emptyBoard.ts"; +import type { BoardDiscoveryShape } from "../Services/BoardDiscovery.ts"; +import type { BoardRegistryShape } from "../Services/BoardRegistry.ts"; +import type { ProjectScriptTrustShape } from "../Services/ProjectScriptTrust.ts"; +import type { ProjectWorkspaceResolverShape } from "../Services/ProjectWorkspaceResolver.ts"; +import type { WorkflowBoardEventsShape } from "../Services/WorkflowBoardEvents.ts"; +import type { WorkflowBoardSaveLocksShape } from "../Services/WorkflowBoardSaveLocks.ts"; +import type { + WorkflowBoardVersionSource, + WorkflowBoardVersionSummaryRow, + WorkflowBoardVersionStoreShape, +} from "../Services/WorkflowBoardVersionStore.ts"; +import type { WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; +import type { WorkflowEventStoreShape } from "../Services/WorkflowEventStore.ts"; +import type { WorkflowFileLoaderShape } from "../Services/WorkflowFileLoader.ts"; +import type { + BoardRow, + StepRunRow, + TicketRow, + WorkflowReadModelShape, +} from "../Services/WorkflowReadModel.ts"; +import type { TicketDiffQueryShape } from "../Services/TicketDiffQuery.ts"; +import type { WorkflowIntakeShape } from "../Services/WorkflowIntake.ts"; +import type { PredicateEvaluatorShape } from "../Services/PredicateEvaluator.ts"; +import type { WorkflowWebhookShape } from "../Services/WorkflowWebhook.ts"; +import type { WorkflowThreadJanitorShape } from "../Services/WorkflowThreadJanitor.ts"; +import type { WorkflowWorktreeJanitorShape } from "../Services/WorkflowWorktreeJanitor.ts"; +import type { WorkSourceConnectionStoreShape } from "../Services/WorkSourceConnectionStore.ts"; +import type { WorkflowOutboundConnectionStoreShape } from "../Services/WorkflowOutboundConnectionStore.ts"; +import type { WorkSourceProviderRegistryShape } from "../Services/WorkSourceProvider.ts"; +import type { + SourceDelta, + WorkflowSourceCommitterShape, +} from "../Services/WorkflowSourceCommitter.ts"; +import { + deleteWorkflowBoardOwnedState, + type WorkflowBoardOwnedStateDeletionDeps, +} from "../boardDeletion.ts"; +import { + chunkArray, + describeWorkSourceProviderError, + MAX_DELTAS_PER_RECONCILE_CHUNK, + scanSource, +} from "../scanSource.ts"; +import { buildNewSourceDelta } from "../sourceReconcileDiff.ts"; +import { simulateBoardRoute } from "../dryRun.ts"; +import { sha256Hex } from "../workflowVersionHash.ts"; +import { encodeWorkflowDefinitionJson, type LintError } from "../workflowFile.ts"; +import { buildProposalPrompt, parseBoardProposal } from "../selfImprove/boardProposalPrompt.ts"; +import { + buildCreatePrompt, + containsForbiddenStepType, + injectAgentIntoSteps, +} from "../createWizard/createWorkflowPrompt.ts"; +import { dryRunRegression, preservationGate } from "../selfImprove/boardProposalValidation.ts"; +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; + +export interface TicketWorktreeResolverShape { + readonly resolveForTicket: ( + ticketId: TicketId, + ) => Effect.Effect<{ readonly cwd: string; readonly baseRef: string }, WorkflowRpcError>; +} + +interface WorkflowCreateTicketInput { + readonly boardId: BoardId; + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: LaneKey; + readonly dependsOn?: ReadonlyArray<TicketId> | undefined; + readonly tokenBudget?: number | undefined; +} + +interface WorkflowEditTicketInput { + readonly ticketId: TicketId; + readonly title?: string | undefined; + readonly description?: string | undefined; + readonly dependsOn?: ReadonlyArray<TicketId> | undefined; + readonly tokenBudget?: number | null | undefined; +} + +interface WorkflowAnswerTicketStepInput { + readonly stepRunId: StepRunId; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray<TicketAttachment> | undefined; +} + +interface WorkflowDeleteBoardInput { + readonly boardId: BoardId; +} + +type WorkflowCreateBoardHandlerInput = WorkflowCreateBoardInputType; +type WorkflowRenameBoardHandlerInput = WorkflowRenameBoardInputType; + +interface WorkflowGetBoardDefinitionInput { + readonly boardId: BoardId; +} + +interface WorkflowGetBoardVersionInput { + readonly boardId: BoardId; + readonly versionId: number; +} + +interface WorkflowRpcHandlerDeps { + readonly engine: WorkflowEngineShape; + // Optional transaction wrapper for the board-deletion cascade. When omitted (the + // ws RPC layer does not have SqlClient in its context), deleteBoard runs the + // cascade via a passthrough wrapper — non-transactional but still ordered (DB + // writes before in-memory/git/thread cleanup) under the board save lock. + // The non-atomicity is bounded and self-healing: the board FILE is deleted + // before the cascade, so a crash mid-cascade leaves a file-less board whose + // remaining owned rows are reclaimed transactionally by WorkflowRecovery's + // missing-file cleanup on the next startup. The recovery/discovery deletion + // paths, which DO have a SqlClient, run the cascade transactionally up front + // (see WorkflowBoardOwnedStateDeletionDeps). + readonly sql?: Pick<SqlClient.SqlClient, "withTransaction">; + readonly eventStore?: Pick<WorkflowEventStoreShape, "deleteForBoard">; + readonly readModel: WorkflowReadModelShape; + readonly boardRegistry: BoardRegistryShape; + readonly boardDiscovery: BoardDiscoveryShape; + readonly projectWorkspaceResolver: ProjectWorkspaceResolverShape; + readonly workspaceFileSystem: WorkspaceFileSystemShape; + readonly ticketDiff: TicketDiffQueryShape; + readonly ticketWorktrees: TicketWorktreeResolverShape; + readonly boardEvents: WorkflowBoardEventsShape; + readonly saveLocks?: WorkflowBoardSaveLocksShape; + readonly versionStore: WorkflowBoardVersionStoreShape; + readonly worktreeJanitor?: Pick<WorkflowWorktreeJanitorShape, "collectBoardPlan" | "run">; + readonly threadJanitor?: Pick< + WorkflowThreadJanitorShape, + "collectBoardThreads" | "deleteThreads" + >; + readonly intake?: WorkflowIntakeShape; + readonly webhook?: Pick<WorkflowWebhookShape, "getConfig" | "deleteForBoard">; + // Per-agent session teardown for the board-deletion cascade (A8). + readonly agentSessions?: WorkflowBoardOwnedStateDeletionDeps["agentSessions"]; + readonly provider?: WorkflowBoardOwnedStateDeletionDeps["provider"]; + readonly predicates?: PredicateEvaluatorShape; + // Self-improve (E4): no-tool board-proposal generation. Optional — a server + // without a configured generation provider simply has the propose RPC fail + // with a clear "not available" error. + readonly textGeneration?: Pick<TextGenerationShape, "generateBoardProposal">; + readonly fileLoader: WorkflowFileLoaderShape; + readonly projectScriptTrust: ProjectScriptTrustShape; + readonly connectionStore: WorkSourceConnectionStoreShape; + readonly outboundConnectionStore?: WorkflowOutboundConnectionStoreShape; + readonly workSourceProviders?: WorkSourceProviderRegistryShape; + readonly sourceCommitter?: Pick<WorkflowSourceCommitterShape, "reconcileChunk">; + /** + * Gate that defers a mutating effect until the server runtime has finished + * startup + workflow recovery (and fails it if recovery failed). Optional so + * tests can construct handlers without the gate; production wires it from + * `ServerRuntimeStartup.awaitCommandReady`. Applied only to MUTATING_METHODS — + * reads/streams/generation run ungated, mirroring orchestration command gating. + */ + readonly gate?: <A, E, R>( + effect: Effect.Effect<A, E, R>, + ) => Effect.Effect<A, E | WorkflowRpcError, R>; + readonly observeRpcEffect: <A, E, R>( + method: string, + effect: Effect.Effect<A, E, R>, + traceAttributes?: Readonly<Record<string, unknown>>, + ) => Effect.Effect<A, E | EnvironmentAuthorizationError, R>; + readonly observeRpcStreamEffect: <A, StreamError, StreamContext, EffectError, EffectContext>( + method: string, + effect: Effect.Effect<Stream.Stream<A, StreamError, StreamContext>, EffectError, EffectContext>, + traceAttributes?: Readonly<Record<string, unknown>>, + ) => Stream.Stream< + A, + StreamError | EffectError | EnvironmentAuthorizationError, + StreamContext | EffectContext + >; +} + +const MAX_TICKET_ARTIFACTS = 20; +const MAX_TICKET_ARTIFACT_CHARS = 64_000; +// Hard byte ceiling for a single artifact read. Generous enough (UTF-8 worst +// case 4 bytes/char) to still yield > MAX_TICKET_ARTIFACT_CHARS chars so the +// truncated flag stays accurate, while bounding the memory a large artifact can +// force on this read RPC. +const MAX_TICKET_ARTIFACT_READ_BYTES = (MAX_TICKET_ARTIFACT_CHARS + 1) * 4; +const MAX_DRY_RUN_DEFINITION_CHARS = 256_000; +const MAX_DRY_RUN_LANES = 200; +const MAX_DRY_RUN_PER_LANE = 100; + +// projection_ticket.attention_kind is plain TEXT with no DB CHECK constraint, so +// clamp it to the contract's literal domain (WorkflowTicketAttentionKind) before +// exposing it on a ticket view. An out-of-domain value is dropped rather than +// type-lied onto the view via `as never`. +const NEEDS_ATTENTION_KINDS = new Set<string>([ + "waiting_for_approval", + "waiting_for_input", + "blocked", +]); +const validAttentionKind = (raw: string | null | undefined): string | null => + raw != null && NEEDS_ATTENTION_KINDS.has(raw) ? raw : null; + +// Size caps shared by the import/save paths AND the disk load path +// (WorkflowFileLoader.loadAndRegister) — imported from a single module so the +// two never diverge. Generous enough that any realistically-authored board +// round-trips (export → re-import, edit → save), but bounding memory/CPU so no +// path can persist/register an arbitrarily large definition. A pure DoS +// backstop, deliberately decoupled from dryRunBoard's tighter MAX_DRY_RUN_*. +// (See ../definitionCaps.ts; re-aliased here so the existing references below +// keep their names.) + +// Lint codes that depend on the target environment (which provider instances +// are configured / which instruction files are checked in) rather than on the +// structural correctness of the definition itself. On import these are surfaced +// as warnings — the board is still created — because the importing environment +// may legitimately differ from the source environment. Every other lint code is +// a blocking authoring error. The literals MUST match LintCode in workflowFile.ts. +const ENV_BOUND_LINT_CODES: ReadonlySet<LintError["code"]> = new Set([ + "unknown_provider_instance", + "missing_instruction_file", +]); + +const toBoardTicketView = (ticket: TicketRow): BoardTicketView => ({ + ticketId: ticket.ticketId as TicketId, + boardId: ticket.boardId as BoardId, + title: ticket.title, + ...(ticket.description === null ? {} : { description: ticket.description }), + currentLaneKey: ticket.currentLaneKey as LaneKey, + status: ticket.status as TicketStatus, + ...(ticket.queuedAt === null ? {} : { queuedAt: ticket.queuedAt }), + ...(ticket.dependsOn === undefined || ticket.dependsOn.length === 0 + ? {} + : { dependsOn: ticket.dependsOn as ReadonlyArray<TicketId> }), + ...(ticket.unresolvedDependencyCount === undefined || ticket.unresolvedDependencyCount === 0 + ? {} + : { unresolvedDependencyCount: ticket.unresolvedDependencyCount }), + ...(typeof ticket.tokenBudget === "number" ? { tokenBudget: ticket.tokenBudget } : {}), + ...(ticket.updatedAt === undefined ? {} : { updatedAt: ticket.updatedAt }), + ...(typeof ticket.totalTokens === "number" && ticket.totalTokens > 0 + ? { totalTokens: ticket.totalTokens } + : {}), + ...(typeof ticket.totalDurationMs === "number" && ticket.totalDurationMs > 0 + ? { totalDurationMs: ticket.totalDurationMs } + : {}), + ...(ticket.pr === undefined ? {} : { pr: ticket.pr }), + // Attention fields — present when the ticket is in a needs-attention state. + ...(validAttentionKind(ticket.attentionKind) === null + ? {} + : { attentionKind: validAttentionKind(ticket.attentionKind) as never }), + ...(ticket.attentionReason == null ? {} : { attentionReason: ticket.attentionReason }), + // Current lane detail — present on detail reads (resolved from board definition). + ...(ticket.currentLane === undefined + ? {} + : { + currentLane: { + key: ticket.currentLane.key as LaneKey, + name: ticket.currentLane.name, + actions: ticket.currentLane.actions.map((a) => ({ + label: a.label, + to: a.to as LaneKey, + ...(a.hint === undefined ? {} : { hint: a.hint }), + })), + }, + }), +}); + +const toStepUsageView = (step: StepRunRow) => { + if ( + step.inputTokens === null && + step.cachedInputTokens === null && + step.outputTokens === null && + step.totalTokens === null + ) { + return undefined; + } + return { + ...(step.inputTokens === null ? {} : { inputTokens: step.inputTokens }), + ...(step.cachedInputTokens === null ? {} : { cachedInputTokens: step.cachedInputTokens }), + ...(step.outputTokens === null ? {} : { outputTokens: step.outputTokens }), + ...(step.totalTokens === null ? {} : { totalTokens: step.totalTokens }), + }; +}; + +const toStepRunView = (step: StepRunRow): WorkflowStepRunView => ({ + stepRunId: step.stepRunId as never, + stepKey: step.stepKey as never, + stepType: step.stepType as "agent" | "approval", + ...(step.attempt === null || step.attempt === 1 ? {} : { attempt: step.attempt }), + status: step.status as StepRunStatus, + waitingReason: step.waitingReason, + blockedReason: step.blockedReason, + providerResponseKind: step.providerResponseKind, + scriptThreadId: step.scriptThreadId as never, + terminalId: step.terminalId, + scriptStatus: step.scriptStatus as never, + exitCode: step.exitCode, + signal: step.signal, + ...(step.output === null ? {} : { output: step.output }), + ...(step.startedAt === null ? {} : { startedAt: step.startedAt as never }), + ...(step.finishedAt === null ? {} : { finishedAt: step.finishedAt as never }), + ...(toStepUsageView(step) === undefined ? {} : { usage: toStepUsageView(step) }), + ...(step.providerThreadId === null ? {} : { providerThreadId: step.providerThreadId as never }), +}); + +const workflowRpcError = (message: string, cause?: unknown) => + new WorkflowRpcError({ + message, + ...(cause === undefined ? {} : { cause }), + }); + +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); +const decodeWorkflowCreateBoardInput = Schema.decodeUnknownEffect(WorkflowCreateBoardInput); +const decodeWorkflowRenameBoardInput = Schema.decodeUnknownEffect(WorkflowRenameBoardInput); +const decodeWorkflowDefinitionJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(WorkflowDefinition), +); +const encodeWorkflowDefinition = Schema.encodeSync(WorkflowDefinition); +const encodeAgentSelectionJson = Schema.encodeSync(Schema.fromJsonString(AgentSelection)); +const encodeWorkflowProposalValidationJson = Schema.encodeSync( + Schema.fromJsonString(WorkflowProposalValidation), +); +const WORKFLOW_BOARD_FILE_PATH_PATTERN = /^\.t3\/boards\/[A-Za-z0-9_-]+\.json$/; + +const toWorkflowRpcError = (message: string) => (cause: unknown) => + workflowRpcError(message, cause); + +const toContractLintError = (error: LintError): WorkflowLintError => ({ + code: error.code, + message: error.message, + ...(error.laneKey === undefined ? {} : { laneKey: LaneKey.make(error.laneKey) }), + ...(error.stepKey === undefined ? {} : { stepKey: StepKey.make(error.stepKey) }), + ...(error.transitionIndex === undefined ? {} : { transitionIndex: error.transitionIndex }), +}); + +const workflowDefinitionContentJson = (definition: WorkflowDefinitionType): string => + `${encodeWorkflowDefinitionJson(definition)}\n`; + +const workflowDefinitionVersionHash = (definition: WorkflowDefinitionType): string => + sha256Hex(workflowDefinitionContentJson(definition)); + +const recordBoardVersionBestEffort = ( + deps: Pick<WorkflowRpcHandlerDeps, "versionStore">, + input: { + readonly boardId: BoardId; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; + }, +): Effect.Effect<void> => + deps.versionStore.record(input).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to record workflow board version", { + boardId: input.boardId, + source: input.source, + cause: Cause.pretty(cause), + }), + ), + ); + +const recordBoardVersionRequired = ( + deps: Pick<WorkflowRpcHandlerDeps, "versionStore">, + input: { + readonly boardId: BoardId; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; + }, +): Effect.Effect<void, WorkflowRpcError> => + deps.versionStore + .record(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to record workflow board version"))); + +const boardSnapshot = ( + deps: Pick<WorkflowRpcHandlerDeps, "boardRegistry" | "readModel">, + boardId: BoardId, +): Effect.Effect<BoardSnapshot, WorkflowRpcError> => + Effect.gen(function* () { + const board = yield* deps.readModel + .getBoard(boardId) + .pipe(Effect.mapError((cause) => workflowRpcError("Failed to load workflow board", cause))); + if (!board) { + return yield* workflowRpcError(`Workflow board ${boardId} was not found`); + } + + const definition = yield* deps.boardRegistry.getDefinition(boardId); + if (!definition) { + return yield* workflowRpcError(`Workflow board definition ${boardId} was not found`); + } + + const tickets = yield* deps.readModel + .listTickets(boardId) + .pipe(Effect.mapError((cause) => workflowRpcError("Failed to load workflow tickets", cause))); + + return { + projectId: board.projectId as ProjectId, + board: { + boardId, + name: board.name, + lanes: definition.lanes.map((lane) => ({ + key: lane.key, + name: lane.name, + entry: lane.entry, + pipelineStepCount: lane.pipeline?.length ?? 0, + ...(lane.wipLimit === undefined ? {} : { wipLimit: lane.wipLimit }), + ...(lane.terminal === undefined ? {} : { terminal: lane.terminal }), + ...(lane.actions === undefined || lane.actions.length === 0 + ? {} + : { actions: lane.actions }), + })), + }, + tickets: tickets.map(toBoardTicketView), + } satisfies BoardSnapshot; + }); + +const ticketDetail = ( + deps: Pick<WorkflowRpcHandlerDeps, "readModel">, + ticketId: TicketId, +): Effect.Effect<WorkflowTicketDetailView, WorkflowRpcError> => + Effect.gen(function* () { + const detail = yield* deps.readModel + .getTicketDetail(ticketId) + .pipe( + Effect.mapError((cause) => + workflowRpcError("Failed to load workflow ticket detail", cause), + ), + ); + if (!detail) { + return yield* workflowRpcError(`Workflow ticket ${ticketId} was not found`); + } + const routeDecisions = yield* deps.readModel + .listTicketRouteDecisions(ticketId) + .pipe( + Effect.mapError((cause) => + workflowRpcError("Failed to load workflow ticket route history", cause), + ), + ); + + return { + routeHistory: routeDecisions.map((decision) => ({ + occurredAt: decision.occurredAt as never, + ...(decision.fromLane === null ? {} : { fromLane: decision.fromLane as never }), + toLane: decision.toLane as never, + source: decision.source, + ...(decision.matchedTransitionIndex === null + ? {} + : { matchedTransitionIndex: decision.matchedTransitionIndex }), + ...(decision.eventName === null ? {} : { eventName: decision.eventName }), + ...(decision.pipelineResult === null ? {} : { pipelineResult: decision.pipelineResult }), + ...(decision.laneRunCount === null ? {} : { laneRunCount: decision.laneRunCount }), + ...(decision.steps === null + ? {} + : { + steps: Object.fromEntries( + Object.entries(decision.steps).map(([stepKey, step]) => [ + stepKey, + { + status: step.status, + ...(step.exitCode === null ? {} : { exitCode: step.exitCode }), + ...(step.verdict === null ? {} : { verdict: step.verdict }), + }, + ]), + ), + }), + })), + ticket: toBoardTicketView(detail.ticket), + steps: detail.steps.map(toStepRunView), + messages: detail.messages.map((message) => ({ + messageId: message.messageId, + ticketId: message.ticketId, + ...(message.stepRunId === null ? {} : { stepRunId: message.stepRunId }), + author: message.author, + body: message.body, + attachments: [...message.attachments], + createdAt: message.createdAt, + ...(message.editedAt == null ? {} : { editedAt: message.editedAt }), + })), + ...(detail.syncedSource !== undefined ? { syncedSource: detail.syncedSource } : {}), + } satisfies WorkflowTicketDetailView; + }); + +const slugFromBoardEntry = (entry: BoardListEntry): string | null => { + const fileName = entry.filePath.split("/").at(-1); + return fileName?.endsWith(".json") ? fileName.slice(0, -".json".length) : null; +}; + +const createBoardFromDefinition = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "boardDiscovery" + | "projectWorkspaceResolver" + | "workspaceFileSystem" + | "fileLoader" + | "boardRegistry" + | "readModel" + | "saveLocks" + | "versionStore" + >, + args: { + readonly projectId: ProjectId; + readonly definition: WorkflowDefinitionType; + readonly versionSource: WorkflowBoardVersionSource; + // "strict" (default) runs the file loader's full lint on register and fails + // on any error. "skip" is used by import, which has already linted and + // decided env-bound findings are warnings rather than blockers. + readonly lintMode?: "strict" | "skip"; + }, +): Effect.Effect< + { readonly boardId: BoardId; readonly snapshot: BoardSnapshot }, + WorkflowRpcError +> => + Effect.gen(function* () { + const workspaceRoot = yield* deps.projectWorkspaceResolver + .resolve(args.projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + const existingEntries = yield* deps.boardDiscovery.discover(args.projectId); + const existingSlugs = new Set( + existingEntries.flatMap((entry) => { + const slug = slugFromBoardEntry(entry); + return slug === null ? [] : [slug]; + }), + ); + const slug = uniqueBoardSlug(slugifyBoardName(args.definition.name), existingSlugs); + const boardId = BoardId.make(`${args.projectId}__${slug}`); + const relativePath = `.t3/boards/${slug}.json`; + const contentJson = workflowDefinitionContentJson(args.definition); + + return yield* (deps.saveLocks?.withSaveLock ?? ((_boardId, effect) => effect))( + boardId, + Effect.gen(function* () { + yield* deps.workspaceFileSystem + .createFileExclusive({ + projectRoot: workspaceRoot, + relativePath, + contents: contentJson, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to create workflow board file"))); + yield* deps.fileLoader + .loadAndRegister({ + boardId, + projectId: args.projectId, + workspaceRoot, + relativePath, + ...(args.lintMode === undefined ? {} : { lintMode: args.lintMode }), + }) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to register created workflow board")), + // The file was created above; if register fails we would otherwise + // leave an orphan board file. Best-effort delete before re-failing. + Effect.tapError(() => + deps.workspaceFileSystem + .deleteFile({ cwd: workspaceRoot, relativePath }) + .pipe(Effect.ignore), + ), + ); + + const createdBoard = yield* deps.readModel + .getBoard(boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load created workflow board"))); + if (!createdBoard) { + return yield* workflowRpcError(`Workflow board ${boardId} was not found after create`); + } + yield* recordBoardVersionBestEffort(deps, { + boardId, + versionHash: createdBoard.workflowVersionHash, + contentJson, + source: args.versionSource, + }); + + const snapshot = yield* boardSnapshot(deps, boardId); + return { boardId, snapshot }; + }), + ); + }); + +const createBoard = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "boardDiscovery" + | "projectWorkspaceResolver" + | "workspaceFileSystem" + | "fileLoader" + | "boardRegistry" + | "readModel" + | "saveLocks" + | "versionStore" + >, + input: WorkflowCreateBoardHandlerInput, +): Effect.Effect< + { readonly boardId: BoardId; readonly snapshot: BoardSnapshot }, + WorkflowRpcError +> => + decodeWorkflowCreateBoardInput(input).pipe( + Effect.mapError(toWorkflowRpcError("workflow board create input decode failed")), + Effect.flatMap((decoded) => + createBoardFromDefinition(deps, { + projectId: decoded.projectId, + definition: defaultBoardDefinition({ name: decoded.name, agent: decoded.agent }), + versionSource: "create", + }), + ), + ); + +// Shared deps Pick for the validate-and-create pipeline. Identical to the set +// createBoardFromDefinition needs — validateAndCreateBoard only adds the lint +// step, which uses fileLoader/projectWorkspaceResolver already in the set. +type ValidateAndCreateDeps = Pick< + WorkflowRpcHandlerDeps, + | "boardDiscovery" + | "projectWorkspaceResolver" + | "workspaceFileSystem" + | "fileLoader" + | "boardRegistry" + | "readModel" + | "saveLocks" + | "versionStore" +>; + +/** + * Shared defense + create pipeline for untrusted client-supplied board + * definitions. Used by importBoard (mode:"import") and createWorkflowBoard + * (mode:"create"). Both modes run the IDENTICAL size/lane/decode/lint gates; + * they differ ONLY in how lint findings are partitioned: + * + * - mode:"import" → env-bound codes (unknown provider instance / missing + * instruction file) are downgraded to non-blocking + * warnings, because the importing environment may + * legitimately differ from the source environment. Every + * other code blocks. + * - mode:"create" → ALL lint errors block; there are never warnings. A board + * being authored locally must reference things that exist + * in THIS environment. + * + * Returns the same discriminated shape importBoard returns today + * ({ok:true, boardId, warnings} | {ok:false, lintErrors}). In create-mode + * `warnings` is always []. + */ +export const validateAndCreateBoard = ( + deps: ValidateAndCreateDeps, + args: { + readonly projectId: ProjectId; + // The RAW, untrusted, still-encoded client payload (NOT yet decoded). + readonly encodedDefinition: WorkflowImportBoardInputType["definition"]; + readonly mode: "import" | "create"; + // OPTIONAL post-lint hook. Invoked on the DECODED definition AFTER the + // strict lint gate passes and BEFORE the board is persisted. Lets a caller + // run an additional, ALREADY-bounded check (e.g. createWorkflowBoard's + // dead-end dry-run) at the correct point in the pipeline — after the cheap + // size/lane/lint caps have validated and bounded the def — without + // duplicating those caps. Returning {ok:false, message} rejects the def as a + // renderable "invalid_step" lintError and persists nothing. When undefined + // (import-mode + empty/template create) behavior is UNCHANGED. + readonly afterLint?: ( + decoded: WorkflowDefinitionType, + ) => Effect.Effect< + { readonly ok: true } | { readonly ok: false; readonly message: string }, + WorkflowRpcError + >; + }, +): Effect.Effect<WorkflowImportBoardResult, WorkflowRpcError> => + Effect.gen(function* () { + // Helper: every USER-INPUT validation failure (too large, bad shape, too many + // lanes) must return a renderable {ok:false, lintErrors} — NOT a transport + // WorkflowRpcError — so the calling dialog can show it inline as an actionable + // lint error. "invalid_step" is the most fitting existing WorkflowLintCode for + // a malformed/oversized definition. WorkflowRpcError stays reserved for genuine + // server-side failures (workspace resolve, file write, etc.). + const importLintFailure = (message: string): WorkflowImportBoardResult => ({ + ok: false, + lintErrors: [{ code: "invalid_step", message }], + }); + + // 1. Guarded byte-size probe on the RAW payload — the ONLY thing that touches + // untyped input before decode. JSON.stringify is wrapped in try/catch + // because a pathologically deep/circular object can throw a RangeError + // before the length comparison; treat that as "too large" and return a + // clean {ok:false} result rather than letting a defect escape. + let definitionJsonLength: number; + // @effect-diagnostics-next-line tryCatchInEffectGen:off — synchronous size probe; not an Effect failure + try { + // @effect-diagnostics-next-line preferSchemaOverJson:off — pure size probe, not parsing + definitionJsonLength = JSON.stringify(args.encodedDefinition).length; + } catch { + return importLintFailure( + `Board definition is too large to import (exceeds ${MAX_IMPORT_DEFINITION_CHARS} characters)`, + ); + } + if (definitionJsonLength > MAX_IMPORT_DEFINITION_CHARS) { + return importLintFailure( + `Board definition is too large to import (exceeds ${MAX_IMPORT_DEFINITION_CHARS} characters)`, + ); + } + + // 2. Decode BEFORE reading any typed fields. A structural decode failure maps + // to a single blocking lintError so the calling dialog can render it like + // any other rejection. + const decodeExit = Schema.decodeUnknownExit(WorkflowDefinition)(args.encodedDefinition); + if (Exit.isFailure(decodeExit)) { + return importLintFailure( + "Workflow definition is structurally invalid and could not be decoded", + ); + } + const decoded = decodeExit.value; + + // 3. Lane / per-lane caps on the DECODED definition (never on raw input). + // Generous DoS-only ceilings, decoupled from dryRunBoard's tighter limits, + // so a large-but-valid saved board round-trips through export → import. + if (decoded.lanes.length > MAX_IMPORT_LANES) { + return importLintFailure( + `Board definition is too large to import (exceeds ${MAX_IMPORT_LANES} lanes)`, + ); + } + if ( + decoded.lanes.some( + (lane) => + (lane.pipeline?.length ?? 0) > MAX_IMPORT_PER_LANE || + (lane.transitions?.length ?? 0) > MAX_IMPORT_PER_LANE || + (lane.onEvent?.length ?? 0) > MAX_IMPORT_PER_LANE, + ) + ) { + return importLintFailure( + `Board definition is too large to import (a lane exceeds ${MAX_IMPORT_PER_LANE} pipeline steps, transitions, or event handlers)`, + ); + } + + // 4. Lint — run the SAME strict lint persist uses (identical in both modes). + const workspaceRoot = yield* deps.projectWorkspaceResolver + .resolve(args.projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + const lintErrors = yield* deps.fileLoader + .lintDefinition({ + definition: decoded, + projectId: args.projectId, + workspaceRoot, + }) + .pipe(Effect.mapError(toWorkflowRpcError("workflow lint failed"))); + + // 5. Partition. import-mode downgrades env-bound codes to warnings; + // create-mode blocks on EVERY lint error (no warnings). + const blocking = + args.mode === "import" + ? lintErrors.filter((error) => !ENV_BOUND_LINT_CODES.has(error.code)) + : lintErrors; + const warnings = + args.mode === "import" + ? lintErrors + .filter((error) => ENV_BOUND_LINT_CODES.has(error.code)) + .map((error) => error.message) + : []; + + // Any blocking error → no file written, no board created. + if (blocking.length > 0) { + return { ok: false, lintErrors: blocking.map(toContractLintError) }; + } + + // 5b. Optional post-lint hook (e.g. the dead-end dry-run gate). Runs only + // AFTER caps + decode + lint have bounded/validated the def, so an + // untrusted oversized def is rejected by the cheap caps above BEFORE any + // expensive per-lane simulation. A {ok:false} surfaces as a renderable + // "invalid_step" lintError (the only failure shape this helper returns) + // and persists nothing. + if (args.afterLint !== undefined) { + const hook = yield* args.afterLint(decoded); + if (!hook.ok) { + return importLintFailure(hook.message); + } + } + + // 6. Create the board WITHOUT re-running the strict lint (already linted above). + // lintMode:"skip" in BOTH modes — re-linting in createBoardFromDefinition + // would be redundant and, in import-mode, would re-reject the env-bound + // codes we intentionally downgraded to warnings. + const created = yield* createBoardFromDefinition(deps, { + projectId: args.projectId, + definition: decoded, + versionSource: args.mode === "import" ? "import" : "create", + lintMode: "skip", + }); + return { ok: true, boardId: created.boardId, warnings }; + }); + +const importBoard = ( + deps: ValidateAndCreateDeps, + input: WorkflowImportBoardInputType, +): Effect.Effect<WorkflowImportBoardResult, WorkflowRpcError> => + validateAndCreateBoard(deps, { + projectId: input.projectId, + encodedDefinition: input.definition, + mode: "import", + }); + +/** + * `createWorkflowBoard` — the Create Workflow Wizard's board-create handler. It + * resolves the wizard's {@link WorkflowCreateChoice} into a raw/encoded + * {@link WorkflowDefinitionEncoded} and routes it through the SAME create-mode + * {@link validateAndCreateBoard} pipeline import uses (size caps → decode → lane + * caps → strict lint → create-from-def). In create-mode EVERY lint error blocks + * and there are never warnings. + * + * Two early returns happen BEFORE any definition is built or written: + * - an unknown templateId, and + * - a `requiresAgent` template invoked without an agent. + * Both surface as {ok:false, lintErrors:[], message} so the wizard can show the + * reason inline without inventing a fake lint error. + * + * The helper's success shape ({ok:true, boardId, warnings}) is narrowed to the + * wizard contract result ({ok:true, boardId}) — the `warnings` field is dropped + * (create-mode warnings are always []). Exported for direct testing and for + * Task 7 to register against the WS RPC group once it declares the method. + */ +export const createWorkflowBoard = ( + deps: ValidateAndCreateDeps & Pick<WorkflowRpcHandlerDeps, "predicates">, + input: WorkflowCreateWorkflowBoardInputType, +): Effect.Effect<WorkflowCreateWorkflowBoardResult, WorkflowRpcError> => + Effect.gen(function* () { + const { choice } = input; + let encodedDefinition: WorkflowDefinitionEncoded; + switch (choice.kind) { + case "empty": + encodedDefinition = encodeWorkflowDefinition(emptyBoardDefinition({ name: input.name })); + break; + case "template": { + const tpl = BOARD_TEMPLATES.find((t) => t.id === choice.templateId); + if (!tpl) { + return { + ok: false, + lintErrors: [], + message: `Unknown template "${choice.templateId}"`, + } satisfies WorkflowCreateWorkflowBoardResult; + } + const agent = choice.agent; + if (tpl.requiresAgent && agent === undefined) { + return { + ok: false, + lintErrors: [], + message: "This template requires an agent.", + } satisfies WorkflowCreateWorkflowBoardResult; + } + // Every current template `requiresAgent`, so the guard above guarantees an + // agent here. The non-null assertion documents that invariant for the + // build signature (which takes a required AgentSelection); a future + // agent-free template would build without one. + encodedDefinition = encodeWorkflowDefinition( + tpl.build({ name: input.name, agent: agent! }), + ); + break; + } + case "definition": { + // Already a raw/encoded WorkflowDefinitionEncoded (untrusted) — the helper + // re-validates (size caps + decode + lint) it. + encodedDefinition = choice.definition; + break; + } + } + + // Dead-end dry-run gate — ONLY on the untrusted "definition" choice (the + // empty/template choices are server-built and dry-run-clean). lint does not + // check terminal reachability, so a definition can pass the helper's gates + // yet strand tickets. Run it as validateAndCreateBoard's afterLint hook so it + // fires only AFTER the cheap caps + decode + lint have bounded/validated the + // def — never before, so an oversized untrusted def cannot trigger thousands + // of route simulations before the caps reject it. The dry-run is itself + // bounded by MAX_DRY_RUN_LANES (mirroring proposeBoardImprovement): a def + // larger than that already cleared lint + the MAX_IMPORT_LANES cap, so we + // treat it as not-stranded rather than running an unbounded simulation. + const afterLint = + input.choice.kind === "definition" && deps.predicates !== undefined + ? (decoded: WorkflowDefinitionType) => + Effect.gen(function* () { + if (decoded.lanes.length > MAX_DRY_RUN_LANES) { + return { ok: true } as const; + } + const deadEndLanes = yield* dryRunDeadEndLanes(decoded, deps.predicates!); + return deadEndLanes.length > 0 + ? ({ ok: false, message: strandingMessage(deadEndLanes) } as const) + : ({ ok: true } as const); + }) + : undefined; + + const result = yield* validateAndCreateBoard(deps, { + projectId: input.projectId, + encodedDefinition, + mode: "create", + ...(afterLint === undefined ? {} : { afterLint }), + }); + // Narrow the helper result to the wizard contract: drop `warnings` on success + // (create-mode warnings are always []); pass lintErrors through on failure. + return result.ok + ? ({ ok: true, boardId: result.boardId } satisfies WorkflowCreateWorkflowBoardResult) + : ({ ok: false, lintErrors: result.lintErrors } satisfies WorkflowCreateWorkflowBoardResult); + }); + +const deleteBoard = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "readModel" + | "engine" + | "eventStore" + | "boardRegistry" + | "versionStore" + | "saveLocks" + | "projectWorkspaceResolver" + | "workspaceFileSystem" + | "worktreeJanitor" + | "threadJanitor" + | "webhook" + | "agentSessions" + | "provider" + | "sql" + >, + input: WorkflowDeleteBoardInput, +): Effect.Effect<void, WorkflowRpcError> => + (deps.saveLocks?.withSaveLock ?? ((_boardId, effect) => effect))( + input.boardId, + Effect.gen(function* () { + const board = yield* deps.readModel + .getBoard(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board"))); + + if (board) { + if (!WORKFLOW_BOARD_FILE_PATH_PATTERN.test(board.workflowFilePath)) { + return yield* workflowRpcError( + `Workflow board ${input.boardId} is not a deletable workflow board file`, + ); + } + + const workspaceRoot = yield* deps.projectWorkspaceResolver + .resolve(board.projectId as ProjectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + + yield* deps.workspaceFileSystem + .deleteFile({ + cwd: workspaceRoot, + relativePath: board.workflowFilePath, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to delete workflow board file"))); + } + + yield* deleteWorkflowBoardOwnedState( + { + sql: deps.sql ?? { withTransaction: (effect) => effect }, + boardRegistry: deps.boardRegistry, + engine: deps.engine, + eventStore: deps.eventStore ?? { deleteForBoard: () => Effect.void }, + readModel: deps.readModel, + versionStore: deps.versionStore, + ...(deps.worktreeJanitor === undefined ? {} : { worktreeJanitor: deps.worktreeJanitor }), + ...(deps.threadJanitor === undefined ? {} : { threadJanitor: deps.threadJanitor }), + ...(deps.webhook === undefined ? {} : { webhook: deps.webhook }), + ...(deps.agentSessions === undefined ? {} : { agentSessions: deps.agentSessions }), + ...(deps.provider === undefined ? {} : { provider: deps.provider }), + }, + input.boardId, + ).pipe(Effect.mapError(toWorkflowRpcError("Failed to delete workflow board state"))); + }), + ).pipe( + // After the board is deleted (and the save lock released), drop its cached + // save semaphore so it doesn't leak for the process lifetime. No-op if the + // lock service doesn't implement eviction. + Effect.tap(() => deps.saveLocks?.evict?.(input.boardId) ?? Effect.void), + ); + +const getBoardDefinition = ( + deps: Pick<WorkflowRpcHandlerDeps, "boardRegistry" | "readModel">, + input: WorkflowGetBoardDefinitionInput, +): Effect.Effect<WorkflowGetBoardDefinitionResult, WorkflowRpcError> => + Effect.gen(function* () { + const definition = yield* deps.boardRegistry.getDefinition(input.boardId); + if (!definition) { + return yield* workflowRpcError(`Workflow board definition ${input.boardId} was not found`); + } + + const board = yield* deps.readModel + .getBoard(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board"))); + if (!board) { + return yield* workflowRpcError(`Workflow board ${input.boardId} was not found`); + } + + return { + definition: encodeWorkflowDefinition(definition), + versionHash: board.workflowVersionHash, + }; + }); + +// Default metrics window for a proposal — wide enough to surface dead routes +// and chronic step failures. +const PROPOSAL_METRICS_WINDOW_DAYS = 30; + +// All three terminal scenarios a dry run can take in each lane. +const DRY_RUN_SCENARIOS: ReadonlyArray<WorkflowDryRunScenario> = ["success", "failure", "blocked"]; + +// Run every {startLane, scenario} combo for a definition. Bounded: lanes × 3. +const dryRunAllCombos = (definition: WorkflowDefinitionType, evaluator: PredicateEvaluatorShape) => + Effect.gen(function* () { + const results = []; + for (const lane of definition.lanes) { + for (const scenario of DRY_RUN_SCENARIOS) { + results.push( + yield* simulateBoardRoute({ + definition, + startLane: lane.key, + scenario, + evaluator, + }), + ); + } + } + return results; + }); + +// A dry-run result "strands" a ticket when its route ends with no way out: +// `no_route` (dead-end lane) or `cycle_cap` (looped without reaching terminal). +const STRANDING_DRY_RUN_ENDS: ReadonlySet<WorkflowDryRunResultType["end"]> = new Set([ + "no_route", + "cycle_cap", +]); + +// Cap how many offending lane keys are echoed back in a stranding message so a +// pathological def can't produce an unbounded error string. +const MAX_STRANDING_LANES_IN_MESSAGE = 5; + +/** + * Dry-run dead-end gate for a NEW board (no base to diff against — unlike + * proposeBoardImprovement, which compares base vs proposed). Runs every + * lane × scenario combo and returns the DISTINCT start-lane keys whose route + * strands a ticket (`no_route` / `cycle_cap`). An empty array means every lane + * has a route out. Bounded: lanes × 3. + */ +const dryRunDeadEndLanes = ( + definition: WorkflowDefinitionType, + evaluator: PredicateEvaluatorShape, +): Effect.Effect<ReadonlyArray<string>, never> => + Effect.gen(function* () { + const results = yield* dryRunAllCombos(definition, evaluator); + const deadEndLanes = new Set<string>(); + for (const result of results) { + if (STRANDING_DRY_RUN_ENDS.has(result.end)) { + deadEndLanes.add(result.startLane as string); + } + } + return [...deadEndLanes]; + }); + +// Build the user-facing stranding message for a set of dead-end lane keys +// (capped). Shared by both wizard entry points so the copy stays identical. +const strandingMessage = (laneKeys: ReadonlyArray<string>): string => { + const shown = laneKeys.slice(0, MAX_STRANDING_LANES_IN_MESSAGE); + const suffix = laneKeys.length > shown.length ? ", …" : ""; + const lanes = shown.map((key) => `"${key}"`).join(", "); + return `Generated board strands tickets: ${lanes}${suffix} has no route out.`; +}; + +/** + * Generate + validate + store a board-improvement proposal. NEVER calls + * `saveBoardDefinition` — this path only produces a `workflow_board_proposal` + * row (pending when all gates pass, invalid otherwise). Applying a proposal is + * a separate, human-gated path (E5+). Exported for direct testing and for E6 to + * register against the WS RPC group once it declares the method. + */ +export const proposeBoardImprovement = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "boardRegistry" + | "readModel" + | "projectWorkspaceResolver" + | "fileLoader" + | "predicates" + | "textGeneration" + >, + input: WorkflowProposeBoardImprovementInputType, +): Effect.Effect<WorkflowProposeBoardImprovementResult, WorkflowRpcError> => + Effect.gen(function* () { + const textGeneration = deps.textGeneration; + if (textGeneration === undefined) { + return yield* workflowRpcError("Board proposals are not available on this server"); + } + const predicates = deps.predicates; + if (predicates === undefined) { + return yield* workflowRpcError("Board proposals are not available on this server"); + } + + // 1. Load the current definition + version hash + project root + metrics. + const baseDef = yield* deps.boardRegistry.getDefinition(input.boardId); + if (!baseDef) { + return yield* workflowRpcError(`Workflow board definition ${input.boardId} was not found`); + } + const board = yield* deps.readModel + .getBoard(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board"))); + if (!board) { + return yield* workflowRpcError(`Workflow board ${input.boardId} was not found`); + } + const baseVersionHash = board.workflowVersionHash; + const workspaceRoot = yield* deps.projectWorkspaceResolver + .resolve(board.projectId as ProjectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + const metrics = yield* deps.readModel + .getBoardMetrics(input.boardId, PROPOSAL_METRICS_WINDOW_DAYS) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to compute board metrics"))); + + // Shared bits for whichever proposal row we end up writing. + const proposalId = yield* Effect.sync( + // @effect-diagnostics-next-line cryptoRandomUUIDInEffect:off + () => globalThis.crypto.randomUUID() as string, + ); + const createdAt = DateTime.formatIso(yield* DateTime.now); + const baseDefJson = encodeWorkflowDefinitionJson(baseDef); + const agentJson = encodeAgentSelectionJson(input.agent); + + // Build a proposal view + persist it. `proposedDefJson` defaults to the base + // def for early failures (gen / decode) where there is no valid proposed def. + const finalize = (args: { + readonly status: WorkflowBoardProposalView["status"]; + readonly rationale: string; + readonly validation: WorkflowProposalValidation; + readonly proposedDefJson: string; + }) => + Effect.gen(function* () { + yield* deps.readModel + .recordBoardProposal({ + proposalId, + boardId: input.boardId, + baseVersionHash, + baseDefJson, + agentJson, + proposedDefJson: args.proposedDefJson, + rationale: args.rationale, + validationJson: encodeWorkflowProposalValidationJson(args.validation), + status: args.status, + createdAt, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to record board proposal"))); + const proposal: WorkflowBoardProposalView = { + proposalId, + boardId: input.boardId, + status: args.status, + rationale: args.rationale, + validation: args.validation, + baseVersionHash, + appliedVersionHash: null, + outdated: false, + agent: input.agent, + createdAt, + resolvedAt: null, + }; + return { proposal } satisfies WorkflowProposeBoardImprovementResult; + }); + + const invalid = (rationale: string, validation: WorkflowProposalValidation) => + finalize({ status: "invalid", rationale, validation, proposedDefJson: baseDefJson }); + + const failValidation = ( + overrides: Partial<WorkflowProposalValidation> & { readonly messages: ReadonlyArray<string> }, + ): WorkflowProposalValidation => ({ + preservationOk: false, + lintOk: false, + dryRunOk: false, + laneDiffCount: 0, + lintErrors: [], + dryRunRegressions: [], + ...overrides, + }); + + // 2. Build the prompt (titles stripped + redacted) and generate. The + // agent's instance/model/effort flow through as the model selection so + // the right provider (at the requested reasoning effort) is invoked. + const prompt = buildProposalPrompt({ definition: baseDef, metrics }); + const modelSelection: ModelSelectionType = { + instanceId: input.agent.instance as ModelSelectionType["instanceId"], + model: input.agent.model, + ...(input.agent.options === undefined ? {} : { options: input.agent.options }), + }; + const genExit = yield* textGeneration + .generateBoardProposal({ prompt, modelSelection }) + .pipe(Effect.exit); + if (Exit.isFailure(genExit)) { + const detail = Cause.squash(genExit.cause); + return yield* invalid( + `Board proposal generation failed: ${ + detail instanceof Error ? detail.message : String(detail) + }`, + failValidation({ messages: ["Generation failed; no proposal was produced."] }), + ); + } + + const parsedExit = yield* Effect.try({ + try: () => parseBoardProposal(genExit.value), + catch: (error) => (error instanceof Error ? error.message : String(error)), + }).pipe(Effect.exit); + if (Exit.isFailure(parsedExit)) { + const detail = Cause.squash(parsedExit.cause); + return yield* invalid( + "Board proposal output was malformed.", + failValidation({ messages: [typeof detail === "string" ? detail : String(detail)] }), + ); + } + const parsed = parsedExit.value; + const rationale = parsed.rationale; + + // 3. Decode the proposed definition. + const decodeExit = Schema.decodeUnknownExit(WorkflowDefinition)(parsed.proposedDefinition); + if (Exit.isFailure(decodeExit)) { + return yield* invalid( + rationale, + failValidation({ + messages: ["Proposed definition is structurally invalid and could not be decoded."], + }), + ); + } + const proposedDef = decodeExit.value; + + // 3b. Size cap — BLOCKING (defense-in-depth). A garbage LLM proposal with a + // huge lane count would amplify the cost of lint + the lanes×3 dry-run + // below; bound it locally (not at the schema level, which would affect + // all defs incl. save/import). Reuses dryRunBoard's lane ceiling. + if (proposedDef.lanes.length > MAX_DRY_RUN_LANES) { + return yield* invalid( + rationale, + failValidation({ + messages: [`Proposed definition has too many lanes (max ${MAX_DRY_RUN_LANES}).`], + }), + ); + } + const proposedDefJson = encodeWorkflowDefinitionJson(proposedDef); + + // 4. Preservation gate (name/sources/outbound + no lane-key removal) — BLOCKING. + const preservation = preservationGate(baseDef, proposedDef); + if (!preservation.ok) { + return yield* invalid( + rationale, + failValidation({ + preservationOk: false, + laneDiffCount: preservation.laneDiffCount, + messages: preservation.violations, + }), + ); + } + + // 5. Strict lint — BLOCKING. Any lint error invalidates the proposal. + const lintErrors = yield* deps.fileLoader + .lintDefinition({ + definition: proposedDef, + projectId: board.projectId as ProjectId, + workspaceRoot, + }) + .pipe(Effect.mapError(toWorkflowRpcError("workflow lint failed"))); + if (lintErrors.length > 0) { + return yield* invalid( + rationale, + failValidation({ + preservationOk: true, + laneDiffCount: preservation.laneDiffCount, + lintErrors: lintErrors.map(toContractLintError), + messages: [`Proposed definition has ${lintErrors.length} lint error(s).`], + }), + ); + } + + // 6. Dry-run regression — BLOCKING. Run every {lane, scenario} combo on BOTH + // base and proposed; a NEW dead end in proposed is a regression. + const baseResults = yield* dryRunAllCombos(baseDef, predicates); + const proposedResults = yield* dryRunAllCombos(proposedDef, predicates); + const regression = dryRunRegression(baseResults, proposedResults); + if (!regression.ok) { + return yield* invalid( + rationale, + failValidation({ + preservationOk: true, + lintOk: true, + laneDiffCount: preservation.laneDiffCount, + dryRunRegressions: regression.regressions, + messages: [ + `Proposed definition introduces ${regression.regressions.length} routing regression(s).`, + ], + }), + ); + } + + // 7. All gates pass → pending. + return yield* finalize({ + status: "pending", + rationale, + validation: { + preservationOk: true, + lintOk: true, + dryRunOk: true, + laneDiffCount: preservation.laneDiffCount, + lintErrors: [], + dryRunRegressions: [], + messages: [], + }, + proposedDefJson, + }); + }); + +/** + * Create-wizard "agent-assisted" path. A no-tool LLM op drafts a board from the + * user's free-text description; we FORCE the user's chosen agent into every + * agent step, FORBID executable step types (script/merge/pullRequest), + * strict-lint, and return the draft WITHOUT persisting it. The wizard renders + * the draft for the user to review before a separate create call persists it. + * + * Failures along the way (generation, parse, forbidden type, decode, lint) are + * surfaced as `{ ok: false, ... }` results — NOT RpcErrors — so the wizard + * dialog can show them. Only the textGeneration-unavailable case uses + * `workflowRpcError`, matching `proposeBoardImprovement` (a server without the + * text-generation dep cannot offer this feature at all). + * + * Does NOT persist: no board create, no proposal record, no file write. + */ +export const generateWorkflowDraft = ( + deps: Pick< + WorkflowRpcHandlerDeps, + "projectWorkspaceResolver" | "fileLoader" | "textGeneration" | "predicates" + >, + input: WorkflowGenerateWorkflowDraftInputType, +): Effect.Effect<WorkflowGenerateWorkflowDraftResult, WorkflowRpcError> => + Effect.gen(function* () { + const textGeneration = deps.textGeneration; + if (textGeneration === undefined) { + return yield* workflowRpcError("Workflow draft generation is not available on this server"); + } + // The dead-end dry-run gate (below) needs a predicate evaluator. A server + // without one cannot offer this feature, matching proposeBoardImprovement. + const predicates = deps.predicates; + if (predicates === undefined) { + return yield* workflowRpcError("Workflow draft generation is not available on this server"); + } + + const workspaceRoot = yield* deps.projectWorkspaceResolver + .resolve(input.projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + + const prompt = buildCreatePrompt({ + name: input.name, + description: input.description, + agent: input.agent, + }); + const modelSelection: ModelSelectionType = { + instanceId: input.agent.instance as ModelSelectionType["instanceId"], + model: input.agent.model, + ...(input.agent.options === undefined ? {} : { options: input.agent.options }), + }; + + const genExit = yield* textGeneration + .generateBoardProposal({ prompt, modelSelection }) + .pipe(Effect.exit); + if (Exit.isFailure(genExit)) { + const detail = Cause.squash(genExit.cause); + return { + ok: false, + message: `Workflow draft generation failed: ${ + detail instanceof Error ? detail.message : String(detail) + }`, + } satisfies WorkflowGenerateWorkflowDraftResult; + } + + const parsedExit = yield* Effect.try({ + try: () => parseBoardProposal(genExit.value), + catch: (error) => (error instanceof Error ? error.message : String(error)), + }).pipe(Effect.exit); + if (Exit.isFailure(parsedExit)) { + const detail = Cause.squash(parsedExit.cause); + return { + ok: false, + message: `Generated draft was malformed: ${ + typeof detail === "string" ? detail : String(detail) + }`, + } satisfies WorkflowGenerateWorkflowDraftResult; + } + const parsed = parsedExit.value; + + // Raw byte cap BEFORE the expensive raw-pipeline walk (injectAgentIntoSteps + // loops every lane/step) + decode + lint. A provider can return one lane with + // tens of thousands of steps that the lane-COUNT guard below would never + // catch; cap the RAW parsed definition first so the inject walk is bounded. + // JSON.stringify is wrapped in try/catch because a pathologically deep/ + // circular object can throw before the comparison; treat that as "too large". + let parsedJsonLength: number; + // @effect-diagnostics-next-line tryCatchInEffectGen:off — synchronous size probe; not an Effect failure + try { + // @effect-diagnostics-next-line preferSchemaOverJson:off — pure size probe, not parsing + parsedJsonLength = JSON.stringify(parsed.proposedDefinition).length; + } catch { + return { + ok: false, + message: "Generated board is too large.", + } satisfies WorkflowGenerateWorkflowDraftResult; + } + if (parsedJsonLength > MAX_IMPORT_DEFINITION_CHARS) { + return { + ok: false, + message: "Generated board is too large.", + } satisfies WorkflowGenerateWorkflowDraftResult; + } + + // Force the chosen agent into every agent step on the RAW parsed object, + // BEFORE decode — an LLM draft whose agent step omits `agent` is FIXED here, + // not rejected by the schema. Bounded: the byte cap above already capped the + // step count this walk iterates. + const injected = injectAgentIntoSteps(parsed.proposedDefinition, input.agent); + + // The user's Step-1 board name is authoritative; overwrite whatever name the + // model emitted so the returned draft (and any board later created from it) + // carries the user's chosen name. Done on the RAW object before decode. + if (typeof injected === "object" && injected !== null && !Array.isArray(injected)) { + (injected as { name?: unknown }).name = input.name; + } + + if (containsForbiddenStepType(injected)) { + return { + ok: false, + message: "Generated board contains a forbidden step type (script/merge/pullRequest).", + } satisfies WorkflowGenerateWorkflowDraftResult; + } + + const decodeExit = Schema.decodeUnknownExit(WorkflowDefinition)(injected); + if (Exit.isFailure(decodeExit)) { + // Surface the specific schema violation (e.g. `Expected "auto" | "manual", + // got "automatic" at ["lanes"][0]["entry"]`) so the user can see WHY the + // draft was rejected and regenerate — an opaque "invalid" message is + // undebuggable. The SchemaError message is concise and path-specific; cap + // it so a pathological draft can't return an unbounded message. + const detail = Cause.squash(decodeExit.cause); + const reason = (detail instanceof Error ? detail.message : String(detail)) + .replace(/\s+/g, " ") + .trim() + .slice(0, 400); + return { + ok: false, + message: `Generated definition is structurally invalid: ${reason}`, + } satisfies WorkflowGenerateWorkflowDraftResult; + } + const decoded = decodeExit.value; + + // Size cap — defense-in-depth. The schema has no maxItems, so a runaway LLM + // draft with thousands of lanes would decode fine and flow into lint + // unbounded (cost amplification). Bound it before lint, mirroring + // proposeBoardImprovement. + if (decoded.lanes.length > MAX_DRY_RUN_LANES) { + return { + ok: false, + message: `Generated board has too many lanes (max ${MAX_DRY_RUN_LANES}).`, + } satisfies WorkflowGenerateWorkflowDraftResult; + } + + // Per-lane caps — the lane-COUNT guard above does not bound a single lane's + // pipeline/transitions/onEvent count. A runaway lane would still amplify + // lint + the dead-end dry-run below; bound it here. + if ( + decoded.lanes.some( + (lane) => + (lane.pipeline?.length ?? 0) > MAX_IMPORT_PER_LANE || + (lane.transitions?.length ?? 0) > MAX_IMPORT_PER_LANE || + (lane.onEvent?.length ?? 0) > MAX_IMPORT_PER_LANE, + ) + ) { + return { + ok: false, + message: "Generated board has a lane that is too large.", + } satisfies WorkflowGenerateWorkflowDraftResult; + } + + const lintErrors = yield* deps.fileLoader + .lintDefinition({ definition: decoded, projectId: input.projectId, workspaceRoot }) + .pipe(Effect.mapError(toWorkflowRpcError("workflow lint failed"))); + if (lintErrors.length > 0) { + return { + ok: false, + lintErrors: lintErrors.map(toContractLintError), + message: "Generated board failed validation.", + } satisfies WorkflowGenerateWorkflowDraftResult; + } + + // Dead-end dry-run gate. lint does NOT check terminal reachability + // (`unreachable_terminal` is declared but unimplemented), so a board can + // pass lint yet strand tickets in a lane with no route out. Reject those. + const deadEndLanes = yield* dryRunDeadEndLanes(decoded, predicates); + if (deadEndLanes.length > 0) { + return { + ok: false, + message: strandingMessage(deadEndLanes), + } satisfies WorkflowGenerateWorkflowDraftResult; + } + + return { + ok: true, + definition: encodeWorkflowDefinition(decoded), + rationale: parsed.rationale, + } satisfies WorkflowGenerateWorkflowDraftResult; + }); + +/** + * List all proposals for a board. Exported for direct testing and for E8 to + * register in the WS RPC group once it declares the method. + */ +export const listBoardProposals = ( + deps: Pick<WorkflowRpcHandlerDeps, "readModel">, + input: WorkflowListBoardProposalsInputType, +): Effect.Effect<WorkflowListBoardProposalsResult, WorkflowRpcError> => + Effect.gen(function* () { + const proposals = yield* deps.readModel + .listBoardProposals(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list board proposals"))); + return { proposals: [...proposals] } satisfies WorkflowListBoardProposalsResult; + }); + +/** + * Get a single proposal by proposalId (view + both encoded defs). Exported for + * direct testing and for E8 to register in the WS RPC group. + */ +export const getBoardProposal = ( + deps: Pick<WorkflowRpcHandlerDeps, "readModel">, + input: WorkflowGetBoardProposalInputType, +): Effect.Effect<WorkflowGetBoardProposalResult, WorkflowRpcError> => + Effect.gen(function* () { + const result = yield* deps.readModel + .getBoardProposal(input.proposalId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load board proposal"))); + if (result === null) { + return yield* workflowRpcError(`Board proposal ${input.proposalId} was not found`); + } + return { + proposal: result.view, + proposedDefinition: result.proposedDefinition, + baseDefinition: result.baseDefinition, + } satisfies WorkflowGetBoardProposalResult; + }); + +/** + * Live-compatibility gate for resolve-approve. Returns the keys of lanes whose + * DEFINITION DIFFERS between base and proposed AND currently hold live work + * (a non-terminal admitted ticket OR a running pipeline). Applying a proposal + * that restructures such a lane could disrupt in-flight work, so those lanes + * block the apply. + * + * Only MODIFIED lanes matter: an unchanged lane (even if occupied) is fine, and + * a modified lane that is idle/empty is fine. The intersection is + * (changed lanes) × (live-occupied lanes). + */ +const liveIncompatibleLanes = ( + deps: Pick<WorkflowRpcHandlerDeps, "readModel">, + boardId: BoardId, + baseDef: WorkflowDefinitionEncoded, + proposedDef: WorkflowDefinitionEncoded, +): Effect.Effect<ReadonlyArray<string>, WorkflowRpcError> => + Effect.gen(function* () { + // Canonical per-lane serialization keyed by lane key. The defs are decoded + // then re-encoded by getBoardProposal, so JSON.stringify is stable. + const laneJsonByKey = (def: WorkflowDefinitionEncoded) => { + const map = new Map<string, string>(); + for (const lane of def.lanes) { + map.set(lane.key as string, JSON.stringify(lane)); + } + return map; + }; + const baseLanes = laneJsonByKey(baseDef); + const proposedLanes = laneJsonByKey(proposedDef); + + // A lane is "changed" when its serialized form differs across base/proposed. + // (E4 forbids removing/renaming a lane key, so a key present in base always + // remains; a key only in proposed is new and cannot already hold live work.) + const changedLaneKeys = new Set<string>(); + for (const [key, baseJson] of baseLanes) { + const proposedJson = proposedLanes.get(key); + if (proposedJson === undefined || proposedJson !== baseJson) { + changedLaneKeys.add(key); + } + } + if (changedLaneKeys.size === 0) { + return []; + } + + const occupied = yield* deps.readModel + .listLiveOccupiedLanes(boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to inspect live lane occupancy"))); + const occupiedSet = new Set(occupied); + + const incompatible: Array<string> = []; + for (const key of changedLaneKeys) { + if (occupiedSet.has(key)) { + incompatible.push(key); + } + } + return incompatible; + }); + +/** + * Apply-time RE-VALIDATION (preservation + dry-run) using the CURRENT validator + * code. Proposal-time validation may have run under older/weaker code, and + * `saveBoardDefinition` only re-runs lint — not preservation or dry-run. So at + * approve we re-run both gates against the proposed def: if either fails NOW the + * proposal is no longer applicable. Returns `null` when both pass, or a failure + * `{reason, message}` to surface + mark the proposal `invalid`. + * + * Requires `predicates` for the dry-run; when absent (server without proposals) + * the re-validation is skipped (apply still cannot be reached without it). + */ +const revalidateProposalForApply = ( + deps: Pick<WorkflowRpcHandlerDeps, "predicates">, + baseDef: WorkflowDefinitionType, + proposedDef: WorkflowDefinitionType, +): Effect.Effect<{ readonly message: string } | null, WorkflowRpcError> => + Effect.gen(function* () { + // Preservation (name/sources/outbound + no lane-key removal) — BLOCKING. + const preservation = preservationGate(baseDef, proposedDef); + if (!preservation.ok) { + return { + message: `this proposal no longer passes preservation checks: ${preservation.violations.join("; ")}`, + }; + } + + const predicates = deps.predicates; + if (predicates === undefined) { + // No evaluator available; we cannot re-run the dry-run. Preservation passed. + return null; + } + + // Dry-run regression — BLOCKING. A NEW dead end in proposed is a regression. + const baseResults = yield* dryRunAllCombos(baseDef, predicates); + const proposedResults = yield* dryRunAllCombos(proposedDef, predicates); + const regression = dryRunRegression(baseResults, proposedResults); + if (!regression.ok) { + return { + message: `this proposal now introduces routing regression(s): ${regression.regressions.join("; ")}`, + }; + } + return null; + }); + +/** + * `resolveBoardProposal` — apply (approve) or dismiss (reject) a board-improvement + * proposal. This is the SOLE path that writes a board definition from a proposal: + * the propose/list/get paths never call `saveBoardDefinition`, and neither does + * reject. Approve is gated by optimistic concurrency (the proposal's + * base_version_hash must still be the board's current version), a re-run of the + * preservation + dry-run validators with CURRENT code, and a live-ticket + * compatibility check (a modified lane holding in-flight work blocks the apply). + * + * The gate-check + saveBoardDefinition + status flip run INSIDE the board + * ADMISSION lock (OUTER) wrapping the save lock (INNER, taken by + * saveBoardDefinition). The admission lock — not the save lock — serializes WIP + * admission, so without it a ticket could enter a changed lane between the + * live-gate and the write (TOCTOU). Holding it across the whole region closes + * that window; reject takes the same lock so it cannot flip a proposal mid-apply. + * All status transitions run under a DB transaction in the read model. + * + * Apply-state durability invariant: once the board file IS the proposed def + * (save succeeded), the proposal MUST be `approved` (revertable) — never left + * pending/rejected/superseded. After the save we mark approved unconditionally; + * if a concurrent reject/supersede slipped the row out of `pending` (affected + * count 0), we RECONCILE by forcing it back to `approved`. + * + * Exported for direct testing and for E8 to register against the WS RPC group. + */ +export const resolveBoardProposal = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "readModel" + | "engine" + | "boardRegistry" + | "projectWorkspaceResolver" + | "fileLoader" + | "workspaceFileSystem" + | "saveLocks" + | "versionStore" + | "predicates" + >, + input: WorkflowResolveBoardProposalInputType, +): Effect.Effect<WorkflowResolveBoardProposalResult, WorkflowRpcError> => + Effect.gen(function* () { + const loaded = yield* deps.readModel + .getBoardProposal(input.proposalId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load board proposal"))); + if (loaded === null) { + return { + ok: false, + reason: "invalid", + message: `Board proposal ${input.proposalId} was not found`, + } satisfies WorkflowResolveBoardProposalResult; + } + + const { view, proposedDefinition, baseDefinition } = loaded; + const boardId = view.boardId as BoardId; + + // Non-pending proposals are not actionable. Returning ok:true here would tell + // the UI the action applied; instead report it is no longer approvable. + if (view.status !== "pending") { + const verb = input.action === "reject" ? "rejectable" : "approvable"; + return { + ok: false, + reason: "invalid", + message: `this proposal is no longer ${verb} (status "${view.status}")`, + } satisfies WorkflowResolveBoardProposalResult; + } + + const resolvedAt = DateTime.formatIso(yield* DateTime.now); + + // ── reject ────────────────────────────────────────────────────────────── + // Reject takes the admission lock too, so it cannot flip a proposal out of + // `pending` while an approve is mid-save (which would orphan an applied board + // change as a non-approved row). + if (input.action === "reject") { + return yield* deps.engine.withBoardAdmissionLock( + boardId, + Effect.gen(function* () { + const affected = yield* deps.readModel + .resolveBoardProposalStatus({ + proposalId: input.proposalId, + status: "rejected", + resolvedAt, + fromStatus: "pending", + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to reject board proposal"))); + // Lost a race against a concurrent resolve — no longer rejectable. + if (affected === 0) { + return { + ok: false, + reason: "invalid", + message: "this proposal is no longer rejectable (it was resolved concurrently)", + } satisfies WorkflowResolveBoardProposalResult; + } + return { + ok: true, + proposal: { ...view, status: "rejected", resolvedAt }, + } satisfies WorkflowResolveBoardProposalResult; + }), + ); + } + + // ── approve ───────────────────────────────────────────────────────────── + // 1. Optimistic concurrency: the board must not have changed since this + // proposal was generated (its base_version_hash vs the CURRENT hash). + if (view.outdated) { + yield* deps.readModel + .resolveBoardProposalStatus({ + proposalId: input.proposalId, + status: "superseded", + resolvedAt, + fromStatus: "pending", + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to supersede board proposal"))); + return { + ok: false, + reason: "conflict", + message: "the board changed since this proposal — re-run", + } satisfies WorkflowResolveBoardProposalResult; + } + + // 2. RE-VALIDATE with current code (preservation + dry-run). A pending + // proposal generated under older/weaker validators must still pass NOW. + const baseDefDecoded = yield* decodeWorkflowDefinition(baseDefinition).pipe( + Effect.mapError(toWorkflowRpcError("Failed to decode proposal base definition")), + ); + const proposedDefDecoded = yield* decodeWorkflowDefinition(proposedDefinition).pipe( + Effect.mapError(toWorkflowRpcError("Failed to decode proposal proposed definition")), + ); + const revalidation = yield* revalidateProposalForApply( + deps, + baseDefDecoded, + proposedDefDecoded, + ); + if (revalidation !== null) { + yield* deps.readModel + .resolveBoardProposalStatus({ + proposalId: input.proposalId, + status: "invalid", + resolvedAt, + fromStatus: "pending", + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to invalidate board proposal"))); + return { + ok: false, + reason: "invalid", + message: revalidation.message, + } satisfies WorkflowResolveBoardProposalResult; + } + + // 3-5. Live-gate + save + status flip, all INSIDE the admission lock (OUTER) + // so no ticket can enter a changed lane between the gate and the write. + // saveBoardDefinition takes the save lock (INNER) — never invert. + return yield* deps.engine.withBoardAdmissionLock( + boardId, + Effect.gen(function* () { + // Live-compatibility gate: a modified lane holding in-flight work blocks + // the apply. The proposal stays pending; saveBoardDefinition is NOT called. + const incompatible = yield* liveIncompatibleLanes( + deps, + boardId, + baseDefinition, + proposedDefinition, + ); + if (incompatible.length > 0) { + return { + ok: false, + reason: "live_tickets", + message: `applying this would disrupt in-flight work in lane(s): ${incompatible.join(", ")} — let them finish or move them, then approve`, + } satisfies WorkflowResolveBoardProposalResult; + } + + // Apply via the SOLE saveBoardDefinition call. expectedVersionHash is the + // proposal's base_version_hash so a stale proposal conflicts (and is + // superseded) rather than clobbering newer changes. + const saveResult = yield* saveBoardDefinition(deps, { + boardId, + definition: proposedDefinition, + expectedVersionHash: view.baseVersionHash, + source: "self-improve", + }); + + if (saveResult.ok === false && "conflict" in saveResult) { + yield* deps.readModel + .resolveBoardProposalStatus({ + proposalId: input.proposalId, + status: "superseded", + resolvedAt, + fromStatus: "pending", + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to supersede board proposal"))); + return { + ok: false, + reason: "conflict", + message: "the board changed since this proposal — re-run", + } satisfies WorkflowResolveBoardProposalResult; + } + + if (saveResult.ok === false) { + // lintErrors — leave the proposal pending so it can be re-examined. + return { + ok: false, + reason: "lint", + message: "the proposed definition failed lint", + lintErrors: saveResult.lintErrors, + } satisfies WorkflowResolveBoardProposalResult; + } + + // SAVE SUCCEEDED → the board file IS the proposed def. The apply-state + // durability invariant requires the proposal be `approved` (revertable) + // from here on. Mark approved + record applied_version_hash. + const affected = yield* deps.readModel + .resolveBoardProposalStatus({ + proposalId: input.proposalId, + status: "approved", + resolvedAt, + appliedVersionHash: saveResult.versionHash, + fromStatus: "pending", + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to mark board proposal approved"))); + + // RECONCILE: if a concurrent reject/supersede slipped the row out of + // `pending` after we wrote the board (affected 0), force it to `approved` + // — the board change is live, so revert must stay available. Reject takes + // the admission lock so this is near-impossible, but the forced write + // closes the residual window deterministically. + if (affected === 0) { + yield* deps.readModel + .resolveBoardProposalStatus({ + proposalId: input.proposalId, + status: "approved", + resolvedAt, + appliedVersionHash: saveResult.versionHash, + }) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to reconcile board proposal to approved")), + ); + } + + return { + ok: true, + proposal: { + ...view, + status: "approved", + resolvedAt, + appliedVersionHash: saveResult.versionHash, + }, + } satisfies WorkflowResolveBoardProposalResult; + }), + ); + }); + +/** + * `revertBoardProposal` — one-click rollback of an APPLIED improvement. + * + * Restores the proposal's retained `base_def_json` (the definition that was + * in effect before the improvement was applied) so a not-working-out + * improvement can be undone. Reuses E6's `saveBoardDefinition` / live-gate / + * `resolveBoardProposalStatus` helpers under the same concurrency model. + * + * Only valid for a proposal in `approved` status (already applied). + * The board-changed-since-apply guard: the board's current versionHash must + * equal `applied_version_hash` stored on the proposal — if it differs, someone + * edited the board after the improvement was applied and we refuse to silently + * discard those later changes. + * + * Exported for direct testing; E8 registers it against the WS RPC group. + */ +export const revertBoardProposal = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "readModel" + | "engine" + | "boardRegistry" + | "projectWorkspaceResolver" + | "fileLoader" + | "workspaceFileSystem" + | "saveLocks" + | "versionStore" + >, + input: WorkflowRevertBoardProposalInputType, +): Effect.Effect<WorkflowRevertBoardProposalResult, WorkflowRpcError> => + Effect.gen(function* () { + const loaded = yield* deps.readModel + .getBoardProposal(input.proposalId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load board proposal"))); + if (loaded === null) { + return { + ok: false, + reason: "invalid", + message: `Board proposal ${input.proposalId} was not found`, + } satisfies WorkflowRevertBoardProposalResult; + } + + const { view, proposedDefinition, baseDefinition } = loaded; + const boardId = view.boardId as BoardId; + + // Only an `approved` (applied) proposal can be reverted. Any other status is + // not actionable → ok:false (never ok:true, which would close the UI as done). + if (view.status !== "approved") { + return { + ok: false, + reason: "invalid", + message: `this proposal is no longer revertable (status "${view.status}"); only an applied (approved) proposal can be reverted`, + } satisfies WorkflowRevertBoardProposalResult; + } + + // Board-changed-since-apply guard: the board's current hash must equal + // the hash recorded at apply time. If it differs, subsequent edits happened + // after the improvement and reverting would silently discard them. + const board = yield* deps.readModel + .getBoard(boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board"))); + if (!board) { + return yield* workflowRpcError(`Workflow board ${boardId} was not found`); + } + if (board.workflowVersionHash !== view.appliedVersionHash) { + return { + ok: false, + reason: "conflict", + message: + "the board changed since this improvement was applied — reverting would discard those changes; revert manually via version history", + } satisfies WorkflowRevertBoardProposalResult; + } + + // Live-gate + save + status flip, all INSIDE the admission lock (OUTER) so no + // ticket can enter a changed lane between the gate and the write. + // saveBoardDefinition takes the save lock (INNER) — never invert. + return yield* deps.engine.withBoardAdmissionLock( + boardId, + Effect.gen(function* () { + // Live-compatibility gate: compare proposed (currently live) vs base + // (target after revert). If a lane the improvement CHANGED now holds live + // work, reverting it would disrupt in-flight work. + const incompatible = yield* liveIncompatibleLanes( + deps, + boardId, + proposedDefinition, + baseDefinition, + ); + if (incompatible.length > 0) { + return { + ok: false, + reason: "live_tickets", + message: `reverting this would disrupt in-flight work in lane(s): ${incompatible.join(", ")} — let them finish or move them, then revert`, + } satisfies WorkflowRevertBoardProposalResult; + } + + // Apply the revert: write base_def_json back. expectedVersionHash is the + // CURRENT board hash (= applied_version_hash, confirmed above) so a + // concurrent edit between the guard and the write still conflicts safely. + const saveResult = yield* saveBoardDefinition(deps, { + boardId, + definition: baseDefinition, + expectedVersionHash: board.workflowVersionHash, + source: "self-improve-revert", + }); + + if (saveResult.ok === false && "conflict" in saveResult) { + return { + ok: false, + reason: "conflict", + message: + "the board changed since this improvement was applied — revert manually via version history", + } satisfies WorkflowRevertBoardProposalResult; + } + + if (saveResult.ok === false) { + // Lint on the base definition — should never happen (it linted before), + // but handle defensively to keep the shape exhaustive. + return { + ok: false, + reason: "lint", + message: "the base definition failed lint during revert (unexpected)", + lintErrors: saveResult.lintErrors, + } satisfies WorkflowRevertBoardProposalResult; + } + + // SAVE SUCCEEDED → the board file IS the base def again. Mark reverted; + // if a concurrent transition raced the row out of `approved`, force it + // (the board is rolled back, so the row must reflect `reverted`). + const resolvedAt = DateTime.formatIso(yield* DateTime.now); + const affected = yield* deps.readModel + .resolveBoardProposalStatus({ + proposalId: input.proposalId, + status: "reverted", + resolvedAt, + fromStatus: "approved", + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to mark board proposal reverted"))); + if (affected === 0) { + yield* deps.readModel + .resolveBoardProposalStatus({ + proposalId: input.proposalId, + status: "reverted", + resolvedAt, + }) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to reconcile board proposal to reverted")), + ); + } + + return { + ok: true, + proposal: { + ...view, + status: "reverted", + resolvedAt, + }, + } satisfies WorkflowRevertBoardProposalResult; + }), + ); + }); + +const toBoardVersionSummary = ( + version: WorkflowBoardVersionSummaryRow, + index: number, +): WorkflowBoardVersionSummary => ({ + versionId: version.versionId, + versionHash: version.versionHash, + source: version.source, + createdAt: version.createdAt, + isCurrent: index === 0, +}); + +const backfillImportedBoardVersion = ( + deps: Pick< + WorkflowRpcHandlerDeps, + "readModel" | "projectWorkspaceResolver" | "workspaceFileSystem" | "versionStore" + >, + boardId: BoardId, +): Effect.Effect<void, WorkflowRpcError> => + Effect.gen(function* () { + const board = yield* deps.readModel + .getBoard(boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board"))); + if (!board) { + return yield* workflowRpcError(`Workflow board ${boardId} was not found`); + } + + const projectId = board.projectId as ProjectId; + const workspaceRoot = yield* deps.projectWorkspaceResolver + .resolve(projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + const contentJson = yield* deps.workspaceFileSystem + .readFileString({ + cwd: workspaceRoot, + relativePath: board.workflowFilePath, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to read workflow board file"))); + const versionHash = sha256Hex(contentJson); + if (versionHash !== board.workflowVersionHash) { + yield* Effect.logWarning("Skipping workflow board version import for stale projection", { + boardId, + projectedVersionHash: board.workflowVersionHash, + fileVersionHash: versionHash, + }); + return; + } + + yield* deps.versionStore + .record({ + boardId, + versionHash, + contentJson, + source: "import", + }) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to record imported workflow board version")), + ); + }); + +const listBoardVersions = ( + deps: Pick< + WorkflowRpcHandlerDeps, + "readModel" | "projectWorkspaceResolver" | "workspaceFileSystem" | "versionStore" | "saveLocks" + >, + input: WorkflowGetBoardDefinitionInput, +): Effect.Effect<ReadonlyArray<WorkflowBoardVersionSummary>, WorkflowRpcError> => + Effect.gen(function* () { + const existing = yield* deps.versionStore + .list(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list workflow board versions"))); + if (existing.length > 0) { + return existing.map(toBoardVersionSummary); + } + + yield* (deps.saveLocks?.withSaveLock ?? ((_boardId, effect) => effect))( + input.boardId, + Effect.gen(function* () { + const lockedExisting = yield* deps.versionStore + .list(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list workflow board versions"))); + if (lockedExisting.length > 0) { + return; + } + yield* backfillImportedBoardVersion(deps, input.boardId); + }), + ); + const imported = yield* deps.versionStore + .list(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list workflow board versions"))); + return imported.map(toBoardVersionSummary); + }); + +const getBoardVersion = ( + deps: Pick<WorkflowRpcHandlerDeps, "versionStore">, + input: WorkflowGetBoardVersionInput, +): Effect.Effect<WorkflowGetBoardVersionResult, WorkflowRpcError> => + Effect.gen(function* () { + const version = yield* deps.versionStore + .get(input.boardId, input.versionId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board version"))); + if (!version) { + return yield* workflowRpcError( + `Workflow board version ${input.versionId} was not found for board ${input.boardId}`, + ); + } + + const definition = yield* decodeWorkflowDefinitionJson(version.contentJson).pipe( + Effect.mapError(toWorkflowRpcError("workflow board version decode failed")), + ); + return { + versionId: version.versionId, + definition: encodeWorkflowDefinition(definition), + versionHash: version.versionHash, + source: version.source, + createdAt: version.createdAt, + }; + }); + +interface WritableWorkflowBoardFile { + readonly board: BoardRow; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly currentRaw: string; +} + +interface PersistedWorkflowBoardDefinition { + readonly _tag: "persisted"; + readonly definition: WorkflowDefinitionEncoded; + readonly versionHash: string; + readonly contentJson: string; +} + +interface WorkflowBoardDefinitionLintFailure { + readonly _tag: "lintErrors"; + readonly lintErrors: ReadonlyArray<WorkflowLintError>; +} + +type PersistWorkflowBoardDefinitionResult = + | PersistedWorkflowBoardDefinition + | WorkflowBoardDefinitionLintFailure; + +const loadWritableWorkflowBoardFile = ( + deps: Pick< + WorkflowRpcHandlerDeps, + "readModel" | "projectWorkspaceResolver" | "workspaceFileSystem" + >, + boardId: BoardId, +): Effect.Effect<WritableWorkflowBoardFile, WorkflowRpcError> => + Effect.gen(function* () { + const board = yield* deps.readModel + .getBoard(boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board"))); + if (!board) { + return yield* workflowRpcError(`Workflow board ${boardId} was not found`); + } + + if (!WORKFLOW_BOARD_FILE_PATH_PATTERN.test(board.workflowFilePath)) { + return yield* workflowRpcError( + `Workflow board ${boardId} is not a writable workflow board file`, + ); + } + + const projectId = board.projectId as ProjectId; + const workspaceRoot = yield* deps.projectWorkspaceResolver + .resolve(projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + const currentRaw = yield* deps.workspaceFileSystem + .readFileString({ + cwd: workspaceRoot, + relativePath: board.workflowFilePath, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to read workflow board file"))); + + return { + board, + projectId, + workspaceRoot, + currentRaw, + }; + }); + +const persistWorkflowBoardDefinition = ( + deps: Pick< + WorkflowRpcHandlerDeps, + "readModel" | "fileLoader" | "workspaceFileSystem" | "versionStore" + >, + input: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly relativePath: string; + readonly definition: WorkflowDefinitionType; + readonly source: WorkflowBoardVersionSource; + readonly notFoundAfterWriteMessage: string; + readonly versionRecording?: "best-effort" | "required"; + }, +): Effect.Effect<PersistWorkflowBoardDefinitionResult, WorkflowRpcError> => + Effect.gen(function* () { + // DoS backstop: bound the definition before the expensive lint + load, + // mirroring the import caps. A legitimate edited board is far below these + // generous ceilings; this stops an operate-scoped client from persisting an + // arbitrarily large definition. The def is already decoded, so stringify is + // safe (no circular refs). (PR review: the save path previously had no caps.) + const contentJson = workflowDefinitionContentJson(input.definition); + const tooLarge = (message: string): PersistWorkflowBoardDefinitionResult => ({ + _tag: "lintErrors", + lintErrors: [{ code: "invalid_step", message }], + }); + // Use the SHARED caps helpers (not a hand-coded copy) so the save path and the + // disk-load path (WorkflowFileLoader.loadAndRegister) can never silently drift. + if (exceedsDefinitionCharCap(contentJson.length)) { + return tooLarge( + `Board definition is too large to save (exceeds ${MAX_IMPORT_DEFINITION_CHARS} characters)`, + ); + } + const laneCapViolation = definitionLaneCapViolation(input.definition); + if (laneCapViolation !== null) { + return tooLarge(laneCapViolation); + } + + const lintErrors = yield* deps.fileLoader + .lintDefinition({ + definition: input.definition, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + }) + .pipe(Effect.mapError(toWorkflowRpcError("workflow lint failed"))); + if (lintErrors.length > 0) { + return { _tag: "lintErrors", lintErrors: lintErrors.map(toContractLintError) }; + } + + // Capture the prior on-disk contents so a post-write failure can roll the + // durable file back to match what is still registered, instead of leaving + // the file ahead of the registry/read-model while the RPC reports an error. + // CRUCIAL: only a genuine file-absence (notFound) reads as null → "brand-new + // board", which the rollback deletes. A transient read error (EACCES / EIO / + // EBUSY) or a path-containment error must ABORT the save BEFORE we write — + // otherwise a later finalize failure would delete a real board file whose + // contents we merely failed to read. + const previousContents = yield* deps.workspaceFileSystem + .readFileString({ cwd: input.workspaceRoot, relativePath: input.relativePath }) + .pipe( + Effect.map((contents): string | null => contents), + Effect.catch((error) => + error._tag === "WorkspaceFileSystemError" && error.notFound === true + ? Effect.succeed<string | null>(null) + : Effect.fail( + toWorkflowRpcError("Failed to read existing workflow board file before save")( + error, + ), + ), + ), + ); + + yield* deps.workspaceFileSystem + .writeFile({ + cwd: input.workspaceRoot, + relativePath: input.relativePath, + contents: contentJson, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to write workflow board file"))); + + // Everything after the durable write either fully succeeds or rolls the file + // back, so a save is all-or-nothing from the caller's perspective. + const finalize = Effect.gen(function* () { + yield* deps.fileLoader + .loadAndRegister({ + boardId: input.boardId, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to register saved workflow board"))); + + const updatedBoard = yield* deps.readModel + .getBoard(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load saved workflow board"))); + if (!updatedBoard) { + return yield* workflowRpcError(input.notFoundAfterWriteMessage); + } + const versionRecordInput = { + boardId: input.boardId, + versionHash: updatedBoard.workflowVersionHash, + contentJson, + source: input.source, + }; + if (input.versionRecording === "required") { + yield* recordBoardVersionRequired(deps, versionRecordInput); + } else { + yield* recordBoardVersionBestEffort(deps, versionRecordInput); + } + + return { + _tag: "persisted" as const, + definition: encodeWorkflowDefinition(input.definition), + versionHash: updatedBoard.workflowVersionHash, + contentJson, + }; + }); + + return yield* finalize.pipe( + Effect.tapError(() => + (previousContents === null + ? deps.workspaceFileSystem.deleteFile({ + cwd: input.workspaceRoot, + relativePath: input.relativePath, + }) + : deps.workspaceFileSystem.writeFile({ + cwd: input.workspaceRoot, + relativePath: input.relativePath, + contents: previousContents, + }) + ).pipe(Effect.ignore), + ), + ); + }); + +const saveBoardDefinition = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "readModel" + | "boardRegistry" + | "projectWorkspaceResolver" + | "fileLoader" + | "workspaceFileSystem" + | "saveLocks" + | "versionStore" + >, + input: WorkflowSaveBoardDefinitionInput, +): Effect.Effect<WorkflowSaveBoardDefinitionResult, WorkflowRpcError> => + (deps.saveLocks?.withSaveLock ?? ((_boardId, effect) => effect))( + input.boardId, + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition(input.definition).pipe( + Effect.mapError(toWorkflowRpcError("workflow definition decode failed")), + ); + // NOTE: save deliberately runs lint + caps (via persistWorkflowBoardDefinition) + // but NOT the create-time dead-end/reachability dry-run gate. Save is the + // iterative editor flow (visual canvas editor, self-improve apply) where an + // author legitimately passes through transient or intentionally-incomplete + // states; the dry-run heuristic must not hard-block those. The from-scratch + // create/import path keeps the gate. If a future product decision wants save + // to also reject dead-ends, thread `predicates` into these deps and reuse + // dryRunDeadEndLanes here. + const boardFile = yield* loadWritableWorkflowBoardFile(deps, input.boardId); + const currentVersionHash = sha256Hex(boardFile.currentRaw); + if (currentVersionHash !== input.expectedVersionHash) { + return { + ok: false, + conflict: true, + currentVersionHash, + }; + } + + const persisted = yield* persistWorkflowBoardDefinition(deps, { + boardId: input.boardId, + projectId: boardFile.projectId, + workspaceRoot: boardFile.workspaceRoot, + relativePath: boardFile.board.workflowFilePath, + definition, + source: input.source ?? "save", + notFoundAfterWriteMessage: `Workflow board ${input.boardId} was not found after save`, + }); + if (persisted._tag === "lintErrors") { + return { ok: false, lintErrors: persisted.lintErrors }; + } + + const snapshot = yield* boardSnapshot(deps, input.boardId); + return { + ok: true, + definition: persisted.definition, + versionHash: persisted.versionHash, + snapshot, + }; + }), + ); + +const renameBoard = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "readModel" + | "boardRegistry" + | "projectWorkspaceResolver" + | "fileLoader" + | "workspaceFileSystem" + | "saveLocks" + | "versionStore" + >, + input: WorkflowRenameBoardHandlerInput, +): Effect.Effect<void, WorkflowRpcError> => + decodeWorkflowRenameBoardInput(input).pipe( + Effect.mapError(toWorkflowRpcError("workflow board rename input decode failed")), + Effect.flatMap((decoded) => + (deps.saveLocks?.withSaveLock ?? ((_boardId, effect) => effect))( + decoded.boardId, + Effect.gen(function* () { + const boardFile = yield* loadWritableWorkflowBoardFile(deps, decoded.boardId); + const currentDefinition = yield* decodeWorkflowDefinitionJson(boardFile.currentRaw).pipe( + Effect.mapError(toWorkflowRpcError("workflow board file decode failed")), + ); + if (currentDefinition.name === decoded.name) { + const fileVersionHash = sha256Hex(boardFile.currentRaw); + const registeredDefinition = yield* deps.boardRegistry.getDefinition(decoded.boardId); + const registeredDefinitionHash = + registeredDefinition === null + ? null + : workflowDefinitionVersionHash(registeredDefinition); + const currentDefinitionHash = workflowDefinitionVersionHash(currentDefinition); + const versions = yield* deps.versionStore + .list(decoded.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list workflow board versions"))); + const projectionIsCurrent = boardFile.board.workflowVersionHash === fileVersionHash; + const registryIsCurrent = registeredDefinitionHash === currentDefinitionHash; + const historyIsCurrent = versions[0]?.versionHash === fileVersionHash; + if (projectionIsCurrent && registryIsCurrent && historyIsCurrent) { + return; + } + + if (!projectionIsCurrent || !registryIsCurrent) { + yield* deps.fileLoader + .loadAndRegister({ + boardId: decoded.boardId, + projectId: boardFile.projectId, + workspaceRoot: boardFile.workspaceRoot, + relativePath: boardFile.board.workflowFilePath, + }) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to register saved workflow board")), + ); + + const updatedBoard = yield* deps.readModel + .getBoard(decoded.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load saved workflow board"))); + if (!updatedBoard) { + return yield* workflowRpcError( + `Workflow board ${decoded.boardId} was not found after rename`, + ); + } + } + + if (!historyIsCurrent) { + yield* recordBoardVersionRequired(deps, { + boardId: decoded.boardId, + versionHash: fileVersionHash, + contentJson: boardFile.currentRaw, + source: "rename", + }); + } + return; + } + + const persisted = yield* persistWorkflowBoardDefinition(deps, { + boardId: decoded.boardId, + projectId: boardFile.projectId, + workspaceRoot: boardFile.workspaceRoot, + relativePath: boardFile.board.workflowFilePath, + definition: { ...currentDefinition, name: decoded.name }, + source: "rename", + notFoundAfterWriteMessage: `Workflow board ${decoded.boardId} was not found after rename`, + versionRecording: "required", + }); + if (persisted._tag === "lintErrors") { + return yield* workflowRpcError( + `Workflow lint failed: ${persisted.lintErrors.map((error) => error.code).join(", ")}`, + ); + } + }), + ), + ), + ); + +/** + * List the board templates the create-workflow wizard offers. A pure mapping + * over the static registry — exported for direct testing and for a later task + * to register in the WS RPC group. + */ +export const listBoardTemplates = (): Effect.Effect< + WorkflowListBoardTemplatesResult, + WorkflowRpcError +> => + Effect.succeed({ + templates: [...listBoardTemplateSummaries()], + } satisfies WorkflowListBoardTemplatesResult); + +/** + * Workflow RPC methods that MUTATE durable state (event store, board files, + * registry, connections, proposals). These are gated behind startup/recovery + * readiness so a client cannot create/move/run/save/connect while recovery is + * still reconciling or has failed. Reads, streams, dry-runs, and no-tool draft + * generation are intentionally NOT listed — they run ungated. + */ +const MUTATING_METHODS: ReadonlySet<string> = new Set([ + WORKFLOW_WS_METHODS.createBoard, + WORKFLOW_WS_METHODS.importBoard, + WORKFLOW_WS_METHODS.createWorkflowBoard, + WORKFLOW_WS_METHODS.deleteBoard, + WORKFLOW_WS_METHODS.renameBoard, + WORKFLOW_WS_METHODS.saveBoardDefinition, + WORKFLOW_WS_METHODS.createTicket, + WORKFLOW_WS_METHODS.editTicket, + WORKFLOW_WS_METHODS.moveTicket, + WORKFLOW_WS_METHODS.runLane, + WORKFLOW_WS_METHODS.resolveApproval, + WORKFLOW_WS_METHODS.answerTicketStep, + WORKFLOW_WS_METHODS.postTicketMessage, + WORKFLOW_WS_METHODS.editTicketMessage, + WORKFLOW_WS_METHODS.setProjectScriptTrust, + WORKFLOW_WS_METHODS.cancelStep, + // getWebhookConfig can rotate (write) the token, so treat it as mutating. + WORKFLOW_WS_METHODS.getWebhookConfig, + WORKFLOW_WS_METHODS.intakeTickets, + WORKFLOW_WS_METHODS.createWorkSourceConnection, + WORKFLOW_WS_METHODS.deleteWorkSourceConnection, + WORKFLOW_WS_METHODS.createOutboundConnection, + WORKFLOW_WS_METHODS.deleteOutboundConnection, + WORKFLOW_WS_METHODS.proposeBoardImprovement, + WORKFLOW_WS_METHODS.resolveBoardProposal, + WORKFLOW_WS_METHODS.revertBoardProposal, + WORKFLOW_WS_METHODS.importWorkItems, +]); + +export const workflowRpcHandlers = (deps: WorkflowRpcHandlerDeps) => { + const handlers = { + [WORKFLOW_WS_METHODS.listBoards]: (input: { readonly projectId: ProjectId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.listBoards, + deps.boardDiscovery.discover(input.projectId), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.createBoard]: (input: WorkflowCreateBoardHandlerInput) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.createBoard, createBoard(deps, input), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.importBoard]: (input: WorkflowImportBoardInputType) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.importBoard, importBoard(deps, input), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.createWorkflowBoard]: (input: WorkflowCreateWorkflowBoardInputType) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.createWorkflowBoard, + createWorkflowBoard(deps, input), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.generateWorkflowDraft]: (input: WorkflowGenerateWorkflowDraftInputType) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.generateWorkflowDraft, + generateWorkflowDraft(deps, input), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.listBoardTemplates]: (_input: Record<string, never>) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.listBoardTemplates, listBoardTemplates(), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.deleteBoard]: (input: WorkflowDeleteBoardInput) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.deleteBoard, deleteBoard(deps, input), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.renameBoard]: (input: WorkflowRenameBoardHandlerInput) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.renameBoard, renameBoard(deps, input), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.getBoard]: (input: { readonly boardId: BoardId }) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.getBoard, boardSnapshot(deps, input.boardId), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.getBoardDefinition]: (input: WorkflowGetBoardDefinitionInput) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.getBoardDefinition, + getBoardDefinition(deps, input), + { + "rpc.aggregate": "workflow", + }, + ), + [WORKFLOW_WS_METHODS.saveBoardDefinition]: (input: WorkflowSaveBoardDefinitionInput) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.saveBoardDefinition, + saveBoardDefinition(deps, input), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.listBoardVersions]: (input: WorkflowGetBoardDefinitionInput) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.listBoardVersions, listBoardVersions(deps, input), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.getBoardVersion]: (input: WorkflowGetBoardVersionInput) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.getBoardVersion, getBoardVersion(deps, input), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.subscribeBoard]: (input: { readonly boardId: BoardId }) => + deps.observeRpcStreamEffect( + WORKFLOW_WS_METHODS.subscribeBoard, + Effect.succeed( + // Subscribe to live board events BEFORE reading the snapshot so a ticket + // update committed/published during the snapshot read is buffered in the + // subscription and replayed after the snapshot, rather than lost in the + // gap between the read finishing and a lazy `Stream.fromPubSub` + // subscription activating. A ticket already in the snapshot that also + // arrives on the live stream is a benign duplicate (client upserts by id). + Stream.unwrap( + Effect.gen(function* () { + const live = yield* deps.boardEvents.subscribe(input.boardId); + const snapshot = yield* boardSnapshot(deps, input.boardId); + return Stream.concat( + Stream.make({ kind: "snapshot" as const, snapshot }), + live.pipe(Stream.map((ticket) => ({ kind: "ticket" as const, ticket }))), + ); + }), + ), + ), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.createTicket]: (input: WorkflowCreateTicketInput) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.createTicket, + deps.engine + .createTicket({ + boardId: input.boardId, + title: input.title, + initialLane: input.initialLane, + ...(input.description === undefined ? {} : { description: input.description }), + ...(input.dependsOn === undefined ? {} : { dependsOn: input.dependsOn }), + ...(input.tokenBudget === undefined ? {} : { tokenBudget: input.tokenBudget }), + }) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to create workflow ticket")), + Effect.map((ticketId) => ({ ticketId })), + ), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.editTicket]: (input: WorkflowEditTicketInput) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.editTicket, + deps.engine + .editTicket(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to edit workflow ticket"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.moveTicket]: (input: { + readonly ticketId: TicketId; + readonly toLane: LaneKey; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.moveTicket, + deps.engine + .moveTicket(input.ticketId, input.toLane) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to move workflow ticket"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.runLane]: (input: { readonly ticketId: TicketId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.runLane, + deps.engine + .runLane(input.ticketId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to run workflow lane"))), + { + "rpc.aggregate": "workflow", + }, + ), + [WORKFLOW_WS_METHODS.resolveApproval]: (input: { + readonly stepRunId: StepRunId; + readonly approved: boolean; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.resolveApproval, + deps.engine + .resolveApproval(input.stepRunId, input.approved) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow approval"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.answerTicketStep]: (input: WorkflowAnswerTicketStepInput) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.answerTicketStep, + deps.engine + .answerTicketStep(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to answer workflow ticket step"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.postTicketMessage]: (input: { + readonly ticketId: TicketId; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray<TicketAttachment> | undefined; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.postTicketMessage, + deps.engine + .postTicketMessage(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to post workflow ticket message"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.editTicketMessage]: (input: { + readonly ticketId: TicketId; + readonly messageId: MessageId; + readonly body: string; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.editTicketMessage, + deps.engine + .editTicketMessage(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to edit workflow ticket message"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.setProjectScriptTrust]: (input: { + readonly projectId: ProjectId; + readonly trusted: boolean; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.setProjectScriptTrust, + deps.projectScriptTrust + .setTrusted(input.projectId, input.trusted) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to update project script trust"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.cancelStep]: (input: { readonly stepRunId: StepRunId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.cancelStep, + deps.engine + .cancelStep(input.stepRunId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to cancel workflow step"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.getTicketDetail]: (input: { readonly ticketId: TicketId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.getTicketDetail, + ticketDetail(deps, input.ticketId), + { + "rpc.aggregate": "workflow", + }, + ), + [WORKFLOW_WS_METHODS.getTicketDiff]: (input: { readonly ticketId: TicketId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.getTicketDiff, + deps.ticketWorktrees + .resolveForTicket(input.ticketId) + .pipe( + Effect.flatMap(({ cwd, baseRef }) => + deps.ticketDiff + .getTicketDiff(input.ticketId, cwd, baseRef) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow ticket diff"))), + ), + ), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.listTicketArtifacts]: (input: { readonly ticketId: TicketId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.listTicketArtifacts, + Effect.gen(function* () { + const worktree = yield* deps.ticketWorktrees.resolveForTicket(input.ticketId); + const scratchDir = `.t3/ticket/${input.ticketId}`; + // Recurse so nested scratch (design/SPEC.md, handoff/x.md) is visible, + // not just direct files. Fall back to the flat listing when a + // lightweight mock omits the recursive method. + const listRecursive = deps.workspaceFileSystem.listFilesRecursive; + const names = yield* ( + listRecursive + ? listRecursive({ cwd: worktree.cwd, relativePath: scratchDir }) + : deps.workspaceFileSystem.listFiles({ cwd: worktree.cwd, relativePath: scratchDir }) + ).pipe(Effect.mapError(toWorkflowRpcError("Failed to list ticket artifacts"))); + const artifacts: Array<{ + readonly name: string; + readonly content: string; + readonly truncated?: boolean; + }> = []; + for (const name of names.slice(0, MAX_TICKET_ARTIFACTS)) { + const relativePath = `${scratchDir}/${name}`; + // Bound the read so a large artifact can't force a full-memory read + // over this RPC. Fall back to the unbounded read only when the capped + // method is unavailable (lightweight mocks). + const cappedRead = deps.workspaceFileSystem.readFileStringCapped; + const content = yield* ( + cappedRead + ? cappedRead({ + cwd: worktree.cwd, + relativePath, + maxBytes: MAX_TICKET_ARTIFACT_READ_BYTES, + }) + : deps.workspaceFileSystem.readFileString({ cwd: worktree.cwd, relativePath }) + ).pipe(Effect.mapError(toWorkflowRpcError("Failed to read ticket artifact"))); + artifacts.push({ + name, + content: content.slice(0, MAX_TICKET_ARTIFACT_CHARS), + ...(content.length > MAX_TICKET_ARTIFACT_CHARS ? { truncated: true } : {}), + }); + } + return { artifacts }; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.getBoardDigest]: (input: { + readonly boardId: BoardId; + readonly windowHours?: number | undefined; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.getBoardDigest, + Effect.gen(function* () { + const windowHours = + input.windowHours === undefined || !Number.isFinite(input.windowHours) + ? 24 + : Math.min(24 * 7, Math.max(1, Math.floor(input.windowHours))); + const digest = yield* deps.readModel + .getBoardDigest(input.boardId, windowHours) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to compute board digest"))); + return { + windowHours: digest.windowHours, + createdCount: digest.createdCount, + shippedCount: digest.shippedCount, + totalTokens: digest.totalTokens, + totalDurationMs: digest.totalDurationMs, + needsAttention: digest.needsAttention.map((row) => ({ + ticketId: row.ticketId as TicketId, + title: row.title, + status: row.status, + laneKey: row.laneKey as LaneKey, + sinceMs: Math.max(0, Math.floor(row.sinceMs)), + })), + }; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.getBoardMetrics]: (input: { + readonly boardId: BoardId; + readonly windowDays?: number | undefined; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.getBoardMetrics, + Effect.gen(function* () { + const VALID_WINDOW_DAYS = [1, 7, 30] as const; + const windowDays = + input.windowDays !== undefined && + VALID_WINDOW_DAYS.includes(input.windowDays as (typeof VALID_WINDOW_DAYS)[number]) + ? input.windowDays + : 7; + const metrics = yield* deps.readModel + .getBoardMetrics(input.boardId, windowDays) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to compute board metrics"))); + return metrics; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.dryRunBoard]: (input: { + readonly definition: WorkflowDefinitionEncoded; + readonly startLane: LaneKey; + readonly scenario: WorkflowDryRunScenario; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.dryRunBoard, + Effect.gen(function* () { + const predicates = deps.predicates; + if (predicates === undefined) { + return yield* workflowRpcError("Dry run is not available on this server"); + } + // Read-scoped callers send arbitrary definitions — bound the work + // before decoding so a huge payload cannot burn CPU/memory. + // The JSON.stringify call is wrapped in a try/catch because a pathologically + // deep object can throw a RangeError before the length comparison — treat + // that as "too large" so we always return a clean error response. + let definitionJsonLength: number; + // @effect-diagnostics-next-line tryCatchInEffectGen:off — synchronous size probe; not an Effect failure + try { + // @effect-diagnostics-next-line preferSchemaOverJson:off — pure size probe, not parsing + definitionJsonLength = JSON.stringify(input.definition).length; + } catch { + return yield* workflowRpcError("Workflow definition is too large to dry-run"); + } + if ( + definitionJsonLength > MAX_DRY_RUN_DEFINITION_CHARS || + input.definition.lanes.length > MAX_DRY_RUN_LANES || + input.definition.lanes.some( + (lane) => + (lane.pipeline?.length ?? 0) > MAX_DRY_RUN_PER_LANE || + (lane.transitions?.length ?? 0) > MAX_DRY_RUN_PER_LANE || + (lane.onEvent?.length ?? 0) > MAX_DRY_RUN_PER_LANE, + ) + ) { + return yield* workflowRpcError("Workflow definition is too large to dry-run"); + } + const definition = yield* Schema.decodeUnknownEffect(WorkflowDefinition)( + input.definition, + ).pipe(Effect.mapError(toWorkflowRpcError("Workflow definition is invalid"))); + if ( + !definition.lanes.some((lane) => (lane.key as string) === (input.startLane as string)) + ) { + return yield* workflowRpcError(`Start lane "${input.startLane}" was not found`); + } + return yield* simulateBoardRoute({ + definition, + startLane: input.startLane, + scenario: input.scenario, + evaluator: predicates, + }); + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.getWebhookConfig]: (input: { + readonly boardId: BoardId; + readonly rotate?: boolean | undefined; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.getWebhookConfig, + Effect.gen(function* () { + const webhook = deps.webhook; + if (webhook === undefined) { + return yield* workflowRpcError("Webhooks are not available on this server"); + } + const board = yield* deps.readModel + .getBoard(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board"))); + if (board === null) { + return yield* workflowRpcError(`Workflow board ${input.boardId} was not found`); + } + const config = yield* webhook + .getConfig(input.boardId, input.rotate === true) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load webhook config"))); + return { + path: config.path, + hasToken: config.hasToken, + ...(config.tokenPrefix === undefined ? {} : { tokenPrefix: config.tokenPrefix }), + ...(config.token === undefined ? {} : { token: config.token }), + }; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.listNeedsAttentionTickets]: (_input: Record<string, never>) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.listNeedsAttentionTickets, + Effect.gen(function* () { + const rows = yield* deps.readModel + .listNeedsAttentionTickets() + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list needs-attention tickets"))); + return rows.map( + (row): WorkflowNeedsAttentionTicketView => ({ + ticketId: row.ticketId as never, + boardId: row.boardId as never, + boardName: row.boardName, + title: row.title, + status: row.status as never, + currentLaneKey: row.currentLaneKey as never, + // Clamp the raw projection_ticket.attention_kind (plain TEXT, no DB + // CHECK) to the contract's literal domain before the cast. + attentionKind: validAttentionKind(row.attentionKind) as never, + attentionReason: row.attentionReason, + updatedAt: row.updatedAt, + }), + ); + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.intakeTickets]: (input: { + readonly boardId: BoardId; + readonly braindump: string; + readonly agent: AgentSelection; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.intakeTickets, + Effect.gen(function* () { + const intake = deps.intake; + if (intake === undefined) { + return yield* workflowRpcError("Ticket intake is not available on this server"); + } + const proposals = yield* intake + .proposeTickets(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to propose tickets from braindump"))); + return { proposals: [...proposals] } satisfies WorkflowIntakeResult; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.listWorkSourceConnections]: (_input: Record<string, never>) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.listWorkSourceConnections, + deps.connectionStore + .list() + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list work-source connections"))), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.createWorkSourceConnection]: (input: { + readonly provider: WorkSourceProviderName; + readonly displayName: string; + readonly token: string; + readonly authMode?: "pat" | "basic" | "bearer" | undefined; + readonly baseUrl?: string | undefined; + readonly email?: string | undefined; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.createWorkSourceConnection, + Effect.gen(function* () { + const view = yield* deps.connectionStore + .create(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to create work-source connection"))); + return view satisfies WorkSourceConnectionView; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.deleteWorkSourceConnection]: (input: { readonly connectionRef: string }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.deleteWorkSourceConnection, + deps.connectionStore + .remove(input.connectionRef) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to delete work-source connection"))), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.listOutboundConnections]: (_input: Record<string, never>) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.listOutboundConnections, + Effect.gen(function* () { + const store = deps.outboundConnectionStore; + if (store === undefined) { + return yield* workflowRpcError("Outbound connections are not available on this server"); + } + const connections = yield* store + .list() + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list outbound connections"))); + return { connections: [...connections] satisfies ReadonlyArray<OutboundConnectionView> }; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.createOutboundConnection]: (input: CreateOutboundConnectionInput) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.createOutboundConnection, + Effect.gen(function* () { + const store = deps.outboundConnectionStore; + if (store === undefined) { + return yield* workflowRpcError("Outbound connections are not available on this server"); + } + const connection = yield* store + .create(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to create outbound connection"))); + return { connection } satisfies { connection: OutboundConnectionView }; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.deleteOutboundConnection]: (input: { readonly connectionRef: string }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.deleteOutboundConnection, + Effect.gen(function* () { + const store = deps.outboundConnectionStore; + if (store === undefined) { + return yield* workflowRpcError("Outbound connections are not available on this server"); + } + yield* store + .remove(input.connectionRef) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to delete outbound connection"))); + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.proposeBoardImprovement]: ( + input: WorkflowProposeBoardImprovementInputType, + ) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.proposeBoardImprovement, + proposeBoardImprovement(deps, input), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.listBoardProposals]: (input: WorkflowListBoardProposalsInputType) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.listBoardProposals, + listBoardProposals(deps, input), + { + "rpc.aggregate": "workflow", + }, + ), + + [WORKFLOW_WS_METHODS.getBoardProposal]: (input: WorkflowGetBoardProposalInputType) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.getBoardProposal, getBoardProposal(deps, input), { + "rpc.aggregate": "workflow", + }), + + [WORKFLOW_WS_METHODS.resolveBoardProposal]: (input: WorkflowResolveBoardProposalInputType) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.resolveBoardProposal, + resolveBoardProposal(deps, input), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.revertBoardProposal]: (input: WorkflowRevertBoardProposalInputType) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.revertBoardProposal, + revertBoardProposal(deps, input), + { "rpc.aggregate": "workflow" }, + ), + + // ── Import picker RPCs (B3/B4 implement the real logic) ────────────────── + // Stubs that gate on the optional deps being present. B3 replaces + // listImportableWorkItems; B4 replaces importWorkItems. + [WORKFLOW_WS_METHODS.listImportableWorkItems]: (input: { readonly boardId: BoardId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.listImportableWorkItems, + Effect.gen(function* () { + const providers = deps.workSourceProviders; + if (providers === undefined) { + return yield* workflowRpcError("work-source providers are not configured"); + } + const definition = yield* deps.boardRegistry + .getDefinition(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load board"))); + if (definition === null) return yield* workflowRpcError("board not found"); + const sources = definition.sources ?? []; + + const mappingRows = yield* deps.readModel + .listWorkSourceMappingsForBoard(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load mappings"))); + // Key: "{provider}:{sourceId}:{externalId}" → { ticketId, lane } + const mappingIndex = new Map( + mappingRows.map( + (m) => + [ + `${m.provider}:${m.sourceId}:${m.externalId}`, + { ticketId: m.ticketId, lane: m.currentLaneKey }, + ] as const, + ), + ); + + const items: Array<ListImportableWorkItemsResult["items"][number]> = []; + const sourceSummaries: Array<ListImportableWorkItemsResult["sources"][number]> = []; + const viewer: Record<string, { id: string; aliases: ReadonlyArray<string> } | null> = {}; + const truncated: Record<string, boolean> = {}; + const sourceErrors: Record<string, string> = {}; + + for (const source of sources) { + const sourceId = String(source.id); + const provider = providers.get(source.provider); + // Effect.result preserves the typed WorkSourceProviderError on the + // Failure branch (vs Effect.exit + Cause.squash, which would erase + // the type and lie on an Effect.die). Mirrors WorkflowSourceSyncer. + const scanResult = yield* scanSource(provider, source, undefined).pipe(Effect.result); + if (scanResult._tag === "Failure") { + sourceErrors[sourceId] = describeWorkSourceProviderError(scanResult.failure); + continue; + } + const scan = scanResult.success; + truncated[sourceId] = !scan.scanCompleted; + const viewerResult = yield* provider + .viewer({ connectionRef: source.connectionRef }) + .pipe(Effect.orElseSucceed(() => null)); + viewer[sourceId] = viewerResult; + + // Only surface a source in `sources` when it has ≥1 scanned item: + // a zero-item source has nothing importable, and its container label + // is derived from the first item — falling back to the opaque source + // UUID would render as a garbage label. `truncated`/`sourceErrors` + // stay set for every successfully-scanned source above. + const firstItem = scan.items[0]; + if (firstItem !== undefined) { + sourceSummaries.push({ + sourceId, + provider: source.provider, + container: provider.toImportableView({ + selector: source.selector, + item: firstItem, + }).container, + destinationLane: source.destinationLane, + }); + } + + for (const item of scan.items) { + const parts = provider.toImportableView({ selector: source.selector, item }); + const m = mappingIndex.get(`${item.provider}:${sourceId}:${item.externalId}`) ?? null; + items.push({ + provider: item.provider, + sourceId, + externalId: item.externalId, + displayRef: parts.displayRef, + title: item.fields.title, + container: parts.container, + url: item.url, + assignees: [...(item.fields.assignees ?? [])], + lifecycle: item.lifecycle, + mappedTicketId: (m?.ticketId ?? null) as TicketId | null, + mappedLane: (m?.lane ?? null) as LaneKey | null, + }); + } + } + return { + items, + sources: sourceSummaries, + viewer, + truncated, + sourceErrors, + } satisfies ListImportableWorkItemsResult; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.importWorkItems]: (input: { + readonly boardId: BoardId; + readonly sourceId: string; + readonly externalIds: ReadonlyArray<string>; + readonly destinationLane?: LaneKey | undefined; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.importWorkItems, + Effect.gen(function* () { + const providers = deps.workSourceProviders; + const committer = deps.sourceCommitter; + if (providers === undefined || committer === undefined) { + return yield* workflowRpcError("work-source import is not configured"); + } + const definition = yield* deps.boardRegistry + .getDefinition(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load board"))); + if (definition === null) return yield* workflowRpcError("board not found"); + const source = (definition.sources ?? []).find((s) => String(s.id) === input.sourceId); + if (source === undefined) + return yield* workflowRpcError("source not found on this board"); + const sourceId = String(source.id); + const provider = providers.get(source.provider); + const lanes = { + destinationLane: input.destinationLane ?? source.destinationLane, + closedLane: source.closedLane, + }; + + // 1) Authoritative in-scope candidate set — re-scan applies the source's + // selector filters server-side (closes the selector-escape hole). + // Use Effect.result (NOT Effect.exit/Cause.squash) per B3 + syncer precedent. + const scanResult = yield* scanSource(provider, source, undefined).pipe(Effect.result); + if (scanResult._tag === "Failure") { + return yield* workflowRpcError(describeWorkSourceProviderError(scanResult.failure)); + } + const inScope = new Map(scanResult.success.items.map((i) => [i.externalId, i] as const)); + + // 2) Before-state mapping index — items already on the board. + const beforeRows = yield* deps.readModel + .listWorkSourceMappingsForBoard(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load mappings"))); + const beforeKeys = new Set( + beforeRows.map((m) => `${m.provider}:${m.sourceId}:${m.externalId}`), + ); + + // 3) Partition the requested ids (deduped) into deltas / skipped. + const skipped: Array<{ externalId: string; reason: string }> = []; + const deltas: Array<SourceDelta> = []; + const attempted: Array<string> = []; + for (const id of new Set(input.externalIds)) { + const key = `${source.provider}:${sourceId}:${id}`; + if (beforeKeys.has(key)) { + skipped.push({ externalId: id, reason: "already on board" }); + continue; + } + const item = inScope.get(id); + if (item === undefined) { + skipped.push({ + externalId: id, + reason: "not in source (out of scope or beyond scan window)", + }); + continue; + } + attempted.push(id); + deltas.push(buildNewSourceDelta(sourceId, item)); + } + + // 4) Chunk + reconcile — same chunk size as the syncer. + // NO outer save lock: reconcileChunk owns admission→save→tx internally; + // double-locking would deadlock. + for (const chunk of chunkArray(deltas, MAX_DELTAS_PER_RECONCILE_CHUNK)) { + const chunkResult = yield* committer + .reconcileChunk(input.boardId, lanes, chunk) + .pipe(Effect.result); + if (chunkResult._tag === "Failure") { + // A failed chunk leaves its items unmapped → they fall through to + // "import failed" in step 5. We log but do NOT fail the whole RPC. + yield* Effect.logError("importWorkItems reconcileChunk failed", chunkResult.failure); + } + } + + // 5) After-state: report ids now present in the mapping projection. + // We cannot distinguish "we imported it" from "a racing syncer beat us", + // which is fine — the item is on the board either way. + const afterRows = yield* deps.readModel + .listWorkSourceMappingsForBoard(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load mappings"))); + const afterIndex = new Map( + afterRows.map( + (m) => [`${m.provider}:${m.sourceId}:${m.externalId}`, m.ticketId] as const, + ), + ); + const imported: Array<{ externalId: string; ticketId: TicketId }> = []; + for (const id of attempted) { + const ticketId = afterIndex.get(`${source.provider}:${sourceId}:${id}`); + if (ticketId !== undefined) { + imported.push({ externalId: id, ticketId: ticketId as TicketId }); + } else { + skipped.push({ externalId: id, reason: "import failed" }); + } + } + return { imported, skipped } satisfies ImportWorkItemsResult; + }), + { "rpc.aggregate": "workflow" }, + ), + }; + + const gate = deps.gate; + if (gate === undefined) { + return handlers; + } + // Wrap the mutating handlers so their effect first awaits startup/recovery + // readiness. Reads/streams pass through untouched. (All MUTATING_METHODS + // handlers return Effects — the only Stream handler, subscribeBoard, is a read.) + const gated = { ...handlers } as Record<string, (input: never) => unknown>; + for (const method of MUTATING_METHODS) { + const handler = (handlers as Record<string, ((input: never) => unknown) | undefined>)[method]; + if (handler === undefined) { + continue; + } + const effectHandler = handler as (input: never) => Effect.Effect<unknown, unknown, unknown>; + // @effect-diagnostics-next-line anyUnknownInErrorContext:off -- generic gate wrapper over heterogeneous handler effects + gated[method] = (input: never) => gate(effectHandler(input)); + } + return gated as unknown as typeof handlers; +}; diff --git a/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts new file mode 100644 index 00000000000..fabf15277e8 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts @@ -0,0 +1,1102 @@ +import { assert, it } from "@effect/vitest"; +import type { TerminalEvent } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { ProjectScriptTrust } from "../Services/ProjectScriptTrust.ts"; +import { SetupTerminalPort } from "../Services/SetupRunService.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { MergeGitPort } from "../Services/TicketMergeService.ts"; +import { TicketPullRequestService } from "../Services/TicketPullRequestService.ts"; +import { GitHubCli } from "../../sourceControl/GitHubCli.ts"; +import { SourceControlProviderRegistry } from "../../sourceControl/SourceControlProviderRegistry.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { WorktreePort } from "../Services/WorktreePort.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowRecovery } from "../Services/WorkflowRecovery.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { MockAcpProvider, MockAcpProviderLive } from "./MockAcpProvider.ts"; +import { WorkflowRuntimeCoreLive } from "../WorkflowRuntimeLive.ts"; + +const definition = { + name: "runtime-wf", + lanes: [ + { + key: "code", + name: "Code", + entry: "auto", + pipeline: [ + { + key: "code-step", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Write the code", + }, + ], + on: { success: "review", failure: "code" }, + }, + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [ + { + key: "review-step", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Review the code", + }, + ], + on: { success: "done", failure: "code" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const smartRoutingDefinition = { + name: "smart-routing-runtime-wf", + lanes: [ + { + key: "impl", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "tests", + type: "script", + run: "pnpm test", + allowFailure: true, + }, + { + key: "review", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Review the test result", + captureOutput: true, + }, + ], + transitions: [ + { + when: { + and: [ + { "!=": [{ var: "steps.tests.exitCode" }, 0] }, + { "==": [{ var: "steps.review.output.verdict" }, "block"] }, + ], + }, + to: "needs", + }, + ], + on: { success: "done" }, + }, + { key: "needs", name: "Needs Attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const wipDrainDefinition = { + name: "wip-runtime-wf", + lanes: [ + { + key: "build", + name: "Build", + entry: "auto", + wipLimit: 1, + pipeline: [ + { + key: "build-step", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Build the ticket", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const terminalManagerLayer = (scriptExitCode: number) => + Layer.effect( + TerminalManager, + Effect.gen(function* () { + const listeners = yield* Ref.make< + ReadonlyArray<(event: TerminalEvent) => Effect.Effect<void>> + >([]); + + return TerminalManager.of({ + open: (input) => + Effect.succeed({ + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + status: "running", + } as never), + attachStream: () => Effect.die("unused terminal.attachStream"), + attachHistoryStream: () => Effect.die("unused terminal.attachHistoryStream"), + write: (input) => + Ref.get(listeners).pipe( + Effect.flatMap((current) => + Effect.forEach( + current, + (listener) => + listener({ + type: "exited", + threadId: input.threadId, + terminalId: input.terminalId, + exitCode: scriptExitCode, + exitSignal: null, + } as never), + { discard: true }, + ), + ), + ), + resize: () => Effect.void, + clear: () => Effect.void, + restart: () => Effect.die("unused terminal.restart"), + close: () => Effect.void, + getSnapshot: () => Effect.succeed(null), + subscribe: (listener) => + Ref.update(listeners, (current) => [...current, listener as never]).pipe( + Effect.as(() => undefined), + ), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + }), + ); + +const makeRuntimeLayer = (scriptExitCode: number) => + WorkflowRuntimeCoreLive.pipe( + Layer.provideMerge(MockAcpProviderLive), + Layer.provideMerge(terminalManagerLayer(scriptExitCode)), + Layer.provideMerge( + Layer.succeed(SetupTerminalPort, { + launch: () => Effect.succeed({ threadId: "workflow-setup:stub", terminalId: null }), + awaitExit: () => Effect.succeed({ exitCode: 0 }), + }), + ), + Layer.provideMerge( + Layer.effect( + WorktreePort, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return WorktreePort.of({ + ensureWorktree: (ticketId) => + Effect.gen(function* () { + const worktreePath = yield* fileSystem + .makeTempDirectory({ + prefix: `t3-runtime-${ticketId}-`, + }) + .pipe( + Effect.mapError( + (cause) => + new WorkflowEventStoreError({ + message: "test worktree tempdir failed", + cause, + }), + ), + ); + return { + repoRoot: worktreePath, + worktreeRef: `wt-${ticketId}`, + path: worktreePath, + }; + }), + }); + }), + ), + ), + Layer.provideMerge( + Layer.succeed(MergeGitPort, { + run: () => Effect.succeed({ exitCode: 0, stdout: "", stderr: "" }), + }), + ), + Layer.provideMerge( + Layer.succeed(TicketPullRequestService, { + open: () => Effect.succeed({ _tag: "completed" }), + land: () => Effect.succeed({ _tag: "completed" }), + }), + ), + // The real GitHubPortLive is wired into the executor; these tests never run + // PR steps, so stub its source-control deps to keep the layer self-contained. + Layer.provideMerge(Layer.succeed(GitHubCli, {} as never)), + Layer.provideMerge(Layer.succeed(SourceControlProviderRegistry, {} as never)), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: () => Effect.succeed(false), + captureBaseline: (ticketId) => Effect.succeed(`refs/t3/tickets/${ticketId}/base` as string), + captureStep: (ticketId, stepRunId, _cwd, kind) => + Effect.succeed(`refs/t3/tickets/${ticketId}/steps/${stepRunId}/${kind}` as string), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), + ); + +const runtimeLayer = it.layer(makeRuntimeLayer(0)); +const smartRoutingLayer = it.layer(makeRuntimeLayer(1)); + +const advanceRuntime = Effect.gen(function* () { + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; +}); + +const waitFor = <E>(predicate: Effect.Effect<boolean, E>, label: string): Effect.Effect<void, E> => + Effect.gen(function* () { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (yield* predicate) { + return; + } + yield* advanceRuntime; + } + assert.fail(`Timed out waiting for ${label}`); + }); + +const waitForDetail = ( + read: WorkflowReadModel["Service"], + ticketId: string, + predicate: (detail: TicketDetail | null) => boolean, + label: string, +) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 20; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId as never); + if (predicate(detail)) { + return detail; + } + yield* advanceRuntime; + } + assert.fail(`Timed out waiting for ${label}`); + }); + +const waitForDispatchForTicket = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + for (let attempt = 0; attempt < 20; attempt += 1) { + const rows = yield* sql<{ readonly threadId: string; readonly turnId: string | null }>` + SELECT thread_id AS "threadId", turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE ticket_id = ${ticketId} + AND turn_id IS NOT NULL + ORDER BY created_at DESC, dispatch_id DESC + LIMIT 1 + `; + const row = rows[0]; + if (row?.turnId) { + return { threadId: row.threadId, turnId: row.turnId }; + } + yield* advanceRuntime; + } + assert.fail(`Timed out waiting for dispatch for ${ticketId}`); + }); + +const seedAssistantOutput = (input: { + readonly threadId: string; + readonly turnId: string; + readonly messageId: string; + readonly text: string; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + ${input.messageId}, + ${input.threadId}, + ${input.turnId}, + 'assistant', + ${input.text}, + NULL, + 0, + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + 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 ( + ${input.threadId}, + ${input.turnId}, + NULL, + NULL, + NULL, + ${input.messageId}, + 'completed', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z', + NULL, + NULL, + NULL, + '[]' + ) + ON CONFLICT (thread_id, turn_id) + DO UPDATE SET + assistant_message_id = excluded.assistant_message_id, + state = excluded.state, + completed_at = excluded.completed_at + `; + }); + +const registerSmartRoutingBoard = (input: { + readonly boardId: string; + readonly projectId: string; +}) => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const trust = yield* ProjectScriptTrust; + + yield* registry.register(input.boardId as never, smartRoutingDefinition); + yield* read.registerBoard({ + boardId: input.boardId as never, + projectId: input.projectId as never, + name: "Smart routing runtime", + workflowFilePath: ".t3/boards/smart-routing.json", + workflowVersionHash: input.boardId, + maxConcurrentTickets: 3, + }); + yield* trust.setTrusted(input.projectId as never, true); + }); + +const registerWipRuntimeBoard = (input: { readonly boardId: string; readonly projectId: string }) => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + + yield* registry.register(input.boardId as never, wipDrainDefinition); + yield* read.registerBoard({ + boardId: input.boardId as never, + projectId: input.projectId as never, + name: "WIP runtime", + workflowFilePath: ".t3/boards/wip-runtime.json", + workflowVersionHash: input.boardId, + maxConcurrentTickets: 3, + }); + }); + +const assertBuildOccupancy = ( + read: WorkflowReadModel["Service"], + boardId: string, + expected: number, +) => + Effect.gen(function* () { + const admitted = yield* read.countAdmittedInLane(boardId as never, "build" as never); + assert.equal(admitted, expected); + assert.isAtMost(admitted, 1); + }); + +runtimeLayer("WorkflowRuntimeCoreLive", (it) => { + it.effect("runs two real agent steps through the durable runtime", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const mock = yield* MockAcpProvider; + + yield* registry.register("board-runtime" as never, definition); + const ticketId = yield* engine.createTicket({ + boardId: "board-runtime" as never, + title: "Ship runtime", + initialLane: "code" as never, + }); + + yield* waitFor(mock.startedCount.pipe(Effect.map((count) => count === 1)), "first turn"); + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + ticketId as string, + (detail) => detail?.ticket.currentLaneKey === "review", + "review lane", + ); + + yield* waitFor(mock.startedCount.pipe(Effect.map((count) => count === 2)), "second turn"); + yield* mock.completeAllRunning(); + const done = yield* waitForDetail( + read, + ticketId as string, + (detail) => detail?.ticket.currentLaneKey === "done", + "done lane", + ); + + assert.equal(done?.steps.filter((step) => step.status === "completed").length, 2); + }), + ); + + it.effect("recovers an in-flight dispatch without starting a duplicate provider turn", () => + Effect.gen(function* () { + const recovery = yield* WorkflowRecovery; + const mock = yield* MockAcpProvider; + const provider = yield* ProviderTurnPort; + const sql = yield* SqlClient.SqlClient; + const baselineStarts = yield* mock.startedCount; + + yield* provider.ensureTurnStarted({ + dispatchId: "dispatch-restart" as never, + ticketId: "ticket-restart" as never, + stepRunId: "step-run-restart" as never, + threadId: "thread-restart" as never, + providerInstance: "codex", + model: "gpt-5.5", + instruction: "recover the turn", + worktreePath: "/tmp/wt-restart", + }); + assert.equal(yield* mock.startedCount, baselineStarts + 1); + + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-restart', + 'project-restart', + 'Restart Board', + '.t3/boards/restart.json', + 'hash-restart', + 3 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-restart', + 'board-restart', + 'Recover restart', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-restart', + 'ticket-restart', + 'step-run-restart', + 'thread-restart', + 'codex', + 'gpt-5.5', + 'recover the turn', + '/tmp/wt-restart', + 'pending', + '2026-06-07T00:00:00.000Z' + ) + `; + + const fiber = yield* Effect.forkChild(recovery.recover()); + yield* Effect.yieldNow; + yield* mock.completeAllRunning(); + yield* advanceRuntime; + yield* Fiber.join(fiber); + + yield* recovery.recover(); + + assert.equal(yield* mock.startedCount, baselineStarts + 1); + const rows = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = 'dispatch-restart' + `; + assert.equal(rows[0]?.status, "confirmed"); + }), + ); + + it.effect("enforces WIP limit and drains queued auto-lane tickets FIFO", () => + Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const mock = yield* MockAcpProvider; + const baselineStarts = yield* mock.startedCount; + + yield* registerWipRuntimeBoard({ + boardId: "board-wip-live", + projectId: "project-wip-live", + }); + const firstTicketId = yield* engine.createTicket({ + boardId: "board-wip-live" as never, + title: "First WIP ticket", + initialLane: "build" as never, + }); + const secondTicketId = yield* engine.createTicket({ + boardId: "board-wip-live" as never, + title: "Second WIP ticket", + initialLane: "build" as never, + }); + const thirdTicketId = yield* engine.createTicket({ + boardId: "board-wip-live" as never, + title: "Third WIP ticket", + initialLane: "build" as never, + }); + + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count === baselineStarts + 1)), + "first WIP ticket start", + ); + yield* assertBuildOccupancy(read, "board-wip-live", 1); + const firstQueuedState = yield* waitForDetail( + read, + secondTicketId as string, + (detail) => detail?.ticket.status === "queued" && detail.ticket.queuedAt !== null, + "second ticket queued", + ); + const thirdQueuedState = yield* waitForDetail( + read, + thirdTicketId as string, + (detail) => detail?.ticket.status === "queued" && detail.ticket.queuedAt !== null, + "third ticket queued", + ); + assert.equal(firstQueuedState?.ticket.currentLaneEntryToken, null); + assert.equal(thirdQueuedState?.ticket.currentLaneEntryToken, null); + + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + firstTicketId as string, + (detail) => detail?.ticket.currentLaneKey === "done", + "first ticket drained", + ); + const secondAdmitted = yield* waitForDetail( + read, + secondTicketId as string, + (detail) => + detail?.ticket.currentLaneKey === "build" && + detail.ticket.currentLaneEntryToken !== null && + detail.ticket.queuedAt === null, + "second ticket FIFO admit", + ); + const thirdStillQueued = yield* waitForDetail( + read, + thirdTicketId as string, + (detail) => detail?.ticket.status === "queued" && detail.ticket.queuedAt !== null, + "third ticket still queued after first drain", + ); + assert.isNotNull(secondAdmitted?.ticket.currentLaneEntryToken); + assert.equal(thirdStillQueued?.ticket.currentLaneEntryToken, null); + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count === baselineStarts + 2)), + "second WIP ticket start", + ); + yield* assertBuildOccupancy(read, "board-wip-live", 1); + + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + secondTicketId as string, + (detail) => detail?.ticket.currentLaneKey === "done", + "second ticket drained", + ); + const thirdAdmitted = yield* waitForDetail( + read, + thirdTicketId as string, + (detail) => + detail?.ticket.currentLaneKey === "build" && + detail.ticket.currentLaneEntryToken !== null && + detail.ticket.queuedAt === null, + "third ticket FIFO admit", + ); + assert.isNotNull(thirdAdmitted?.ticket.currentLaneEntryToken); + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count === baselineStarts + 3)), + "third WIP ticket start", + ); + yield* assertBuildOccupancy(read, "board-wip-live", 1); + + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + thirdTicketId as string, + (detail) => detail?.ticket.currentLaneKey === "done", + "third ticket drained", + ); + yield* assertBuildOccupancy(read, "board-wip-live", 0); + }), + ); + + it.effect("recovers stranded WIP admission and drains queued tickets FIFO", () => + Effect.gen(function* () { + const recovery = yield* WorkflowRecovery; + const read = yield* WorkflowReadModel; + const committer = yield* WorkflowEventCommitter; + const mock = yield* MockAcpProvider; + const baselineStarts = yield* mock.startedCount; + + yield* registerWipRuntimeBoard({ + boardId: "board-wip-recovered-runtime", + projectId: "project-wip-recovered-runtime", + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-wip-recovered-first-created" as never, + ticketId: "ticket-wip-recovered-first" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "board-wip-recovered-runtime" as never, + title: "Recovered first WIP ticket", + laneKey: "build" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-wip-recovered-first-admitted" as never, + ticketId: "ticket-wip-recovered-first" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "build" as never, + laneEntryToken: "tok-wip-recovered-first" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-wip-recovered-second-created" as never, + ticketId: "ticket-wip-recovered-second" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + boardId: "board-wip-recovered-runtime" as never, + title: "Recovered second WIP ticket", + laneKey: "build" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketQueued", + eventId: "evt-wip-recovered-second-queued" as never, + ticketId: "ticket-wip-recovered-second" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { lane: "build" as never }, + } as never); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-wip-recovered-third-created" as never, + ticketId: "ticket-wip-recovered-third" as never, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + boardId: "board-wip-recovered-runtime" as never, + title: "Recovered third WIP ticket", + laneKey: "build" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketQueued", + eventId: "evt-wip-recovered-third-queued" as never, + ticketId: "ticket-wip-recovered-third" as never, + occurredAt: "2026-06-07T00:00:05.000Z" as never, + payload: { lane: "build" as never }, + } as never); + + yield* recovery.recover(); + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count === baselineStarts + 1)), + "stranded recovered WIP ticket start", + ); + yield* assertBuildOccupancy(read, "board-wip-recovered-runtime", 1); + + yield* recovery.recover(); + assert.equal(yield* mock.startedCount, baselineStarts + 1); + + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + "ticket-wip-recovered-first", + (detail) => detail?.ticket.currentLaneKey === "done", + "recovered first ticket drained", + ); + yield* waitForDetail( + read, + "ticket-wip-recovered-second", + (detail) => + detail?.ticket.currentLaneKey === "build" && + detail.ticket.currentLaneEntryToken !== null && + detail.ticket.queuedAt === null, + "recovered second ticket FIFO admit", + ); + yield* waitForDetail( + read, + "ticket-wip-recovered-third", + (detail) => detail?.ticket.status === "queued" && detail.ticket.queuedAt !== null, + "recovered third ticket still queued", + ); + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count === baselineStarts + 2)), + "recovered second ticket start", + ); + yield* assertBuildOccupancy(read, "board-wip-recovered-runtime", 1); + + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + "ticket-wip-recovered-second", + (detail) => detail?.ticket.currentLaneKey === "done", + "recovered second ticket drained", + ); + yield* waitForDetail( + read, + "ticket-wip-recovered-third", + (detail) => + detail?.ticket.currentLaneKey === "build" && + detail.ticket.currentLaneEntryToken !== null && + detail.ticket.queuedAt === null, + "recovered third ticket FIFO admit", + ); + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count === baselineStarts + 3)), + "recovered third ticket start", + ); + yield* assertBuildOccupancy(read, "board-wip-recovered-runtime", 1); + + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + "ticket-wip-recovered-third", + (detail) => detail?.ticket.currentLaneKey === "done", + "recovered third ticket drained", + ); + yield* assertBuildOccupancy(read, "board-wip-recovered-runtime", 0); + }), + ); +}); + +smartRoutingLayer("WorkflowRuntime smart routing integration", (it) => { + it.effect("branches live on script exit code and captured agent verdict", () => + Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const mock = yield* MockAcpProvider; + const store = yield* WorkflowEventStore; + const baselineStarts = yield* mock.startedCount; + + yield* registerSmartRoutingBoard({ + boardId: "board-smart-live", + projectId: "project-smart-live", + }); + const ticketId = yield* engine.createTicket({ + boardId: "board-smart-live" as never, + title: "Smart live route", + initialLane: "impl" as never, + }); + + const afterScript = yield* waitForDetail( + read, + ticketId as string, + (detail) => + detail?.steps.some((step) => step.stepKey === "tests" && step.status !== "running") === + true, + "script terminal step", + ); + assert.equal( + afterScript?.steps.find((step) => step.stepKey === "tests")?.status, + "completed", + ); + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count >= baselineStarts + 1)), + "review turn", + ); + const dispatch = yield* waitForDispatchForTicket(ticketId as string); + yield* seedAssistantOutput({ + ...dispatch, + messageId: "assistant-smart-live", + text: 'Review complete.\n```json\n{"verdict":"block"}\n```', + }); + yield* mock.completeAllRunning(); + + const detail = yield* waitForDetail( + read, + ticketId as string, + (detail) => detail?.ticket.currentLaneKey === "needs", + "needs lane", + ); + assert.equal(detail?.steps.find((step) => step.stepKey === "tests")?.exitCode, 1); + assert.deepEqual(detail?.steps.find((step) => step.stepKey === "review")?.output, { + verdict: "block", + }); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const audit = events.find((event) => event.type === "TicketRouteDecided"); + assert.equal(audit?.type, "TicketRouteDecided"); + if (audit?.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(audit.payload.source, "lane_transition"); + assert.equal(audit.payload.toLane, "needs"); + }), + ); + + it.effect("branches recovered on script exit code and recovered agent output", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const committer = yield* WorkflowEventCommitter; + const store = yield* WorkflowEventStore; + const provider = yield* ProviderTurnPort; + const mock = yield* MockAcpProvider; + const recovery = yield* WorkflowRecovery; + const sql = yield* SqlClient.SqlClient; + + yield* registerSmartRoutingBoard({ + boardId: "board-smart-recovered", + projectId: "project-smart-recovered", + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-smart-recovered-ticket" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "board-smart-recovered" as never, + title: "Smart recovered route", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-smart-recovered-move" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-smart-recovered" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-smart-recovered-pipeline" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-smart-recovered" as never, + laneKey: "impl" as never, + laneEntryToken: "tok-smart-recovered" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-smart-recovered-tests" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-smart-recovered" as never, + stepRunId: "step-smart-tests" as never, + stepKey: "tests" as never, + stepType: "script", + }, + } as never); + yield* committer.commit({ + type: "ScriptStepStarted", + eventId: "evt-smart-recovered-script-started" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + scriptRunId: "script-smart-recovered" as never, + stepRunId: "step-smart-tests" as never, + scriptThreadId: "workflow-script:script-smart-recovered" as never, + terminalId: "script-smart-recovered", + }, + } as never); + yield* committer.commit({ + type: "ScriptStepExited", + eventId: "evt-smart-recovered-script-exited" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:05.000Z" as never, + payload: { + scriptRunId: "script-smart-recovered" as never, + exitCode: 1, + signal: null, + outcome: "exited", + }, + } as never); + yield* committer.commit({ + type: "StepCompleted", + eventId: "evt-smart-recovered-tests-completed" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:06.000Z" as never, + payload: { stepRunId: "step-smart-tests" as never }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-smart-recovered-review" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:07.000Z" as never, + payload: { + pipelineRunId: "pipeline-smart-recovered" as never, + stepRunId: "step-smart-review" as never, + stepKey: "review" as never, + stepType: "agent", + }, + } as never); + + const { turnId } = yield* provider.ensureTurnStarted({ + dispatchId: "dispatch-smart-recovered" as never, + ticketId: "ticket-smart-recovered" as never, + stepRunId: "step-smart-review" as never, + threadId: "thread-smart-recovered" as never, + providerInstance: "codex", + model: "gpt-5.5", + instruction: "Review the test result", + worktreePath: "/tmp/wt-smart-recovered", + }); + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-smart-recovered', + 'ticket-smart-recovered', + 'step-smart-review', + 'thread-smart-recovered', + 'codex', + 'gpt-5.5', + 'Review the test result', + '/tmp/wt-smart-recovered', + 'started', + ${turnId}, + '2026-06-07T00:00:08.000Z', + '2026-06-07T00:00:08.000Z' + ) + `; + yield* seedAssistantOutput({ + threadId: "thread-smart-recovered", + turnId: turnId as string, + messageId: "assistant-smart-recovered", + text: 'Recovered review.\n```json\n{"verdict":"block"}\n```', + }); + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at, + confirmed_at + ) + VALUES ( + 'dispatch-smart-recovered-newer', + 'ticket-smart-recovered', + 'step-smart-review', + 'thread-smart-recovered', + 'codex', + 'gpt-5.5', + 'Newer unrelated dispatch', + '/tmp/wt-smart-recovered', + 'confirmed', + 'turn-smart-recovered-newer', + '2026-06-07T00:00:09.000Z', + '2026-06-07T00:00:09.000Z', + '2026-06-07T00:00:10.000Z' + ) + `; + yield* seedAssistantOutput({ + threadId: "thread-smart-recovered", + turnId: "turn-smart-recovered-newer", + messageId: "assistant-smart-recovered-newer", + text: 'Newer unrelated review.\n```json\n{"verdict":"pass"}\n```', + }); + yield* mock.completeAllRunning(); + yield* recovery.recover(); + + const detail = yield* waitForDetail( + read, + "ticket-smart-recovered", + (detail) => detail?.ticket.currentLaneKey === "needs", + "recovered needs lane", + ); + assert.equal(detail?.steps.find((step) => step.stepKey === "tests")?.exitCode, 1); + assert.deepEqual(detail?.steps.find((step) => step.stepKey === "review")?.output, { + verdict: "block", + }); + + const events = yield* Stream.runCollect( + store.readByTicket("ticket-smart-recovered" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + const audit = events.find((event) => event.type === "TicketRouteDecided"); + assert.equal(audit?.type, "TicketRouteDecided"); + if (audit?.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(audit.payload.source, "lane_transition"); + assert.equal(audit.payload.toLane, "needs"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts b/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts new file mode 100644 index 00000000000..670fa2540c0 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts @@ -0,0 +1,1468 @@ +// @effect-diagnostics globalTimers:off +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { describe } from "vitest"; +import { BoardId, LaneKey, ProjectId, TicketId, TurnId, type VcsError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; +import * as GitManager from "../../git/GitManager.ts"; +import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { ServerConfig } from "../../config.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import { GitHubCli } from "../../sourceControl/GitHubCli.ts"; +import { SourceControlProviderRegistry } from "../../sourceControl/SourceControlProviderRegistry.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ProviderTurnPort, type DispatchRequest } from "../Services/ProviderDispatchOutbox.ts"; +import { + ProviderResponsePort, + type ProviderResponseInput, +} from "../Services/ProviderResponsePort.ts"; +import { SetupTerminalPort } from "../Services/SetupRunService.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { TicketDiffQuery } from "../Services/TicketDiffQuery.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowRecovery } from "../Services/WorkflowRecovery.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { TicketCheckpointServiceLive } from "./TicketCheckpointService.ts"; +import { TicketDiffQueryLive, WorktreeDiffPortLive } from "./TicketDiffQuery.ts"; +import { WorktreePortLive } from "./RealStepExecutor.ts"; +import { TurnProjectionPortLive } from "./TurnStateReader.ts"; +import { WorkflowRuntimeCoreLive } from "../WorkflowRuntimeLive.ts"; +import { MergeGitPortLive } from "./TicketMergeService.ts"; +import { TicketPullRequestService } from "../Services/TicketPullRequestService.ts"; +import { ticketBaseRef } from "../ticketRefs.ts"; +import { WorktreePort } from "../Services/WorktreePort.ts"; + +interface ProviderCall { + readonly threadId: string; + readonly instruction: string; + readonly turnId: string; + readonly worktreePath: string; +} + +interface RealPathProviderDoubleShape { + readonly calls: Effect.Effect<ReadonlyArray<ProviderCall>>; + readonly completeThread: (threadId: string) => Effect.Effect<void, WorkflowEventStoreError>; + readonly reset: Effect.Effect<void>; + readonly responses: Effect.Effect<ReadonlyArray<ProviderResponseInput>>; +} + +class RealPathProviderDouble extends Context.Service< + RealPathProviderDouble, + RealPathProviderDoubleShape +>()("t3/workflow/Layers/WorkflowRuntime.realpath.test/RealPathProviderDouble") {} + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-workflow-realpath-test-", +}); +const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const PullRequestStubLive = Layer.mergeAll( + Layer.succeed(TicketPullRequestService, { + open: () => Effect.succeed({ _tag: "completed" }), + land: () => Effect.succeed({ _tag: "completed" }), + }), + Layer.succeed(GitHubCli, {} as never), + Layer.succeed(SourceControlProviderRegistry, {} as never), +); +const GitVcsDriverTestLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe( + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); +const GitWorkflowServiceTestLayer = GitWorkflowService.layer.pipe( + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(GitVcsDriverTestLayer), + Layer.provide(Layer.mock(GitManager.GitManager)({})), +); + +const toProviderDoubleError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const RealPathProviderDoubleLive = Layer.unwrap( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const sql = yield* SqlClient.SqlClient; + const calls = yield* Ref.make<ReadonlyArray<ProviderCall>>([]); + const responses = yield* Ref.make<ReadonlyArray<ProviderResponseInput>>([]); + const heldAfterAnswerThreads = yield* Ref.make<ReadonlySet<string>>(new Set()); + const turnCounters = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); + + const appendInstruction = (request: DispatchRequest) => + Effect.gen(function* () { + const outputPath = path.join(request.worktreePath, "workflow-output.txt"); + const existing = yield* fileSystem + .exists(outputPath) + .pipe( + Effect.flatMap((exists) => + exists ? fileSystem.readFileString(outputPath) : Effect.succeed(""), + ), + ); + yield* fileSystem.writeFileString(outputPath, `${existing}${request.instruction}\n`); + }); + + const upsertTurnState = (input: { + readonly threadId: string; + readonly turnId: string; + readonly state: "running" | "completed" | "interrupted"; + }) => + 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 ( + ${input.threadId}, + ${input.turnId}, + NULL, + NULL, + NULL, + NULL, + ${input.state}, + '2026-06-07T00:01:00.000Z', + '2026-06-07T00:01:00.000Z', + ${input.state === "running" ? null : "2026-06-07T00:01:01.000Z"}, + NULL, + NULL, + NULL, + '[]' + ) + ON CONFLICT (thread_id, turn_id) + DO UPDATE SET + state = excluded.state, + completed_at = excluded.completed_at + `.pipe(Effect.mapError(toProviderDoubleError("provider double turn state failed"))); + + const nextTurnId = (threadId: string) => + Ref.modify(turnCounters, (current) => { + const nextValue = (current.get(threadId) ?? 0) + 1; + const next = new Map(current); + next.set(threadId, nextValue); + return [`turn-${threadId}-${nextValue}`, next] as const; + }); + + const activeProjectedTurn = (threadId: string) => + sql<{ readonly turnId: string; readonly state: string }>` + SELECT turn_id AS "turnId", state + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + ORDER BY requested_at ASC, turn_id ASC + `.pipe( + Effect.map( + (rows) => + rows.findLast((row) => row.state === "pending" || row.state === "running") ?? null, + ), + Effect.mapError(toProviderDoubleError("provider double active turn lookup failed")), + ); + + const insertUserInputRequest = (threadId: string, turnId: string) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES ( + ${`activity-user-input-requested-${threadId}`}, + ${threadId}, + ${turnId}, + 'approval', + 'user-input.requested', + 'Question for workflow', + ${JSON.stringify({ + requestId: `request-${threadId}`, + questions: [ + { + id: `question-${threadId}`, + question: "Question for workflow", + }, + ], + })}, + 1, + '2026-06-07T00:00:00.000Z' + ) + `.pipe(Effect.mapError(toProviderDoubleError("provider double user input failed"))); + + const insertUserInputResolved = (input: ProviderResponseInput) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES ( + ${`activity-user-input-resolved-${input.threadId}`}, + ${input.threadId}, + NULL, + 'approval', + 'user-input.resolved', + 'Question answered', + ${JSON.stringify({ requestId: input.requestId, approved: input.approved })}, + 2, + '2026-06-07T00:00:01.000Z' + ) + `.pipe(Effect.mapError(toProviderDoubleError("provider double user input failed"))); + + const completeThread = (threadId: string) => + Effect.gen(function* () { + const active = yield* activeProjectedTurn(threadId); + if (active === null) { + return; + } + yield* upsertTurnState({ threadId, turnId: active.turnId, state: "completed" }); + }); + + const providerTurnPort = ProviderTurnPort.of({ + ensureTurnStarted: (request) => + Effect.gen(function* () { + const threadKey = request.threadId as string; + const activeTurn = yield* activeProjectedTurn(threadKey); + if (activeTurn !== null) { + return { turnId: TurnId.make(activeTurn.turnId) }; + } + + const turnIdString = yield* nextTurnId(threadKey); + const turnId = TurnId.make(turnIdString); + yield* Ref.update(calls, (current) => [ + ...current, + { + threadId: threadKey, + instruction: request.instruction, + turnId: turnIdString, + worktreePath: request.worktreePath, + }, + ]); + yield* upsertTurnState({ threadId: threadKey, turnId: turnIdString, state: "running" }); + yield* appendInstruction(request); + if (request.instruction.includes("ASK_PROVIDER_QUESTION")) { + yield* insertUserInputRequest(threadKey, turnIdString); + if (request.instruction.includes("DELAY_AFTER_ANSWER")) { + yield* Ref.update(heldAfterAnswerThreads, (current) => { + const next = new Set(current); + next.add(threadKey); + return next; + }); + } + return { turnId }; + } + yield* upsertTurnState({ threadId: threadKey, turnId: turnIdString, state: "completed" }); + return { turnId }; + }).pipe(Effect.mapError(toProviderDoubleError("provider double turn failed"))), + }); + + const providerResponsePort = ProviderResponsePort.of({ + respond: (input) => + Effect.gen(function* () { + yield* Ref.update(responses, (current) => [...current, input]); + yield* insertUserInputResolved(input); + const threadId = input.threadId as string; + const heldThreads = yield* Ref.get(heldAfterAnswerThreads); + if (!heldThreads.has(threadId)) { + yield* completeThread(threadId); + } + }).pipe(Effect.mapError(toProviderDoubleError("provider double response failed"))), + }); + + const tracker = RealPathProviderDouble.of({ + calls: Ref.get(calls), + completeThread, + reset: Effect.all( + [ + Ref.set(calls, []), + Ref.set(responses, []), + Ref.set(heldAfterAnswerThreads, new Set()), + Ref.set(turnCounters, new Map()), + ], + { discard: true }, + ), + responses: Ref.get(responses), + }); + + return Layer.mergeAll( + Layer.succeed(ProviderTurnPort, providerTurnPort), + Layer.succeed(ProviderResponsePort, providerResponsePort), + Layer.succeed(RealPathProviderDouble, tracker), + ); + }), +); + +const TestLayer = Layer.mergeAll(WorkflowRuntimeCoreLive, TicketDiffQueryLive).pipe( + Layer.provideMerge(RealPathProviderDoubleLive), + Layer.provideMerge( + Layer.succeed(TerminalManager, { + open: () => Effect.die("unused terminal.open"), + attachStream: () => Effect.die("unused terminal.attachStream"), + attachHistoryStream: () => Effect.die("unused terminal.attachHistoryStream"), + write: () => Effect.die("unused terminal.write"), + resize: () => Effect.die("unused terminal.resize"), + clear: () => Effect.die("unused terminal.clear"), + restart: () => Effect.die("unused terminal.restart"), + close: () => Effect.void, + getSnapshot: () => Effect.succeed(null), + subscribe: () => Effect.succeed(() => undefined), + subscribeMetadata: () => Effect.succeed(() => undefined), + }), + ), + Layer.provideMerge(TurnProjectionPortLive), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(WorktreePortLive), + Layer.provideMerge(TicketCheckpointServiceLive), + Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(WorktreeDiffPortLive), + Layer.provideMerge( + Layer.succeed(SetupTerminalPort, { + launch: () => Effect.succeed({ threadId: "workflow-setup:stub", terminalId: null }), + awaitExit: () => Effect.succeed({ exitCode: 0 }), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + // The real GitHubPortLive is wired into the executor; these tests never run + // PR steps, so stub the PR service and its source-control deps to keep the + // layer self-contained. + Layer.provideMerge(PullRequestStubLive), + Layer.provideMerge(GitWorkflowServiceTestLayer), + Layer.provideMerge(MergeGitPortLive), + Layer.provideMerge(GitVcsDriverTestLayer), + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); + +const makeTmpDir = ( + prefix = "workflow-realpath-", +): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + filePath: string, + contents: string, +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); + +const makeDirectory = ( + directoryPath: string, +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(directoryPath, { recursive: true }); + }); + +const git = ( + cwd: string, + args: ReadonlyArray<string>, +): Effect.Effect<string, VcsError, VcsProcess.VcsProcess> => + Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ + operation: "WorkflowRuntime.realpath.git", + command: "git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + void, + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem +> => + Effect.gen(function* () { + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(cwd, "README.md"), "# workflow repo\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + }); + +const withProcessCwd = <A, E, R>( + cwd: string, + effect: Effect.Effect<A, E, R>, +): Effect.Effect<A, E, R> => + Effect.gen(function* () { + const previous = process.cwd(); + yield* Effect.sync(() => process.chdir(cwd)); + return yield* effect.pipe(Effect.ensuring(Effect.sync(() => process.chdir(previous)))); + }); + +const waitForDetail = ( + read: WorkflowReadModel["Service"], + ticketId: TicketId, + predicate: (detail: TicketDetail | null) => boolean, + label: string, +) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 80; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId); + if (predicate(detail)) { + return detail; + } + yield* TestClock.adjust("50 millis"); + yield* Effect.promise<void>(() => new Promise((resolve) => setTimeout(resolve, 25))); + yield* Effect.yieldNow; + } + assert.fail(`Timed out waiting for ${label}`); + }); + +const seedProject = (projectId: ProjectId, repoRoot: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${projectId}, + 'Workflow project', + ${repoRoot}, + NULL, + '[]', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z', + NULL + ) + `; + }); + +const registerBoardProjection = (input: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly name: string; + readonly repoRoot: string; +}) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + yield* read.registerBoard({ + boardId: input.boardId, + projectId: input.projectId, + name: input.name, + workflowFilePath: path.join(input.repoRoot, ".t3", "boards", "delivery.json"), + workflowVersionHash: "test", + maxConcurrentTickets: 1, + }); + }); + +describe.sequential("Workflow runtime real path", () => { + it.effect("runs a two-step agent pipeline in one project worktree with accumulated diff", () => + Effect.gen(function* () { + const targetRepo = yield* makeTmpDir("workflow-target-repo-"); + const wrongRepo = yield* makeTmpDir("workflow-wrong-cwd-"); + yield* initRepoWithCommit(targetRepo); + yield* initRepoWithCommit(wrongRepo); + yield* makeDirectory(path.join(targetRepo, "prompts")); + yield* writeTextFile(path.join(targetRepo, "prompts", "step-one.md"), "first file prompt"); + + const boardId = BoardId.make("board-realpath"); + const projectId = ProjectId.make("project-realpath"); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* seedProject(projectId, targetRepo); + yield* registry.register(boardId, { + name: "Real path board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: { file: "prompts/step-one.md" }, + }, + { + key: "review", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "second inline prompt", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Real path board", + repoRoot: targetRepo, + }); + + const { ticketId, done } = yield* withProcessCwd( + wrongRepo, + Effect.gen(function* () { + const ticketId = yield* engine.createTicket({ + boardId, + title: "Ship a real worktree ticket", + initialLane: LaneKey.make("implement"), + }); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => + detail?.ticket.currentLaneKey === "done" || + detail?.ticket.currentLaneKey === "needs_attention", + "terminal lane", + ); + return { ticketId, done }; + }), + ); + const calls = yield* provider.calls; + + assert.equal(done?.ticket.currentLaneKey, "done"); + assert.equal(calls.length, 2); + assert.equal(calls[0]?.worktreePath, calls[1]?.worktreePath); + assert.isTrue((calls[0]?.worktreePath ?? "").includes(path.basename(targetRepo))); + assert.equal(calls[0]?.instruction, "first file prompt"); + assert.equal(calls[1]?.instruction, "second inline prompt"); + assert.match( + yield* git(targetRepo, ["branch", "--list", "workflow/ticket-1"]), + /workflow\/ticket-1/, + ); + assert.equal(yield* git(wrongRepo, ["branch", "--list", "workflow/ticket-1"]), ""); + + const ticketDiff = yield* TicketDiffQuery; + const diff = yield* ticketDiff.getTicketDiff( + ticketId, + calls[0]?.worktreePath ?? "", + ticketBaseRef(ticketId), + ); + assert.include(diff.patch, "+first file prompt"); + assert.include(diff.patch, "+second inline prompt"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("surfaces a real provider question as waiting_on_user and resumes the pipeline", () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-question-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-question"); + const projectId = ProjectId.make("project-question"); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Question board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "ask", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "ASK_PROVIDER_QUESTION", + }, + { + key: "continue", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "continue after answer", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Question board", + repoRoot: repo, + }); + + const ticketId = yield* withProcessCwd( + repo, + engine.createTicket({ + boardId, + title: "Question ticket", + initialLane: LaneKey.make("implement"), + }), + ); + const waiting = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.status === "waiting_on_user", + "provider question", + ); + if (waiting === null) { + assert.fail("Expected provider question detail"); + } + const awaitingStep = waiting.steps.find((step) => step.status === "awaiting_user"); + assert.isDefined(awaitingStep); + + yield* engine.answerTicketStep({ + stepRunId: awaitingStep?.stepRunId as never, + text: "Continue after answer.", + }); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "question pipeline completion", + ); + if (done === null) { + assert.fail("Expected completed question detail"); + } + const calls = yield* provider.calls; + const responses = yield* provider.responses; + + assert.equal(done.ticket.currentLaneKey, "done"); + assert.equal(calls.length, 2); + assert.deepEqual( + responses.map((response) => response.responseKind), + ["user-input"], + ); + assert.deepEqual( + responses.map((response) => response.text), + ["Continue after answer."], + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("restarts a dead autonomous agent turn and continues the recovered pipeline", () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-autonomous-restart-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-autonomous-restart"); + const projectId = ProjectId.make("project-autonomous-restart"); + const ticketId = TicketId.make("ticket-autonomous-restart"); + const pipelineRunId = "pipeline-autonomous-restart" as never; + const stepRunId = "step-autonomous-restart" as never; + const threadId = "thread-autonomous-restart" as never; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const recovery = yield* WorkflowRecovery; + const committer = yield* WorkflowEventCommitter; + const provider = yield* RealPathProviderDouble; + const worktrees = yield* WorktreePort; + const sql = yield* SqlClient.SqlClient; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Autonomous restart board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "first", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "recover interrupted autonomous step", + }, + { + key: "second", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "continue after recovered step", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Autonomous restart board", + repoRoot: repo, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-autonomous-ticket-created" as never, + ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId, + title: "Autonomous restart ticket", + laneKey: LaneKey.make("implement"), + }, + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-autonomous-ticket-moved" as never, + ticketId, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: LaneKey.make("implement"), + laneEntryToken: "token-autonomous-restart" as never, + reason: "initial", + }, + }); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-autonomous-pipeline-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId, + laneKey: LaneKey.make("implement"), + laneEntryToken: "token-autonomous-restart" as never, + }, + }); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-autonomous-step-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId, + stepRunId, + stepKey: "first" as never, + stepType: "agent", + }, + }); + + const worktree = yield* worktrees.ensureWorktree(ticketId); + 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 ( + ${threadId}, + 'turn-autonomous-dead', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + turn_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES ( + 'dispatch-autonomous-restart', + ${ticketId}, + ${stepRunId}, + ${threadId}, + 'turn-autonomous-dead', + 'codex', + 'gpt-5.5', + 'recover interrupted autonomous step', + ${worktree.path}, + 'started', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z' + ) + `; + + yield* recovery.recover(); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "autonomous restart completion", + ); + if (done === null) { + assert.fail("Expected completed autonomous restart detail"); + } + const calls = yield* provider.calls; + + assert.equal(done.ticket.currentLaneKey, "done"); + assert.deepEqual( + calls.map((call) => call.instruction), + ["recover interrupted autonomous step", "continue after recovered step"], + ); + assert.isAbove(new Set(calls.map((call) => call.turnId)).size, 1); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "does not start the next provider-question step before the answered turn completes", + () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-question-race-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-question-race"); + const projectId = ProjectId.make("project-question-race"); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Question race board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "ask", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "ASK_PROVIDER_QUESTION DELAY_AFTER_ANSWER", + }, + { + key: "after-answer", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "must wait for answered turn terminal", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Question race board", + repoRoot: repo, + }); + + const ticketId = yield* engine.createTicket({ + boardId, + title: "Question race ticket", + initialLane: LaneKey.make("implement"), + }); + const waiting = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.status === "waiting_on_user", + "delayed provider question", + ); + const awaitingStep = waiting?.steps.find((step) => step.status === "awaiting_user"); + assert.isDefined(awaitingStep); + + const firstCalls = yield* provider.calls; + const questionThreadId = firstCalls[0]?.threadId; + assert.isDefined(questionThreadId); + + const resolveFiber = yield* Effect.forkChild( + engine.answerTicketStep({ + stepRunId: awaitingStep?.stepRunId as never, + text: "Continue after delayed answer.", + }), + ); + yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.status !== "waiting_on_user", + "question answer projection", + ); + yield* TestClock.adjust("250 millis"); + yield* Effect.promise<void>(() => new Promise((resolve) => setTimeout(resolve, 25))); + const callsBeforeTerminal = yield* provider.calls; + assert.deepEqual( + callsBeforeTerminal.map((call) => call.instruction), + ["ASK_PROVIDER_QUESTION DELAY_AFTER_ANSWER"], + ); + + yield* provider.completeThread(questionThreadId); + yield* Fiber.join(resolveFiber); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "question race completion", + ); + assert.equal(done?.ticket.currentLaneKey, "done"); + const callsAfterTerminal = yield* provider.calls; + assert.equal(callsAfterTerminal.length, 2); + assert.equal( + callsAfterTerminal[0]?.instruction, + "ASK_PROVIDER_QUESTION DELAY_AFTER_ANSWER", + ); + // The question/answer dialogue becomes ticket messages, so the next + // step's instruction carries the appended discussion transcript. + assert.match( + callsAfterTerminal[1]?.instruction ?? "", + /^must wait for answered turn terminal\n\n## Ticket discussion\n\n/, + ); + assert.include(callsAfterTerminal[1]?.instruction ?? "", "Continue after delayed answer."); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "recovery returns promptly for a non-terminal dispatch whose provider session is gone", + () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-recovery-repo-"); + yield* initRepoWithCommit(repo); + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-nonterminal', + 'project-nonterminal', + 'Nonterminal Board', + '.t3/boards/nonterminal.json', + 'hash-nonterminal', + 3 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-nonterminal', + 'board-nonterminal', + 'Recover nonterminal dispatch', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES ( + 'dispatch-nonterminal', + 'ticket-nonterminal', + 'step-nonterminal', + 'thread-nonterminal', + 'codex', + 'gpt-5.5', + 'recover without hanging', + ${repo}, + 'started', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + + const fiber = yield* Effect.forkChild(recovery.recover()); + let completed = false; + for (let attempt = 0; attempt < 20; attempt += 1) { + const exit = yield* Effect.sync(() => fiber.pollUnsafe()); + if (exit !== undefined) { + completed = true; + break; + } + yield* TestClock.adjust("100 millis"); + yield* Effect.yieldNow; + } + if (!completed) { + yield* Fiber.interrupt(fiber); + assert.fail("Timed out waiting for workflow recovery to return"); + } + yield* Fiber.join(fiber); + const calls = yield* provider.calls; + + assert.deepEqual( + calls.map((call) => call.threadId), + ["thread-nonterminal"], + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("resumes an approval step across restart and continues the pipeline", () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-approval-restart-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-approval-restart"); + const projectId = ProjectId.make("project-approval-restart"); + const ticketId = TicketId.make("ticket-approval-restart"); + const approvalStepRunId = "step-approval-restart" as never; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const recovery = yield* WorkflowRecovery; + const committer = yield* WorkflowEventCommitter; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Approval restart board", + lanes: [ + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [ + { + key: "approve", + type: "approval", + prompt: "Approve continuing?", + }, + { + key: "after-approval", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "continue after durable approval", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Approval restart board", + repoRoot: repo, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-approval-ticket-created" as never, + ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId, + title: "Approval restart ticket", + laneKey: LaneKey.make("review"), + }, + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-approval-ticket-moved" as never, + ticketId, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: LaneKey.make("review"), + laneEntryToken: "token-approval-restart" as never, + reason: "initial", + }, + }); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-approval-pipeline-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-approval-restart" as never, + laneKey: LaneKey.make("review"), + laneEntryToken: "token-approval-restart" as never, + }, + }); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-approval-step-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-approval-restart" as never, + stepRunId: approvalStepRunId, + stepKey: "approve" as never, + stepType: "approval", + }, + }); + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-approval-awaiting-user" as never, + ticketId, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + stepRunId: approvalStepRunId, + waitingReason: "Approve continuing?", + }, + }); + + yield* recovery.recover(); + yield* engine.resolveApproval(approvalStepRunId, true); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "approval restart completion", + ); + if (done === null) { + assert.fail("Expected completed approval restart detail"); + } + const calls = yield* provider.calls; + + assert.equal(done.ticket.currentLaneKey, "done"); + assert.equal(calls.length, 1); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("resumes a provider question across restart and continues the pipeline", () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-provider-restart-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-provider-restart"); + const projectId = ProjectId.make("project-provider-restart"); + const ticketId = TicketId.make("ticket-provider-restart"); + const stepRunId = "step-provider-restart" as never; + const threadId = "thread-provider-restart" as never; + const requestId = "request-provider-restart" as never; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const recovery = yield* WorkflowRecovery; + const committer = yield* WorkflowEventCommitter; + const provider = yield* RealPathProviderDouble; + const sql = yield* SqlClient.SqlClient; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Provider restart board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "ask", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "ASK_PROVIDER_QUESTION", + }, + { + key: "continue", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "continue after durable provider question", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Provider restart board", + repoRoot: repo, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-provider-ticket-created" as never, + ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId, + title: "Provider restart ticket", + laneKey: LaneKey.make("implement"), + }, + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-provider-ticket-moved" as never, + ticketId, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: LaneKey.make("implement"), + laneEntryToken: "token-provider-restart" as never, + reason: "initial", + }, + }); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-provider-pipeline-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-provider-restart" as never, + laneKey: LaneKey.make("implement"), + laneEntryToken: "token-provider-restart" as never, + }, + }); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-provider-step-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-provider-restart" as never, + stepRunId, + stepKey: "ask" as never, + stepType: "agent", + }, + }); + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-provider-awaiting-user" as never, + ticketId, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + stepRunId, + waitingReason: "Provider is waiting for user input", + providerThreadId: threadId, + providerRequestId: requestId, + providerResponseKind: "user-input", + }, + }); + 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 ( + ${threadId}, + 'turn-provider-restart', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:04.000Z', + '2026-06-07T00:00:04.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES ( + 'dispatch-provider-restart', + ${ticketId}, + ${stepRunId}, + ${threadId}, + 'codex', + 'gpt-5.5', + 'ASK_PROVIDER_QUESTION', + ${repo}, + 'started', + '2026-06-07T00:00:04.000Z', + '2026-06-07T00:00:04.000Z' + ) + `; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES ( + 'activity-provider-restart-user-input', + ${threadId}, + 'turn-provider-restart', + 'approval', + 'user-input.requested', + 'Provider restart question', + ${`{"requestId":"${requestId}"}`}, + 1, + '2026-06-07T00:00:04.000Z' + ) + `; + + yield* recovery.recover(); + yield* engine.answerTicketStep({ + stepRunId, + text: "Continue after restart.", + }); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "provider restart completion", + ); + if (done === null) { + assert.fail("Expected completed provider restart detail"); + } + const calls = yield* provider.calls; + const responses = yield* provider.responses; + + assert.equal(done.ticket.currentLaneKey, "done"); + assert.equal(calls.length, 1); + assert.deepEqual( + responses.map((response) => response.requestId), + [requestId], + ); + assert.deepEqual( + responses.map((response) => response.responseKind), + ["user-input"], + ); + assert.deepEqual( + responses.map((response) => response.text), + ["Continue after restart."], + ); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowSourceCommitter.test.ts b/apps/server/src/workflow/Layers/WorkflowSourceCommitter.test.ts new file mode 100644 index 00000000000..8f6a597d35a --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowSourceCommitter.test.ts @@ -0,0 +1,780 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import type { BoardTicketView } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowBoardEvents } from "../Services/WorkflowBoardEvents.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { + WorkflowSourceCommitter, + type ReconcileLanes, + type SourceDelta, + type SourceItemFields, +} from "../Services/WorkflowSourceCommitter.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; +import { WorkflowSourceCommitterLive } from "./WorkflowSourceCommitter.ts"; + +// A step that blocks forever so a ticket admitted into an auto lane keeps a +// running pipeline (lets us prove the post-tx pipeline-start path runs). +const blockingExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.never, +} satisfies StepExecutorShape); + +const layer = it.layer( + WorkflowSourceCommitterLive.pipe( + Layer.provideMerge(WorkflowEngineLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(blockingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +// inbox is WIP-1 (a second create queues), work is auto (admitted tickets start +// a blocking pipeline), done is the terminal lane closes route into. +const definition = { + name: "work source committer", + lanes: [ + { key: "inbox", name: "Inbox", entry: "manual", wipLimit: 1 }, + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const lanes: ReconcileLanes = { + destinationLane: "inbox" as never, + closedLane: "done" as never, +}; + +const item = (over: Partial<SourceItemFields> = {}): SourceItemFields => ({ + sourceId: "src-1", + provider: "github_issues", + externalId: "issue-1", + title: "Upstream issue", + description: "body", + contentHash: "hash-v1", + providerVersion: "v1", + metadata: { provider: "github_issues", url: "https://example/1", labels: ["bug"] }, + ...over, +}); + +interface MappingRow { + readonly ticketId: string; + readonly contentHash: string; + readonly providerVersion: string | null; + readonly lifecycle: string; + readonly syncStatus: string; +} + +const readMapping = (boardId: string, ext: SourceItemFields) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<MappingRow>` + SELECT ticket_id AS "ticketId", content_hash AS "contentHash", + provider_version AS "providerVersion", lifecycle AS "lifecycle", + sync_status AS "syncStatus" + FROM work_source_mapping + WHERE board_id = ${boardId} AND source_id = ${ext.sourceId} + AND provider = ${ext.provider} AND external_id = ${ext.externalId} + LIMIT 1 + `; + return rows[0] ?? null; + }); + +const countMappings = (boardId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM work_source_mapping WHERE board_id = ${boardId} + `; + return rows[0]?.count ?? 0; + }); + +layer("WorkflowSourceCommitter.reconcileChunk", (it) => { + it.effect("create: a new delta creates a ticket + mapping row in the same tx", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-create" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const read = yield* WorkflowReadModel; + + const ext = item(); + yield* committer.reconcileChunk("b-create" as never, lanes, [{ _tag: "new", item: ext }]); + + const mapping = yield* readMapping("b-create", ext); + assert.isNotNull(mapping); + assert.equal(mapping?.contentHash, "hash-v1"); + assert.equal(mapping?.lifecycle, "open"); + assert.equal(mapping?.syncStatus, "active"); + + const detail = yield* read.getTicketDetail(mapping?.ticketId as never); + assert.equal(detail?.ticket.currentLaneKey, "inbox"); + assert.equal(detail?.ticket.title, "Upstream issue"); + assert.equal(detail?.ticket.description, "body"); + }), + ); + + it.effect("idempotent create: the same new delta twice yields exactly one ticket + mapping", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-idem" as never, definition); + const committer = yield* WorkflowSourceCommitter; + + const ext = item(); + yield* committer.reconcileChunk("b-idem" as never, lanes, [{ _tag: "new", item: ext }]); + // Re-run with the SAME new delta (simulating a stale out-of-lock diff). + yield* committer.reconcileChunk("b-idem" as never, lanes, [{ _tag: "new", item: ext }]); + + assert.equal(yield* countMappings("b-idem"), 1); + + const sql = yield* SqlClient.SqlClient; + const tickets = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_ticket WHERE board_id = 'b-idem' + `; + assert.equal(tickets[0]?.count ?? 0, 1); + }), + ); + + it.effect( + "change: differing content_hash edits the ticket + bumps the mapping; same hash is a no-op", + () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-change" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const ext = item(); + yield* committer.reconcileChunk("b-change" as never, lanes, [{ _tag: "new", item: ext }]); + const created = yield* readMapping("b-change", ext); + const ticketId = created?.ticketId as string; + + // Same hash → no write. + yield* committer.reconcileChunk("b-change" as never, lanes, [ + { _tag: "changed", item: ext, ticketId }, + ]); + let events = yield* Stream.runCollect(store.readByTicket(ticketId as never)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isUndefined(events.find((event) => event.type === "TicketEdited")); + + // Differing hash → edit + mapping bump. + const changed = item({ + title: "Renamed issue", + description: "new body", + contentHash: "hash-v2", + providerVersion: "v2", + }); + yield* committer.reconcileChunk("b-change" as never, lanes, [ + { _tag: "changed", item: changed, ticketId }, + ]); + + const detail = yield* read.getTicketDetail(ticketId as never); + assert.equal(detail?.ticket.title, "Renamed issue"); + assert.equal(detail?.ticket.description, "new body"); + + const mapping = yield* readMapping("b-change", ext); + assert.equal(mapping?.contentHash, "hash-v2"); + assert.equal(mapping?.providerVersion, "v2"); + + events = yield* Stream.runCollect(store.readByTicket(ticketId as never)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isDefined(events.find((event) => event.type === "TicketEdited")); + }), + ); + + it.effect( + "close: a closed delta routes to closedLane with source work_source + mapping lifecycle closed", + () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-close" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const read = yield* WorkflowReadModel; + + const ext = item(); + yield* committer.reconcileChunk("b-close" as never, lanes, [{ _tag: "new", item: ext }]); + const created = yield* readMapping("b-close", ext); + const ticketId = created?.ticketId as string; + + yield* committer.reconcileChunk("b-close" as never, lanes, [ + { _tag: "closed", item: ext, ticketId }, + ]); + + const detail = yield* read.getTicketDetail(ticketId as never); + assert.equal(detail?.ticket.currentLaneKey, "done"); + + const mapping = yield* readMapping("b-close", ext); + assert.equal(mapping?.lifecycle, "closed"); + + const decisions = yield* read.listTicketRouteDecisions(ticketId as never); + assert.isDefined(decisions.find((row) => row.source === "work_source")); + }), + ); + + it.effect( + "orphan: a missing delta marks sync_status orphaned; confirmedDeleted also closes", + () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-orphan" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const read = yield* WorkflowReadModel; + + // Orphan-only path. + const a = item({ externalId: "issue-a" }); + yield* committer.reconcileChunk("b-orphan" as never, lanes, [{ _tag: "new", item: a }]); + const mappedA = yield* readMapping("b-orphan", a); + yield* committer.reconcileChunk("b-orphan" as never, lanes, [ + { _tag: "missing", item: a, ticketId: mappedA?.ticketId as string }, + ]); + const orphanA = yield* readMapping("b-orphan", a); + assert.equal(orphanA?.syncStatus, "orphaned"); + assert.equal(orphanA?.lifecycle, "open"); + const detailA = yield* read.getTicketDetail(mappedA?.ticketId as never); + assert.equal(detailA?.ticket.currentLaneKey, "inbox"); + + // confirmedDeleted path → also terminal route + lifecycle closed. + const b = item({ externalId: "issue-b" }); + yield* committer.reconcileChunk("b-orphan" as never, lanes, [{ _tag: "new", item: b }]); + const mappedB = yield* readMapping("b-orphan", b); + yield* committer.reconcileChunk("b-orphan" as never, lanes, [ + { + _tag: "missing", + item: b, + ticketId: mappedB?.ticketId as string, + confirmedDeleted: true, + }, + ]); + const orphanB = yield* readMapping("b-orphan", b); + assert.equal(orphanB?.syncStatus, "orphaned"); + assert.equal(orphanB?.lifecycle, "closed"); + const detailB = yield* read.getTicketDetail(mappedB?.ticketId as never); + assert.equal(detailB?.ticket.currentLaneKey, "done"); + }), + ); + + it.effect("WIP serialization: a second create into a WIP-1 lane already at capacity queues", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-wip" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const read = yield* WorkflowReadModel; + + const occupant = item({ externalId: "wip-1", title: "Occupant" }); + const queued = item({ externalId: "wip-2", title: "Queued" }); + // Both into the WIP-1 inbox lane via the committer (admission lock path). + yield* committer.reconcileChunk("b-wip" as never, lanes, [ + { _tag: "new", item: occupant }, + { _tag: "new", item: queued }, + ]); + + const admitted = yield* read.countAdmittedInLane("b-wip" as never, "inbox" as never); + assert.equal(admitted, 1); + + const queuedMapping = yield* readMapping("b-wip", queued); + const queuedDetail = yield* read.getTicketDetail(queuedMapping?.ticketId as never); + assert.equal(queuedDetail?.ticket.currentLaneKey, "inbox"); + assert.equal(queuedDetail?.ticket.queuedAt !== null, true); + }), + ); + + it.effect( + "post-tx pipeline start: a create into an auto lane starts the pipeline after the chunk", + () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pipeline" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + + // Destination = the auto `work` lane: createTicketAndEnterUnlocked drops the + // pipeline start inside the chunk tx; recoverBoardWip (post-tx) starts it. + const autoLanes: ReconcileLanes = { + destinationLane: "work" as never, + closedLane: "done" as never, + }; + const ext = item({ externalId: "auto-1" }); + yield* committer.reconcileChunk("b-pipeline" as never, autoLanes, [ + { _tag: "new", item: ext }, + ]); + + const mapping = yield* readMapping("b-pipeline", ext); + const runs = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_pipeline_run + WHERE ticket_id = ${mapping?.ticketId as string} + `; + assert.isAbove(runs[0]?.count ?? 0, 0); + }), + ); + + // Fix 4: a chunk whose destinationLane/closedLane does not exist on the + // CURRENT board definition fails with a typed WorkflowEventStoreError and + // creates/moves nothing. + it.effect("validate lanes: a missing destination lane fails the chunk; nothing is created", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-badlane" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + + const badLanes: ReconcileLanes = { + destinationLane: "ghost" as never, + closedLane: "done" as never, + }; + const ext = item({ externalId: "bad-1" }); + const exit = yield* committer + .reconcileChunk("b-badlane" as never, badLanes, [{ _tag: "new", item: ext }]) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + + assert.equal(yield* countMappings("b-badlane"), 0); + const tickets = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_ticket WHERE board_id = 'b-badlane' + `; + assert.equal(tickets[0]?.count ?? 0, 0); + }), + ); + + it.effect("validate lanes: a missing closed lane fails the chunk; nothing is created", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-badclosed" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + + const badLanes: ReconcileLanes = { + destinationLane: "inbox" as never, + closedLane: "ghost" as never, + }; + const ext = item({ externalId: "bad-2" }); + const exit = yield* committer + .reconcileChunk("b-badclosed" as never, badLanes, [{ _tag: "new", item: ext }]) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + + assert.equal(yield* countMappings("b-badclosed"), 0); + const tickets = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_ticket WHERE board_id = 'b-badclosed' + `; + assert.equal(tickets[0]?.count ?? 0, 0); + }), + ); +}); + +// --------------------------------------------------------------------------- +// Fixes 2 (post-tx provider cancel), 3 (no orphan on UNIQUE), 6 (post-tx +// publish). Each uses a fresh layer so DeterministicWorkflowIds counters start +// clean (Fix 3 predicts the first ticket id) and a recording ProviderService / +// WorkflowBoardEvents captures the post-tx side effects. +// --------------------------------------------------------------------------- + +interface ProviderCall { + readonly kind: "interrupt" | "stop"; + readonly threadId: string; +} + +// Decorates the real engine so recoverBoardWip FAILS post-commit — proves the +// committer's publish + provider-cancel still run and reconcileChunk does not +// fail. Requires the real engine under the same tag (provided via Layer.provide) +// and re-publishes it with only recoverBoardWip overridden to fail. +const failingRecoverEngineLayer = Layer.effect( + WorkflowEngine, + Effect.gen(function* () { + const base = yield* WorkflowEngine; + return { + ...base, + recoverBoardWip: () => + new WorkflowEventStoreError({ message: "boom: recoverBoardWip failed" }), + } satisfies typeof base; + }), +).pipe(Layer.provide(WorkflowEngineLayer)); + +const makeCommitterLayer = ( + providerCalls: Ref.Ref<ReadonlyArray<ProviderCall>>, + published: Ref.Ref<ReadonlyArray<string>>, + engineLayer: typeof WorkflowEngineLayer = WorkflowEngineLayer, +) => + WorkflowSourceCommitterLive.pipe( + Layer.provideMerge(engineLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(blockingExecutor), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: (input) => + Ref.update(providerCalls, (calls) => [ + ...calls, + { kind: "interrupt" as const, threadId: input.threadId as string }, + ]), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: (input) => + Ref.update(providerCalls, (calls) => [ + ...calls, + { kind: "stop" as const, threadId: input.threadId as string }, + ]), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + } satisfies ProviderServiceShape), + ), + Layer.provideMerge( + Layer.succeed(WorkflowBoardEvents, { + publish: (ticket: BoardTicketView) => + Ref.update(published, (ids) => [...ids, ticket.ticketId as string]), + stream: () => Stream.empty, + subscribe: () => Effect.succeed(Stream.empty), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +it.effect("Fix 2: a closed delta with running provider work cancels the session POST-commit", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make<ReadonlyArray<ProviderCall>>([]); + const published = yield* Ref.make<ReadonlyArray<string>>([]); + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-cancel" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + + const ext = item({ externalId: "cancel-1" }); + yield* committer.reconcileChunk("b-cancel" as never, lanes, [{ _tag: "new", item: ext }]); + const created = yield* readMapping("b-cancel", ext); + const ticketId = created?.ticketId as string; + + // Seed an in-flight provider dispatch row for the ticket. The in-tx close + // tombstones it (DB), and the committer cancels the provider session + // POST-tx using the snapshot captured before the tombstone. + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, ticket_id, step_run_id, thread_id, turn_id, provider_instance, + model, instruction, worktree_path, status, created_at, started_at + ) VALUES ( + 'dispatch-cancel-1', ${ticketId}, 'step-cancel-1', 'thread-cancel-1', + 'turn-cancel-1', 'codex', 'gpt-5.5', 'cancel me', '/tmp/wt', 'started', + '2026-06-13T00:00:00.000Z', '2026-06-13T00:00:00.000Z' + ) + `; + + yield* committer.reconcileChunk("b-cancel" as never, lanes, [ + { _tag: "closed", item: ext, ticketId }, + ]); + + // The provider session was interrupted + stopped (post-commit). + const calls = yield* Ref.get(providerCalls); + assert.deepEqual(calls, [ + { kind: "interrupt", threadId: "thread-cancel-1" }, + { kind: "stop", threadId: "thread-cancel-1" }, + ]); + + // The dispatch row was tombstoned in-tx (no live pending/started rows). + const live = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_dispatch_outbox + WHERE ticket_id = ${ticketId} AND status IN ('pending', 'started') + `; + assert.equal(live[0]?.count ?? 0, 0); + + const detail = yield* (yield* WorkflowReadModel).getTicketDetail(ticketId as never); + assert.equal(detail?.ticket.currentLaneKey, "done"); + }).pipe(Effect.provide(makeCommitterLayer(providerCalls, published))); + }), +); + +it.effect( + "Fix 2: a later failing delta rolls back the close WITHOUT cancelling the provider mid-tx", + () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make<ReadonlyArray<ProviderCall>>([]); + const published = yield* Ref.make<ReadonlyArray<string>>([]); + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-rollback" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + const read = yield* WorkflowReadModel; + + const closing = item({ externalId: "rollback-close" }); + yield* committer.reconcileChunk("b-rollback" as never, lanes, [ + { _tag: "new", item: closing }, + ]); + const created = yield* readMapping("b-rollback", closing); + const ticketId = created?.ticketId as string; + + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, ticket_id, step_run_id, thread_id, turn_id, provider_instance, + model, instruction, worktree_path, status, created_at, started_at + ) VALUES ( + 'dispatch-rollback-1', ${ticketId}, 'step-rollback-1', 'thread-rollback-1', + 'turn-rollback-1', 'codex', 'gpt-5.5', 'cancel me', '/tmp/wt', 'started', + '2026-06-13T00:00:00.000Z', '2026-06-13T00:00:00.000Z' + ) + `; + + // A LATER `new` delta whose ticket_id collides: pre-insert a mapping row + // whose ticket_id equals the id the next created ticket WILL receive, so + // its mapping INSERT hits the UNIQUE(ticket_id) index and fails the tx. + // The re-read (by external key) misses, so the create proceeds to the + // failing insert. This forces a chunk rollback AFTER the close applied + // in-tx. + const failing = item({ externalId: "rollback-new" }); + const nextTicketId = yield* sql<{ readonly value: string }>` + SELECT 'ticket-' || ( + COALESCE(MAX(CAST(SUBSTR(ticket_id, 8) AS INTEGER)), 0) + 1 + ) AS value + FROM projection_ticket WHERE ticket_id LIKE 'ticket-%' + `.pipe(Effect.map((rows) => rows[0]?.value as string)); + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, source_metadata_json, + created_at, last_synced_at + ) VALUES ( + 'mapping-collide', 'b-rollback', 'other-src', 'other-prov', 'other-ext', + ${nextTicketId}, 'h', 'open', 'active', '{}', + '2026-06-13T00:00:00.000Z', '2026-06-13T00:00:00.000Z' + ) + `; + + const exit = yield* committer + .reconcileChunk("b-rollback" as never, lanes, [ + { _tag: "closed", item: closing, ticketId }, + { _tag: "new", item: failing }, + ]) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + + // The close's DB change rolled back: the ticket is NOT in `done`. + const detail = yield* read.getTicketDetail(ticketId as never); + assert.equal(detail?.ticket.currentLaneKey, "inbox"); + // The dispatch tombstone rolled back too. + const live = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_dispatch_outbox + WHERE ticket_id = ${ticketId} AND status IN ('pending', 'started') + `; + assert.equal(live[0]?.count ?? 0, 1); + // Critically: the provider session was NOT cancelled, because the + // cancellation only runs post-tx and the tx rolled back. + assert.deepEqual(yield* Ref.get(providerCalls), []); + }).pipe(Effect.provide(makeCommitterLayer(providerCalls, published))); + }), +); + +it.effect( + "Fix 3: a UNIQUE violation on the mapping insert rolls back the chunk; no orphan ticket", + () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make<ReadonlyArray<ProviderCall>>([]); + const published = yield* Ref.make<ReadonlyArray<string>>([]); + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-orphan-guard" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + + // Pre-seed a mapping row whose ticket_id is the one the FIRST created + // ticket will get (ticket-1 on this fresh, deterministic layer), under a + // DIFFERENT external key so the in-tx re-read for our delta misses. The + // create then collides on UNIQUE(ticket_id) — the violation must NOT be + // swallowed, rolling back the just-created ticket. + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, source_metadata_json, + created_at, last_synced_at + ) VALUES ( + 'mapping-pre', 'b-orphan-guard', 'pre-src', 'pre-prov', 'pre-ext', + 'ticket-1', 'h', 'open', 'active', '{}', + '2026-06-13T00:00:00.000Z', '2026-06-13T00:00:00.000Z' + ) + `; + + const ext = item({ externalId: "orphan-guard-1" }); + const exit = yield* committer + .reconcileChunk("b-orphan-guard" as never, lanes, [{ _tag: "new", item: ext }]) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + + // No orphan ticket survives: the only mapping is the pre-seeded one, and + // no ticket exists for our delta's external key. + assert.equal(yield* countMappings("b-orphan-guard"), 1); + const tickets = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_ticket WHERE board_id = 'b-orphan-guard' + `; + assert.equal(tickets[0]?.count ?? 0, 0); + // Our delta's mapping was never created. + assert.isNull(yield* readMapping("b-orphan-guard", ext)); + }).pipe(Effect.provide(makeCommitterLayer(providerCalls, published))); + }), +); + +it.effect( + "Fix 6: created / changed / closed tickets are published to WorkflowBoardEvents post-tx", + () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make<ReadonlyArray<ProviderCall>>([]); + const published = yield* Ref.make<ReadonlyArray<string>>([]); + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-publish" as never, definition); + const committer = yield* WorkflowSourceCommitter; + + // Create. + const ext = item({ externalId: "publish-1" }); + yield* committer.reconcileChunk("b-publish" as never, lanes, [{ _tag: "new", item: ext }]); + const created = yield* readMapping("b-publish", ext); + const ticketId = created?.ticketId as string; + assert.include(yield* Ref.get(published), ticketId); + + // Reset and exercise a change publish. + yield* Ref.set(published, []); + const changed = item({ externalId: "publish-1", contentHash: "hash-v2", title: "Renamed" }); + yield* committer.reconcileChunk("b-publish" as never, lanes, [ + { _tag: "changed", item: changed, ticketId }, + ]); + assert.include(yield* Ref.get(published), ticketId); + + // Reset and exercise a close publish. + yield* Ref.set(published, []); + yield* committer.reconcileChunk("b-publish" as never, lanes, [ + { _tag: "closed", item: ext, ticketId }, + ]); + assert.include(yield* Ref.get(published), ticketId); + }).pipe(Effect.provide(makeCommitterLayer(providerCalls, published))); + }), +); + +it.effect( + "post-commit ordering: a failing recoverBoardWip does NOT suppress publish + provider-cancel for a committed close", + () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make<ReadonlyArray<ProviderCall>>([]); + const published = yield* Ref.make<ReadonlyArray<string>>([]); + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-recover-fail" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + const read = yield* WorkflowReadModel; + + const ext = item({ externalId: "recover-fail-1" }); + yield* committer.reconcileChunk("b-recover-fail" as never, lanes, [ + { _tag: "new", item: ext }, + ]); + const created = yield* readMapping("b-recover-fail", ext); + const ticketId = created?.ticketId as string; + + // In-flight provider work on the ticket. + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, ticket_id, step_run_id, thread_id, turn_id, provider_instance, + model, instruction, worktree_path, status, created_at, started_at + ) VALUES ( + 'dispatch-recover-fail-1', ${ticketId}, 'step-recover-fail-1', 'thread-recover-fail-1', + 'turn-recover-fail-1', 'codex', 'gpt-5.5', 'cancel me', '/tmp/wt', 'started', + '2026-06-13T00:00:00.000Z', '2026-06-13T00:00:00.000Z' + ) + `; + + // The close commits; recoverBoardWip then FAILS post-commit. The + // committer must still publish + cancel the provider session, and + // reconcileChunk must NOT fail (recoverBoardWip is backstopped). + yield* Ref.set(published, []); + const exit = yield* committer + .reconcileChunk("b-recover-fail" as never, lanes, [ + { _tag: "closed", item: ext, ticketId }, + ]) + .pipe(Effect.exit); + assert.isTrue(Exit.isSuccess(exit)); + + // Provider cancellation STILL ran (independent of recoverBoardWip). + assert.deepEqual(yield* Ref.get(providerCalls), [ + { kind: "interrupt", threadId: "thread-recover-fail-1" }, + { kind: "stop", threadId: "thread-recover-fail-1" }, + ]); + + // The closed ticket's view was STILL published. + assert.include(yield* Ref.get(published), ticketId); + + // The close itself durably landed (it committed before recovery failed). + const detail = yield* read.getTicketDetail(ticketId as never); + assert.equal(detail?.ticket.currentLaneKey, "done"); + }).pipe( + Effect.provide(makeCommitterLayer(providerCalls, published, failingRecoverEngineLayer)), + ); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowSourceCommitter.ts b/apps/server/src/workflow/Layers/WorkflowSourceCommitter.ts new file mode 100644 index 00000000000..b87242d9f95 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowSourceCommitter.ts @@ -0,0 +1,482 @@ +import type { BoardId, LaneKey, ThreadId, TicketId, TurnId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { + WorkflowSourceCommitter, + type ReconcileLanes, + type SourceDelta, + type SourceItemFields, + type WorkflowSourceCommitterShape, +} from "../Services/WorkflowSourceCommitter.ts"; +import { serializeSourceMetadata } from "../sourceReconcileDiff.ts"; + +const toCommitterError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrap = <A>(message: string, effect: Effect.Effect<A, SqlError>) => + effect.pipe(Effect.mapError(toCommitterError(message))); + +interface MappingRow { + readonly mappingId: string; + readonly ticketId: string; + readonly contentHash: string; + readonly lifecycle: string; + readonly syncStatus: string; + readonly sourceMetadataJson: string | null; +} + +// What a single delta application touched. Drives the POST-TX steps: +// - `publishTicketId`: a created/edited/closed/orphaned ticket whose live view +// must be pushed to WorkflowBoardEvents after the lock/tx releases (the +// unlocked cores append+project but never publish). +// - `republishDependents`: a terminal/closed move republishes dependents too. +// - `cancelTurns`: provider turns SNAPSHOTTED in-tx (before the close tombstoned +// the outbox rows) to interrupt+cancel after the tx commits — no provider IO +// runs inside the transaction. +// - `stopAgentThreads`: per-agent session thread ids SNAPSHOTTED in-tx (before +// the close's terminal teardown deleted the rows) to `provider.stopSession` +// after the tx commits — `stopSession` is a non-rollbackable live side effect +// (kills the session + its own SQL write) so it must not run inside the tx. +interface DeltaEffect { + readonly publishTicketId: TicketId | null; + readonly republishDependents: boolean; + readonly cancelTicketId: TicketId | null; + readonly cancelTurns: ReadonlyArray<{ + readonly threadId: ThreadId; + readonly turnId: TurnId | null; + }>; + readonly stopAgentThreads: ReadonlyArray<string>; +} + +const noEffect: DeltaEffect = { + publishTicketId: null, + republishDependents: false, + cancelTicketId: null, + cancelTurns: [], + stopAgentThreads: [], +}; + +// Re-use the diff's canonical serializer so the value we WRITE is byte-identical +// to what the diff COMPARES against — otherwise every sync would observe a phantom +// metadata change and churn. +const serializeMetadata = serializeSourceMetadata; + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const registry = yield* BoardRegistry; + const saveLocks = yield* WorkflowBoardSaveLocks; + const ids = yield* WorkflowIds; + + // Re-read the mapping row by the UNIQUE key INSIDE the transaction. The diff + // that produced the delta ran outside the lock, so a concurrent batch may have + // mutated the table since; this revalidation is the authority. + const readMapping = (boardId: BoardId, item: SourceItemFields) => + wrap( + "WorkflowSourceCommitter.readMapping", + sql<MappingRow>` + SELECT + mapping_id AS "mappingId", + ticket_id AS "ticketId", + content_hash AS "contentHash", + lifecycle AS "lifecycle", + sync_status AS "syncStatus", + source_metadata_json AS "sourceMetadataJson" + FROM work_source_mapping + WHERE board_id = ${String(boardId)} + AND source_id = ${item.sourceId} + AND provider = ${item.provider} + AND external_id = ${item.externalId} + LIMIT 1 + `, + ).pipe(Effect.map((rows) => rows[0] ?? null)); + + const applyNew = ( + boardId: BoardId, + lanes: ReconcileLanes, + item: SourceItemFields, + ): Effect.Effect<DeltaEffect, WorkflowEventStoreError> => + Effect.gen(function* () { + // In-tx recheck: if a mapping already exists (a stale "new" diff, or a + // racing batch won) do nothing — exactly one ticket/mapping per item. + const existing = yield* readMapping(boardId, item); + if (existing !== null) { + return noEffect; + } + const created = yield* engine.createTicketAndEnterUnlocked({ + boardId, + title: item.title, + ...(item.description === undefined ? {} : { description: item.description }), + destinationLane: lanes.destinationLane, + }); + const mappingId = yield* ids.mappingId(); + const now = DateTime.formatIso(yield* DateTime.now); + // INSERT in the SAME transaction as the ticket create. A UNIQUE violation + // here means a genuine conflict the in-tx re-read above did NOT catch (the + // mapping was committed by a racing writer between the re-read and this + // insert). We do NOT swallow it: letting it propagate ROLLS BACK the whole + // chunk tx (the just-created ticket rolls back with it), so we never commit + // an orphan ticket with no mapping. The next sync cycle's in-tx re-read + // finds the now-committed mapping and skips. + yield* wrap( + "WorkflowSourceCommitter.insertMapping", + sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + provider_version, content_hash, lifecycle, sync_status, + source_metadata_json, created_at, last_synced_at + ) VALUES ( + ${mappingId}, ${String(boardId)}, ${item.sourceId}, ${item.provider}, + ${item.externalId}, ${String(created.ticketId)}, + ${item.providerVersion ?? null}, ${item.contentHash}, 'open', 'active', + ${serializeMetadata(item.metadata)}, ${now}, ${now} + ) + `, + ); + return { ...noEffect, publishTicketId: created.ticketId }; + }); + + const applyChanged = ( + boardId: BoardId, + item: SourceItemFields, + ): Effect.Effect<DeltaEffect, WorkflowEventStoreError> => + Effect.gen(function* () { + const row = yield* readMapping(boardId, item); + if (row === null) { + return noEffect; + } + const metadataJson = serializeMetadata(item.metadata); + const contentChanged = item.contentHash !== row.contentHash; + const metadataChanged = metadataJson !== row.sourceMetadataJson; + // Idempotency gate: neither the synced content (title/description) NOR the + // metadata (labels/assignees/url, surfaced as syncedSource) changed → no write. + if (!contentChanged && !metadataChanged) { + return noEffect; + } + // Only EDIT the ticket when the content actually changed — a metadata-only + // change must not emit a redundant TicketEdited (it rewrites title/desc). + if (contentChanged) { + yield* engine.editTicketFieldsUnlocked(row.ticketId as TicketId, { + title: item.title, + ...(item.description === undefined ? {} : { description: item.description }), + }); + } + const now = DateTime.formatIso(yield* DateTime.now); + yield* wrap( + "WorkflowSourceCommitter.updateChanged", + sql` + UPDATE work_source_mapping + SET provider_version = ${item.providerVersion ?? null}, + content_hash = ${item.contentHash}, + source_metadata_json = ${metadataJson}, + last_synced_at = ${now} + WHERE mapping_id = ${row.mappingId} + `, + ); + // Publish so the ticket's syncedSource view refreshes even on a + // metadata-only change. + return { ...noEffect, publishTicketId: row.ticketId as TicketId }; + }); + + // Reopen/reactivate a mapped item that is OPEN upstream but whose mapping is + // closed (previously source-closed) or orphaned (previously went missing). + // Restores lifecycle='open'/sync_status='active', routes the ticket back out of + // the closed lane when it was closed, and refreshes content. Idempotent: the + // in-tx re-read drives what actually needs restoring. + const applyReopened = ( + boardId: BoardId, + lanes: ReconcileLanes, + item: SourceItemFields, + ): Effect.Effect<DeltaEffect, WorkflowEventStoreError> => + Effect.gen(function* () { + const row = yield* readMapping(boardId, item); + if (row === null) { + return noEffect; + } + let effectResult: DeltaEffect = { ...noEffect, publishTicketId: row.ticketId as TicketId }; + if (row.lifecycle === "closed") { + // Route the ticket out of the closed lane back into the destination lane. + yield* engine.reopenTicketFromSourceUnlocked( + row.ticketId as TicketId, + lanes.destinationLane, + ); + effectResult = { ...effectResult, republishDependents: true }; + } + // Refresh the ticket fields only when the content actually changed (a pure + // reopen with unchanged content must not emit a redundant edit). + if (item.contentHash !== row.contentHash) { + yield* engine.editTicketFieldsUnlocked(row.ticketId as TicketId, { + title: item.title, + ...(item.description === undefined ? {} : { description: item.description }), + }); + } + const now = DateTime.formatIso(yield* DateTime.now); + yield* wrap( + "WorkflowSourceCommitter.updateReopened", + sql` + UPDATE work_source_mapping + SET lifecycle = 'open', + sync_status = 'active', + content_hash = ${item.contentHash}, + provider_version = ${item.providerVersion ?? null}, + source_metadata_json = ${serializeMetadata(item.metadata)}, + last_synced_at = ${now} + WHERE mapping_id = ${row.mappingId} + `, + ); + return effectResult; + }); + + // Close a ticket from the source: snapshot its cancellable provider turns AND + // its stored per-agent session threads BEFORE the in-tx supersession/teardown + // hides them, then route it to the closed lane (DB-only supersession: tombstone + // + tx-safe deleteByTicket, no provider IO). The returned DeltaEffect carries + // both snapshots so the committer cancels turns AND stops agent sessions POST-TX + // — `stopSession` is a non-rollbackable live side effect that must not run in + // the chunk transaction. + const closeTicket = ( + ticketId: TicketId, + closedLane: LaneKey, + ): Effect.Effect<DeltaEffect, WorkflowEventStoreError> => + Effect.gen(function* () { + const cancelTurns = yield* engine.cancellableProviderTurnsForTicket(ticketId); + const stopAgentThreads = yield* engine.terminalAgentSessionThreadsForTicket(ticketId); + yield* engine.closeTicketFromSourceUnlocked(ticketId, closedLane); + return { + publishTicketId: ticketId, + republishDependents: true, + cancelTicketId: ticketId, + cancelTurns, + stopAgentThreads, + }; + }); + + const applyClosed = ( + boardId: BoardId, + lanes: ReconcileLanes, + item: SourceItemFields, + ): Effect.Effect<DeltaEffect, WorkflowEventStoreError> => + Effect.gen(function* () { + const row = yield* readMapping(boardId, item); + if (row === null || row.lifecycle === "closed") { + return noEffect; + } + const effectResult = yield* closeTicket(row.ticketId as TicketId, lanes.closedLane); + const now = DateTime.formatIso(yield* DateTime.now); + yield* wrap( + "WorkflowSourceCommitter.updateClosed", + sql` + UPDATE work_source_mapping + SET lifecycle = 'closed', content_hash = ${item.contentHash}, last_synced_at = ${now} + WHERE mapping_id = ${row.mappingId} + `, + ); + return effectResult; + }); + + const applyMissing = ( + boardId: BoardId, + lanes: ReconcileLanes, + item: SourceItemFields, + confirmedDeleted: boolean, + ): Effect.Effect<DeltaEffect, WorkflowEventStoreError> => + Effect.gen(function* () { + const row = yield* readMapping(boardId, item); + if (row === null) { + return noEffect; + } + const now = DateTime.formatIso(yield* DateTime.now); + // Mark-only: flag the mapping orphaned. The getItem confirmation is a + // provider call done OUTSIDE this tx (in the syncer); when it confirms + // deletion the syncer sets confirmedDeleted and we also terminal-route. + if (confirmedDeleted) { + let effectResult: DeltaEffect = { ...noEffect, publishTicketId: row.ticketId as TicketId }; + if (row.lifecycle !== "closed") { + effectResult = yield* closeTicket(row.ticketId as TicketId, lanes.closedLane); + } + yield* wrap( + "WorkflowSourceCommitter.markOrphanClosed", + sql` + UPDATE work_source_mapping + SET sync_status = 'orphaned', lifecycle = 'closed', last_synced_at = ${now} + WHERE mapping_id = ${row.mappingId} + `, + ); + return effectResult; + } + if (row.syncStatus === "orphaned") { + return noEffect; + } + yield* wrap( + "WorkflowSourceCommitter.markOrphan", + sql` + UPDATE work_source_mapping + SET sync_status = 'orphaned', last_synced_at = ${now} + WHERE mapping_id = ${row.mappingId} + `, + ); + return { ...noEffect, publishTicketId: row.ticketId as TicketId }; + }); + + const applyDelta = ( + boardId: BoardId, + lanes: ReconcileLanes, + delta: SourceDelta, + ): Effect.Effect<DeltaEffect, WorkflowEventStoreError> => { + switch (delta._tag) { + case "new": + return applyNew(boardId, lanes, delta.item); + case "changed": + return applyChanged(boardId, delta.item); + case "reopened": + return applyReopened(boardId, lanes, delta.item); + case "closed": + return applyClosed(boardId, lanes, delta.item); + case "missing": + return applyMissing(boardId, lanes, delta.item, delta.confirmedDeleted === true); + } + }; + + // Validate the destination/closed lanes against the CURRENT board definition + // (the in-memory registry is the source of truth) BEFORE applying any delta. + // A board edited between sync cycles may have removed a lane the diff named; + // enterLaneCore would otherwise emit a move/create for a lane that no longer + // exists and corrupt lane state. Fail the whole chunk (typed error → the + // syncer backs the source off) without creating or moving any ticket. + const validateLanes = (boardId: BoardId, lanes: ReconcileLanes) => + Effect.gen(function* () { + const definition = yield* registry.getDefinition(boardId); + if (definition === null) { + return yield* new WorkflowEventStoreError({ + message: `WorkflowSourceCommitter: board ${String(boardId)} is no longer registered`, + }); + } + const laneKeys = new Set(definition.lanes.map((lane) => lane.key as string)); + for (const laneKey of [lanes.destinationLane, lanes.closedLane]) { + if (!laneKeys.has(laneKey as string)) { + return yield* new WorkflowEventStoreError({ + message: `WorkflowSourceCommitter: lane ${String(laneKey)} does not exist on board ${String(boardId)}`, + }); + } + } + }); + + const reconcileChunk: WorkflowSourceCommitterShape["reconcileChunk"] = (boardId, lanes, deltas) => + Effect.gen(function* () { + if (deltas.length === 0) { + return; + } + // Constraint A — lock order: admission (OUTER) -> save (INNER) -> + // transaction (innermost). The unlocked engine cores assume the admission + // lock is held so sync admits serialize against concurrent user moves and + // cannot violate a WIP limit; this matches the public enterLane order. + const effects = yield* engine.withBoardAdmissionLock( + boardId, + saveLocks.withSaveLock( + boardId, + sql + .withTransaction( + Effect.gen(function* () { + // Lane validation runs INSIDE the locked tx and BEFORE any delta + // so a missing destination/closed lane fails the chunk atomically + // (nothing created/moved). + yield* validateLanes(boardId, lanes); + const collected: Array<DeltaEffect> = []; + for (const delta of deltas) { + collected.push(yield* applyDelta(boardId, lanes, delta)); + } + return collected; + }), + ) + .pipe(Effect.mapError(toCommitterError("WorkflowSourceCommitter.transaction"))), + ), + ); + + // ---- POST-COMMIT phase ---------------------------------------------- + // Everything below runs ONLY because the tx committed and the locks + // released — a rolled-back chunk never reaches here (correct: a rollback + // skips all of it). The close's OWN durable side effects (publish + + // provider-cancel) run FIRST and UNCONDITIONALLY; board WIP recovery runs + // LAST and defensively, because it is an unrelated, backstopped sweep + // whose failure must never suppress side effects tied to THIS committed + // close. + + // 1) Publish the collected ticket views. The unlocked cores append+project + // but never publish to WorkflowBoardEvents, so push a live view for every + // created/edited/closed/orphaned ticket (and dependents on a terminal/ + // closed move) now that the lock/tx has released. Mirrors commitMany's + // post-lock publish. + const published = new Set<string>(); + for (const effectResult of effects) { + if (effectResult.publishTicketId === null) { + continue; + } + const key = `${effectResult.publishTicketId as string}:${effectResult.republishDependents}`; + if (published.has(key)) { + continue; + } + published.add(key); + yield* committer + .publishTicketView(effectResult.publishTicketId, { + republishDependents: effectResult.republishDependents, + }) + .pipe(Effect.catch(() => Effect.void)); + } + + // 2) Provider cancellation for source-closed tickets: interrupt the + // running pipeline fiber + cancel the provider turns snapshotted in-tx. + // Idempotent. Tied to THIS committed close, so it must always run. + for (const effectResult of effects) { + if (effectResult.cancelTicketId !== null) { + yield* engine + .supersedeProviderWorkForTicket(effectResult.cancelTicketId, effectResult.cancelTurns) + .pipe(Effect.catch(() => Effect.void)); + } + } + + // 2b) Stop the per-agent session threads snapshotted in-tx for source-closed + // tickets. The in-tx terminal teardown already deleted the rows (tx-safe); + // `provider.stopSession` is a non-rollbackable live side effect (it kills the + // session + does its own SQL write) so it runs ONLY here, after the chunk + // committed. Best-effort: a stop failure must never fail reconcileChunk. + for (const effectResult of effects) { + if (effectResult.stopAgentThreads.length > 0) { + yield* engine + .stopAgentSessionsForTicket(effectResult.stopAgentThreads) + .pipe(Effect.catch(() => Effect.void)); + } + } + + // 3) Board WIP recovery LAST. The unlocked cores DROP auto-lane pipeline + // starts (SQLite cannot BEGIN-within-BEGIN); recoverBoardWip sweeps the + // board (taking its own admission lock) and starts admitted-but-not-yet- + // started pipelines. It does DB reads + pipeline starts and CAN fail; a + // failure here must NOT propagate to fail reconcileChunk nor suppress the + // publish/provider-cancel above. It is backstopped — WT11's syncer calls + // recoverBoardWip per board per cycle regardless of deltas — so a transient + // failure self-heals. Wrap defensively: log a warning and swallow. + yield* engine.recoverBoardWip(boardId).pipe( + Effect.catch((cause) => + Effect.logWarning("WorkflowSourceCommitter.recoverBoardWip failed post-commit", { + boardId, + cause, + }), + ), + ); + }); + + return { reconcileChunk } satisfies WorkflowSourceCommitterShape; +}); + +export const WorkflowSourceCommitterLive = Layer.effect(WorkflowSourceCommitter, make); diff --git a/apps/server/src/workflow/Layers/WorkflowSourceSyncer.test.ts b/apps/server/src/workflow/Layers/WorkflowSourceSyncer.test.ts new file mode 100644 index 00000000000..bee68a28ee3 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowSourceSyncer.test.ts @@ -0,0 +1,906 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import type { BoardId, LaneKey, WorkflowDefinition } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { PredicateEvaluatorLive } from "../Layers/PredicateEvaluator.ts"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry, type BoardRegistryShape } from "../Services/BoardRegistry.ts"; +import { WorkflowEngine, type WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; +import { + WorkSourceProviderRegistry, + WorkSourceRateLimitError, + WorkSourceTransientError, + type ExternalWorkItem, + type WorkSourcePage, + type WorkSourceProvider, + type WorkSourceProviderError, +} from "../Services/WorkSourceProvider.ts"; +import { + WorkflowSourceCommitter, + type ReconcileLanes, + type SourceDelta, +} from "../Services/WorkflowSourceCommitter.ts"; +import { + MAX_DELTAS_PER_RECONCILE_CHUNK, + WorkflowSourceSyncerLive, +} from "./WorkflowSourceSyncer.ts"; +import { WorkflowSourceSyncer } from "../Services/WorkflowSourceSyncer.ts"; + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- + +// A scriptable provider: each source maps to a sequence of pages keyed by +// pageToken (undefined = first page). getItem returns a configured map of +// externalId -> item|null (null = provider confirms deletion). +interface ProviderScript { + readonly pages: ReadonlyArray<WorkSourcePage>; + readonly getItems: ReadonlyMap<string, ExternalWorkItem | null>; + readonly failWith?: WorkSourceRateLimitError | WorkSourceTransientError; + // When set, getItem fails with this error instead of resolving an item — used + // to prove a getItem ERROR does NOT confirm deletion (it feeds backoff). + readonly getItemFailWith?: WorkSourceRateLimitError | WorkSourceTransientError; +} + +const item = (externalId: string, overrides?: Partial<ExternalWorkItem>): ExternalWorkItem => ({ + provider: "github", + externalId, + url: `https://example.test/${externalId}`, + lifecycle: "open", + version: { updatedAt: "2026-06-13T00:00:00Z" }, + fields: { title: `Item ${externalId}` }, + ...overrides, +}); + +// Build a multi-page script. `pageTokens` are the nextPageToken values; the +// final page omits nextPageToken (exhaustion) unless `lastHasToken` is set. +const makePages = ( + itemsPerPage: ReadonlyArray<ReadonlyArray<ExternalWorkItem>>, + options?: { readonly lastHasToken?: boolean }, +): ReadonlyArray<WorkSourcePage> => + itemsPerPage.map((items, idx) => { + const isLast = idx === itemsPerPage.length - 1; + const hasToken = !isLast || options?.lastHasToken === true; + return hasToken ? { items, nextPageToken: `tok-${idx + 1}` } : { items }; + }); + +// A recording stub committer: appends each reconcileChunk's deltas. +const recordingCommitter = ( + chunks: Ref.Ref< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >, +) => + Layer.succeed(WorkflowSourceCommitter, { + reconcileChunk: (boardId, lanes, deltas) => + Ref.update(chunks, (acc) => [...acc, { boardId: String(boardId), lanes, deltas }]), + }); + +// A recording stub engine: counts recoverBoardWip calls per board. Optionally +// fails recoverBoardWip to prove the defensive wrap swallows it. +const recordingEngine = ( + recoveries: Ref.Ref<Array<string>>, + options?: { readonly recoverFails?: boolean }, +): WorkflowEngineShape => + ({ + recoverBoardWip: (boardId: BoardId) => + Effect.flatMap( + Ref.update(recoveries, (acc) => [...acc, String(boardId)]), + () => (options?.recoverFails === true ? Effect.die("recoverBoardWip boom") : Effect.void), + ), + }) as unknown as WorkflowEngineShape; + +const board = ( + boardId: string, + sources: WorkflowDefinition["sources"], +): { readonly boardId: BoardId; readonly definition: WorkflowDefinition } => ({ + boardId: boardId as BoardId, + definition: { + name: boardId, + lanes: [ + { key: "todo" as LaneKey, name: "Todo", entry: "manual" }, + { key: "done" as LaneKey, name: "Done", entry: "manual", terminal: true }, + ], + sources, + } as unknown as WorkflowDefinition, +}); + +const stubBoardRegistry = ( + boards: ReadonlyArray<{ readonly boardId: BoardId; readonly definition: WorkflowDefinition }>, +): BoardRegistryShape => + ({ + listDefinitions: () => Effect.succeed(boards), + getDefinition: (boardId: BoardId) => + Effect.succeed(boards.find((b) => b.boardId === boardId)?.definition ?? null), + }) as unknown as BoardRegistryShape; + +const stubProviderRegistry = (scripts: ReadonlyMap<string, ProviderScript>) => { + const make = (): WorkSourceProvider => { + // Track per-sourceKey page index so successive listPage calls advance. + const cursors = new Map<string, number>(); + return { + provider: "github", + selectorSchema: undefined as never, + listPage: (input): Effect.Effect<WorkSourcePage, WorkSourceProviderError> => + Effect.suspend(() => { + const key = (input.selector as { readonly key: string }).key; + const script = scripts.get(key); + if (script === undefined) { + return Effect.succeed<WorkSourcePage>({ items: [] }); + } + if (script.failWith !== undefined) { + return Effect.fail(script.failWith); + } + const idx = cursors.get(key) ?? 0; + cursors.set(key, idx + 1); + const page: WorkSourcePage = script.pages[idx] ?? { items: [] }; + return Effect.succeed(page); + }), + getItem: (input) => + Effect.suspend(() => { + // Resolve which script this externalId belongs to (selector carries + // the source key in these tests). + const key = (input.selector as { readonly key?: string } | undefined)?.key; + const keyedScript = key === undefined ? undefined : scripts.get(key); + if (keyedScript?.getItemFailWith !== undefined) { + return Effect.fail(keyedScript.getItemFailWith); + } + for (const script of scripts.values()) { + if (script.getItemFailWith !== undefined && script.getItems.has(input.externalId)) { + return Effect.fail(script.getItemFailWith); + } + if (script.getItems.has(input.externalId)) { + return Effect.succeed(script.getItems.get(input.externalId) ?? null); + } + } + return Effect.succeed(null); + }), + viewer: () => Effect.succeed(null), + toImportableView: () => ({ displayRef: "", container: "" }), + }; + }; + const provider = make(); + return Layer.succeed(WorkSourceProviderRegistry, { + get: () => provider, + }); +}; + +// State-table helpers --------------------------------------------------------- + +const readState = (boardId: string, sourceId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ + readonly consecutiveFailures: number; + readonly backoffUntil: string | null; + readonly lastFullRunAt: string | null; + readonly lastError: string | null; + }>` + SELECT consecutive_failures AS "consecutiveFailures", + backoff_until AS "backoffUntil", + last_full_run_at AS "lastFullRunAt", + last_error AS "lastError" + FROM work_source_state + WHERE board_id = ${boardId} AND source_id = ${sourceId} + `; + return rows[0] ?? null; + }); + +const seedState = ( + boardId: string, + sourceId: string, + fields: { + backoffUntil?: string | null; + consecutiveFailures?: number; + lastFullRunAt?: string | null; + }, +) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO work_source_state (board_id, source_id, cursor_or_etag, last_full_run_at, backoff_until, consecutive_failures, last_error) + VALUES (${boardId}, ${sourceId}, NULL, ${fields.lastFullRunAt ?? null}, ${fields.backoffUntil ?? null}, ${fields.consecutiveFailures ?? 0}, NULL) + `; + }); + +const seedMapping = (boardId: string, sourceId: string, externalId: string, ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = DateTime.formatIso(yield* DateTime.now); + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + provider_version, content_hash, lifecycle, sync_status, + source_metadata_json, created_at, last_synced_at + ) VALUES ( + ${`m-${externalId}`}, ${boardId}, ${sourceId}, 'github', ${externalId}, ${ticketId}, + NULL, ${"stale-hash"}, 'open', 'active', NULL, ${now}, ${now} + ) + `; + }); + +const githubSource = ( + id: string, + selectorKey: string, + enabled = true, + extra?: { readonly syncIntervalSec?: number }, +): WorkflowDefinition["sources"] => + [ + { + id: id as never, + provider: "github" as const, + connectionRef: "conn-1", + selector: { key: selectorKey }, + destinationLane: "todo" as LaneKey, + closedLane: "done" as LaneKey, + enabled, + ...(extra?.syncIntervalSec === undefined ? {} : { syncIntervalSec: extra.syncIntervalSec }), + }, + ] as unknown as WorkflowDefinition["sources"]; + +// Compose the syncer under test with all stub deps + real sqlite. +const makeLayer = (params: { + readonly boards: ReadonlyArray<{ + readonly boardId: BoardId; + readonly definition: WorkflowDefinition; + }>; + readonly scripts: ReadonlyMap<string, ProviderScript>; + readonly chunks: Ref.Ref< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >; + readonly recoveries: Ref.Ref<Array<string>>; + readonly recoverFails?: boolean; +}) => + WorkflowSourceSyncerLive.pipe( + Layer.provide(Layer.succeed(BoardRegistry, stubBoardRegistry(params.boards))), + Layer.provide(stubProviderRegistry(params.scripts)), + Layer.provide(recordingCommitter(params.chunks)), + Layer.provide( + Layer.succeed( + WorkflowEngine, + recordingEngine(params.recoveries, { recoverFails: params.recoverFails ?? false }), + ), + ), + Layer.provide(PredicateEvaluatorLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +it.effect( + "multi-page scan that exhausts → scanCompleted true, missing detected, last_full_run_at set", + () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + const scripts = new Map<string, ProviderScript>([ + [ + "src-a", + { + // 2 pages, exhausts (last has no token). Item "1" present. + pages: makePages([[item("1")], [item("2")]]), + getItems: new Map(), + }, + ], + ]); + const boards = [board("board-1", githubSource("source-a", "src-a"))]; + + const run = Effect.gen(function* () { + // Pre-existing mapping "gone" not in the scan → should produce a missing delta. + yield* seedMapping("board-1", "source-a", "gone", "ticket-gone"); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + const allDeltas = recorded.flatMap((c) => c.deltas); + const tags = allDeltas.map((d) => d._tag); + assert.include(tags, "new"); // items 1,2 unmapped + assert.include(tags, "missing"); // "gone" mapping not in scan + const state = yield* readState("board-1", "source-a"); + assert.isNotNull(state!.lastFullRunAt); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect( + "page cap hit with nextPageToken still present → scanCompleted false, NO missing, but cadence anchor (last_full_run_at) IS advanced (M19)", + () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + // 12 pages each still carrying a nextPageToken (lastHasToken) → MAX_PAGES cap (10) reached + // while nextPageToken still present. + const pages = makePages( + Array.from({ length: 12 }, (_, i) => [item(`p${i}`)]), + { lastHasToken: true }, + ); + const scripts = new Map<string, ProviderScript>([ + ["src-cap", { pages, getItems: new Map() }], + ]); + const boards = [board("board-1", githubSource("source-cap", "src-cap"))]; + + const run = Effect.gen(function* () { + yield* seedMapping("board-1", "source-cap", "gone", "ticket-gone"); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + const tags = recorded.flatMap((c) => c.deltas).map((d) => d._tag); + // A partial scan must NEVER orphan items it simply did not fetch yet. + assert.notInclude(tags, "missing"); + // M19: the cadence anchor advances even on a partial scan so the source + // respects its syncIntervalSec instead of re-scanning every tick. (This + // does NOT enable missing-detection — that stays suppressed above.) + const state = yield* readState("board-1", "source-cap"); + assert.isNotNull(state!.lastFullRunAt); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect( + "rate-limit error → backoff_until from retryAfterMs, consecutive_failures incremented; other sources still processed", + () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + const scripts = new Map<string, ProviderScript>([ + [ + "src-fail", + { + pages: [], + getItems: new Map(), + failWith: new WorkSourceRateLimitError({ retryAfterMs: 60_000 }), + }, + ], + ["src-ok", { pages: makePages([[item("ok1")]]), getItems: new Map() }], + ]); + // Two boards so isolation across the sweep is exercised. + const boards = [ + board("board-fail", githubSource("source-fail", "src-fail")), + board("board-ok", githubSource("source-ok", "src-ok")), + ]; + + const run = Effect.gen(function* () { + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const failState = yield* readState("board-fail", "source-fail"); + assert.equal(failState!.consecutiveFailures, 1); + assert.isNotNull(failState!.backoffUntil); + assert.isNotNull(failState!.lastError); + + // The OK source still produced a chunk. + const recorded = yield* Ref.get(chunks); + assert.isTrue(recorded.some((c) => c.boardId === "board-ok")); + // recoverBoardWip still called for BOTH boards. + const recs = yield* Ref.get(recoveries); + assert.include(recs, "board-fail"); + assert.include(recs, "board-ok"); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("success after failure → consecutive_failures reset to 0, backoff cleared", () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + const scripts = new Map<string, ProviderScript>([ + ["src-recover", { pages: makePages([[item("r1")]]), getItems: new Map() }], + ]); + const boards = [board("board-1", githubSource("source-recover", "src-recover"))]; + + const run = Effect.gen(function* () { + // Seed a PAST backoff + prior failures: backoff has elapsed so the source runs. + yield* seedState("board-1", "source-recover", { + backoffUntil: "2000-01-01T00:00:00Z", + consecutiveFailures: 3, + }); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const state = yield* readState("board-1", "source-recover"); + assert.equal(state!.consecutiveFailures, 0); + assert.isNull(state!.backoffUntil); + assert.isNull(state!.lastError); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("deltas chunked at MAX_DELTAS_PER_RECONCILE_CHUNK → multiple reconcileChunk calls", () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + const count = MAX_DELTAS_PER_RECONCILE_CHUNK + 5; // forces 2 chunks + const items = Array.from({ length: count }, (_, i) => item(`x${i}`)); + const scripts = new Map<string, ProviderScript>([ + ["src-big", { pages: makePages([items]), getItems: new Map() }], + ]); + const boards = [board("board-1", githubSource("source-big", "src-big"))]; + + const run = Effect.gen(function* () { + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + const recorded = yield* Ref.get(chunks); + assert.equal(recorded.length, 2); + assert.equal(recorded[0]!.deltas.length, MAX_DELTAS_PER_RECONCILE_CHUNK); + assert.equal(recorded[1]!.deltas.length, 5); + // Lanes threaded through from the source config. + assert.equal(recorded[0]!.lanes.destinationLane, "todo"); + assert.equal(recorded[0]!.lanes.closedLane, "done"); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect( + "missing mapping → getItem called; null → confirmedDeleted true; non-null → confirmedDeleted false", + () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + // Scan exhausts with NO items → both seeded mappings are "missing". + // getItem: "deleted-id" → null (confirmed deleted); "exists-id" → still exists. + const scripts = new Map<string, ProviderScript>([ + [ + "src-miss", + { + pages: makePages([[]]), + getItems: new Map<string, ExternalWorkItem | null>([ + ["deleted-id", null], + ["exists-id", item("exists-id")], + ]), + }, + ], + ]); + const boards = [board("board-1", githubSource("source-miss", "src-miss"))]; + + const run = Effect.gen(function* () { + yield* seedMapping("board-1", "source-miss", "deleted-id", "ticket-del"); + yield* seedMapping("board-1", "source-miss", "exists-id", "ticket-ex"); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + const missing = recorded + .flatMap((c) => c.deltas) + .filter((d): d is Extract<SourceDelta, { _tag: "missing" }> => d._tag === "missing"); + assert.equal(missing.length, 2); + const del = missing.find((d) => d.item.externalId === "deleted-id"); + const exist = missing.find((d) => d.item.externalId === "exists-id"); + assert.equal(del!.confirmedDeleted, true); + assert.equal(exist!.confirmedDeleted, false); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect( + "Finding #5: zero-delta board STILL calls recoverBoardWip; a recoverBoardWip failure is caught (sweep continues)", + () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + // A source whose scan exhausts with NO items and NO mappings → zero deltas. + const scripts = new Map<string, ProviderScript>([ + ["src-empty", { pages: makePages([[]]), getItems: new Map() }], + ]); + const boards = [board("board-empty", githubSource("source-empty", "src-empty"))]; + + const run = Effect.gen(function* () { + const syncer = yield* WorkflowSourceSyncer; + // recoverFails: true → must be swallowed, sweep must not crash. + yield* syncer.sweep; + const recorded = yield* Ref.get(chunks); + assert.equal(recorded.flatMap((c) => c.deltas).length, 0); + const recs = yield* Ref.get(recoveries); + assert.include(recs, "board-empty"); + }); + yield* run.pipe( + Effect.provide(makeLayer({ boards, scripts, chunks, recoveries, recoverFails: true })), + ); + }), +); + +it.effect("source in backoff (backoff_until in the future) is SKIPPED this tick", () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + const scripts = new Map<string, ProviderScript>([ + ["src-backoff", { pages: makePages([[item("s1")]]), getItems: new Map() }], + ]); + const boards = [board("board-1", githubSource("source-backoff", "src-backoff"))]; + + const run = Effect.gen(function* () { + yield* seedState("board-1", "source-backoff", { + backoffUntil: "2999-01-01T00:00:00Z", + consecutiveFailures: 2, + }); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + // Source skipped → no chunks at all. + assert.equal(recorded.length, 0); + // State untouched (still 2 failures). + const state = yield* readState("board-1", "source-backoff"); + assert.equal(state!.consecutiveFailures, 2); + // recoverBoardWip STILL runs per board (Finding #5) even with a skipped source. + const recs = yield* Ref.get(recoveries); + assert.include(recs, "board-1"); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect( + "Fix 2: a getItem ERROR does NOT confirm deletion (no terminal-route) and feeds backoff", + () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + // Scan exhausts with NO items → the seeded mapping is "missing". + // getItem FAILS (transient) → must NOT mark confirmedDeleted; the whole + // source pass becomes a recorded backoff, so NO chunk is committed. + const scripts = new Map<string, ProviderScript>([ + [ + "src-err", + { + pages: makePages([[]]), + getItems: new Map<string, ExternalWorkItem | null>([["orphan-id", null]]), + getItemFailWith: new WorkSourceTransientError({ message: "github 500 (getItem)" }), + }, + ], + ]); + const boards = [board("board-1", githubSource("source-err", "src-err"))]; + + const run = Effect.gen(function* () { + yield* seedMapping("board-1", "source-err", "orphan-id", "ticket-orphan"); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + // No chunk committed → ticket never terminal-routed on a getItem error. + const recorded = yield* Ref.get(chunks); + assert.equal(recorded.flatMap((c) => c.deltas).length, 0); + // The error fed the per-source backoff (consecutive_failures incremented, + // backoff_until set) — i.e. it behaved like a listPage failure. + const state = yield* readState("board-1", "source-err"); + assert.equal(state!.consecutiveFailures, 1); + assert.isNotNull(state!.backoffUntil); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect( + "Fix 4: a source with syncIntervalSec=600 + a recent last_full_run_at is SKIPPED this sweep", + () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + const scripts = new Map<string, ProviderScript>([ + ["src-throttle", { pages: makePages([[item("t1")]]), getItems: new Map() }], + ]); + const boards = [ + board( + "board-1", + githubSource("source-throttle", "src-throttle", true, { syncIntervalSec: 600 }), + ), + ]; + + const run = Effect.gen(function* () { + // The due-gate compares last_full_run_at + interval against the REAL wall + // clock (DateTime.isFutureUnsafe), not the test clock. Seed a far-future + // last_full_run_at so last_full_run_at + 600s is unambiguously in the + // future → the source is throttled/SKIPPED this sweep. + yield* seedState("board-1", "source-throttle", { + lastFullRunAt: "2999-01-01T00:00:00Z", + }); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + assert.equal(recorded.length, 0); // throttled → no listPage/commit this tick + const recs = yield* Ref.get(recoveries); + assert.include(recs, "board-1"); // recoverBoardWip still runs per board + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("Fix 4: a source with a STALE last_full_run_at (older than the interval) RUNS", () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + const scripts = new Map<string, ProviderScript>([ + ["src-due", { pages: makePages([[item("d1")]]), getItems: new Map() }], + ]); + const boards = [ + board("board-1", githubSource("source-due", "src-due", true, { syncIntervalSec: 600 })), + ]; + + const run = Effect.gen(function* () { + // last_full_run_at far in the past → due → RUNS this tick. + yield* seedState("board-1", "source-due", { lastFullRunAt: "2000-01-01T00:00:00Z" }); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + const tags = recorded.flatMap((c) => c.deltas).map((d) => d._tag); + assert.include(tags, "new"); // it ran → produced a "new" delta for d1 + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +// --------------------------------------------------------------------------- +// C1: scan gate + gateNewDeltas wiring +// --------------------------------------------------------------------------- + +const manualOnlySource = (id: string, selectorKey: string): WorkflowDefinition["sources"] => + [ + { + id: id as never, + provider: "github" as const, + connectionRef: "conn-1", + selector: { key: selectorKey }, + destinationLane: "todo" as LaneKey, + closedLane: "done" as LaneKey, + // no autoPull, no enabled → manual-only (effectiveAutoPullRule returns null) + }, + ] as unknown as WorkflowDefinition["sources"]; + +const autoPullSource = ( + id: string, + selectorKey: string, + rule: unknown, +): WorkflowDefinition["sources"] => + [ + { + id: id as never, + provider: "github" as const, + connectionRef: "conn-1", + selector: { key: selectorKey }, + destinationLane: "todo" as LaneKey, + closedLane: "done" as LaneKey, + autoPull: { rule }, + }, + ] as unknown as WorkflowDefinition["sources"]; + +it.effect("C1: manual-only source with NO mappings is skipped (no scan)", () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + // The script has data but it must never be fetched (source has no autoPull + no mappings). + const scripts = new Map<string, ProviderScript>([ + ["src-manual", { pages: makePages([[item("m1")]]), getItems: new Map() }], + ]); + const boards = [board("board-1", manualOnlySource("source-manual", "src-manual"))]; + + const run = Effect.gen(function* () { + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + // No chunks committed → listPage was never called (no scan happened). + const recorded = yield* Ref.get(chunks); + assert.equal(recorded.flatMap((c) => c.deltas).length, 0); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("C1: auto-pull rule gates NEW creation but never removes existing mapped tickets", () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + + // Rule: only items with label "XS" auto-create + const rule = { in: ["XS", { var: "labels" }] }; + + // Three items: + // A: unmapped, labels=["XS"] → should produce a "new" delta (passes rule) + // B: unmapped, labels=["L"] → must NOT produce a "new" delta (fails rule) + // C: already MAPPED (seeded), lifecycle=closed → must produce a non-"new" delta (not gated) + const itemA = item("A", { fields: { title: "Item A", labels: ["XS"] } }); + const itemB = item("B", { fields: { title: "Item B", labels: ["L"] } }); + const itemC = item("C", { fields: { title: "Item C", labels: ["L"] }, lifecycle: "closed" }); + + const scripts = new Map<string, ProviderScript>([ + [ + "src-rule", + { + pages: makePages([[itemA, itemB, itemC]]), + getItems: new Map<string, ExternalWorkItem | null>([["C", itemC]]), + }, + ], + ]); + const boards = [board("board-1", autoPullSource("source-rule", "src-rule", rule))]; + + const run = Effect.gen(function* () { + // C is pre-mapped (it will not get a "new" delta even without gating). + yield* seedMapping("board-1", "source-rule", "C", "ticket-C"); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + const allDeltas = recorded.flatMap((c) => c.deltas); + + const newIds = allDeltas.filter((d) => d._tag === "new").map((d) => d.item.externalId); + // A passes the rule → new; B fails → excluded. + assert.deepEqual(newIds, ["A"]); + + // C is mapped+closed upstream → gets a closed/changed delta (tracked, not dropped). + const nonNewIds = allDeltas.filter((d) => d._tag !== "new").map((d) => d.item.externalId); + assert.include(nonNewIds, "C"); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +// --------------------------------------------------------------------------- +// A3: Legacy enabled → autoPull migration round-trip (sweep step) +// --------------------------------------------------------------------------- + +// Helper: a source with legacy `enabled` field (no autoPull) — mirrors githubSource +// but explicitly exercises the enabled:true / enabled:false migration path. +const legacySource = ( + id: string, + selectorKey: string, + enabled: boolean, +): WorkflowDefinition["sources"] => + [ + { + id: id as never, + provider: "github" as const, + connectionRef: "conn-1", + selector: { key: selectorKey }, + destinationLane: "todo" as LaneKey, + closedLane: "done" as LaneKey, + enabled, // legacy field — no autoPull + }, + ] as unknown as WorkflowDefinition["sources"]; + +it.effect("A3: legacy enabled:true source auto-pulls all in-scope items (ALWAYS rule)", () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + // Two unmapped items: both should produce "new" deltas (ALWAYS rule passes all). + const scripts = new Map<string, ProviderScript>([ + [ + "src-legacy-on", + { + pages: makePages([[item("alpha"), item("beta")]]), + getItems: new Map(), + }, + ], + ]); + const boards = [board("board-legacy-on", legacySource("src-on", "src-legacy-on", true))]; + + const run = Effect.gen(function* () { + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + const allDeltas = recorded.flatMap((c) => c.deltas); + const newIds = allDeltas.filter((d) => d._tag === "new").map((d) => d.item.externalId); + // Both unmapped items are auto-pulled via the ALWAYS rule. + assert.include(newIds, "alpha"); + assert.include(newIds, "beta"); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect( + "A3: legacy enabled:false source with a seeded mapping is scanned (tracking resumed) but creates NO new deltas", + () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + // Two items: "mapped" is already seeded (lifecycle closed) → produces a closed delta. + // "unmapped" is new → gateNewDeltas(null) must DROP it (no auto-creation). + const mappedItem = item("mapped", { lifecycle: "closed" }); + const unmappedItem = item("unmapped"); + const scripts = new Map<string, ProviderScript>([ + [ + "src-legacy-off", + { + pages: makePages([[mappedItem, unmappedItem]]), + getItems: new Map<string, ExternalWorkItem | null>([["mapped", mappedItem]]), + }, + ], + ]); + const boards = [board("board-legacy-off", legacySource("src-off", "src-legacy-off", false))]; + + const run = Effect.gen(function* () { + // Pre-seed a mapping for "mapped" so tracking is active for it. + yield* seedMapping("board-legacy-off", "src-off", "mapped", "ticket-mapped"); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + const allDeltas = recorded.flatMap((c) => c.deltas); + + // "unmapped" must NOT produce a "new" delta (no auto-creation; rule is null). + const newExternalIds = allDeltas + .filter((d) => d._tag === "new") + .map((d) => d.item.externalId); + assert.notInclude(newExternalIds, "unmapped"); + + // "mapped" is closed upstream → produces a closed delta (tracking resumed). + const trackedIds = allDeltas.filter((d) => d._tag !== "new").map((d) => d.item.externalId); + assert.include(trackedIds, "mapped"); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect( + "A3: legacy enabled:false source with NO mappings is skipped (no listPage, no deltas)", + () => + Effect.gen(function* () { + const chunks = yield* Ref.make< + Array<{ boardId: string; lanes: ReconcileLanes; deltas: ReadonlyArray<SourceDelta> }> + >([]); + const recoveries = yield* Ref.make<Array<string>>([]); + // Script has data but must never be fetched (enabled:false + no mappings → scan gate skips). + const scripts = new Map<string, ProviderScript>([ + ["src-legacy-skip", { pages: makePages([[item("would-be-new")]]), getItems: new Map() }], + ]); + const boards = [ + board("board-legacy-skip", legacySource("src-skip", "src-legacy-skip", false)), + ]; + + const run = Effect.gen(function* () { + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + // No scan happened → no deltas at all. + const recorded = yield* Ref.get(chunks); + assert.equal(recorded.flatMap((c) => c.deltas).length, 0); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowSourceSyncer.ts b/apps/server/src/workflow/Layers/WorkflowSourceSyncer.ts new file mode 100644 index 00000000000..b4371e3b4bc --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowSourceSyncer.ts @@ -0,0 +1,381 @@ +import type { BoardId, LaneKey, WorkflowSourceConfig } from "@t3tools/contracts"; +import { effectiveAutoPullRule } from "@t3tools/contracts/workSource"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { PredicateEvaluator } from "../Services/PredicateEvaluator.ts"; +import { + WorkSourceProviderRegistry, + WorkSourceRateLimitError, + type WorkSourceProvider, + type WorkSourceProviderError, +} from "../Services/WorkSourceProvider.ts"; +import { + scanSource, + chunkArray, + describeWorkSourceProviderError, + MAX_DELTAS_PER_RECONCILE_CHUNK, +} from "../scanSource.ts"; +// Re-export for existing importers (e.g. WorkflowSourceSyncer.test.ts). +export { MAX_DELTAS_PER_RECONCILE_CHUNK } from "../scanSource.ts"; +import { WorkflowSourceCommitter, type SourceDelta } from "../Services/WorkflowSourceCommitter.ts"; +import { + WorkflowSourceSyncer, + type WorkflowSourceSyncerShape, +} from "../Services/WorkflowSourceSyncer.ts"; +import { classifyDeltas, type MappingRow } from "../sourceReconcileDiff.ts"; +import { gateNewDeltas } from "../sourceAutoPull.ts"; + +// --------------------------------------------------------------------------- +// Locked tuning constants (do not change without the plan owner's sign-off). +// --------------------------------------------------------------------------- + +// Fallback sweep cadence when a source omits syncIntervalSec. +export const DEFAULT_SYNC_INTERVAL_SEC = 120; + +// Exponential backoff base + cap for non-rate-limited provider failures. +const BACKOFF_BASE_MS = 30_000; // 30s +const BACKOFF_CAP_MS = 3_600_000; // 1h + +// Schema-aware runtime guard for the rate-limit variant (the codebase forbids +// `instanceof` on Schema TaggedError classes — use Schema.is). +const isRateLimitError = Schema.is(WorkSourceRateLimitError); + +// --------------------------------------------------------------------------- +// SQL row shapes +// --------------------------------------------------------------------------- + +interface SourceStateRow { + readonly backoffUntil: string | null; + readonly consecutiveFailures: number; + readonly lastFullRunAt: string | null; +} + +interface MappingSelectRow { + readonly externalId: string; + readonly ticketId: string; + readonly contentHash: string; + readonly providerVersion: string | null; + readonly lifecycle: string; + readonly syncStatus: string; + readonly sourceMetadataJson: string | null; +} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const registry = yield* WorkSourceProviderRegistry; + const committer = yield* WorkflowSourceCommitter; + const engine = yield* WorkflowEngine; + const boards = yield* BoardRegistry; + const predicates = yield* PredicateEvaluator; + + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + // Read (board, source) state, treating an absent row as fresh (no backoff, + // zero failures). The state row is upserted lazily on the first sweep that + // touches the source. + const readState = (boardId: BoardId, sourceId: string) => + sql<SourceStateRow>` + SELECT backoff_until AS "backoffUntil", + consecutive_failures AS "consecutiveFailures", + last_full_run_at AS "lastFullRunAt" + FROM work_source_state + WHERE board_id = ${String(boardId)} AND source_id = ${sourceId} + `.pipe(Effect.map((rows) => rows[0] ?? null)); + + // Idempotent upsert of the state row keyed by (board_id, source_id). + const ensureStateRow = (boardId: BoardId, sourceId: string) => + sql` + INSERT INTO work_source_state (board_id, source_id, consecutive_failures) + VALUES (${String(boardId)}, ${sourceId}, 0) + ON CONFLICT (board_id, source_id) DO NOTHING + `; + + const readMappings = (boardId: BoardId, sourceId: string) => + sql<MappingSelectRow>` + SELECT external_id AS "externalId", + ticket_id AS "ticketId", + content_hash AS "contentHash", + provider_version AS "providerVersion", + lifecycle AS "lifecycle", + sync_status AS "syncStatus", + source_metadata_json AS "sourceMetadataJson" + FROM work_source_mapping + WHERE board_id = ${String(boardId)} AND source_id = ${sourceId} + `.pipe( + Effect.map((rows) => + rows.map( + (row): MappingRow => ({ + externalId: row.externalId, + ticketId: row.ticketId, + contentHash: row.contentHash, + providerVersion: row.providerVersion, + lifecycle: row.lifecycle, + syncStatus: row.syncStatus, + sourceMetadataJson: row.sourceMetadataJson, + }), + ), + ), + ); + + // For each `missing` delta, ask the provider whether the item still exists. + // Result handling (CRITICAL — only a confirmed null deletes): + // - getItem succeeds with null → confirmedDeleted=true (404/gone), the + // committer may terminal-route the ticket. + // - getItem succeeds with item → the item still exists (merely fell out of + // the FILTERED scan, e.g. label removed) → confirmedDeleted=false, the + // ticket stays orphaned (NOT terminal). + // - getItem FAILS (auth/rate-limit/transient) → we CANNOT confirm deletion. + // The failure propagates to the source-pass failure channel → recorded as + // a backoff by recordFailure; the missing delta is NEVER marked + // confirmedDeleted on the strength of an error. (The whole pass is + // reprocessed next sweep, so no delta is silently dropped as deleted.) + // This getItem call is network and runs OUTSIDE any transaction. + const resolveMissing = ( + provider: WorkSourceProvider, + source: WorkflowSourceConfig, + deltas: ReadonlyArray<SourceDelta>, + ): Effect.Effect<ReadonlyArray<SourceDelta>, WorkSourceProviderError> => + Effect.forEach(deltas, (delta) => { + if (delta._tag !== "missing") { + return Effect.succeed(delta); + } + return provider + .getItem({ + connectionRef: source.connectionRef, + selector: source.selector, + externalId: delta.item.externalId, + }) + .pipe( + Effect.map( + (item): SourceDelta => ({ + ...delta, + confirmedDeleted: item === null, + }), + ), + ); + }); + + // On a successful source pass: reset failure tracking and advance the + // cadence anchor (last_full_run_at). + // + // `last_full_run_at` is the cadence-throttle anchor read by the per-source + // interval gate in `processSource` (its ONLY consumer — it is NOT a + // completeness proof; missing-detection is gated independently by the + // per-tick `scanCompleted` flag passed to classifyDeltas). It is therefore + // advanced on EVERY successful pass, partial or complete. A source larger + // than MAX_ITEMS_PER_SOURCE_TICK only ever produces partial scans; if the + // anchor were frozen on partials it would stay NULL forever and the gate + // would never engage, re-running a full multi-page scan on every tick + // regardless of the configured syncIntervalSec (the M19 hammering bug). + // Advancing it on partials still does NOT enable missing/orphan detection — + // that remains suppressed until a tick actually completes the scan. + const recordSuccess = (boardId: BoardId, sourceId: string) => + Effect.gen(function* () { + const now = yield* nowIso; + yield* sql` + UPDATE work_source_state + SET consecutive_failures = 0, + backoff_until = NULL, + last_error = NULL, + last_full_run_at = ${now} + WHERE board_id = ${String(boardId)} AND source_id = ${sourceId} + `; + }); + + // On a provider error: increment the failure counter and schedule a backoff. + // A rate-limit error uses the server-provided retryAfterMs verbatim; any + // other failure uses exponential backoff min(cap, base * 2^failures). + const recordFailure = ( + boardId: BoardId, + sourceId: string, + priorFailures: number, + error: WorkSourceProviderError, + ) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const isRateLimit = isRateLimitError(error); + const delayMs = isRateLimit + ? error.retryAfterMs + : Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** priorFailures); + const backoffUntil = DateTime.formatIso(DateTime.addDuration(now, Duration.millis(delayMs))); + const message = isRateLimit + ? `rate-limited (retryAfterMs=${error.retryAfterMs})` + : describeWorkSourceProviderError(error); + yield* sql` + UPDATE work_source_state + SET consecutive_failures = consecutive_failures + 1, + backoff_until = ${backoffUntil}, + last_error = ${message} + WHERE board_id = ${String(boardId)} AND source_id = ${sourceId} + `; + }); + + // Process ONE source end-to-end. The whole body is wrapped by the caller in + // Effect.result so a provider/SQL failure here is isolated — it can never + // abort the board's other sources or the sweep. A provider error is caught + // HERE and converted into a recorded backoff (also a non-failing result). + const processSource = (boardId: BoardId, source: WorkflowSourceConfig) => + Effect.gen(function* () { + yield* ensureStateRow(boardId, source.id); + const state = yield* readState(boardId, source.id); + + // Backoff gate: skip this source this tick if its backoff has not passed. + if (state?.backoffUntil != null) { + const until = DateTime.makeUnsafe(state.backoffUntil); + if (DateTime.isFutureUnsafe(until)) { + return; + } + } + + // Per-source interval gate: the global sweep runs every + // DEFAULT_SYNC_INTERVAL_SEC, but a source may request a LONGER cadence via + // syncIntervalSec. Skip this source this tick if its last successful scan + // pass (last_full_run_at, the cadence anchor — advanced on partial scans + // too; see recordSuccess) was more recent than its effective interval. A + // source that has never had a successful pass (no last_full_run_at) always + // runs. + if (state?.lastFullRunAt != null) { + const effectiveIntervalSec = source.syncIntervalSec ?? DEFAULT_SYNC_INTERVAL_SEC; + const dueAt = DateTime.addDuration( + DateTime.makeUnsafe(state.lastFullRunAt), + Duration.seconds(effectiveIntervalSec), + ); + if (DateTime.isFutureUnsafe(dueAt)) { + return; + } + } + + const provider = registry.get(source.provider); + const priorFailures = state?.consecutiveFailures ?? 0; + + // Mapping read is plain SQL (not network). Keep it OUT of the + // provider-error capture below so the captured failure channel is purely + // WorkSourceProviderError — a backoff-able failure. (A SQL failure here + // is handled by the per-source isolation catch in the sweep.) + const mappings = yield* readMappings(boardId, source.id); + + // Scan gate: if the source has no auto-pull rule (manual-only) AND has no + // existing mappings, there is nothing to track and nothing to auto-create — + // skip the network scan entirely for this tick. + if (effectiveAutoPullRule(source) === null && mappings.length === 0) { + yield* recordSuccess(boardId, source.id); // advance cadence anchor + return; + } + + // The network phase (listPage pagination + getItem confirmations) is the + // ONLY part that can raise a provider error; capture it so a rate-limit / + // auth / transient failure becomes a recorded backoff (not an exception). + const outcome = yield* scanSource(provider, source, undefined).pipe( + Effect.flatMap((scanned) => + Effect.gen(function* () { + const deltas = classifyDeltas({ + sourceId: source.id, + provider: source.provider, + items: scanned.items, + mappings, + scanCompleted: scanned.scanCompleted, + }); + // Gate `new` deltas by the auto-pull rule (never gates non-new). + const gated = yield* gateNewDeltas(deltas, effectiveAutoPullRule(source), predicates); + // getItem confirmation for missing deltas — OUTSIDE any tx. + const resolved = yield* resolveMissing(provider, source, gated); + return { resolved, scanCompleted: scanned.scanCompleted }; + }), + ), + Effect.result, + ); + + if (outcome._tag === "Failure") { + yield* recordFailure(boardId, source.id, priorFailures, outcome.failure); + return; + } + + const { resolved } = outcome.success; + // Drive the committer per chunk; each chunk takes/releases its own locks. + for (const chunk of chunkArray(resolved, MAX_DELTAS_PER_RECONCILE_CHUNK)) { + yield* committer.reconcileChunk( + boardId, + { + destinationLane: source.destinationLane as LaneKey, + closedLane: source.closedLane as LaneKey, + }, + chunk, + ); + } + yield* recordSuccess(boardId, source.id); + }); + + const sweep: WorkflowSourceSyncerShape["sweep"] = Effect.gen(function* () { + const definitions = yield* boards + .listDefinitions() + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.source-syncer.list-boards-failed", { cause }).pipe( + Effect.as([] as ReadonlyArray<{ readonly boardId: BoardId }>), + ), + ), + ); + + for (const { boardId, definition } of definitions as ReadonlyArray<{ + readonly boardId: BoardId; + readonly definition: { readonly sources?: ReadonlyArray<WorkflowSourceConfig> }; + }>) { + const sources = definition.sources ?? []; + for (const source of sources) { + // Per-source isolation: any failure (provider error escaping the inner + // capture, SQL error, defect) is logged and swallowed so it never + // aborts the sweep or other sources/boards. + yield* processSource(boardId, source).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.source-syncer.source-failed", { + boardId, + sourceId: source.id, + cause, + }), + ), + ); + } + + // FINDING #5: recover the board's WIP once per board per sweep, + // REGARDLESS of delta count. A prior cycle could have admitted a ticket + // whose committer post-tx recoverBoardWip failed; a later no-change cycle + // produces zero deltas, so without this unconditional call the + // admitted-but-unstarted pipeline is stranded forever. Defensively + // wrapped (catch + log) — a recovery failure must not abort the sweep. + yield* engine + .recoverBoardWip(boardId) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.source-syncer.recover-wip-failed", { boardId, cause }), + ), + ); + } + }); + + const start: WorkflowSourceSyncerShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + sweep.pipe( + Effect.catchDefect((defect: unknown) => + Effect.logWarning("workflow.source-syncer.sweep-defect", { defect }), + ), + Effect.repeat(Schedule.spaced(Duration.seconds(DEFAULT_SYNC_INTERVAL_SEC))), + ), + ); + yield* Effect.logInfo("workflow.source-syncer.started", { + intervalSec: DEFAULT_SYNC_INTERVAL_SEC, + }); + }); + + return { sweep, start } satisfies WorkflowSourceSyncerShape; +}); + +export const WorkflowSourceSyncerLive = Layer.effect(WorkflowSourceSyncer, make); diff --git a/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.test.ts b/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.test.ts new file mode 100644 index 00000000000..102a97f694e --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.test.ts @@ -0,0 +1,569 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import type { WorkflowBoardVersionStoreShape } from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowEngine, type WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowTerminalRetentionSweeper } from "../Services/WorkflowTerminalRetentionSweeper.ts"; +import { deleteWorkflowBoardOwnedState } from "../boardDeletion.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventStoreLive } from "./WorkflowEventStore.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; +import { makeWorkflowTerminalRetentionSweeperLive } from "./WorkflowTerminalRetentionSweeper.ts"; + +const unsupported = () => Effect.die("unsupported workflow engine call") as never; +type TestSaveLocksLayer = Layer.Layer<WorkflowBoardSaveLocks, never, SqlClient.SqlClient>; + +const makeEngineLayer = ( + cancelTicketPipelines: WorkflowEngineShape["cancelTicketPipelines"] = () => Effect.void, +) => + Layer.succeed(WorkflowEngine, { + createTicket: () => unsupported(), + editTicket: () => unsupported(), + moveTicket: () => unsupported(), + createTicketAndEnterUnlocked: () => unsupported(), + closeTicketFromSourceUnlocked: () => unsupported(), + reopenTicketFromSourceUnlocked: () => unsupported(), + cancellableProviderTurnsForTicket: () => unsupported(), + supersedeProviderWorkForTicket: () => unsupported(), + terminalAgentSessionThreadsForTicket: () => unsupported(), + stopAgentSessionsForTicket: () => unsupported(), + editTicketFieldsUnlocked: () => unsupported(), + withBoardAdmissionLock: (_boardId, effect) => effect, + runLane: () => unsupported(), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => unsupported(), + answerTicketStep: () => unsupported(), + postTicketMessage: () => unsupported(), + editTicketMessage: () => unsupported(), + cancelStep: () => unsupported(), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => unsupported(), + } satisfies WorkflowEngineShape); + +const makeSaveLocksLayer = ( + beforeSaveLock: (sql: SqlClient.SqlClient) => Effect.Effect<void, SqlError>, +) => + Layer.effect( + WorkflowBoardSaveLocks, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return { + withSaveLock: (_boardId, effect) => + Effect.gen(function* () { + yield* beforeSaveLock(sql).pipe(Effect.orDie); + return yield* effect; + }), + } satisfies WorkflowBoardSaveLocks["Service"]; + }), + ); + +const makeLayer = ({ + cancelTicketPipelines, + maxDeletesPerSweep, + saveLocksLayer = WorkflowBoardSaveLocksLive as TestSaveLocksLayer, +}: { + readonly cancelTicketPipelines?: WorkflowEngineShape["cancelTicketPipelines"]; + readonly maxDeletesPerSweep?: number; + readonly saveLocksLayer?: TestSaveLocksLayer; +} = {}) => + makeWorkflowTerminalRetentionSweeperLive({ + sweepIntervalMs: 60_000, + ...(maxDeletesPerSweep === undefined ? {} : { maxDeletesPerSweep }), + nowMs: Effect.succeed(Date.parse("2026-06-08T00:00:00.000Z")), + }).pipe( + Layer.provideMerge(makeEngineLayer(cancelTicketPipelines)), + Layer.provideMerge(saveLocksLayer), + Layer.provideMerge(WorkflowEventStoreLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +const registerRetentionBoardFor = (boardId: string) => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register(boardId as never, { + name: "retention sweep", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "done", + name: "Done", + entry: "manual", + terminal: true, + retention: "1 day", + }, + { key: "archive", name: "Archive", entry: "manual", terminal: true }, + ], + }); + }); + +const registerRetentionBoard = registerRetentionBoardFor("board-retention-sweep"); + +const seedTicket = (input: { + readonly boardId?: string; + readonly ticketId: string; + readonly lane: string; + readonly status?: string; + readonly terminalAt: string | null; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const store = yield* WorkflowEventStore; + const now = "2026-06-08T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + terminal_at, + created_at, + updated_at + ) + VALUES ( + ${input.ticketId}, + ${input.boardId ?? "board-retention-sweep"}, + ${input.ticketId}, + ${input.lane}, + ${input.status ?? "done"}, + ${input.terminalAt}, + ${now}, + ${now} + ) + `; + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, + ticket_id, + lane_key, + lane_entry_token, + status, + started_at + ) + VALUES (${`pipeline-${input.ticketId}`}, ${input.ticketId}, ${input.lane}, ${`token-${input.ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES (${`step-${input.ticketId}`}, ${`pipeline-${input.ticketId}`}, ${input.ticketId}, 'cleanup', 'script', 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_script_run ( + script_run_id, + step_run_id, + ticket_id, + script_thread_id, + terminal_id, + status, + started_at + ) + VALUES (${`script-${input.ticketId}`}, ${`step-${input.ticketId}`}, ${input.ticketId}, ${`thread-${input.ticketId}`}, ${`terminal-${input.ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES (${`dispatch-${input.ticketId}`}, ${input.ticketId}, ${`step-${input.ticketId}`}, ${`thread-${input.ticketId}`}, 'codex', 'gpt-5.5', 'cleanup', ${`/tmp/${input.ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES (${`setup-${input.ticketId}`}, ${input.ticketId}, ${`worktree-${input.ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES (${`message-${input.ticketId}`}, ${input.ticketId}, ${`step-${input.ticketId}`}, 'user', 'cleanup', '[]', ${now}) + `; + yield* store.append({ + type: "TicketCreated", + eventId: `event-${input.ticketId}` as never, + ticketId: input.ticketId as never, + occurredAt: now as never, + payload: { + boardId: (input.boardId ?? "board-retention-sweep") as never, + title: input.ticketId as never, + laneKey: input.lane as never, + }, + }); + }); + +const ticketOwnedRowCount = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_ticket WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM projection_pipeline_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM projection_step_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_script_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_dispatch_outbox WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_setup_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM projection_ticket_message WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_events WHERE ticket_id = ${ticketId} + `; + return rows.reduce((total, row) => total + row.count, 0); + }); + +const remainingTicketCountForBoard = (boardId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM projection_ticket + WHERE board_id = ${boardId} + `; + return rows[0]?.count ?? 0; + }); + +it.effect("deletes expired terminal tickets and keeps fresh or no-retention terminal tickets", () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + + yield* registerRetentionBoard; + yield* seedTicket({ + ticketId: "ticket-expired", + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + yield* seedTicket({ + ticketId: "ticket-fresh", + lane: "done", + terminalAt: "2026-06-07T12:00:00.000Z", + }); + yield* seedTicket({ + ticketId: "ticket-no-retention", + lane: "archive", + terminalAt: "2026-06-01T00:00:00.000Z", + }); + + const result = yield* sweeper.sweep(); + + assert.equal(result.deletedCount, 1); + assert.equal(yield* ticketOwnedRowCount("ticket-expired"), 0); + assert.equal(yield* ticketOwnedRowCount("ticket-fresh"), 8); + assert.equal(yield* ticketOwnedRowCount("ticket-no-retention"), 8); + }).pipe(Effect.provide(makeLayer())), +); + +it.effect("skips expired terminal tickets while their workflow status is active", () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + const activeStatuses = ["running", "waiting_on_user", "blocked", "queued"] as const; + + yield* registerRetentionBoard; + for (const status of activeStatuses) { + yield* seedTicket({ + ticketId: `ticket-active-${status}`, + lane: "done", + status, + terminalAt: "2026-06-06T00:00:00.000Z", + }); + } + + const result = yield* sweeper.sweep(); + + assert.equal(result.candidateCount, 0); + assert.equal(result.deletedCount, 0); + assert.equal(result.failedCount, 0); + for (const status of activeStatuses) { + assert.equal(yield* ticketOwnedRowCount(`ticket-active-${status}`), 8); + } + }).pipe(Effect.provide(makeLayer())), +); + +it.effect("deletes expired terminal tickets after their workflow status is settled", () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + const settledStatuses = ["idle", "done", "failed"] as const; + + yield* registerRetentionBoard; + for (const status of settledStatuses) { + yield* seedTicket({ + ticketId: `ticket-settled-${status}`, + lane: "done", + status, + terminalAt: "2026-06-06T00:00:00.000Z", + }); + } + + const result = yield* sweeper.sweep(); + + assert.equal(result.candidateCount, 3); + assert.equal(result.deletedCount, 3); + assert.equal(result.failedCount, 0); + for (const status of settledStatuses) { + assert.equal(yield* ticketOwnedRowCount(`ticket-settled-${status}`), 0); + } + }).pipe(Effect.provide(makeLayer())), +); + +it.effect( + "keeps tickets exactly at the retention boundary and deletes strictly older tickets", + () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + + yield* registerRetentionBoard; + yield* seedTicket({ + ticketId: "ticket-boundary", + lane: "done", + terminalAt: "2026-06-07T00:00:00.000Z", + }); + yield* seedTicket({ + ticketId: "ticket-one-ms-expired", + lane: "done", + terminalAt: "2026-06-06T23:59:59.999Z", + }); + + const result = yield* sweeper.sweep(); + + assert.equal(result.candidateCount, 1); + assert.equal(result.deletedCount, 1); + assert.equal(yield* ticketOwnedRowCount("ticket-boundary"), 8); + assert.equal(yield* ticketOwnedRowCount("ticket-one-ms-expired"), 0); + }).pipe(Effect.provide(makeLayer())), +); + +it.effect("skips a selected ticket that moves out of the terminal lane before delete lock", () => { + let movedCandidate = false; + + return Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + const sql = yield* SqlClient.SqlClient; + + yield* registerRetentionBoard; + yield* seedTicket({ + ticketId: "ticket-stale-candidate", + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + + const result = yield* sweeper.sweep(); + const rows = yield* sql<{ readonly lane: string; readonly terminalAt: string | null }>` + SELECT current_lane_key AS lane, terminal_at AS "terminalAt" + FROM projection_ticket + WHERE ticket_id = 'ticket-stale-candidate' + `; + + assert.equal(result.candidateCount, 1); + assert.equal(result.deletedCount, 0); + assert.equal(result.failedCount, 0); + assert.equal(yield* ticketOwnedRowCount("ticket-stale-candidate"), 8); + assert.deepEqual(rows, [{ lane: "backlog", terminalAt: null }]); + }).pipe( + Effect.provide( + makeLayer({ + saveLocksLayer: makeSaveLocksLayer((sql) => + movedCandidate + ? Effect.void + : Effect.gen(function* () { + movedCandidate = true; + yield* sql` + UPDATE projection_ticket + SET current_lane_key = 'backlog', + terminal_at = NULL, + updated_at = '2026-06-08T00:00:00.000Z' + WHERE ticket_id = 'ticket-stale-candidate' + `; + }), + ), + }), + ), + ); +}); + +it.effect("caps expired ticket deletes per sweep and continues on the next sweep", () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + + yield* registerRetentionBoard; + for (let index = 0; index < 101; index += 1) { + yield* seedTicket({ + ticketId: `ticket-batch-${String(index).padStart(3, "0")}`, + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + } + + const first = yield* sweeper.sweep(); + + assert.equal(first.candidateCount, 100); + assert.equal(first.deletedCount, 100); + assert.equal(first.failedCount, 0); + assert.equal(yield* ticketOwnedRowCount("ticket-batch-000"), 0); + assert.equal(yield* ticketOwnedRowCount("ticket-batch-100"), 8); + + const second = yield* sweeper.sweep(); + + assert.equal(second.candidateCount, 1); + assert.equal(second.deletedCount, 1); + assert.equal(second.failedCount, 0); + assert.equal(yield* ticketOwnedRowCount("ticket-batch-100"), 0); + }).pipe(Effect.provide(makeLayer())), +); + +it.effect("round-robins capped sweeps across boards with expired backlogs", () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + const firstBoard = "board-retention-round-robin-a"; + const secondBoard = "board-retention-round-robin-b"; + + yield* registerRetentionBoardFor(firstBoard); + yield* registerRetentionBoardFor(secondBoard); + for (let index = 0; index < 4; index += 1) { + yield* seedTicket({ + boardId: firstBoard, + ticketId: `ticket-round-robin-a-${index}`, + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + yield* seedTicket({ + boardId: secondBoard, + ticketId: `ticket-round-robin-b-${index}`, + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + } + + const first = yield* sweeper.sweep(); + const second = yield* sweeper.sweep(); + + assert.equal(first.deletedCount, 2); + assert.equal(second.deletedCount, 2); + assert.equal(yield* remainingTicketCountForBoard(firstBoard), 2); + assert.equal(yield* remainingTicketCountForBoard(secondBoard), 2); + }).pipe(Effect.provide(makeLayer({ maxDeletesPerSweep: 2 }))), +); + +it.effect("continues deleting later expired tickets after one ticket cleanup fails", () => { + const failedTickets: string[] = []; + + return Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + + yield* registerRetentionBoard; + yield* seedTicket({ + ticketId: "ticket-fails", + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + yield* seedTicket({ + ticketId: "ticket-after-failure", + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + + const result = yield* sweeper.sweep(); + + assert.equal(result.deletedCount, 1); + assert.equal(result.failedCount, 1); + assert.deepEqual(failedTickets, ["ticket-fails"]); + assert.equal(yield* ticketOwnedRowCount("ticket-fails"), 8); + assert.equal(yield* ticketOwnedRowCount("ticket-after-failure"), 0); + }).pipe( + Effect.provide( + makeLayer({ + cancelTicketPipelines: (ticketId) => + ticketId === "ticket-fails" + ? Effect.sync(() => { + failedTickets.push(ticketId as string); + }).pipe( + Effect.andThen( + Effect.fail(new WorkflowEventStoreError({ message: "cancel failed" })), + ), + ) + : Effect.void, + }), + ), + ); +}); + +it.effect("serializes with a concurrent board delete without leaving ticket-owned rows", () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + const saveLocks = yield* WorkflowBoardSaveLocks; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const eventStore = yield* WorkflowEventStore; + const readModel = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + yield* registerRetentionBoard; + yield* seedTicket({ + ticketId: "ticket-race", + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + + const deleteFiber = yield* Effect.forkChild( + saveLocks.withSaveLock( + "board-retention-sweep" as never, + deleteWorkflowBoardOwnedState( + { + boardRegistry: registry, + engine, + eventStore, + readModel, + versionStore: { + deleteForBoard: () => Effect.void, + } satisfies Pick<WorkflowBoardVersionStoreShape, "deleteForBoard">, + sql, + }, + "board-retention-sweep" as never, + ), + ), + ); + const sweepFiber = yield* Effect.forkChild(sweeper.sweep()); + + yield* Fiber.join(deleteFiber); + yield* Fiber.join(sweepFiber); + + assert.equal(yield* ticketOwnedRowCount("ticket-race"), 0); + }).pipe(Effect.timeout("1 second"), Effect.provide(makeLayer())), +); diff --git a/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.ts b/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.ts new file mode 100644 index 00000000000..e3150178d42 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.ts @@ -0,0 +1,367 @@ +import type { BoardId, LaneKey, TicketId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowAgentSessionStore } from "../Services/WorkflowAgentSessionStore.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { + WorkflowTerminalRetentionSweeper, + type WorkflowTerminalRetentionSweepResult, + type WorkflowTerminalRetentionSweeperShape, +} from "../Services/WorkflowTerminalRetentionSweeper.ts"; +import { WorkflowThreadJanitor } from "../Services/WorkflowThreadJanitor.ts"; +import { WorkflowWorktreeJanitor } from "../Services/WorkflowWorktreeJanitor.ts"; +import { deleteWorkflowBoardTicketOwnedStateWhen } from "../boardDeletion.ts"; + +const DEFAULT_SWEEP_INTERVAL_MS = 15 * 60 * 1000; +const DEFAULT_MAX_DELETES_PER_SWEEP = 100; +const isSettledTerminalTicketStatus = (status: string) => + status === "idle" || status === "done" || status === "failed"; + +export interface WorkflowTerminalRetentionSweeperLiveOptions { + readonly sweepIntervalMs?: number; + readonly maxDeletesPerSweep?: number; + readonly nowMs?: Effect.Effect<number>; +} + +interface ExpiredTicketRow { + readonly ticketId: TicketId; + readonly terminalAt: string; +} + +interface CurrentTicketRetentionRow { + readonly currentLaneKey: LaneKey; + readonly status: string; + readonly terminalAt: string | null; +} + +interface RetentionLaneTarget { + readonly boardId: BoardId; + readonly laneKey: LaneKey; + readonly retentionMs: number; +} + +const makeWorkflowTerminalRetentionSweeper = ( + options?: WorkflowTerminalRetentionSweeperLiveOptions, +) => + Effect.gen(function* () { + const boardRegistry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const eventStore = yield* WorkflowEventStore; + const readModel = yield* WorkflowReadModel; + const saveLocks = yield* WorkflowBoardSaveLocks; + const sql = yield* SqlClient.SqlClient; + const worktreeJanitor = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<WorkflowWorktreeJanitor>, + WorkflowWorktreeJanitor, + ); + const threadJanitor = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<WorkflowThreadJanitor>, + WorkflowThreadJanitor, + ); + // Per-agent session teardown for swept terminal tickets (A8). Optional so + // leaner stacks without these services still build. + const agentSessions = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<WorkflowAgentSessionStore>, + WorkflowAgentSessionStore, + ); + const providerService = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<ProviderService>, + ProviderService, + ); + + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + const maxDeletesPerSweep = Math.max( + 1, + Math.floor(options?.maxDeletesPerSweep ?? DEFAULT_MAX_DELETES_PER_SWEEP), + ); + const nowMs = options?.nowMs ?? Clock.currentTimeMillis; + const listDefinitions = boardRegistry.listDefinitions; + const cancelTicketPipelines = engine.cancelTicketPipelines; + const deleteTicketState = readModel.deleteTicketState; + let nextSweepCursorKey: string | null = null; + + const retentionTargetKey = (target: Pick<RetentionLaneTarget, "boardId" | "laneKey">) => + `${target.boardId as string}::${target.laneKey as string}`; + + const cursorAfter = ( + targets: ReadonlyArray<RetentionLaneTarget>, + target: RetentionLaneTarget, + ) => { + if (targets.length === 0) { + return null; + } + const currentIndex = targets.findIndex( + (candidate) => retentionTargetKey(candidate) === retentionTargetKey(target), + ); + if (currentIndex < 0) { + return retentionTargetKey(targets[0]!); + } + return retentionTargetKey(targets[(currentIndex + 1) % targets.length]!); + }; + + const rotateTargets = (targets: ReadonlyArray<RetentionLaneTarget>) => { + if (nextSweepCursorKey === null) { + return targets; + } + const startIndex = targets.findIndex( + (target) => retentionTargetKey(target) === nextSweepCursorKey, + ); + if (startIndex <= 0) { + return targets; + } + return [...targets.slice(startIndex), ...targets.slice(0, startIndex)]; + }; + + const expiredTicketsForLane = ( + boardId: BoardId, + laneKey: LaneKey, + cutoffIso: string, + limit: number, + ) => + sql<ExpiredTicketRow>` + SELECT + ticket_id AS "ticketId", + terminal_at AS "terminalAt" + FROM projection_ticket + WHERE board_id = ${boardId} + AND current_lane_key = ${laneKey} + AND terminal_at IS NOT NULL + AND terminal_at < ${cutoffIso} + AND status IN ('idle', 'done', 'failed') + ORDER BY terminal_at ASC, ticket_id ASC + LIMIT ${limit} + `; + + const isStillExpiredTerminalTicket = (boardId: BoardId, ticketId: TicketId) => + Effect.gen(function* () { + const rows = yield* sql<CurrentTicketRetentionRow>` + SELECT + current_lane_key AS "currentLaneKey", + status, + terminal_at AS "terminalAt" + FROM projection_ticket + WHERE board_id = ${boardId} + AND ticket_id = ${ticketId} + `; + const ticket = rows[0]; + if (!ticket?.terminalAt) { + return false; + } + if (!isSettledTerminalTicketStatus(ticket.status)) { + return false; + } + + const lane = yield* boardRegistry.getLane(boardId, ticket.currentLaneKey); + if (lane?.terminal !== true || lane.retention === undefined) { + return false; + } + + const retentionMs = Duration.toMillis(lane.retention); + if (retentionMs <= 0) { + return false; + } + + const now = yield* nowMs; + const cutoffIso = DateTime.formatIso(DateTime.makeUnsafe(now - retentionMs)); + return ticket.terminalAt < cutoffIso; + }); + + const sweep: WorkflowTerminalRetentionSweeperShape["sweep"] = () => + Effect.gen(function* () { + const boards = yield* listDefinitions(); + const retentionTargets = boards.flatMap((board) => + board.definition.lanes.flatMap((lane) => + lane.terminal !== true || lane.retention === undefined + ? [] + : [ + { + boardId: board.boardId, + laneKey: lane.key, + retentionMs: Duration.toMillis(lane.retention), + } satisfies RetentionLaneTarget, + ], + ), + ); + const orderedRetentionTargets = rotateTargets(retentionTargets); + const now = yield* nowMs; + const result = { + candidateCount: 0, + deletedCount: 0, + failedCount: 0, + } satisfies WorkflowTerminalRetentionSweepResult; + let candidateCount = result.candidateCount; + let deletedCount = result.deletedCount; + let failedCount = result.failedCount; + let remainingDeleteBudget = maxDeletesPerSweep; + let moreRemaining = false; + + const hasMoreExpiredTickets = Effect.gen(function* () { + for (const target of retentionTargets) { + const cutoffIso = DateTime.formatIso(DateTime.makeUnsafe(now - target.retentionMs)); + const tickets = yield* expiredTicketsForLane( + target.boardId, + target.laneKey, + cutoffIso, + 1, + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.terminal-retention.more-query-failed", { + boardId: target.boardId, + laneKey: target.laneKey, + cause, + }).pipe(Effect.as([] as ReadonlyArray<ExpiredTicketRow>)), + ), + ); + if (tickets.length > 0) { + return true; + } + } + return false; + }); + + targets: for (const target of orderedRetentionTargets) { + if (remainingDeleteBudget <= 0) { + break; + } + + const cutoffIso = DateTime.formatIso(DateTime.makeUnsafe(now - target.retentionMs)); + const tickets = yield* expiredTicketsForLane( + target.boardId, + target.laneKey, + cutoffIso, + remainingDeleteBudget + 1, + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.terminal-retention.ticket-query-failed", { + boardId: target.boardId, + laneKey: target.laneKey, + cause, + }).pipe(Effect.as([] as ReadonlyArray<ExpiredTicketRow>)), + ), + ); + const ticketsToProcess = tickets.slice(0, remainingDeleteBudget); + moreRemaining = moreRemaining || tickets.length > ticketsToProcess.length; + + for (const ticket of ticketsToProcess) { + candidateCount += 1; + const outcome = yield* deleteWorkflowBoardTicketOwnedStateWhen( + { + saveLocks, + engine: { cancelTicketPipelines }, + eventStore, + readModel: { deleteTicketState }, + sql, + ...(Option.isSome(worktreeJanitor) + ? { worktreeJanitor: worktreeJanitor.value } + : {}), + ...(Option.isSome(threadJanitor) ? { threadJanitor: threadJanitor.value } : {}), + ...(Option.isSome(agentSessions) ? { agentSessions: agentSessions.value } : {}), + ...(Option.isSome(providerService) ? { provider: providerService.value } : {}), + }, + target.boardId, + ticket.ticketId, + isStillExpiredTerminalTicket(target.boardId, ticket.ticketId), + ).pipe( + Effect.tap((deleted) => + deleted + ? Effect.logInfo("workflow.terminal-retention.ticket-deleted", { + boardId: target.boardId, + laneKey: target.laneKey, + ticketId: ticket.ticketId, + terminalAt: ticket.terminalAt, + retentionMs: target.retentionMs, + }) + : Effect.logInfo("workflow.terminal-retention.ticket-skip-stale", { + boardId: target.boardId, + laneKey: target.laneKey, + ticketId: ticket.ticketId, + terminalAt: ticket.terminalAt, + }), + ), + Effect.map((deleted): "deleted" | "skipped" => (deleted ? "deleted" : "skipped")), + Effect.catchCause((cause) => + Effect.logWarning("workflow.terminal-retention.ticket-delete-failed", { + boardId: target.boardId, + laneKey: target.laneKey, + ticketId: ticket.ticketId, + cause, + }).pipe(Effect.as("failed" as const)), + ), + ); + + if (outcome === "deleted") { + deletedCount += 1; + } else if (outcome === "failed") { + failedCount += 1; + } + // Budget bounds real work (deletes + failed delete attempts), NOT + // candidates examined. A ticket that passed the coarse SQL cutoff + // but failed the stale re-check is skipped without charging the + // budget, so a lane full of stale-but-undeletable tickets can no + // longer starve deletions in other lanes within one sweep. + if (outcome === "deleted" || outcome === "failed") { + remainingDeleteBudget -= 1; + if (remainingDeleteBudget <= 0) { + nextSweepCursorKey = cursorAfter(retentionTargets, target); + break targets; + } + } + } + } + + if (remainingDeleteBudget <= 0 && !moreRemaining) { + moreRemaining = yield* hasMoreExpiredTickets; + } + + if (candidateCount > 0 || moreRemaining) { + yield* Effect.logInfo("workflow.terminal-retention.sweep-complete", { + candidateCount, + deletedCount, + failedCount, + maxDeletesPerSweep, + moreRemaining, + }); + } + + return { candidateCount, deletedCount, failedCount }; + }); + + const start: WorkflowTerminalRetentionSweeperShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + sweep().pipe( + Effect.catchDefect((defect: unknown) => + Effect.logWarning("workflow.terminal-retention.sweep-defect", { + defect, + }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), + ), + ); + + yield* Effect.logInfo("workflow.terminal-retention.started", { + sweepIntervalMs, + }); + }); + + return { sweep, start } satisfies WorkflowTerminalRetentionSweeperShape; + }); + +export const makeWorkflowTerminalRetentionSweeperLive = ( + options?: WorkflowTerminalRetentionSweeperLiveOptions, +) => Layer.effect(WorkflowTerminalRetentionSweeper, makeWorkflowTerminalRetentionSweeper(options)); + +export const WorkflowTerminalRetentionSweeperLive = makeWorkflowTerminalRetentionSweeperLive(); diff --git a/apps/server/src/workflow/Layers/WorkflowThreadJanitor.ts b/apps/server/src/workflow/Layers/WorkflowThreadJanitor.ts new file mode 100644 index 00000000000..e2e0d8e376a --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowThreadJanitor.ts @@ -0,0 +1,67 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowThreadJanitor, + type WorkflowThreadJanitorShape, +} from "../Services/WorkflowThreadJanitor.ts"; + +const toJanitorError = (cause: unknown) => + new WorkflowEventStoreError({ message: "workflow thread janitor failed", cause }); + +const wrap = <A>(effect: Effect.Effect<A, SqlError>) => + effect.pipe(Effect.mapError(toJanitorError)); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const orchestration = yield* Effect.serviceOption(OrchestrationEngineService); + + const collectBoardThreads: WorkflowThreadJanitorShape["collectBoardThreads"] = (boardId) => + wrap(sql<{ readonly threadId: string }>` + SELECT DISTINCT thread_id AS "threadId" + FROM workflow_dispatch_outbox + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `).pipe(Effect.map((rows) => rows.map((row) => row.threadId))); + + const collectTicketThreads: WorkflowThreadJanitorShape["collectTicketThreads"] = (ticketId) => + wrap(sql<{ readonly threadId: string }>` + SELECT DISTINCT thread_id AS "threadId" + FROM workflow_dispatch_outbox + WHERE ticket_id = ${ticketId} + `).pipe(Effect.map((rows) => rows.map((row) => row.threadId))); + + const deleteThreads: WorkflowThreadJanitorShape["deleteThreads"] = (threadIds) => + Effect.gen(function* () { + if (Option.isNone(orchestration) || threadIds.length === 0) { + return; + } + for (const threadId of threadIds) { + // Best-effort per thread: a thread that never materialized (or was + // already deleted) must not abort cleanup of the rest. + yield* orchestration.value + .dispatch({ + type: "thread.delete", + commandId: `workflow-thread-delete-${threadId}` as never, + threadId: threadId as never, + }) + .pipe(Effect.catch(() => Effect.void)); + } + }); + + return { + collectBoardThreads, + collectTicketThreads, + deleteThreads, + } satisfies WorkflowThreadJanitorShape; +}); + +export const WorkflowThreadJanitorLive = Layer.effect(WorkflowThreadJanitor, make); diff --git a/apps/server/src/workflow/Layers/WorkflowWebhook.concurrency.test.ts b/apps/server/src/workflow/Layers/WorkflowWebhook.concurrency.test.ts new file mode 100644 index 00000000000..fb0de947372 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowWebhook.concurrency.test.ts @@ -0,0 +1,101 @@ +import { assert, it } from "@effect/vitest"; +import { BoardId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { WorkflowWebhook } from "../Services/WorkflowWebhook.ts"; +import { WorkflowWebhookLive } from "./WorkflowWebhook.ts"; + +// Canonical in-memory SQL stack: SqlitePersistenceMemory (which runs migrations +// internally) + MigrationsLive so the workflow_webhook_delivery table + its +// (board_id, delivery_id) PRIMARY KEY from migration 033 exist, plus the layer +// under test. No timers or heavy deps are needed for delivery dedupe. +const layer = it.layer( + WorkflowWebhookLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowWebhook delivery dedupe (concurrency)", (it) => { + it.effect("first record is fresh (false), a repeat of the same id is a duplicate (true)", () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + const board = BoardId.make("board-dedupe"); + + // recordDelivery inserts ON CONFLICT DO NOTHING RETURNING and returns + // `inserted.length === 0`: the first call inserts the row → false (fresh, + // proceed to ingest); the second call with the same id hits the conflict → + // no row returned → true (duplicate, skip). + assert.isFalse(yield* webhook.recordDelivery(board, "delivery-1")); + assert.isTrue(yield* webhook.recordDelivery(board, "delivery-1")); + }), + ); + + it.effect( + "concurrent same-id deliveries: EXACTLY ONE wins (false), the rest are duplicates (true)", + () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + const board = BoardId.make("board-race"); + const N = 8; + + // Fire N concurrent recordDelivery calls for the SAME id. The + // ON CONFLICT(board_id, delivery_id) DO NOTHING ... RETURNING guarantees + // exactly one INSERT succeeds (gets the row → false); every other call + // sees the conflict (no row → true). This is the load-bearing + // exactly-one-winner invariant that prevents double-ingest. We assert the + // invariant (one false, N-1 true), NOT any specific interleaving — the + // in-memory SqlClient may serialize the writes, but the result must hold + // regardless of ordering. + const results = yield* Effect.all( + Array.from({ length: N }, () => webhook.recordDelivery(board, "delivery-concurrent")), + { concurrency: "unbounded" }, + ); + + const winners = results.filter((isDuplicate) => isDuplicate === false); + const duplicates = results.filter((isDuplicate) => isDuplicate === true); + assert.strictEqual(winners.length, 1, "exactly one fresh winner"); + assert.strictEqual(duplicates.length, N - 1, "every other call is a duplicate"); + }), + ); + + it.effect("releaseDelivery forgets the row so a subsequent record is fresh again", () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + const board = BoardId.make("board-release"); + + assert.isFalse(yield* webhook.recordDelivery(board, "delivery-r")); + // Same id now reads as a duplicate... + assert.isTrue(yield* webhook.recordDelivery(board, "delivery-r")); + + // releaseDelivery DELETEs the row (failed-ingest retry path). After it, the + // sender's retry must be treated as FRESH (false), not answered "duplicate". + yield* webhook.releaseDelivery(board, "delivery-r"); + assert.isFalse(yield* webhook.recordDelivery(board, "delivery-r")); + }), + ); + + it.effect("different board OR different id are independent (both fresh)", () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + const boardA = BoardId.make("board-indep-a"); + const boardB = BoardId.make("board-indep-b"); + + // Establish a recorded delivery on board A. + assert.isFalse(yield* webhook.recordDelivery(boardA, "shared-id")); + + // Same id on a DIFFERENT board is independent (the PK is composite) → fresh. + assert.isFalse(yield* webhook.recordDelivery(boardB, "shared-id")); + // A DIFFERENT id on the same board A is independent → fresh. + assert.isFalse(yield* webhook.recordDelivery(boardA, "other-id")); + + // ...and each is now its own duplicate on a repeat. + assert.isTrue(yield* webhook.recordDelivery(boardA, "shared-id")); + assert.isTrue(yield* webhook.recordDelivery(boardB, "shared-id")); + assert.isTrue(yield* webhook.recordDelivery(boardA, "other-id")); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowWebhook.test.ts b/apps/server/src/workflow/Layers/WorkflowWebhook.test.ts new file mode 100644 index 00000000000..bed68607906 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowWebhook.test.ts @@ -0,0 +1,186 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { sanitizeExternalEventPayload } from "../externalEvent.ts"; +import { WorkflowWebhook } from "../Services/WorkflowWebhook.ts"; +import { WorkflowWebhookLive } from "./WorkflowWebhook.ts"; + +const layer = it.layer( + WorkflowWebhookLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowWebhook", (it) => { + it.effect("issues a token once, reveals it only on create/rotate, and verifies it", () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + + const created = yield* webhook.getConfig("board-hook" as never, false); + assert.equal(created.hasToken, true); + assert.isString(created.token); + assert.equal(created.token?.length, 64); + assert.equal(created.tokenPrefix, created.token?.slice(0, 8)); + assert.equal(created.path, "/hooks/workflow/board-hook"); + + // Subsequent reads never reveal the secret again. + const read = yield* webhook.getConfig("board-hook" as never, false); + assert.equal(read.hasToken, true); + assert.equal(read.token, undefined); + assert.equal(read.tokenPrefix, created.tokenPrefix); + + assert.isTrue(yield* webhook.verifyToken("board-hook" as never, created.token ?? "")); + assert.isFalse(yield* webhook.verifyToken("board-hook" as never, "wrong")); + assert.isFalse(yield* webhook.verifyToken("board-unknown" as never, created.token ?? "")); + + // Rotation invalidates the old token. + const rotated = yield* webhook.getConfig("board-hook" as never, true); + assert.isString(rotated.token); + assert.notEqual(rotated.token, created.token); + assert.isFalse(yield* webhook.verifyToken("board-hook" as never, created.token ?? "")); + assert.isTrue(yield* webhook.verifyToken("board-hook" as never, rotated.token ?? "")); + }), + ); + + it.effect("dedupes a delivery once it has been recorded", () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + + // First record is fresh (false → proceed to ingest); the SECOND record of + // the same id is a duplicate (true → skip), without any further step. + assert.isFalse(yield* webhook.recordDelivery("board-a" as never, "delivery-1")); + assert.isTrue(yield* webhook.recordDelivery("board-a" as never, "delivery-1")); + // Different board, same delivery id: independent. + assert.isFalse(yield* webhook.recordDelivery("board-b" as never, "delivery-1")); + }), + ); + + it.effect( + "two concurrent recordDelivery for the SAME id → exactly one proceeds, the other dedupes", + () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + + // Race two records of the same id. Concurrency-safe dedupe must let + // exactly ONE win the INSERT (false → proceed) and the other see the + // conflict (true → duplicate) — never both false (that is a double-ingest). + const [a, b] = yield* Effect.all( + [ + webhook.recordDelivery("board-race" as never, "delivery-1"), + webhook.recordDelivery("board-race" as never, "delivery-1"), + ], + { concurrency: 2 }, + ); + assert.notStrictEqual(a, b, "exactly one of the two records must proceed"); + assert.isTrue(a || b, "the duplicate must be reported"); + assert.isFalse(a && b, "both cannot proceed (would double-ingest)"); + }), + ); + + it.effect("releaseDelivery lets the sender's retry be ingested after a failed ingest", () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + + // Delivery recorded, then ingest fails: releasing must make the identical + // retry look fresh (not "duplicate") so the event is not lost. + assert.isFalse(yield* webhook.recordDelivery("board-retry" as never, "delivery-1")); + yield* webhook.releaseDelivery("board-retry" as never, "delivery-1"); + // The retry is fresh again (re-ingestable) ... + assert.isFalse(yield* webhook.recordDelivery("board-retry" as never, "delivery-1")); + // ... and once re-recorded (ingest succeeded, no release), it dedupes. + assert.isTrue(yield* webhook.recordDelivery("board-retry" as never, "delivery-1")); + }), + ); + + it.effect("pruneStaleDeliveries reaps only rows older than the cutoff", () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + const sql = yield* SqlClient.SqlClient; + + // Two delivery rows: one old (created 30 days ago), one recent (now). Only + // the old one is past a cutoff of "now − 7 days" and must be reaped; the + // recent row (still inside the retry window) must survive. + const now = yield* DateTime.now; + const oldIso = DateTime.formatIso(DateTime.subtractDuration(now, Duration.days(30))); + const recentIso = DateTime.formatIso(now); + yield* sql` + INSERT INTO workflow_webhook_delivery (board_id, delivery_id, created_at) + VALUES ('board-prune', 'old', ${oldIso}), ('board-prune', 'recent', ${recentIso}) + `; + + const cutoffIso = DateTime.formatIso(DateTime.subtractDuration(now, Duration.days(7))); + const deleted = yield* webhook.pruneStaleDeliveries(cutoffIso); + assert.strictEqual(deleted, 1, "exactly the one stale row is deleted"); + + // The recent row still dedupes (was not pruned); a fresh id is independent. + assert.isTrue(yield* webhook.recordDelivery("board-prune" as never, "recent")); + // The pruned id reads as fresh again (its row is gone). + assert.isFalse(yield* webhook.recordDelivery("board-prune" as never, "old")); + }), + ); + + it.effect("deleteForBoard revokes the token and forgets deliveries", () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + + const created = yield* webhook.getConfig("board-gone" as never, false); + assert.isFalse(yield* webhook.recordDelivery("board-gone" as never, "delivery-1")); + + yield* webhook.deleteForBoard("board-gone" as never); + + // A recreated board with the same id must not inherit the old token. + assert.isFalse(yield* webhook.verifyToken("board-gone" as never, created.token ?? "")); + assert.isFalse(yield* webhook.recordDelivery("board-gone" as never, "delivery-1")); + }), + ); +}); + +describe("sanitizeExternalEventPayload", () => { + it("bounds depth, breadth, and string length while keeping valid JSON", () => { + const deep: Record<string, unknown> = { level: 0 }; + let cursor = deep; + for (let depth = 1; depth < 10; depth += 1) { + const next: Record<string, unknown> = { level: depth }; + cursor["child"] = next; + cursor = next; + } + const sanitized = sanitizeExternalEventPayload({ + deep, + long: "x".repeat(5_000), + many: Object.fromEntries(Array.from({ length: 200 }, (_, index) => [`k${index}`, index])), + list: Array.from({ length: 300 }, (_, index) => index), + fn: () => "never", + }) as Record<string, unknown>; + + assert.equal((sanitized["long"] as string).length, 2_000); + assert.isAtMost(Object.keys(sanitized["many"] as object).length, 100); + assert.equal((sanitized["list"] as unknown[]).length, 100); + assert.isUndefined(sanitized["fn"]); + // Depth capped — walking 6 levels in ends before level 9. + let walker = sanitized["deep"] as Record<string, unknown> | undefined; + let levels = 0; + while (walker !== undefined && typeof walker === "object" && "child" in walker) { + walker = walker["child"] as Record<string, unknown> | undefined; + levels += 1; + } + assert.isAtMost(levels, 6); + // Round-trips as JSON. + assert.doesNotThrow(() => JSON.stringify(sanitized)); + }); + + it("drops prototype-polluting keys", () => { + const sanitized = sanitizeExternalEventPayload( + JSON.parse('{"__proto__":{"admin":true},"constructor":1,"prototype":2,"ok":3}'), + ) as Record<string, unknown>; + assert.deepEqual(sanitized, { ok: 3 }); + assert.isUndefined((sanitized as { admin?: unknown }).admin); + assert.isUndefined(Object.getPrototypeOf(sanitized)?.admin); + }); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowWebhook.ts b/apps/server/src/workflow/Layers/WorkflowWebhook.ts new file mode 100644 index 00000000000..d10e3bd0bc1 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowWebhook.ts @@ -0,0 +1,204 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowWebhook, type WorkflowWebhookShape } from "../Services/WorkflowWebhook.ts"; + +// Dedup-row retention. A delivery row only exists to answer "have I seen this +// deliveryId before?" for a sender's bounded retry window — well past it the row +// is dead weight. Without this sweep every successful keyed delivery leaves a +// permanent row and the table grows unbounded for the life of a board (the +// created_at column from migration 033 exists precisely for time-based pruning). +const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const DEFAULT_PRUNE_INTERVAL_MS = 60 * 60 * 1000; // hourly +const DEFAULT_MAX_DELETES_PER_SWEEP = 5_000; // bound per-sweep work / lock hold + +export interface WorkflowWebhookLiveOptions { + readonly retentionMs?: number; + readonly pruneIntervalMs?: number; + readonly maxDeletesPerSweep?: number; +} + +const toWebhookError = (cause: unknown) => + new WorkflowEventStoreError({ message: "workflow webhook store failed", cause }); + +const wrap = <A>(effect: Effect.Effect<A, SqlError>) => + effect.pipe(Effect.mapError(toWebhookError)); + +const hashToken = (token: string): string => createHash("sha256").update(token).digest("hex"); + +export const workflowWebhookPath = (boardId: string): string => + `/hooks/workflow/${encodeURIComponent(boardId)}`; + +const make = (options?: WorkflowWebhookLiveOptions) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + const retentionMs = Math.max(1, options?.retentionMs ?? DEFAULT_RETENTION_MS); + const pruneIntervalMs = Math.max(1, options?.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS); + const maxDeletesPerSweep = Math.max( + 1, + Math.floor(options?.maxDeletesPerSweep ?? DEFAULT_MAX_DELETES_PER_SWEEP), + ); + + const getConfig: WorkflowWebhookShape["getConfig"] = (boardId, rotate) => + Effect.gen(function* () { + const rows = yield* wrap(sql<{ readonly tokenPrefix: string }>` + SELECT token_prefix AS "tokenPrefix" + FROM workflow_board_webhook + WHERE board_id = ${boardId} + `); + const existing = rows[0]; + if (existing !== undefined && !rotate) { + return { + path: workflowWebhookPath(boardId as string), + hasToken: true, + tokenPrefix: existing.tokenPrefix, + }; + } + const token = randomBytes(32).toString("hex"); + const tokenPrefix = token.slice(0, 8); + const createdAt = yield* nowIso; + yield* wrap(sql` + INSERT INTO workflow_board_webhook (board_id, token_hash, token_prefix, created_at) + VALUES (${boardId}, ${hashToken(token)}, ${tokenPrefix}, ${createdAt}) + ON CONFLICT(board_id) DO UPDATE SET + token_hash = excluded.token_hash, + token_prefix = excluded.token_prefix, + created_at = excluded.created_at + `); + return { + path: workflowWebhookPath(boardId as string), + hasToken: true, + tokenPrefix, + token, + }; + }); + + const verifyToken: WorkflowWebhookShape["verifyToken"] = (boardId, token) => + Effect.gen(function* () { + const rows = yield* wrap(sql<{ readonly tokenHash: string }>` + SELECT token_hash AS "tokenHash" + FROM workflow_board_webhook + WHERE board_id = ${boardId} + `); + const stored = rows[0]?.tokenHash; + if (stored === undefined) { + return false; + } + const expected = Buffer.from(stored, "hex"); + const candidate = Buffer.from(hashToken(token), "hex"); + return expected.length === candidate.length && timingSafeEqual(expected, candidate); + }); + + const recordDelivery: WorkflowWebhookShape["recordDelivery"] = (boardId, deliveryId) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + // RETURNING yields a row ONLY when the insert actually happened. A fresh + // id inserts → returns the row → false (proceed to ingest). A repeat id + // hits ON CONFLICT DO NOTHING → no row → true (duplicate, skip). Across + // two concurrent same-id requests exactly one wins the INSERT and gets + // false; the loser sees the conflict and gets true — no double-ingest. + const inserted = yield* wrap(sql<{ readonly deliveryId: string }>` + INSERT INTO workflow_webhook_delivery (board_id, delivery_id, created_at) + VALUES (${boardId}, ${deliveryId}, ${createdAt}) + ON CONFLICT(board_id, delivery_id) DO NOTHING + RETURNING delivery_id AS "deliveryId" + `); + return inserted.length === 0; + }); + + const releaseDelivery: WorkflowWebhookShape["releaseDelivery"] = (boardId, deliveryId) => + wrap(sql` + DELETE FROM workflow_webhook_delivery + WHERE board_id = ${boardId} AND delivery_id = ${deliveryId} + `).pipe(Effect.asVoid); + + const deleteForBoard: WorkflowWebhookShape["deleteForBoard"] = (boardId) => + Effect.gen(function* () { + yield* wrap(sql`DELETE FROM workflow_webhook_delivery WHERE board_id = ${boardId}`); + yield* wrap(sql`DELETE FROM workflow_board_webhook WHERE board_id = ${boardId}`); + }); + + // Reap dedup rows older than the retention window. Bounded per sweep via an + // (board_id, delivery_id) IN (SELECT ... LIMIT) subquery so a large backlog + // is drained over several ticks rather than one long lock-holding DELETE. + // Returns the deleted count so a sweep can keep going while a batch was full. + const pruneStaleDeliveries: WorkflowWebhookShape["pruneStaleDeliveries"] = (beforeIso) => + Effect.gen(function* () { + const deleted = yield* wrap(sql<{ readonly deliveryId: string }>` + DELETE FROM workflow_webhook_delivery + WHERE (board_id, delivery_id) IN ( + SELECT board_id, delivery_id + FROM workflow_webhook_delivery + WHERE created_at < ${beforeIso} + ORDER BY created_at ASC + LIMIT ${maxDeletesPerSweep} + ) + RETURNING delivery_id AS "deliveryId" + `); + return deleted.length; + }); + + // One prune pass: compute the cutoff once, then drain full batches until a + // batch comes back short (backlog exhausted) so a large accumulation is + // cleared promptly without a single unbounded DELETE. + const pruneOnce = Effect.gen(function* () { + const now = yield* DateTime.now; + const cutoffIso = DateTime.formatIso( + DateTime.subtractDuration(now, Duration.millis(retentionMs)), + ); + let deletedThisPass = 0; + while (true) { + const deleted = yield* pruneStaleDeliveries(cutoffIso); + deletedThisPass += deleted; + if (deleted < maxDeletesPerSweep) { + break; + } + } + if (deletedThisPass > 0) { + yield* Effect.logInfo("workflow.webhook.delivery-pruned", { + deletedCount: deletedThisPass, + cutoffIso, + }); + } + }); + + const start: WorkflowWebhookShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + // A select/DELETE failure is logged and swallowed so a transient store + // error never tears down the prune loop; the next tick retries. + pruneOnce.pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.webhook.prune-failed", { cause }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(pruneIntervalMs))), + ), + ); + yield* Effect.logInfo("workflow.webhook.prune-started", { pruneIntervalMs, retentionMs }); + }); + + return { + getConfig, + verifyToken, + recordDelivery, + releaseDelivery, + deleteForBoard, + pruneStaleDeliveries, + start, + } satisfies WorkflowWebhookShape; + }); + +export const makeWorkflowWebhookLive = (options?: WorkflowWebhookLiveOptions) => + Layer.effect(WorkflowWebhook, make(options)); + +export const WorkflowWebhookLive = makeWorkflowWebhookLive(); diff --git a/apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.test.ts b/apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.test.ts new file mode 100644 index 00000000000..a3cefa33c13 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.test.ts @@ -0,0 +1,113 @@ +import { assert, it } from "@effect/vitest"; +import type { TicketId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MergeGitPort } from "../Services/TicketMergeService.ts"; +import { WorkflowWorktreeJanitor } from "../Services/WorkflowWorktreeJanitor.ts"; +import { ticketRefsPrefix } from "../ticketRefs.ts"; +import { WorkflowWorktreeJanitorLive } from "./WorkflowWorktreeJanitor.ts"; + +interface RecordedGitCall { + readonly cwd: string; + readonly args: ReadonlyArray<string>; +} + +const ticketId = "ticket-gc" as TicketId; + +const gitCalls: Array<RecordedGitCall> = []; + +const stubGit = Layer.succeed(MergeGitPort, { + run: (input) => + Effect.sync(() => { + gitCalls.push({ cwd: input.cwd, args: input.args }); + if (input.args[0] === "worktree" && input.args[1] === "list") { + return { + exitCode: 0, + stdout: [ + "worktree /repo", + "branch refs/heads/main", + "", + "worktree /repo-worktrees/ticket-gc", + `branch refs/heads/workflow/${ticketId}`, + "", + ].join("\n"), + stderr: "", + }; + } + if (input.args[0] === "for-each-ref") { + return { + exitCode: 0, + stdout: `${ticketRefsPrefix(ticketId)}/base\n${ticketRefsPrefix(ticketId)}/step/abc/pre\n`, + stderr: "", + }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }), +}); + +const layer = it.layer( + WorkflowWorktreeJanitorLive.pipe( + Layer.provideMerge(stubGit), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowWorktreeJanitor", (it) => { + it.effect("removes the worktree, branch, refs and lease row for a ticket", () => + Effect.gen(function* () { + gitCalls.length = 0; + const janitor = yield* WorkflowWorktreeJanitor; + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT INTO worktree_lease ( + worktree_ref, owner_kind, owner_id, fence_token, acquired_at, expires_at + ) + VALUES ( + ${`workflow/${ticketId}`}, 'step', 'step-run-gc', 1, + '2026-06-09T00:00:00.000Z', '2026-06-09T01:00:00.000Z' + ) + `; + + yield* janitor.run({ repoRoot: "/repo", ticketIds: [ticketId] }); + + assert.ok( + gitCalls.some( + (call) => + call.args[0] === "worktree" && + call.args[1] === "remove" && + call.args.includes("/repo-worktrees/ticket-gc"), + ), + ); + assert.ok(gitCalls.some((call) => call.args[0] === "branch" && call.args[1] === "-D")); + assert.equal( + gitCalls.filter((call) => call.args[0] === "update-ref" && call.args[1] === "-d").length, + 2, + ); + + const leases = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM worktree_lease + WHERE worktree_ref = ${`workflow/${ticketId}`} + `; + assert.equal(leases[0]?.count, 0); + }), + ); + + it.effect("collects board plans before deletion and tolerates missing rows", () => + Effect.gen(function* () { + const janitor = yield* WorkflowWorktreeJanitor; + const missing = yield* janitor.collectBoardPlan("board-missing" as never); + assert.equal(missing, null); + + const missingTicket = yield* janitor.collectTicketPlan("ticket-missing" as never); + assert.equal(missingTicket, null); + + yield* janitor.run(null); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.ts b/apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.ts new file mode 100644 index 00000000000..db6c2f28239 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.ts @@ -0,0 +1,183 @@ +import type { TicketId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MergeGitPort } from "../Services/TicketMergeService.ts"; +import { + WorkflowWorktreeJanitor, + type WorkflowWorktreeJanitorShape, + type WorktreeCleanupPlan, +} from "../Services/WorkflowWorktreeJanitor.ts"; +import { ticketRefsPrefix } from "../ticketRefs.ts"; + +interface RepoRootRow { + readonly repoRoot: string; +} + +interface TicketIdRow { + readonly ticketId: TicketId; +} + +const ticketWorktreeRef = (ticketId: TicketId) => `workflow/${ticketId}`; + +// Parses `git worktree list --porcelain` into branch-ref → worktree-path. +const worktreePathsByBranch = (porcelain: string): Map<string, string> => { + const out = new Map<string, string>(); + let currentPath: string | null = null; + for (const line of porcelain.split("\n")) { + if (line.startsWith("worktree ")) { + currentPath = line.slice("worktree ".length).trim(); + } else if (line.startsWith("branch ") && currentPath !== null) { + out.set(line.slice("branch ".length).trim(), currentPath); + } else if (line.trim().length === 0) { + currentPath = null; + } + } + return out; +}; + +const make = Effect.gen(function* () { + const git = yield* MergeGitPort; + const sql = yield* SqlClient.SqlClient; + + const bestEffort = <A, E>(label: string, effect: Effect.Effect<A, E>) => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow worktree cleanup step failed", { label, cause }), + ), + Effect.asVoid, + ); + + const collectBoardPlan: WorkflowWorktreeJanitorShape["collectBoardPlan"] = (boardId) => + Effect.gen(function* () { + const roots = yield* sql<RepoRootRow>` + SELECT projects.workspace_root AS "repoRoot" + FROM projection_board AS board + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE board.board_id = ${boardId} + LIMIT 1 + `; + const repoRoot = roots[0]?.repoRoot; + if (repoRoot === undefined) { + return null; + } + const tickets = yield* sql<TicketIdRow>` + SELECT ticket_id AS "ticketId" + FROM projection_ticket + WHERE board_id = ${boardId} + `; + if (tickets.length === 0) { + return null; + } + return { repoRoot, ticketIds: tickets.map((row) => row.ticketId) }; + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow worktree cleanup board plan failed", { boardId, cause }).pipe( + Effect.as(null), + ), + ), + ); + + const collectTicketPlan: WorkflowWorktreeJanitorShape["collectTicketPlan"] = (ticketId) => + Effect.gen(function* () { + const roots = yield* sql<RepoRootRow>` + SELECT projects.workspace_root AS "repoRoot" + FROM projection_ticket AS ticket + INNER JOIN projection_board AS board + ON board.board_id = ticket.board_id + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE ticket.ticket_id = ${ticketId} + LIMIT 1 + `; + const repoRoot = roots[0]?.repoRoot; + if (repoRoot === undefined) { + return null; + } + return { repoRoot, ticketIds: [ticketId] }; + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow worktree cleanup ticket plan failed", { ticketId, cause }).pipe( + Effect.as(null), + ), + ), + ); + + const cleanupTicket = (plan: WorktreeCleanupPlan, ticketId: TicketId) => + Effect.gen(function* () { + const worktreeRef = ticketWorktreeRef(ticketId); + + yield* bestEffort( + "remove worktree", + Effect.gen(function* () { + const list = yield* git.run({ + cwd: plan.repoRoot, + args: ["worktree", "list", "--porcelain"], + }); + const path = worktreePathsByBranch(list.stdout).get(`refs/heads/${worktreeRef}`); + if (path !== undefined) { + yield* git.run({ + cwd: plan.repoRoot, + args: ["worktree", "remove", "--force", path], + allowNonZeroExit: true, + }); + } + yield* git.run({ + cwd: plan.repoRoot, + args: ["worktree", "prune"], + allowNonZeroExit: true, + }); + }), + ); + + yield* bestEffort( + "delete ticket branch", + git.run({ + cwd: plan.repoRoot, + args: ["branch", "-D", worktreeRef], + allowNonZeroExit: true, + }), + ); + + yield* bestEffort( + "delete ticket checkpoint refs", + Effect.gen(function* () { + const refs = yield* git.run({ + cwd: plan.repoRoot, + args: ["for-each-ref", "--format=%(refname)", `${ticketRefsPrefix(ticketId)}/`], + }); + for (const ref of refs.stdout.split("\n")) { + const trimmed = ref.trim(); + if (trimmed.length > 0) { + yield* git.run({ + cwd: plan.repoRoot, + args: ["update-ref", "-d", trimmed], + allowNonZeroExit: true, + }); + } + } + }), + ); + + yield* bestEffort( + "delete worktree lease row", + sql` + DELETE FROM worktree_lease + WHERE worktree_ref = ${worktreeRef} + `, + ); + }); + + const run: WorkflowWorktreeJanitorShape["run"] = (plan) => + plan === null + ? Effect.void + : Effect.forEach(plan.ticketIds, (ticketId) => cleanupTicket(plan, ticketId), { + discard: true, + }); + + return { collectBoardPlan, collectTicketPlan, run } satisfies WorkflowWorktreeJanitorShape; +}); + +export const WorkflowWorktreeJanitorLive = Layer.effect(WorkflowWorktreeJanitor, make); diff --git a/apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts b/apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts new file mode 100644 index 00000000000..ef087921a84 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts @@ -0,0 +1,23 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("M3 migrations", (it) => { + it.effect("creates lease, dispatch outbox, and setup run tables", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'table' + AND name IN ('worktree_lease', 'workflow_dispatch_outbox', 'workflow_setup_run') + `; + assert.equal(rows.length, 3); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts b/apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts new file mode 100644 index 00000000000..23454183f87 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts @@ -0,0 +1,40 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; +import { WorktreeLeaseServiceLive } from "./WorktreeLeaseService.ts"; + +const layer = it.layer( + WorktreeLeaseServiceLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorktreeLeaseService", (it) => { + it.effect("acquire returns a monotonically increasing fence token", () => + Effect.gen(function* () { + const lease = yield* WorktreeLeaseService; + const a = yield* lease.acquire("wt-1", "step", "sr-1"); + yield* lease.release("wt-1", a.fenceToken); + const b = yield* lease.acquire("wt-1", "step", "sr-2"); + + assert.isAbove(b.fenceToken, a.fenceToken); + }), + ); + + it.effect("validate rejects a stale token", () => + Effect.gen(function* () { + const lease = yield* WorktreeLeaseService; + const a = yield* lease.acquire("wt-2", "step", "sr-1"); + yield* lease.release("wt-2", a.fenceToken); + yield* lease.acquire("wt-2", "step", "sr-2"); + const valid = yield* lease.isValid("wt-2", a.fenceToken); + + assert.equal(valid, false); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorktreeLeaseService.ts b/apps/server/src/workflow/Layers/WorktreeLeaseService.ts new file mode 100644 index 00000000000..a1532820e60 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorktreeLeaseService.ts @@ -0,0 +1,104 @@ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorktreeLeaseService, + type Lease, + type WorktreeLeaseServiceShape, +} from "../Services/WorktreeLeaseService.ts"; + +// NOTE: expires_at is written on acquire/release purely to satisfy the +// NOT NULL column (migration 033) and to leave an audit timestamp. It is NOT +// enforced: acquire is unconditional (ON CONFLICT always takes ownership and +// bumps the fence token) and isValid checks only fence_token + owner_kind, so +// no code path reaps or blocks on an expired lease. The lease provides +// fence-token release gating, not expiry-based mutual exclusion — do not treat +// a non-expired lease as a held lock. Enforcing expiry (or dropping the column) +// requires a deliberate behavior/migration change, not a silent one. +const leaseExpiresAt = (now: DateTime.Utc) => DateTime.add(now, { minutes: 30 }); + +const toLeaseError = (cause: unknown) => + new WorkflowEventStoreError({ message: "lease op failed", cause }); + +const wrap = <A>(effect: Effect.Effect<A, SqlError>) => effect.pipe(Effect.mapError(toLeaseError)); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const acquire: WorktreeLeaseServiceShape["acquire"] = (worktreeRef, ownerKind, ownerId) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const acquiredAt = DateTime.formatIso(now); + const expiresAt = DateTime.formatIso(leaseExpiresAt(now)); + const rows = yield* wrap(sql<Lease>` + INSERT INTO worktree_lease ( + worktree_ref, + owner_kind, + owner_id, + fence_token, + acquired_at, + expires_at + ) + VALUES ( + ${worktreeRef}, + ${ownerKind}, + ${ownerId}, + COALESCE( + (SELECT fence_token FROM worktree_lease WHERE worktree_ref = ${worktreeRef}), + 0 + ) + 1, + ${acquiredAt}, + ${expiresAt} + ) + ON CONFLICT(worktree_ref) DO UPDATE SET + owner_kind = excluded.owner_kind, + owner_id = excluded.owner_id, + fence_token = worktree_lease.fence_token + 1, + acquired_at = excluded.acquired_at, + expires_at = excluded.expires_at + RETURNING fence_token AS "fenceToken" + `); + const lease = rows[0]; + if (!lease) { + return yield* new WorkflowEventStoreError({ message: "lease acquire returned no row" }); + } + return lease; + }); + + const release: WorktreeLeaseServiceShape["release"] = (worktreeRef, fenceToken) => + Effect.gen(function* () { + const now = DateTime.formatIso(yield* DateTime.now); + yield* wrap(sql` + UPDATE worktree_lease + SET owner_kind = 'released', + owner_id = '', + fence_token = fence_token + 1, + acquired_at = ${now}, + expires_at = ${now} + WHERE worktree_ref = ${worktreeRef} + AND fence_token = ${fenceToken} + `); + }).pipe(Effect.asVoid); + + const isValid: WorktreeLeaseServiceShape["isValid"] = (worktreeRef, fenceToken) => + wrap(sql<{ readonly fenceToken: number; readonly ownerKind: string }>` + SELECT + fence_token AS "fenceToken", + owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = ${worktreeRef} + `).pipe( + Effect.map((rows) => { + const row = rows[0]; + return row?.fenceToken === fenceToken && row.ownerKind !== "released"; + }), + ); + + return { acquire, release, isValid } satisfies WorktreeLeaseServiceShape; +}); + +export const WorktreeLeaseServiceLive = Layer.effect(WorktreeLeaseService, make); diff --git a/apps/server/src/workflow/Layers/ticketScratchCleanup.test.ts b/apps/server/src/workflow/Layers/ticketScratchCleanup.test.ts new file mode 100644 index 00000000000..cccadb55f8c --- /dev/null +++ b/apps/server/src/workflow/Layers/ticketScratchCleanup.test.ts @@ -0,0 +1,114 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { ServerConfig } from "../../config.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import { MergeGitPort } from "../Services/TicketMergeService.ts"; +import { MergeGitPortLive } from "./TicketMergeService.ts"; +import { cleanupTicketScratch } from "./ticketScratchCleanup.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-ticket-scratch-cleanup-test-", +}); + +const TestLayer = MergeGitPortLive.pipe( + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); + +const writeTextFile = (cwd: string, relativePath: string, contents: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const filePath = pathService.join(cwd, relativePath); + yield* fileSystem.makeDirectory(pathService.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, contents); + }); + +/** Init a real git repo that does NOT gitignore `.t3`, with one committed file. */ +const initRepo = (cwd: string) => + Effect.gen(function* () { + const git = yield* MergeGitPort; + yield* git.run({ cwd, args: ["init"] }); + yield* git.run({ cwd, args: ["config", "user.email", "test@test.com"] }); + yield* git.run({ cwd, args: ["config", "user.name", "Test"] }); + // NB: intentionally no .gitignore for `.t3`, so the scratch leak is real. + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* git.run({ cwd, args: ["add", "-A"] }); + yield* git.run({ cwd, args: ["commit", "--no-verify", "-m", "initial"] }); + }); + +it.layer(TestLayer)("cleanupTicketScratch (real git)", (it) => { + it.effect("purges the whole .t3/ticket/<id> scratch tree from a snapshot commit", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const git = yield* MergeGitPort; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "ticket-scratch-cleanup-", + }); + yield* initRepo(cwd); + + const ticketId = "ticket-1"; + // A real tracked change that MUST survive the cleanup + snapshot. + yield* writeTextFile(cwd, "src/app.ts", "export const x = 1;\n"); + // Pipeline scratch under .t3/ticket/<id> that MUST be purged. + yield* writeTextFile(cwd, `.t3/ticket/${ticketId}/DESCRIPTION.md`, "# desc\n"); + yield* writeTextFile(cwd, `.t3/ticket/${ticketId}/handoff/x.md`, "handoff\n"); + yield* writeTextFile(cwd, `.t3/ticket/${ticketId}/design/SPEC.md`, "spec\n"); + + yield* cleanupTicketScratch(git, cwd, ticketId); + + // Mirror the service: stage + commit the post-cleanup worktree. + yield* git.run({ cwd, args: ["add", "-A"] }); + yield* git.run({ cwd, args: ["commit", "--no-verify", "-m", "snapshot"] }); + + const tracked = (yield* git.run({ + cwd, + args: ["ls-tree", "-r", "--name-only", "HEAD"], + })).stdout; + assert.include(tracked, "src/app.ts"); + assert.notInclude(tracked, ".t3/ticket/ticket-1/"); + }), + ); + + it.effect("purges ignored .t3 scratch from disk when the repo gitignores .t3", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const git = yield* MergeGitPort; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "ticket-scratch-cleanup-ignored-", + }); + yield* git.run({ cwd, args: ["init"] }); + yield* git.run({ cwd, args: ["config", "user.email", "test@test.com"] }); + yield* git.run({ cwd, args: ["config", "user.name", "Test"] }); + // This repo DOES gitignore `.t3` — so the scratch is untracked-AND-ignored, + // which `git clean -f -d` (without -x) would leave on disk. + yield* writeTextFile(cwd, ".gitignore", ".t3\n"); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* git.run({ cwd, args: ["add", "-A"] }); + yield* git.run({ cwd, args: ["commit", "--no-verify", "-m", "initial"] }); + + const ticketId = "ticket-1"; + yield* writeTextFile(cwd, `.t3/ticket/${ticketId}/DESCRIPTION.md`, "# desc\n"); + yield* writeTextFile(cwd, `.t3/ticket/${ticketId}/handoff/x.md`, "handoff\n"); + + yield* cleanupTicketScratch(git, cwd, ticketId); + + // The -x flag removes the ignored scratch from DISK, not just the index. + const descExists = yield* fileSystem.exists( + pathService.join(cwd, `.t3/ticket/${ticketId}/DESCRIPTION.md`), + ); + const handoffExists = yield* fileSystem.exists( + pathService.join(cwd, `.t3/ticket/${ticketId}/handoff/x.md`), + ); + assert.isFalse(descExists); + assert.isFalse(handoffExists); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/ticketScratchCleanup.ts b/apps/server/src/workflow/Layers/ticketScratchCleanup.ts new file mode 100644 index 00000000000..553c087a093 --- /dev/null +++ b/apps/server/src/workflow/Layers/ticketScratchCleanup.ts @@ -0,0 +1,33 @@ +import * as Effect from "effect/Effect"; + +import { ticketScratchDir } from "../instructionTemplate.ts"; +import type { MergeGitPortShape } from "../Services/TicketMergeService.ts"; + +/** Unconditionally purge the whole per-ticket scratch tree (.t3/ticket/<id>): + * DESCRIPTION.md, handoff/, design/ — all pipeline scratch, never deliverables. + * The `-x` on git clean is deliberate: `.t3` is gitignored in many repos, so + * without it the scratch files are untracked-AND-ignored and would survive the + * purge on disk (lingering as stale artifacts). The pathspec scopes `-x` to the + * validated per-ticket dir, so nothing outside the scratch tree is touched. */ +export const cleanupTicketScratch = ( + git: Pick<MergeGitPortShape, "run">, + worktreePath: string, + ticketId: string, +) => + Effect.gen(function* () { + const dir = ticketScratchDir(ticketId); // validates the id + yield* git + .run({ + cwd: worktreePath, + args: ["rm", "-r", "-f", "--ignore-unmatch", "--", dir], + allowNonZeroExit: true, + }) + .pipe(Effect.ignore); + yield* git + .run({ + cwd: worktreePath, + args: ["clean", "-f", "-d", "-x", "--", dir], + allowNonZeroExit: true, + }) + .pipe(Effect.ignore); + }); diff --git a/apps/server/src/workflow/Services/ApprovalGate.ts b/apps/server/src/workflow/Services/ApprovalGate.ts new file mode 100644 index 00000000000..aee2c5153c3 --- /dev/null +++ b/apps/server/src/workflow/Services/ApprovalGate.ts @@ -0,0 +1,13 @@ +import type { StepRunId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface ApprovalGateShape { + readonly park: (stepRunId: StepRunId) => Effect.Effect<void>; + readonly await: (stepRunId: StepRunId) => Effect.Effect<boolean>; + readonly resolve: (stepRunId: StepRunId, approved: boolean) => Effect.Effect<boolean>; +} + +export class ApprovalGate extends Context.Service<ApprovalGate, ApprovalGateShape>()( + "t3/workflow/Services/ApprovalGate", +) {} diff --git a/apps/server/src/workflow/Services/BoardDiscovery.ts b/apps/server/src/workflow/Services/BoardDiscovery.ts new file mode 100644 index 00000000000..b95fa4469d8 --- /dev/null +++ b/apps/server/src/workflow/Services/BoardDiscovery.ts @@ -0,0 +1,17 @@ +import type { BoardListEntry, ProjectId } from "@t3tools/contracts"; +import { WorkflowRpcError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface BoardDiscoveryShape { + readonly discover: ( + projectId: ProjectId, + ) => Effect.Effect<ReadonlyArray<BoardListEntry>, WorkflowRpcError>; + readonly list: ( + projectId: ProjectId, + ) => Effect.Effect<ReadonlyArray<BoardListEntry>, WorkflowRpcError>; +} + +export class BoardDiscovery extends Context.Service<BoardDiscovery, BoardDiscoveryShape>()( + "t3/workflow/Services/BoardDiscovery", +) {} diff --git a/apps/server/src/workflow/Services/BoardRegistry.ts b/apps/server/src/workflow/Services/BoardRegistry.ts new file mode 100644 index 00000000000..aa4600990a2 --- /dev/null +++ b/apps/server/src/workflow/Services/BoardRegistry.ts @@ -0,0 +1,29 @@ +import type { BoardId, LaneKey, WorkflowDefinition, WorkflowLane } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class BoardRegistryError extends Schema.TaggedErrorClass<BoardRegistryError>()( + "BoardRegistryError", + { message: Schema.String }, +) {} + +export interface BoardRegistryShape { + readonly register: ( + boardId: BoardId, + definition: unknown, + ) => Effect.Effect<WorkflowDefinition, BoardRegistryError>; + readonly unregister: (boardId: BoardId) => Effect.Effect<void>; + readonly getDefinition: (boardId: BoardId) => Effect.Effect<WorkflowDefinition | null>; + readonly listDefinitions: () => Effect.Effect< + ReadonlyArray<{ + readonly boardId: BoardId; + readonly definition: WorkflowDefinition; + }> + >; + readonly getLane: (boardId: BoardId, laneKey: LaneKey) => Effect.Effect<WorkflowLane | null>; +} + +export class BoardRegistry extends Context.Service<BoardRegistry, BoardRegistryShape>()( + "t3/workflow/Services/BoardRegistry", +) {} diff --git a/apps/server/src/workflow/Services/CapturedStepOutputReader.ts b/apps/server/src/workflow/Services/CapturedStepOutputReader.ts new file mode 100644 index 00000000000..40d5e525611 --- /dev/null +++ b/apps/server/src/workflow/Services/CapturedStepOutputReader.ts @@ -0,0 +1,22 @@ +import type { StepRunId, ThreadId, TurnId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface CapturedStepOutputReadInput { + readonly stepRunId: StepRunId; + readonly threadId: ThreadId; + readonly turnId: TurnId; +} + +export interface CapturedStepOutputReaderShape { + readonly read: ( + input: CapturedStepOutputReadInput, + ) => Effect.Effect<unknown | undefined, WorkflowEventStoreError>; +} + +export class CapturedStepOutputReader extends Context.Service< + CapturedStepOutputReader, + CapturedStepOutputReaderShape +>()("t3/workflow/Services/CapturedStepOutputReader") {} diff --git a/apps/server/src/workflow/Services/DurableApprovalResume.ts b/apps/server/src/workflow/Services/DurableApprovalResume.ts new file mode 100644 index 00000000000..1b225f63997 --- /dev/null +++ b/apps/server/src/workflow/Services/DurableApprovalResume.ts @@ -0,0 +1,13 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface DurableApprovalResumeShape { + readonly resume: () => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class DurableApprovalResume extends Context.Service< + DurableApprovalResume, + DurableApprovalResumeShape +>()("t3/workflow/Services/DurableApprovalResume") {} diff --git a/apps/server/src/workflow/Services/Errors.ts b/apps/server/src/workflow/Services/Errors.ts new file mode 100644 index 00000000000..062a160ae70 --- /dev/null +++ b/apps/server/src/workflow/Services/Errors.ts @@ -0,0 +1,20 @@ +import * as Schema from "effect/Schema"; + +/** + * Stable, machine-checkable classification codes for WorkflowEventStoreError so + * consumers can branch on a TERMINAL vs RETRYABLE condition without coupling to + * a human-readable message string. Add new codes here rather than matching text. + */ +export const WorkflowEventStoreErrorCode = { + /** External event targeted a ticket that is not on the given board (terminal). */ + ticketNotOnBoard: "ticket_not_on_board", +} as const; + +export class WorkflowEventStoreError extends Schema.TaggedErrorClass<WorkflowEventStoreError>()( + "WorkflowEventStoreError", + { + message: Schema.String, + code: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/apps/server/src/workflow/Services/GitHubPort.ts b/apps/server/src/workflow/Services/GitHubPort.ts new file mode 100644 index 00000000000..95cd95999cd --- /dev/null +++ b/apps/server/src/workflow/Services/GitHubPort.ts @@ -0,0 +1,70 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface GitHubPrDetail { + readonly number: number; + readonly url: string; + readonly state: "open" | "merged" | "closed"; + readonly headSha: string | null; + readonly reviewDecision: "none" | "changes_requested" | "approved"; + readonly ciState: "pending" | "success" | "failure"; +} + +export interface GitHubReviewItem { + readonly id: string; + readonly author: string; + readonly body: string; + readonly submittedAt: string; +} + +export interface GitHubPortShape { + readonly preflight: ( + cwd: string, + ) => Effect.Effect<{ ok: true } | { ok: false; reason: string }, WorkflowEventStoreError>; + readonly resolveRemote: ( + cwd: string, + ) => Effect.Effect<{ remoteName: string; repo: string }, WorkflowEventStoreError>; + readonly defaultBranch: (cwd: string) => Effect.Effect<string, WorkflowEventStoreError>; + readonly openPr: (input: { + readonly cwd: string; + readonly branch: string; + readonly base: string; + readonly title: string; + readonly body: string; + readonly draft: boolean; + }) => Effect.Effect<{ number: number; url: string; adopted: boolean }, WorkflowEventStoreError>; + readonly prDetail: (input: { + readonly cwd: string; + readonly prNumber: number; + }) => Effect.Effect<GitHubPrDetail, WorkflowEventStoreError>; + // Read-only: find an existing open PR for a branch WITHOUT pushing or + // creating one. Used by recovery to adopt a PR that was created before the + // crash but never recorded via TicketPrOpened. + readonly findPrForBranch: (input: { + readonly cwd: string; + readonly branch: string; + }) => Effect.Effect<{ number: number; url: string } | null, WorkflowEventStoreError>; + readonly mergePr: (input: { + readonly cwd: string; + readonly prNumber: number; + readonly strategy: "squash" | "merge" | "rebase"; + readonly deleteBranch: boolean; + readonly branch: string; + readonly remoteName: string; + }) => Effect.Effect<{ ok: true } | { ok: false; reason: string }, WorkflowEventStoreError>; + readonly failingCheckLogs: (input: { + readonly cwd: string; + readonly prNumber: number; + }) => Effect.Effect<string | null, WorkflowEventStoreError>; + readonly listReviewFeedback: (input: { + readonly cwd: string; + readonly prNumber: number; + readonly repo: string; + }) => Effect.Effect<ReadonlyArray<GitHubReviewItem>, WorkflowEventStoreError>; +} + +export class GitHubPort extends Context.Service<GitHubPort, GitHubPortShape>()( + "t3/workflow/Services/GitHubPort", +) {} diff --git a/apps/server/src/workflow/Services/PredicateEvaluator.ts b/apps/server/src/workflow/Services/PredicateEvaluator.ts new file mode 100644 index 00000000000..aea1df78c4f --- /dev/null +++ b/apps/server/src/workflow/Services/PredicateEvaluator.ts @@ -0,0 +1,28 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export interface PredicateEvaluation { + readonly result: boolean; + readonly matchedPaths: ReadonlyArray<string>; +} + +export class PredicateEvaluationError extends Schema.TaggedErrorClass<PredicateEvaluationError>()( + "PredicateEvaluationError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export interface PredicateEvaluatorShape { + readonly evaluate: ( + rule: unknown, + context: unknown, + ) => Effect.Effect<PredicateEvaluation, PredicateEvaluationError>; +} + +export class PredicateEvaluator extends Context.Service< + PredicateEvaluator, + PredicateEvaluatorShape +>()("t3/workflow/Services/PredicateEvaluator") {} diff --git a/apps/server/src/workflow/Services/ProjectScriptTrust.ts b/apps/server/src/workflow/Services/ProjectScriptTrust.ts new file mode 100644 index 00000000000..c40e5d1680d --- /dev/null +++ b/apps/server/src/workflow/Services/ProjectScriptTrust.ts @@ -0,0 +1,24 @@ +import type { ProjectId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface ProjectScriptTrustShape { + readonly isTrusted: (projectId: ProjectId) => Effect.Effect<boolean, WorkflowEventStoreError>; + readonly setTrusted: ( + projectId: ProjectId, + trusted: boolean, + ) => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class ProjectScriptTrust extends Context.Service< + ProjectScriptTrust, + ProjectScriptTrustShape +>()("t3/workflow/Services/ProjectScriptTrust") {} + +export const ProjectScriptTrustDenyAll = Layer.succeed(ProjectScriptTrust, { + isTrusted: () => Effect.succeed(false), + setTrusted: () => Effect.void, +} satisfies ProjectScriptTrustShape); diff --git a/apps/server/src/workflow/Services/ProjectWorkspaceResolver.ts b/apps/server/src/workflow/Services/ProjectWorkspaceResolver.ts new file mode 100644 index 00000000000..7aec6ff1d00 --- /dev/null +++ b/apps/server/src/workflow/Services/ProjectWorkspaceResolver.ts @@ -0,0 +1,21 @@ +import type { ProjectId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class ProjectWorkspaceResolverError extends Schema.TaggedErrorClass<ProjectWorkspaceResolverError>()( + "ProjectWorkspaceResolverError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export interface ProjectWorkspaceResolverShape { + readonly resolve: (projectId: ProjectId) => Effect.Effect<string, ProjectWorkspaceResolverError>; +} + +export class ProjectWorkspaceResolver extends Context.Service< + ProjectWorkspaceResolver, + ProjectWorkspaceResolverShape +>()("t3/workflow/Services/ProjectWorkspaceResolver") {} diff --git a/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts b/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts new file mode 100644 index 00000000000..89af494a811 --- /dev/null +++ b/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts @@ -0,0 +1,84 @@ +import type { + ApprovalRequestId, + DispatchId, + ProviderOptionSelections, + StepRunId, + ThreadId, + TicketId, + TurnId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface DispatchRequest { + readonly dispatchId: DispatchId; + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly threadId: ThreadId; + readonly providerInstance: string; + readonly model: string; + readonly instruction: string; + readonly worktreePath: string; + readonly options?: ProviderOptionSelections; + // Project + title for the hidden thread shell that lets provider runtime + // ingestion project this thread's turns/messages/activities. Without a + // shell, ingestion drops the events and the turn never reaches a terminal + // state from the workflow's perspective. + readonly projectId?: string; + readonly threadTitle?: string; + // Defaults to "full-access" (worktree-isolated steps); intake runs at the + // real project root and passes a stricter mode. + readonly runtimeMode?: "approval-required" | "auto-accept-edits" | "full-access"; +} + +export interface ProviderTurnPortShape { + readonly ensureTurnStarted: ( + req: DispatchRequest, + ) => Effect.Effect<{ readonly turnId: TurnId }, WorkflowEventStoreError>; +} + +export class ProviderTurnPort extends Context.Service<ProviderTurnPort, ProviderTurnPortShape>()( + "t3/workflow/Services/ProviderDispatchOutbox/ProviderTurnPort", +) {} + +export type ProviderDispatchTerminalResult = + | { readonly ok: true } + | { readonly ok: false; readonly error?: string } + | { + readonly ok: false; + readonly awaitingUser: true; + readonly waitingReason: string; + readonly providerThreadId: ThreadId; + readonly providerRequestId: ApprovalRequestId; + readonly providerResponseKind: "request" | "user-input"; + readonly providerQuestionId?: string; + }; + +export interface ProviderDispatchOutboxShape { + readonly confirmStep: (stepRunId: StepRunId) => Effect.Effect<void, WorkflowEventStoreError>; + readonly ensureStarted: ( + req: DispatchRequest, + ) => Effect.Effect<{ readonly turnId: TurnId }, WorkflowEventStoreError>; + readonly getDispatchForStep: ( + stepRunId: StepRunId, + ) => Effect.Effect< + { readonly threadId: ThreadId; readonly turnId: TurnId } | null, + WorkflowEventStoreError + >; + readonly awaitTerminal: ( + dispatchId: DispatchId, + threadId: ThreadId, + ) => Effect.Effect<ProviderDispatchTerminalResult, WorkflowEventStoreError>; + readonly awaitStepTerminal: ( + stepRunId: StepRunId, + threadId: ThreadId, + ) => Effect.Effect<ProviderDispatchTerminalResult, WorkflowEventStoreError>; + readonly recoverPending: () => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class ProviderDispatchOutbox extends Context.Service< + ProviderDispatchOutbox, + ProviderDispatchOutboxShape +>()("t3/workflow/Services/ProviderDispatchOutbox") {} diff --git a/apps/server/src/workflow/Services/ProviderResponsePort.ts b/apps/server/src/workflow/Services/ProviderResponsePort.ts new file mode 100644 index 00000000000..2fa15772b87 --- /dev/null +++ b/apps/server/src/workflow/Services/ProviderResponsePort.ts @@ -0,0 +1,25 @@ +import type { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type ProviderResponseKind = "request" | "user-input"; + +export interface ProviderResponseInput { + readonly threadId: ThreadId; + readonly requestId: ApprovalRequestId; + readonly responseKind: ProviderResponseKind; + readonly approved: boolean; + readonly questionId?: string; + readonly text?: string; +} + +export interface ProviderResponsePortShape { + readonly respond: (input: ProviderResponseInput) => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class ProviderResponsePort extends Context.Service< + ProviderResponsePort, + ProviderResponsePortShape +>()("t3/workflow/Services/ProviderResponsePort") {} diff --git a/apps/server/src/workflow/Services/ScriptCancelRegistry.ts b/apps/server/src/workflow/Services/ScriptCancelRegistry.ts new file mode 100644 index 00000000000..9479ff16dd5 --- /dev/null +++ b/apps/server/src/workflow/Services/ScriptCancelRegistry.ts @@ -0,0 +1,21 @@ +import type { StepRunId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface ScriptCancelHandle { + readonly scriptThreadId: ThreadId; + readonly terminalId: string; +} + +export interface ScriptCancelRegistryShape { + readonly register: (stepRunId: StepRunId, handle: ScriptCancelHandle) => Effect.Effect<void>; + readonly unregister: (stepRunId: StepRunId) => Effect.Effect<void>; + readonly cancel: (stepRunId: StepRunId) => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class ScriptCancelRegistry extends Context.Service< + ScriptCancelRegistry, + ScriptCancelRegistryShape +>()("t3/workflow/Services/ScriptCancelRegistry") {} diff --git a/apps/server/src/workflow/Services/ScriptCommandRunner.ts b/apps/server/src/workflow/Services/ScriptCommandRunner.ts new file mode 100644 index 00000000000..a24b5bd95b6 --- /dev/null +++ b/apps/server/src/workflow/Services/ScriptCommandRunner.ts @@ -0,0 +1,33 @@ +import type { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Duration from "effect/Duration"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type ScriptCommandOutcome = "exited" | "timeout" | "cancelled"; + +export interface ScriptCommandRunInput { + readonly scriptThreadId: ThreadId; + readonly terminalId: string; + readonly cwd: string; + readonly run: string; + readonly timeout: Duration.Input; +} + +export interface ScriptCommandResult { + readonly exitCode: number | null; + readonly signal: number | null; + readonly outcome: ScriptCommandOutcome; +} + +export interface ScriptCommandRunnerShape { + readonly run: ( + input: ScriptCommandRunInput, + ) => Effect.Effect<ScriptCommandResult, WorkflowEventStoreError>; +} + +export class ScriptCommandRunner extends Context.Service< + ScriptCommandRunner, + ScriptCommandRunnerShape +>()("t3/workflow/Services/ScriptCommandRunner") {} diff --git a/apps/server/src/workflow/Services/ScriptStepExecutor.ts b/apps/server/src/workflow/Services/ScriptStepExecutor.ts new file mode 100644 index 00000000000..37dab5d4cc4 --- /dev/null +++ b/apps/server/src/workflow/Services/ScriptStepExecutor.ts @@ -0,0 +1,26 @@ +import type { StepOutcome } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; +import type { StepExecutionContext } from "./StepExecutor.ts"; +import type { WorktreeHandle } from "./WorktreePort.ts"; + +export type ScriptStep = Extract<StepExecutionContext["step"], { readonly type: "script" }>; + +export interface ScriptStepExecutionInput { + readonly ctx: StepExecutionContext; + readonly step: ScriptStep; + readonly worktree: WorktreeHandle; +} + +export interface ScriptStepExecutorShape { + readonly execute: ( + input: ScriptStepExecutionInput, + ) => Effect.Effect<StepOutcome, WorkflowEventStoreError>; +} + +export class ScriptStepExecutor extends Context.Service< + ScriptStepExecutor, + ScriptStepExecutorShape +>()("t3/workflow/Services/ScriptStepExecutor") {} diff --git a/apps/server/src/workflow/Services/SetupRunService.ts b/apps/server/src/workflow/Services/SetupRunService.ts new file mode 100644 index 00000000000..6e47a62f3c4 --- /dev/null +++ b/apps/server/src/workflow/Services/SetupRunService.ts @@ -0,0 +1,48 @@ +import type { SetupRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface SetupTerminalPortShape { + readonly launch: (input: { + readonly threadId?: string; + readonly projectId?: string; + readonly projectCwd?: string; + readonly worktreePath: string; + readonly preferredTerminalId?: string; + }) => Effect.Effect< + { readonly threadId: string; readonly terminalId: string | null }, + WorkflowEventStoreError + >; + readonly awaitExit: (input: { + readonly threadId: string; + readonly terminalId: string | null; + readonly timeoutMs?: number; + }) => Effect.Effect<{ readonly exitCode: number }, WorkflowEventStoreError>; +} + +export class SetupTerminalPort extends Context.Service<SetupTerminalPort, SetupTerminalPortShape>()( + "t3/workflow/Services/SetupRunService/SetupTerminalPort", +) {} + +export type SetupStatus = "completed" | "failed" | "timed_out"; + +export interface SetupRunServiceShape { + readonly runSetup: ( + ticketId: TicketId, + worktreeRef: string, + worktreePath: string, + setupRunId: SetupRunId, + // Required by the setup runner to resolve the project — a worktree path + // alone cannot, and workspace-root matching breaks under canonicalization. + projectId?: string, + ) => Effect.Effect< + { readonly status: SetupStatus; readonly exitCode: number | null }, + WorkflowEventStoreError + >; +} + +export class SetupRunService extends Context.Service<SetupRunService, SetupRunServiceShape>()( + "t3/workflow/Services/SetupRunService", +) {} diff --git a/apps/server/src/workflow/Services/StepExecutor.ts b/apps/server/src/workflow/Services/StepExecutor.ts new file mode 100644 index 00000000000..c053a43fdda --- /dev/null +++ b/apps/server/src/workflow/Services/StepExecutor.ts @@ -0,0 +1,32 @@ +import type { + BoardId, + LaneEntryToken, + LaneKey, + PipelineRunId, + StepKey, + StepOutcome, + StepRunId, + TicketId, + WorkflowStep, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface StepExecutionContext { + readonly ticketId: TicketId; + readonly boardId: BoardId; + readonly pipelineRunId: PipelineRunId; + readonly stepRunId: StepRunId; + readonly laneEntryToken: LaneEntryToken; + readonly laneKey: LaneKey; + readonly laneStepKeys: ReadonlyArray<StepKey>; + readonly step: WorkflowStep; +} + +export interface StepExecutorShape { + readonly execute: (ctx: StepExecutionContext) => Effect.Effect<StepOutcome>; +} + +export class StepExecutor extends Context.Service<StepExecutor, StepExecutorShape>()( + "t3/workflow/Services/StepExecutor", +) {} diff --git a/apps/server/src/workflow/Services/StepOutputHandoffReader.ts b/apps/server/src/workflow/Services/StepOutputHandoffReader.ts new file mode 100644 index 00000000000..bd68420cc6e --- /dev/null +++ b/apps/server/src/workflow/Services/StepOutputHandoffReader.ts @@ -0,0 +1,40 @@ +import type { LaneKey, PipelineRunId, StepKey, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +/** + * Reads a prior step's captured output for inter-agent handoff + * (`{{prev.output}}` / `{{step.<key>.output}}`). Unlike + * `CapturedStepOutputReader` — which reads by exact `{stepRunId, threadId, + * turnId}` — this answers "the latest output for a step key", joining + * `projection_step_run ⋈ projection_pipeline_run` so it can resolve the right + * pass across loops. + */ +export interface StepOutputHandoffReaderShape { + /** + * The newest `completed` step output (parsed `output_json`) for `stepKey` + * within `(ticketId, laneKey)` across all pipeline runs, ordered by + * `finished_at DESC` — loop-aware. `null` when no completed output exists. + */ + readonly latestCompletedOutput: ( + ticketId: TicketId, + laneKey: LaneKey, + stepKey: StepKey, + ) => Effect.Effect<unknown, WorkflowEventStoreError>; + /** + * This pass's output for `stepKey` — the `completed` output captured in the + * given `pipelineRunId`. `null` when this run has no completed output for the + * step (e.g. a forward reference that hasn't run yet this pass). + */ + readonly currentPassOutput: ( + pipelineRunId: PipelineRunId, + stepKey: StepKey, + ) => Effect.Effect<unknown, WorkflowEventStoreError>; +} + +export class StepOutputHandoffReader extends Context.Service< + StepOutputHandoffReader, + StepOutputHandoffReaderShape +>()("t3/workflow/Services/StepOutputHandoffReader") {} diff --git a/apps/server/src/workflow/Services/StepUsageReader.ts b/apps/server/src/workflow/Services/StepUsageReader.ts new file mode 100644 index 00000000000..ba097938701 --- /dev/null +++ b/apps/server/src/workflow/Services/StepUsageReader.ts @@ -0,0 +1,16 @@ +import type { ThreadId, WorkflowStepUsage } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface StepUsageReaderShape { + /** + * Latest token-usage snapshot for a workflow dispatch thread, mapped to the + * workflow usage shape. Undefined when the provider emitted no usage. + * Never fails — usage is best-effort telemetry. + */ + readonly read: (threadId: ThreadId) => Effect.Effect<WorkflowStepUsage | undefined>; +} + +export class StepUsageReader extends Context.Service<StepUsageReader, StepUsageReaderShape>()( + "t3/workflow/Services/StepUsageReader", +) {} diff --git a/apps/server/src/workflow/Services/TicketCheckpointService.ts b/apps/server/src/workflow/Services/TicketCheckpointService.ts new file mode 100644 index 00000000000..9c2d2948572 --- /dev/null +++ b/apps/server/src/workflow/Services/TicketCheckpointService.ts @@ -0,0 +1,27 @@ +import type { StepRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface TicketCheckpointServiceShape { + readonly captureBaseline: ( + ticketId: TicketId, + cwd: string, + ) => Effect.Effect<string, WorkflowEventStoreError>; + readonly hasBaseline: ( + ticketId: TicketId, + cwd: string, + ) => Effect.Effect<boolean, WorkflowEventStoreError>; + readonly captureStep: ( + ticketId: TicketId, + stepRunId: StepRunId, + cwd: string, + kind: "pre" | "post", + ) => Effect.Effect<string, WorkflowEventStoreError>; +} + +export class TicketCheckpointService extends Context.Service< + TicketCheckpointService, + TicketCheckpointServiceShape +>()("t3/workflow/Services/TicketCheckpointService") {} diff --git a/apps/server/src/workflow/Services/TicketDiffQuery.ts b/apps/server/src/workflow/Services/TicketDiffQuery.ts new file mode 100644 index 00000000000..854a7810618 --- /dev/null +++ b/apps/server/src/workflow/Services/TicketDiffQuery.ts @@ -0,0 +1,31 @@ +import type { TicketDiff, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorktreeDiffPortShape { + readonly diffRefToWorktree: (input: { + readonly cwd: string; + readonly baseRef: string; + }) => Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + WorkflowEventStoreError + >; +} + +export class WorktreeDiffPort extends Context.Service<WorktreeDiffPort, WorktreeDiffPortShape>()( + "t3/workflow/Services/TicketDiffQuery/WorktreeDiffPort", +) {} + +export interface TicketDiffQueryShape { + readonly getTicketDiff: ( + ticketId: TicketId, + cwd: string, + baseRef: string, + ) => Effect.Effect<TicketDiff, WorkflowEventStoreError>; +} + +export class TicketDiffQuery extends Context.Service<TicketDiffQuery, TicketDiffQueryShape>()( + "t3/workflow/Services/TicketDiffQuery", +) {} diff --git a/apps/server/src/workflow/Services/TicketMergeService.ts b/apps/server/src/workflow/Services/TicketMergeService.ts new file mode 100644 index 00000000000..758b1850865 --- /dev/null +++ b/apps/server/src/workflow/Services/TicketMergeService.ts @@ -0,0 +1,40 @@ +import type { MergeStep, StepOutcome, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface MergeGitResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface MergeGitPortShape { + readonly run: (input: { + readonly cwd: string; + readonly args: ReadonlyArray<string>; + readonly allowNonZeroExit?: boolean; + }) => Effect.Effect<MergeGitResult, WorkflowEventStoreError>; +} + +export class MergeGitPort extends Context.Service<MergeGitPort, MergeGitPortShape>()( + "t3/workflow/Services/TicketMergeService/MergeGitPort", +) {} + +export interface TicketMergeInput { + readonly ticketId: TicketId; + readonly repoRoot: string; + readonly worktreePath: string; + readonly worktreeRef: string; + readonly step: MergeStep; +} + +export interface TicketMergeServiceShape { + readonly merge: (input: TicketMergeInput) => Effect.Effect<StepOutcome, WorkflowEventStoreError>; +} + +export class TicketMergeService extends Context.Service< + TicketMergeService, + TicketMergeServiceShape +>()("t3/workflow/Services/TicketMergeService") {} diff --git a/apps/server/src/workflow/Services/TicketPullRequestService.ts b/apps/server/src/workflow/Services/TicketPullRequestService.ts new file mode 100644 index 00000000000..98d2d4ad396 --- /dev/null +++ b/apps/server/src/workflow/Services/TicketPullRequestService.ts @@ -0,0 +1,28 @@ +import type { PullRequestStep, StepOutcome, StepRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface TicketPullRequestInput { + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly repoRoot: string; + readonly worktreePath: string; + readonly worktreeRef: string; // the per-ticket branch name, e.g. "workflow/<ticketId>" + readonly step: PullRequestStep; +} + +export interface TicketPullRequestServiceShape { + readonly open: ( + input: TicketPullRequestInput, + ) => Effect.Effect<StepOutcome, WorkflowEventStoreError>; + readonly land: ( + input: TicketPullRequestInput, + ) => Effect.Effect<StepOutcome, WorkflowEventStoreError>; +} + +export class TicketPullRequestService extends Context.Service< + TicketPullRequestService, + TicketPullRequestServiceShape +>()("t3/workflow/Services/TicketPullRequestService") {} diff --git a/apps/server/src/workflow/Services/TurnStateReader.ts b/apps/server/src/workflow/Services/TurnStateReader.ts new file mode 100644 index 00000000000..57043051da2 --- /dev/null +++ b/apps/server/src/workflow/Services/TurnStateReader.ts @@ -0,0 +1,35 @@ +import type { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export type TurnState = + | { readonly _tag: "running" } + | { readonly _tag: "completed" } + | { + readonly _tag: "awaiting_user"; + readonly waitingReason: string; + readonly providerThreadId: ThreadId; + readonly providerRequestId: ApprovalRequestId; + readonly providerResponseKind: "request" | "user-input"; + readonly providerQuestionId?: string; + } + | { readonly _tag: "failed"; readonly error: string }; + +export interface TurnProjectionPortShape { + readonly getLatestTurnState: ( + threadId: ThreadId, + ) => Effect.Effect<{ readonly state: string; readonly completed: boolean }>; +} + +export class TurnProjectionPort extends Context.Service< + TurnProjectionPort, + TurnProjectionPortShape +>()("t3/workflow/Services/TurnStateReader/TurnProjectionPort") {} + +export interface TurnStateReaderShape { + readonly read: (threadId: ThreadId) => Effect.Effect<TurnState>; +} + +export class TurnStateReader extends Context.Service<TurnStateReader, TurnStateReaderShape>()( + "t3/workflow/Services/TurnStateReader", +) {} diff --git a/apps/server/src/workflow/Services/WorkSourceConnectionStore.ts b/apps/server/src/workflow/Services/WorkSourceConnectionStore.ts new file mode 100644 index 00000000000..71ebdc3e385 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkSourceConnectionStore.ts @@ -0,0 +1,68 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Data from "effect/Data"; +import type { WorkSourceConnectionView } from "@t3tools/contracts/workSource"; +import type { WorkSourceProviderName } from "@t3tools/contracts/workSource"; +import type { WorkSourceAuthError } from "./WorkSourceProvider.ts"; + +export class WorkSourceConnectionStoreError extends Data.TaggedError( + "WorkSourceConnectionStoreError", +)<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface WorkSourceConnectionStoreShape { + /** Retrieve the PAT for an existing connection. Fails with WorkSourceAuthError + * when no row matches the connectionRef OR when the row's provider does not + * equal `expectedProvider` (a source must not use another provider's token). */ + readonly getToken: ( + connectionRef: string, + expectedProvider: WorkSourceProviderName, + ) => Effect.Effect<string, WorkSourceAuthError>; + + /** Full auth bundle for providers that need more than the token (Jira needs + * the base URL + auth mode + email). Provider-bound exactly like getToken. */ + readonly getConnectionAuth: ( + connectionRef: string, + expectedProvider: WorkSourceProviderName, + ) => Effect.Effect< + { + readonly token: string; + readonly authMode: "pat" | "basic" | "bearer"; + readonly baseUrl: string | null; + readonly email: string | null; + }, + WorkSourceAuthError + >; + + /** Create a new connection, storing the PAT in the secret store. */ + readonly create: (input: { + readonly provider: WorkSourceProviderName; + readonly displayName: string; + readonly token: string; + readonly authMode?: "pat" | "basic" | "bearer" | undefined; + readonly baseUrl?: string | undefined; + readonly email?: string | undefined; + }) => Effect.Effect<WorkSourceConnectionView, WorkSourceConnectionStoreError>; + + /** List all connections (no token in the view). */ + readonly list: () => Effect.Effect< + ReadonlyArray<WorkSourceConnectionView>, + WorkSourceConnectionStoreError + >; + + /** Remove a connection + delete the stored secret. + * + * v1 note: does NOT check for boards still referencing this connectionRef. + * A dangling connectionRef in a board source will surface as a WorkSourceAuthError + * at sync time (getToken fails → provider backs off). The syncer handles this + * gracefully — that source enters backoff and no tickets are affected. + */ + readonly remove: (connectionRef: string) => Effect.Effect<void, WorkSourceConnectionStoreError>; +} + +export class WorkSourceConnectionStore extends Context.Service< + WorkSourceConnectionStore, + WorkSourceConnectionStoreShape +>()("t3/workflow/Services/WorkSourceConnectionStore") {} diff --git a/apps/server/src/workflow/Services/WorkSourceProvider.ts b/apps/server/src/workflow/Services/WorkSourceProvider.ts new file mode 100644 index 00000000000..40e43b70c3d --- /dev/null +++ b/apps/server/src/workflow/Services/WorkSourceProvider.ts @@ -0,0 +1,117 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type { WorkSourceProviderName } from "@t3tools/contracts/workSource"; + +export class WorkSourceAuthError extends Schema.TaggedErrorClass<WorkSourceAuthError>()( + "WorkSourceAuthError", + { connectionRef: Schema.String }, +) {} + +export class WorkSourceRateLimitError extends Schema.TaggedErrorClass<WorkSourceRateLimitError>()( + "WorkSourceRateLimitError", + { retryAfterMs: Schema.Number }, +) {} + +export class WorkSourceTransientError extends Schema.TaggedErrorClass<WorkSourceTransientError>()( + "WorkSourceTransientError", + { message: Schema.String }, +) {} + +export class WorkSourceConfigError extends Schema.TaggedErrorClass<WorkSourceConfigError>()( + "WorkSourceConfigError", + { message: Schema.String }, +) {} + +export type WorkSourceProviderError = + | WorkSourceAuthError + | WorkSourceRateLimitError + | WorkSourceTransientError + | WorkSourceConfigError; + +export interface ExternalWorkItem { + readonly provider: WorkSourceProviderName; + readonly externalId: string; + readonly url: string; + readonly lifecycle: "open" | "closed" | "deleted"; + readonly version: { readonly updatedAt?: string; readonly etag?: string }; + readonly fields: { + readonly title: string; + readonly description?: string; + readonly assignees?: ReadonlyArray<string>; + readonly labels?: ReadonlyArray<string>; + }; +} + +export interface WorkSourcePage { + readonly items: ReadonlyArray<ExternalWorkItem>; + readonly nextPageToken?: string; // present => more pages remain +} + +export interface Viewer { + readonly id: string; + readonly aliases: ReadonlyArray<string>; // comparable to ExternalWorkItem.fields.assignees of THIS provider +} + +export interface ImportableViewParts { + readonly displayRef: string; // "#82" (GitHub) | "" (Asana) + readonly container: string; // "owner/repo" (GitHub) | projectGid (Asana) +} + +export interface WorkSourceProvider { + readonly provider: WorkSourceProviderName; + readonly selectorSchema: Schema.Schema<any>; // PURE; used by synchronous lint + readonly listPage: (input: { + readonly connectionRef: string; + readonly selector: unknown; + readonly since?: string; + readonly pageToken?: string; + readonly pageSize: number; + }) => Effect.Effect<WorkSourcePage, WorkSourceProviderError>; + readonly getItem: (input: { + readonly connectionRef: string; + // The source's selector (provider-specific). GitHub needs owner/repo from + // it to issue the single-issue lookup; Asana's gid is global so it ignores + // this. A `null` result means the provider CONFIRMS deletion (404/gone); a + // typed failure means "could not confirm" and must NOT be read as deleted. + readonly selector: unknown; + readonly externalId: string; + }) => Effect.Effect<ExternalWorkItem | null, WorkSourceProviderError>; + // writeBack?: FUTURE two-way seam — intentionally omitted in v1. + + // Best-effort "who am I" for the assigned-to-me filter. Returns null when the + // provider cannot identify the viewer (filter then disabled for that source). + readonly viewer: (input: { + readonly connectionRef: string; + }) => Effect.Effect<Viewer | null, WorkSourceProviderError>; + + // Pure formatting for a picker row. No I/O. + readonly toImportableView: (input: { + readonly selector: unknown; + readonly item: ExternalWorkItem; + }) => ImportableViewParts; +} + +export interface WorkSourceProviderRegistryShape { + readonly get: (provider: WorkSourceProviderName) => WorkSourceProvider; +} + +export class WorkSourceProviderRegistry extends Context.Service< + WorkSourceProviderRegistry, + WorkSourceProviderRegistryShape +>()("t3/workflow/Services/WorkSourceProvider/WorkSourceProviderRegistry") {} + +// Placeholder Context.Service tags for the two concrete providers. +// Tasks 4 and 5 provide Layer implementations for these exact tags. +export class GithubIssuesProvider extends Context.Service< + GithubIssuesProvider, + WorkSourceProvider +>()("t3/workflow/Services/WorkSourceProvider/GithubIssuesProvider") {} + +export class AsanaProvider extends Context.Service<AsanaProvider, WorkSourceProvider>()( + "t3/workflow/Services/WorkSourceProvider/AsanaProvider", +) {} + +export class JiraProvider extends Context.Service<JiraProvider, WorkSourceProvider>()( + "t3/workflow/Services/WorkSourceProvider/JiraProvider", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowAgentSessionStore.ts b/apps/server/src/workflow/Services/WorkflowAgentSessionStore.ts new file mode 100644 index 00000000000..137a2e81530 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowAgentSessionStore.ts @@ -0,0 +1,53 @@ +import type { BoardId, LaneKey, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +/** + * One stored per-agent session row: the stable workflow `threadId` minted for a + * given `(ticketId, laneKey, agentKey)`. `getThreadId` reads only `threadId`; + * `listByTicket`/`listByBoard` return enough to drive teardown (best-effort + * `stopSession` per thread) before deleting. + */ +export interface WorkflowAgentSessionRow { + readonly ticketId: TicketId; + readonly laneKey: LaneKey; + readonly agentKey: string; + readonly threadId: string; + readonly createdAt: string; + readonly lastUsedAt: string; +} + +export interface WorkflowAgentSessionStoreShape { + /** + * Record the stable `threadId` for `(ticketId, laneKey, agentKey)`. On + * conflict it bumps `last_used_at` and PRESERVES the existing `thread_id` + * (resume must keep reusing the same stable thread across steps/loops). + */ + readonly upsert: ( + ticketId: TicketId, + laneKey: LaneKey, + agentKey: string, + threadId: string, + ) => Effect.Effect<void, WorkflowEventStoreError>; + /** The stored thread id for the key, or `null` when none has been recorded. */ + readonly getThreadId: ( + ticketId: TicketId, + laneKey: LaneKey, + agentKey: string, + ) => Effect.Effect<string | null, WorkflowEventStoreError>; + readonly listByTicket: ( + ticketId: TicketId, + ) => Effect.Effect<ReadonlyArray<WorkflowAgentSessionRow>, WorkflowEventStoreError>; + readonly deleteByTicket: (ticketId: TicketId) => Effect.Effect<void, WorkflowEventStoreError>; + readonly listByBoard: ( + boardId: BoardId, + ) => Effect.Effect<ReadonlyArray<WorkflowAgentSessionRow>, WorkflowEventStoreError>; + readonly deleteByBoard: (boardId: BoardId) => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class WorkflowAgentSessionStore extends Context.Service< + WorkflowAgentSessionStore, + WorkflowAgentSessionStoreShape +>()("t3/workflow/Services/WorkflowAgentSessionStore") {} diff --git a/apps/server/src/workflow/Services/WorkflowBoardEvents.ts b/apps/server/src/workflow/Services/WorkflowBoardEvents.ts new file mode 100644 index 00000000000..d0c3e12a5a4 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowBoardEvents.ts @@ -0,0 +1,24 @@ +import type { BoardId, BoardTicketView } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; +import type * as Stream from "effect/Stream"; + +export interface WorkflowBoardEventsShape { + readonly publish: (ticket: BoardTicketView) => Effect.Effect<void>; + readonly stream: (boardId: BoardId) => Stream.Stream<BoardTicketView>; + /** + * Eagerly acquire a PubSub subscription (scoped) and return a stream over it. + * Unlike `stream`, the subscription is registered the instant this effect + * returns — so a snapshot read performed AFTER awaiting this will not race a + * concurrent publish into a gap. Pair with `Stream.unwrapScoped`. + */ + readonly subscribe: ( + boardId: BoardId, + ) => Effect.Effect<Stream.Stream<BoardTicketView>, never, Scope.Scope>; +} + +export class WorkflowBoardEvents extends Context.Service< + WorkflowBoardEvents, + WorkflowBoardEventsShape +>()("t3/workflow/Services/WorkflowBoardEvents") {} diff --git a/apps/server/src/workflow/Services/WorkflowBoardNotificationDispatcher.ts b/apps/server/src/workflow/Services/WorkflowBoardNotificationDispatcher.ts new file mode 100644 index 00000000000..849bd5143d9 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowBoardNotificationDispatcher.ts @@ -0,0 +1,20 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface WorkflowBoardNotificationSweepResult { + readonly claimed: number; + readonly sent: number; + readonly superseded: number; + readonly failed: number; +} + +export interface WorkflowBoardNotificationDispatcherShape { + readonly sweep: () => Effect.Effect<WorkflowBoardNotificationSweepResult>; + readonly start: () => Effect.Effect<void, never, Scope.Scope>; +} + +export class WorkflowBoardNotificationDispatcher extends Context.Service< + WorkflowBoardNotificationDispatcher, + WorkflowBoardNotificationDispatcherShape +>()("t3/workflow/Services/WorkflowBoardNotificationDispatcher") {} diff --git a/apps/server/src/workflow/Services/WorkflowBoardNotificationRelay.ts b/apps/server/src/workflow/Services/WorkflowBoardNotificationRelay.ts new file mode 100644 index 00000000000..faaf5ca60f7 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowBoardNotificationRelay.ts @@ -0,0 +1,20 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayBoardTicketState } from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowBoardNotificationRelayShape { + readonly publishTicket: (input: { + readonly environmentId: EnvironmentId; + readonly boardId: string; + readonly ticketId: string; + readonly state: RelayBoardTicketState; + }) => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class WorkflowBoardNotificationRelay extends Context.Service< + WorkflowBoardNotificationRelay, + WorkflowBoardNotificationRelayShape +>()("t3/workflow/Services/WorkflowBoardNotificationRelay") {} diff --git a/apps/server/src/workflow/Services/WorkflowBoardSaveLocks.ts b/apps/server/src/workflow/Services/WorkflowBoardSaveLocks.ts new file mode 100644 index 00000000000..6895d78d872 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowBoardSaveLocks.ts @@ -0,0 +1,22 @@ +import type { BoardId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface WorkflowBoardSaveLocksShape { + readonly withSaveLock: <A, E, R>( + boardId: BoardId, + effect: Effect.Effect<A, E, R>, + ) => Effect.Effect<A, E, R>; + /** + * Drop the cached per-board save semaphore so a deleted board does not leak its + * lock entry for the process lifetime. Call AFTER a board's owned state is + * deleted (no legitimate concurrent save can target a deleted board). Optional + * so lightweight mocks need not implement it; callers must no-op when absent. + */ + readonly evict?: (boardId: BoardId) => Effect.Effect<void>; +} + +export class WorkflowBoardSaveLocks extends Context.Service< + WorkflowBoardSaveLocks, + WorkflowBoardSaveLocksShape +>()("t3/workflow/Services/WorkflowBoardSaveLocks") {} diff --git a/apps/server/src/workflow/Services/WorkflowBoardVersionStore.ts b/apps/server/src/workflow/Services/WorkflowBoardVersionStore.ts new file mode 100644 index 00000000000..c55d30b548a --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowBoardVersionStore.ts @@ -0,0 +1,51 @@ +import type { BoardId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type WorkflowBoardVersionSource = + | "create" + | "save" + | "revert" + | "import" + | "rename" + | "self-improve" + | "self-improve-revert"; + +export interface WorkflowBoardVersionRecordInput { + readonly boardId: BoardId; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; +} + +export interface WorkflowBoardVersionSummaryRow { + readonly versionId: number; + readonly versionHash: string; + readonly source: WorkflowBoardVersionSource; + readonly createdAt: string; +} + +export interface WorkflowBoardVersionRow extends WorkflowBoardVersionSummaryRow { + readonly contentJson: string; +} + +export interface WorkflowBoardVersionStoreShape { + readonly record: ( + input: WorkflowBoardVersionRecordInput, + ) => Effect.Effect<void, WorkflowEventStoreError>; + readonly list: ( + boardId: BoardId, + ) => Effect.Effect<ReadonlyArray<WorkflowBoardVersionSummaryRow>, WorkflowEventStoreError>; + readonly get: ( + boardId: BoardId, + versionId: number, + ) => Effect.Effect<WorkflowBoardVersionRow | null, WorkflowEventStoreError>; + readonly deleteForBoard: (boardId: BoardId) => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class WorkflowBoardVersionStore extends Context.Service< + WorkflowBoardVersionStore, + WorkflowBoardVersionStoreShape +>()("t3/workflow/Services/WorkflowBoardVersionStore") {} diff --git a/apps/server/src/workflow/Services/WorkflowEngine.ts b/apps/server/src/workflow/Services/WorkflowEngine.ts new file mode 100644 index 00000000000..9802afa32d3 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowEngine.ts @@ -0,0 +1,169 @@ +import type { + BoardId, + LaneKey, + MessageId, + StepRunId, + ThreadId, + TicketAttachment, + TicketId, + TurnId, + WorkflowStepUsage, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type RecoveredStepResult = + | { readonly _tag: "completed"; readonly output?: unknown; readonly usage?: WorkflowStepUsage } + | { + readonly _tag: "failed"; + readonly error: string; + readonly retryable?: boolean; + readonly usage?: WorkflowStepUsage; + } + | { readonly _tag: "blocked"; readonly reason: string }; + +export interface WorkflowEngineShape { + readonly createTicket: (input: { + readonly boardId: BoardId; + readonly title: string; + readonly description?: string; + readonly initialLane: LaneKey; + readonly dependsOn?: ReadonlyArray<TicketId>; + readonly tokenBudget?: number; + }) => Effect.Effect<TicketId, WorkflowEventStoreError>; + readonly editTicket: (input: { + readonly ticketId: TicketId; + readonly title?: string | undefined; + readonly description?: string | undefined; + readonly dependsOn?: ReadonlyArray<TicketId> | undefined; + readonly tokenBudget?: number | null | undefined; + }) => Effect.Effect<void, WorkflowEventStoreError>; + readonly moveTicket: ( + ticketId: TicketId, + toLane: LaneKey, + ) => Effect.Effect<void, WorkflowEventStoreError>; + // Committer-facing UNLOCKED ops for the work-source syncer (Task 9). The CALLER + // MUST already hold the board save lock for the affected board AND be inside an + // open `sql.withTransaction`; these never acquire the save lock, never open a + // transaction, and never take the admission lock. Driving them is how a batch + // syncer creates/closes/edits tickets under ONE lock + ONE transaction per + // chunk without deadlocking the non-reentrant save lock. + readonly createTicketAndEnterUnlocked: (input: { + readonly boardId: BoardId; + readonly title: string; + readonly description?: string; + readonly destinationLane: LaneKey; + }) => Effect.Effect< + { readonly ticketId: TicketId; readonly outcome: "moved" | "queued" | "none" }, + WorkflowEventStoreError + >; + readonly closeTicketFromSourceUnlocked: ( + ticketId: TicketId, + closedLane: LaneKey, + ) => Effect.Effect<void, WorkflowEventStoreError>; + // Inverse of closeTicketFromSourceUnlocked: route a source-closed ticket OUT of + // its current (closed) lane back into `destinationLane` when the upstream item + // is reopened. DB-only move via the external path (no provider IO in-tx). Like + // the close, any auto-lane pipeline start is dropped here (single-transaction + // reason) and the committer's post-commit recoverBoardWip sweep starts it. + readonly reopenTicketFromSourceUnlocked: ( + ticketId: TicketId, + destinationLane: LaneKey, + ) => Effect.Effect<void, WorkflowEventStoreError>; + // Snapshot the ticket's cancellable provider turns (pending/started dispatch + // outbox rows). The source committer captures this INSIDE the chunk tx, BEFORE + // closeTicketFromSourceUnlocked tombstones those rows, then replays it through + // supersedeProviderWorkForTicket AFTER the tx commits. + readonly cancellableProviderTurnsForTicket: ( + ticketId: TicketId, + ) => Effect.Effect< + ReadonlyArray<{ readonly threadId: ThreadId; readonly turnId: TurnId | null }>, + WorkflowEventStoreError + >; + // POST-TX provider cancellation for a source-closed ticket: interrupt the + // running pipeline fiber + cancel the captured provider turns. NO DB writes + // (the in-tx close already tombstoned the outbox). Idempotent. The committer + // calls this after the chunk transaction commits so no provider/fiber IO runs + // inside the transaction. + readonly supersedeProviderWorkForTicket: ( + ticketId: TicketId, + turns: ReadonlyArray<{ readonly threadId: ThreadId; readonly turnId: TurnId | null }>, + ) => Effect.Effect<void, WorkflowEventStoreError>; + // Snapshot the ticket's stored per-agent session thread ids. The source + // committer captures this INSIDE the chunk tx, BEFORE closeTicketFromSourceUnlocked + // tears down (deletes) the rows on its terminal entry, then replays it through + // stopAgentSessionsForTicket AFTER the tx commits. `provider.stopSession` is a + // non-rollbackable live side effect (it kills the provider session + does its + // own SQL write) so it must never run inside the chunk transaction. + readonly terminalAgentSessionThreadsForTicket: ( + ticketId: TicketId, + ) => Effect.Effect<ReadonlyArray<string>, WorkflowEventStoreError>; + // POST-TX best-effort live stop of the agent-session threads snapshotted by + // terminalAgentSessionThreadsForTicket. The in-tx teardown already deleted the + // rows; this only fires provider.stopSession after the chunk transaction commits. + readonly stopAgentSessionsForTicket: ( + threadIds: ReadonlyArray<string>, + ) => Effect.Effect<void, WorkflowEventStoreError>; + readonly editTicketFieldsUnlocked: ( + ticketId: TicketId, + fields: { readonly title?: string | undefined; readonly description?: string | undefined }, + ) => Effect.Effect<void, WorkflowEventStoreError>; + // Acquire the per-board admission semaphore (the WIP read-decide serializer). + // The source committer MUST wrap its chunk in this (OUTER) -> the board save + // lock (INNER) -> the transaction, matching the public enterLane lock order + // (admission->save), so sync admits serialize against concurrent user moves + // and cannot violate a WIP limit. The unlocked enterLane cores assume this is + // already held. + readonly withBoardAdmissionLock: <A, E, R>( + boardId: BoardId, + effect: Effect.Effect<A, E, R>, + ) => Effect.Effect<A, E, R>; + readonly runLane: (ticketId: TicketId) => Effect.Effect<void, WorkflowEventStoreError>; + // Webhook-correlated event: evaluates the ticket's current lane onEvent + // matchers and moves/queues the ticket like a manual move when one fires. + readonly ingestExternalEvent: (input: { + readonly boardId: BoardId; + readonly name: string; + readonly ticketId: TicketId; + readonly payload: unknown; + }) => Effect.Effect< + { readonly outcome: "moved" | "queued" | "noop"; readonly toLane?: string }, + WorkflowEventStoreError + >; + readonly resolveApproval: ( + stepRunId: StepRunId, + approved: boolean, + ) => Effect.Effect<void, WorkflowEventStoreError>; + readonly answerTicketStep: (input: { + readonly stepRunId: StepRunId; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray<TicketAttachment> | undefined; + }) => Effect.Effect<void, WorkflowEventStoreError>; + readonly postTicketMessage: (input: { + readonly ticketId: TicketId; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray<TicketAttachment> | undefined; + }) => Effect.Effect<void, WorkflowEventStoreError>; + readonly editTicketMessage: (input: { + readonly ticketId: TicketId; + readonly messageId: MessageId; + readonly body: string; + }) => Effect.Effect<void, WorkflowEventStoreError>; + readonly cancelStep: (stepRunId: StepRunId) => Effect.Effect<void, WorkflowEventStoreError>; + readonly cancelBoardPipelines: (boardId: BoardId) => Effect.Effect<void, WorkflowEventStoreError>; + readonly cancelTicketPipelines: ( + ticketId: TicketId, + ) => Effect.Effect<void, WorkflowEventStoreError>; + readonly recoverBoardWip: (boardId: BoardId) => Effect.Effect<void, WorkflowEventStoreError>; + readonly completeRecoveredStep: ( + stepRunId: StepRunId, + result: RecoveredStepResult, + captureTurn?: { readonly threadId: ThreadId; readonly turnId: TurnId }, + ) => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class WorkflowEngine extends Context.Service<WorkflowEngine, WorkflowEngineShape>()( + "t3/workflow/Services/WorkflowEngine", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowEventCommitter.ts b/apps/server/src/workflow/Services/WorkflowEventCommitter.ts new file mode 100644 index 00000000000..addcaeca113 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowEventCommitter.ts @@ -0,0 +1,37 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; +import type { PersistedWorkflowEvent, WorkflowEventInput } from "./WorkflowEventStore.ts"; + +export interface WorkflowEventCommitterShape { + readonly commit: (event: WorkflowEventInput) => Effect.Effect<void, WorkflowEventStoreError>; + readonly commitMany: ( + events: ReadonlyArray<WorkflowEventInput>, + ) => Effect.Effect<void, WorkflowEventStoreError>; + // Lock-free append+project core. CALLER MUST already hold the board save lock + // for every affected board AND be inside an open `sql.withTransaction`. Unlike + // commit/commitMany this neither acquires the save lock nor opens a + // transaction (it would deadlock the non-reentrant lock / nest the tx), and it + // does NOT publish ticket views — the caller is responsible for the post-lock + // recheck, publish, and pipeline starts. Used by batch syncers (Task 9) that + // open one lock + one transaction per chunk and then call engine unlocked ops. + readonly appendManyUnlocked: ( + events: ReadonlyArray<WorkflowEventInput>, + ) => Effect.Effect<ReadonlyArray<PersistedWorkflowEvent>, WorkflowEventStoreError>; + // Publish a live ticket view to WorkflowBoardEvents for a ticket id, mirroring + // the post-lock publish commit/commitMany perform. Batch syncers that drive + // appendManyUnlocked (which does NOT publish) call this AFTER releasing the + // lock/tx so synced creates/edits/closes reach the live board stream. + // `republishDependents` republishes the ticket's dependents too, matching + // publishTicket's behavior on a terminal/lane move. + readonly publishTicketView: ( + ticketId: PersistedWorkflowEvent["ticketId"], + options?: { readonly republishDependents?: boolean }, + ) => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class WorkflowEventCommitter extends Context.Service< + WorkflowEventCommitter, + WorkflowEventCommitterShape +>()("t3/workflow/Services/WorkflowEventCommitter") {} diff --git a/apps/server/src/workflow/Services/WorkflowEventStore.ts b/apps/server/src/workflow/Services/WorkflowEventStore.ts new file mode 100644 index 00000000000..055cb921356 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowEventStore.ts @@ -0,0 +1,32 @@ +import type { BoardId, TicketId, WorkflowEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type PersistedWorkflowEvent = WorkflowEvent & { readonly sequence: number }; + +type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never; +export type WorkflowEventInput = DistributiveOmit<WorkflowEvent, "streamVersion">; + +export interface WorkflowEventStoreShape { + readonly append: ( + event: WorkflowEventInput, + ) => Effect.Effect<PersistedWorkflowEvent, WorkflowEventStoreError>; + readonly readByTicket: ( + ticketId: TicketId, + ) => Stream.Stream<PersistedWorkflowEvent, WorkflowEventStoreError>; + readonly readFromSequence: ( + sequenceExclusive: number, + limit?: number, + ) => Stream.Stream<PersistedWorkflowEvent, WorkflowEventStoreError>; + readonly readAll: () => Stream.Stream<PersistedWorkflowEvent, WorkflowEventStoreError>; + readonly deleteForBoard: (boardId: BoardId) => Effect.Effect<void, WorkflowEventStoreError>; + readonly deleteForTicket: (ticketId: TicketId) => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class WorkflowEventStore extends Context.Service< + WorkflowEventStore, + WorkflowEventStoreShape +>()("t3/workflow/Services/WorkflowEventStore") {} diff --git a/apps/server/src/workflow/Services/WorkflowFileLoader.ts b/apps/server/src/workflow/Services/WorkflowFileLoader.ts new file mode 100644 index 00000000000..388fdf859d2 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowFileLoader.ts @@ -0,0 +1,58 @@ +import type { BoardId, ProjectId, WorkflowDefinition } from "@t3tools/contracts"; +import { WorkflowRpcError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { LintError } from "../workflowFile.ts"; + +export interface WorkflowFilePortShape { + readonly readFileString: (filePath: string) => Effect.Effect<string, WorkflowRpcError>; + readonly instructionFileExists: (input: { + readonly repoRoot: string; + readonly repoRelativePath: string; + }) => Effect.Effect<boolean, WorkflowRpcError>; +} + +export class WorkflowFilePort extends Context.Service<WorkflowFilePort, WorkflowFilePortShape>()( + "t3/workflow/Services/WorkflowFileLoader/WorkflowFilePort", +) {} + +export interface WorkflowProviderInstancePortShape { + readonly providerInstanceExists: (instanceId: string) => Effect.Effect<boolean, WorkflowRpcError>; + // Whether the instance's provider adapter supports resuming its own session + // across turns/steps (Codex/Claude/Grok/Cursor do; OpenCode does not). Backs + // the `continueSession` lint capability gate. Unknown instances resolve false. + readonly providerInstanceSupportsResume: ( + instanceId: string, + ) => Effect.Effect<boolean, WorkflowRpcError>; +} + +export class WorkflowProviderInstancePort extends Context.Service< + WorkflowProviderInstancePort, + WorkflowProviderInstancePortShape +>()("t3/workflow/Services/WorkflowFileLoader/WorkflowProviderInstancePort") {} + +export interface WorkflowFileLoaderShape { + readonly lintDefinition: (input: { + readonly definition: WorkflowDefinition; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + }) => Effect.Effect<ReadonlyArray<LintError>, WorkflowRpcError>; + readonly loadAndRegister: (input: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly relativePath: string; + // "strict" (default) runs the full provider/instruction lint and fails on + // any error. "skip" bypasses that strict lint — used by import, which has + // already linted and intentionally tolerates env-bound (unknown provider + // instance / missing instruction file) findings as warnings. The permissive + // lint inside BoardRegistry.register still runs in both modes. + readonly lintMode?: "strict" | "skip"; + }) => Effect.Effect<BoardId, WorkflowRpcError>; +} + +export class WorkflowFileLoader extends Context.Service< + WorkflowFileLoader, + WorkflowFileLoaderShape +>()("t3/workflow/Services/WorkflowFileLoader") {} diff --git a/apps/server/src/workflow/Services/WorkflowGitHubPoller.ts b/apps/server/src/workflow/Services/WorkflowGitHubPoller.ts new file mode 100644 index 00000000000..d56416267c7 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowGitHubPoller.ts @@ -0,0 +1,24 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface WorkflowGitHubPollerSweepResult { + // Watched tickets observed this sweep (pr_state='open', non-terminal). + readonly observedTickets: number; + // New observation rows inserted (deduped by dedup_key). + readonly recordedObservations: number; + // Pending observations marked 'applied' in phase 2. + readonly appliedObservations: number; + // Watched tickets whose gh observation failed (logged + skipped). + readonly failedTickets: number; +} + +export interface WorkflowGitHubPollerShape { + readonly sweep: () => Effect.Effect<WorkflowGitHubPollerSweepResult>; + readonly start: () => Effect.Effect<void, never, Scope.Scope>; +} + +export class WorkflowGitHubPoller extends Context.Service< + WorkflowGitHubPoller, + WorkflowGitHubPollerShape +>()("t3/workflow/Services/WorkflowGitHubPoller") {} diff --git a/apps/server/src/workflow/Services/WorkflowIds.ts b/apps/server/src/workflow/Services/WorkflowIds.ts new file mode 100644 index 00000000000..14acad2a701 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowIds.ts @@ -0,0 +1,28 @@ +import type { + LaneEntryToken, + MessageId, + PipelineRunId, + ScriptRunId, + StepRunId, + TicketId, + WorkflowEventId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface WorkflowIdsShape { + readonly ticketId: () => Effect.Effect<TicketId>; + readonly pipelineRunId: () => Effect.Effect<PipelineRunId>; + readonly scriptRunId: () => Effect.Effect<ScriptRunId>; + readonly stepRunId: () => Effect.Effect<StepRunId>; + readonly messageId: () => Effect.Effect<MessageId>; + readonly eventId: () => Effect.Effect<WorkflowEventId>; + readonly token: () => Effect.Effect<LaneEntryToken>; + // Opaque unique id for a work_source_mapping row (Task 9 committer). Not a + // branded contract type — the mapping_id column is a plain TEXT primary key. + readonly mappingId: () => Effect.Effect<string>; +} + +export class WorkflowIds extends Context.Service<WorkflowIds, WorkflowIdsShape>()( + "t3/workflow/Services/WorkflowIds", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowIntake.ts b/apps/server/src/workflow/Services/WorkflowIntake.ts new file mode 100644 index 00000000000..615bea6d369 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowIntake.ts @@ -0,0 +1,27 @@ +import type { AgentSelection, BoardId, WorkflowTicketProposal } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowIntakeInput { + readonly boardId: BoardId; + readonly braindump: string; + readonly agent: AgentSelection; +} + +/** + * Turns a free-form braindump into proposed tickets by running a one-shot + * agent turn at the board's project root. Proposals are returned to the + * client for review — nothing is created server-side. + */ +export interface WorkflowIntakeShape { + readonly proposeTickets: ( + input: WorkflowIntakeInput, + ) => Effect.Effect<ReadonlyArray<WorkflowTicketProposal>, WorkflowEventStoreError>; +} + +export class WorkflowIntakeService extends Context.Service< + WorkflowIntakeService, + WorkflowIntakeShape +>()("t3/workflow/Services/WorkflowIntake/WorkflowIntakeService") {} diff --git a/apps/server/src/workflow/Services/WorkflowOutboundConnectionStore.ts b/apps/server/src/workflow/Services/WorkflowOutboundConnectionStore.ts new file mode 100644 index 00000000000..2a96f4e1482 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowOutboundConnectionStore.ts @@ -0,0 +1,48 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import type * as Effect from "effect/Effect"; +import type { OutboundConnectionKind, OutboundConnectionView } from "@t3tools/contracts"; + +export class OutboundConfigError extends Data.TaggedError("OutboundConfigError")<{ + readonly reason: string; +}> {} + +export interface OutboundTarget { + readonly kind: OutboundConnectionKind; + readonly url: string; +} + +export interface WorkflowOutboundConnectionStoreShape { + /** Create a new outbound connection, storing the URL in the secret store. + * + * Fails with OutboundConfigError if the URL is SSRF-blocked, malformed, + * not https://, or if the DB insert / secret write fails. + */ + readonly create: (input: { + readonly kind: OutboundConnectionKind; + readonly displayName: string; + readonly url: string; + }) => Effect.Effect<OutboundConnectionView, OutboundConfigError>; + + /** List all connections (no URL in the view). */ + readonly list: () => Effect.Effect<ReadonlyArray<OutboundConnectionView>, OutboundConfigError>; + + /** Remove a connection and best-effort delete its stored secret. + * + * Does NOT check for boards still referencing this connectionRef — a + * dangling ref will surface as an OutboundConfigError at delivery time. + */ + readonly remove: (connectionRef: string) => Effect.Effect<void, OutboundConfigError>; + + /** Retrieve the delivery target (kind + url) for an existing connection. + * + * Fails with OutboundConfigError when no row matches the connectionRef + * or when the stored secret is missing. + */ + readonly getTarget: (connectionRef: string) => Effect.Effect<OutboundTarget, OutboundConfigError>; +} + +export class WorkflowOutboundConnectionStore extends Context.Service< + WorkflowOutboundConnectionStore, + WorkflowOutboundConnectionStoreShape +>()("t3/workflow/Services/WorkflowOutboundConnectionStore") {} diff --git a/apps/server/src/workflow/Services/WorkflowOutboundDispatcher.ts b/apps/server/src/workflow/Services/WorkflowOutboundDispatcher.ts new file mode 100644 index 00000000000..70df8f4dcb3 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowOutboundDispatcher.ts @@ -0,0 +1,27 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface WorkflowOutboundDispatcherShape { + /** Drain due `workflow_outbound_delivery` rows and POST each rendered payload. + * + * Per-delivery isolation: a transient/expected failure (HTTP error, dangling + * connection, SSRF block) backs off ONLY that row; a programming defect + * (die/interrupt) re-raises to the sweep-level guard. Never aborts the sweep. + */ + readonly sweep: () => Effect.Effect<void>; + /** + * Reset rows stranded mid-claim ('processing') back to 'pending' so a future + * sweep re-selects them. A crash after claimRow but before markSent / + * recordFailure would otherwise leave a row 'processing' forever (sweeps only + * select 'pending'). Run once at boot, before the sweep loop starts. + */ + readonly recoverStaleClaims: () => Effect.Effect<void>; + /** Fork the sweep on a fixed interval. Recovery-gating is the caller's job. */ + readonly start: () => Effect.Effect<void, never, Scope.Scope>; +} + +export class WorkflowOutboundDispatcher extends Context.Service< + WorkflowOutboundDispatcher, + WorkflowOutboundDispatcherShape +>()("t3/workflow/Services/WorkflowOutboundDispatcher") {} diff --git a/apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts b/apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts new file mode 100644 index 00000000000..809b209d81e --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts @@ -0,0 +1,14 @@ +import type { WorkflowEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowProjectionPipelineShape { + readonly projectEvent: (event: WorkflowEvent) => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class WorkflowProjectionPipeline extends Context.Service< + WorkflowProjectionPipeline, + WorkflowProjectionPipelineShape +>()("t3/workflow/Services/WorkflowProjectionPipeline") {} diff --git a/apps/server/src/workflow/Services/WorkflowReadModel.ts b/apps/server/src/workflow/Services/WorkflowReadModel.ts new file mode 100644 index 00000000000..eefa246875f --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowReadModel.ts @@ -0,0 +1,386 @@ +import type { + BoardId, + IsoDateTime, + LaneKey, + MessageId, + PipelineRunId, + ProjectId, + StepRunId, + TicketAttachment, + TicketId, + WorkflowBoardMetrics, + WorkflowBoardProposalView, + WorkflowDefinitionEncoded, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface BoardRow { + readonly boardId: string; + readonly projectId: string; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; +} + +export interface BoardListRow { + readonly boardId: string; + readonly name: string; + readonly filePath: string; +} + +export interface TicketPrView { + readonly number: number; + readonly url: string; + readonly state: "open" | "merged" | "closed"; + readonly ciState?: "pending" | "success" | "failure"; +} + +// One actionable lane transition offered to a human, mirroring the +// WorkflowLaneAction shape ({ label, to, hint? }) from the board definition. +export interface WorkflowLaneActionRow { + readonly label: string; + readonly to: string; + readonly hint?: string; +} + +// The ticket's current lane resolved from the board definition — name and the +// human-facing actions available from here. Falls back to key-only when the +// board definition is not registered. +export interface WorkflowCurrentLaneRow { + readonly key: string; + readonly name: string; + readonly actions: ReadonlyArray<WorkflowLaneActionRow>; +} + +// "waiting_for_approval" | "waiting_for_input" | "blocked" — kept loose here so +// the read model does not depend on the contracts enum; the RPC layer narrows. +export type TicketAttentionKind = string; + +export interface TicketPrStateRow { + readonly prNumber: number; + readonly prUrl: string; + readonly branch: string; + readonly remoteName: string; + readonly repo: string; + readonly prState: string; + readonly lastHeadSha: string | null; + readonly lastCiState: string | null; + readonly lastReviewDecision: string | null; + readonly lastCommentCursor: string | null; +} + +export interface TicketRow { + readonly ticketId: string; + readonly boardId: string; + readonly title: string; + readonly description: string | null; + readonly currentLaneKey: string; + readonly currentLaneEntryToken: string | null; + readonly status: string; + readonly queuedAt: string | null; + readonly totalTokens: number | null; + readonly totalDurationMs: number | null; + // Blocked-by edges; optional so non-dependency readers stay untouched. + readonly dependsOn?: ReadonlyArray<string>; + readonly unresolvedDependencyCount?: number; + readonly tokenBudget?: number | null; + readonly updatedAt?: string; + // PR view — present when a workflow_pr_state row exists for this ticket. + readonly pr?: TicketPrView; + // Attention fields — projected onto the ticket; null when the ticket is not + // in a needs-you state. + readonly attentionKind?: TicketAttentionKind | null; + readonly attentionReason?: string | null; + // Current lane detail (key/name/actions) — present on detail reads, resolved + // from the board definition. + readonly currentLane?: WorkflowCurrentLaneRow; +} + +// A ticket awaiting human attention across the boards in this environment's DB, +// joined with its board name. Mirrors WorkflowNeedsAttentionTicketView (T1). +export interface WorkflowNeedsAttentionTicketRow { + readonly ticketId: string; + readonly boardId: string; + readonly boardName: string; + readonly title: string; + readonly status: string; + readonly currentLaneKey: string; + readonly attentionKind: TicketAttentionKind | null; + readonly attentionReason: string | null; + readonly updatedAt: string; +} + +export interface BoardDigestRow { + readonly windowHours: number; + readonly createdCount: number; + readonly shippedCount: number; + readonly totalTokens: number; + readonly totalDurationMs: number; + readonly needsAttention: ReadonlyArray<{ + readonly ticketId: string; + readonly title: string; + readonly status: string; + readonly laneKey: string; + readonly sinceMs: number; + }>; +} + +// A queued dependent whose last unresolved dependency just resolved — the +// admission sweep should visit its lane. +export interface ReleasableDependentRow { + readonly ticketId: string; + readonly boardId: string; + readonly laneKey: string; +} + +export interface TicketMessageRow { + readonly messageId: MessageId; + readonly ticketId: TicketId; + readonly stepRunId: StepRunId | null; + readonly author: "agent" | "user"; + readonly body: string; + readonly attachments: ReadonlyArray<TicketAttachment>; + readonly createdAt: string; + readonly editedAt: IsoDateTime | null; +} + +/** + * Lightweight discussion row for agent instruction context — deliberately + * carries only an attachment count so listing a long thread never decodes + * attachment data URLs. + */ +export interface TicketDiscussionRow { + readonly author: "agent" | "user"; + readonly body: string; + readonly createdAt: string; + readonly attachmentCount: number; +} + +export interface RouteDecisionStepSnapshot { + readonly status: string; + readonly exitCode: number | null; + // Bounded highlight only — raw captured output stays on the step run. + readonly verdict: string | null; +} + +/** + * Why a ticket arrived in a lane, derived from the event log: + * TicketRouteDecided events (automatic routing, with snapshot highlights) + * plus manual TicketMovedToLane events. Snapshot fields are null when the + * stored snapshot is missing or malformed. + */ +export interface TicketRouteDecisionRow { + readonly occurredAt: string; + readonly fromLane: string | null; + readonly toLane: string; + readonly source: + | "step_on" + | "lane_transition" + | "lane_on" + | "manual" + | "external_event" + | "work_source"; + readonly matchedTransitionIndex: number | null; + readonly eventName: string | null; + readonly pipelineResult: "success" | "failure" | "blocked" | null; + readonly laneRunCount: number | null; + readonly steps: Readonly<Record<string, RouteDecisionStepSnapshot>> | null; +} + +export interface StepRunRow { + readonly stepRunId: string; + readonly stepKey: string; + readonly stepType: string; + readonly attempt: number | null; + readonly status: string; + readonly waitingReason: string | null; + readonly blockedReason: string | null; + readonly providerResponseKind: "request" | "user-input" | null; + readonly scriptThreadId: string | null; + readonly terminalId: string | null; + readonly scriptStatus: string | null; + readonly exitCode: number | null; + readonly signal: number | null; + readonly output: unknown | null; + readonly startedAt: string | null; + readonly finishedAt: string | null; + readonly providerThreadId: string | null; + readonly inputTokens: number | null; + readonly cachedInputTokens: number | null; + readonly outputTokens: number | null; + readonly totalTokens: number | null; +} + +export interface PipelineStepRunRow { + readonly stepKey: string; + readonly stepType: string; + readonly status: string; + readonly exitCode: number | null; + readonly output: unknown | null; +} + +export interface TicketDetail { + readonly ticket: TicketRow; + readonly steps: ReadonlyArray<StepRunRow>; + readonly messages: ReadonlyArray<TicketMessageRow>; + readonly syncedSource?: { + readonly provider: "github" | "asana" | "jira"; + readonly url: string; + readonly assignees?: ReadonlyArray<string>; + readonly labels?: ReadonlyArray<string>; + }; +} + +export interface WorkflowReadModelShape { + readonly registerBoard: (board: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; + }) => Effect.Effect<void, WorkflowEventStoreError>; + readonly getBoard: (boardId: BoardId) => Effect.Effect<BoardRow | null, WorkflowEventStoreError>; + readonly deleteBoard: (boardId: BoardId) => Effect.Effect<void, WorkflowEventStoreError>; + readonly deleteBoardTicketState: ( + boardId: BoardId, + ) => Effect.Effect<void, WorkflowEventStoreError>; + readonly deleteTicketState: (ticketId: TicketId) => Effect.Effect<void, WorkflowEventStoreError>; + readonly listBoardsForProject: ( + projectId: ProjectId, + ) => Effect.Effect<ReadonlyArray<BoardListRow>, WorkflowEventStoreError>; + readonly listTickets: ( + boardId: BoardId, + ) => Effect.Effect<ReadonlyArray<TicketRow>, WorkflowEventStoreError>; + readonly countAdmittedInLane: ( + boardId: BoardId, + laneKey: LaneKey, + ) => Effect.Effect<number, WorkflowEventStoreError>; + readonly oldestQueuedForLane: ( + boardId: BoardId, + laneKey: LaneKey, + ) => Effect.Effect<TicketRow | null, WorkflowEventStoreError>; + readonly getTicketDetail: ( + ticketId: TicketId, + ) => Effect.Effect<TicketDetail | null, WorkflowEventStoreError>; + readonly listTicketMessages: ( + ticketId: TicketId, + ) => Effect.Effect<ReadonlyArray<TicketMessageRow>, WorkflowEventStoreError>; + // The newest `limit` messages in chronological order, attachment counts + // only — cheap enough to call on every agent step. + readonly listTicketDiscussion: ( + ticketId: TicketId, + limit: number, + ) => Effect.Effect<ReadonlyArray<TicketDiscussionRow>, WorkflowEventStoreError>; + readonly listTicketRouteDecisions: ( + ticketId: TicketId, + ) => Effect.Effect<ReadonlyArray<TicketRouteDecisionRow>, WorkflowEventStoreError>; + readonly listReleasableDependents: ( + ticketId: TicketId, + ) => Effect.Effect<ReadonlyArray<ReleasableDependentRow>, WorkflowEventStoreError>; + // Every ticket that depends on the given one, regardless of state — used to + // republish their views when the dependency's resolution changes. + readonly listDependentTicketIds: ( + ticketId: TicketId, + ) => Effect.Effect<ReadonlyArray<string>, WorkflowEventStoreError>; + readonly getBoardDigest: ( + boardId: BoardId, + windowHours: number, + ) => Effect.Effect<BoardDigestRow, WorkflowEventStoreError>; + // Read-only board-scoped aggregation for the metrics dashboard. windowDays is + // clamped to {1,7,30} (defaults to 7). Cycle-time percentiles are computed in + // TypeScript because SQLite has no PERCENTILE_CONT. + readonly getBoardMetrics: ( + boardId: BoardId, + windowDays: number, + ) => Effect.Effect<WorkflowBoardMetrics, WorkflowEventStoreError>; + // Every ticket awaiting human attention (waiting_on_user / blocked) across + // the boards in this DB, joined with board name, oldest-touched first. The WS + // connection is environment-scoped, so no environment filter is needed. + readonly listNeedsAttentionTickets: () => Effect.Effect< + ReadonlyArray<WorkflowNeedsAttentionTicketRow>, + WorkflowEventStoreError + >; + // Pipeline runs (including the given one) this ticket has had in the same + // lane — feeds the lane.runCount routing variable for bounded loops. + readonly countLanePipelineRuns: ( + pipelineRunId: PipelineRunId, + ) => Effect.Effect<number, WorkflowEventStoreError>; + readonly listStepRunsForPipeline: ( + pipelineRunId: PipelineRunId, + ) => Effect.Effect<ReadonlyArray<PipelineStepRunRow>, WorkflowEventStoreError>; + // Full workflow_pr_state row for a ticket, or null when no PR has been opened. + readonly getTicketPrState: ( + ticketId: TicketId, + ) => Effect.Effect<TicketPrStateRow | null, WorkflowEventStoreError>; + // Insert a board-improvement proposal row (self-improve, E4). All JSON + // columns are pre-encoded strings — the caller owns encode/redact. + readonly recordBoardProposal: ( + proposal: BoardProposalInsert, + ) => Effect.Effect<void, WorkflowEventStoreError>; + // List all proposals for a board, pending-first then by created_at DESC. + // The `outdated` flag is computed against the board's CURRENT versionHash. + readonly listBoardProposals: ( + boardId: BoardId, + ) => Effect.Effect<ReadonlyArray<WorkflowBoardProposalView>, WorkflowEventStoreError>; + // Get a single proposal by proposalId; returns null when not found. + // Includes the full proposed + base encoded definitions. + readonly getBoardProposal: (proposalId: string) => Effect.Effect< + { + view: WorkflowBoardProposalView; + proposedDefinition: WorkflowDefinitionEncoded; + baseDefinition: WorkflowDefinitionEncoded; + } | null, + WorkflowEventStoreError + >; + // Lane keys on this board that currently hold live work: either a + // non-terminal admitted ticket (terminal_at IS NULL AND + // current_lane_entry_token IS NOT NULL) or a running pipeline. Feeds the + // resolve-approve live-compatibility gate (modified lane × live work). + readonly listLiveOccupiedLanes: ( + boardId: BoardId, + ) => Effect.Effect<ReadonlyArray<string>, WorkflowEventStoreError>; + // Transition a proposal's status (resolve-approve / reject / supersede), + // optionally stamping resolved_at + applied_version_hash, under a single DB + // transaction. Idempotent at the caller: only flips a row when it is still in + // an expected source status, returning the resulting row count. + readonly resolveBoardProposalStatus: (input: { + readonly proposalId: string; + readonly status: string; + readonly resolvedAt: string; + readonly appliedVersionHash?: string | null; + readonly fromStatus?: string; + }) => Effect.Effect<number, WorkflowEventStoreError>; + readonly listWorkSourceMappingsForBoard: ( + boardId: BoardId, + ) => Effect.Effect<ReadonlyArray<WorkSourceMappingRow>, WorkflowEventStoreError>; +} + +export interface WorkSourceMappingRow { + readonly provider: string; + readonly sourceId: string; + readonly externalId: string; + readonly ticketId: string; + readonly currentLaneKey: string; +} + +export interface BoardProposalInsert { + readonly proposalId: string; + readonly boardId: BoardId; + readonly baseVersionHash: string; + readonly baseDefJson: string; + readonly agentJson: string; + readonly proposedDefJson: string; + readonly rationale: string; + readonly validationJson: string; + readonly status: string; + readonly createdAt: string; +} + +export class WorkflowReadModel extends Context.Service<WorkflowReadModel, WorkflowReadModelShape>()( + "t3/workflow/Services/WorkflowReadModel", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowRecovery.ts b/apps/server/src/workflow/Services/WorkflowRecovery.ts new file mode 100644 index 00000000000..269de5ba9d9 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowRecovery.ts @@ -0,0 +1,12 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowRecoveryShape { + readonly recover: () => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class WorkflowRecovery extends Context.Service<WorkflowRecovery, WorkflowRecoveryShape>()( + "t3/workflow/Services/WorkflowRecovery", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowRoutingContextBuilder.ts b/apps/server/src/workflow/Services/WorkflowRoutingContextBuilder.ts new file mode 100644 index 00000000000..4cfa925d810 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowRoutingContextBuilder.ts @@ -0,0 +1,40 @@ +import type { PipelineRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type RoutingPipelineResult = "success" | "failure" | "blocked"; + +export interface WorkflowRoutingStepContext { + readonly exitCode: number | null; + readonly status: string; + readonly output: unknown | null; +} + +export interface WorkflowRoutingContext { + readonly pipeline: { + readonly result: RoutingPipelineResult; + }; + readonly lane: { + // How many pipeline runs (including this one) this ticket has had in the + // current lane — lets transitions bound loops, e.g. re-enter the lane + // while runCount < 3 and escalate to a manual lane afterwards. + readonly runCount: number; + }; + readonly status: string; + readonly steps: Readonly<Record<string, WorkflowRoutingStepContext>>; +} + +export interface WorkflowRoutingContextBuilderShape { + readonly build: (input: { + readonly ticketId: TicketId; + readonly pipelineRunId: PipelineRunId; + readonly result: RoutingPipelineResult; + }) => Effect.Effect<WorkflowRoutingContext, WorkflowEventStoreError>; +} + +export class WorkflowRoutingContextBuilder extends Context.Service< + WorkflowRoutingContextBuilder, + WorkflowRoutingContextBuilderShape +>()("t3/workflow/Services/WorkflowRoutingContextBuilder") {} diff --git a/apps/server/src/workflow/Services/WorkflowSourceCommitter.ts b/apps/server/src/workflow/Services/WorkflowSourceCommitter.ts new file mode 100644 index 00000000000..19957b20ee9 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowSourceCommitter.ts @@ -0,0 +1,101 @@ +import type { BoardId, LaneKey } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +// Read-only display metadata captured from the external provider item. Serialized +// into `work_source_mapping.source_metadata_json` and surfaced by Task 13. +export interface SourceItemMetadata { + readonly provider: string; + readonly url?: string | undefined; + readonly assignees?: ReadonlyArray<string> | undefined; + readonly labels?: ReadonlyArray<string> | undefined; + readonly lifecycle?: string | undefined; +} + +// The external item fields a reconcile delta carries. These are the +// provider-derived values the committer writes to the ticket + mapping row. +export interface SourceItemFields { + readonly sourceId: string; + readonly provider: string; + readonly externalId: string; + readonly title: string; + readonly description?: string | undefined; + // Stable hash of the upstream content. The change/close gate compares this + // against the stored mapping.content_hash so a re-run with no upstream change + // writes nothing (idempotency). + readonly contentHash: string; + readonly providerVersion?: string | undefined; + readonly metadata: SourceItemMetadata; +} + +// A single per-item reconcile decision computed by the Task 10 diff (OUTSIDE the +// lock). The committer re-validates each one in-tx before applying it. +// +// - `new`: unmapped upstream item → create ticket + mapping. +// - `changed`: mapped item whose content may differ → version-gated edit. +// - `closed`: mapped item the provider reports terminal → source-aware close. +// - `reopened`: mapped item that is OPEN upstream but whose mapping is +// closed/orphaned (it was previously source-closed or orphaned) → +// restore lifecycle='open'/sync_status='active', route the ticket +// out of the closed lane back into the destination lane, and refresh +// its content. Without this an upstream reopen is stuck terminal. +// - `missing`: mapped item not seen in a COMPLETE scan → mark orphaned; if the +// syncer (Task 11) confirmed deletion via a provider getItem call +// (network OUT of this tx) it sets `confirmedDeleted` so the +// committer also terminal-routes the ticket. +export type SourceDelta = + | { + readonly _tag: "new"; + readonly item: SourceItemFields; + } + | { + readonly _tag: "changed"; + readonly item: SourceItemFields; + // The mapping row as seen by the out-of-lock diff. The committer re-reads + // by the unique key in-tx and uses the fresh row for the version gate. + readonly ticketId: string; + } + | { + readonly _tag: "reopened"; + readonly item: SourceItemFields; + readonly ticketId: string; + } + | { + readonly _tag: "closed"; + readonly item: SourceItemFields; + readonly ticketId: string; + } + | { + readonly _tag: "missing"; + readonly item: SourceItemFields; + readonly ticketId: string; + // Set true by the syncer only after a provider getItem confirms the item + // is genuinely gone (404/closed), authorizing a terminal route here. + readonly confirmedDeleted?: boolean | undefined; + }; + +export interface ReconcileLanes { + // Lane new tickets are admitted into. + readonly destinationLane: LaneKey; + // Terminal lane a source-driven close routes into. + readonly closedLane: LaneKey; +} + +export interface WorkflowSourceCommitterShape { + // Apply a per-board batch ("chunk") of reconcile deltas to tickets + the + // work_source_mapping table under admission(OUTER) -> save(INNER) -> + // transaction (innermost), then trigger the board's auto-lane pipeline starts + // AFTER the transaction commits. Idempotent. No network here. + readonly reconcileChunk: ( + boardId: BoardId, + lanes: ReconcileLanes, + deltas: ReadonlyArray<SourceDelta>, + ) => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class WorkflowSourceCommitter extends Context.Service< + WorkflowSourceCommitter, + WorkflowSourceCommitterShape +>()("t3/workflow/Services/WorkflowSourceCommitter") {} diff --git a/apps/server/src/workflow/Services/WorkflowSourceSyncer.ts b/apps/server/src/workflow/Services/WorkflowSourceSyncer.ts new file mode 100644 index 00000000000..b6bb64d5bc6 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowSourceSyncer.ts @@ -0,0 +1,22 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +// The syncer fiber: a background loop that, each tick ("sweep"), pulls every +// registered board's enabled work sources, diffs them against the stored +// mappings, and drives the committer to admit/edit/close/orphan tickets. +// +// - `sweep` runs ONE full pass over all boards/sources and returns when done. +// Per-source failures are isolated (one source failing never aborts the +// sweep) and recorded as backoff in `work_source_state`. +// - `start()` forks the sweep loop on a fixed schedule under the current scope +// (mirrors WorkflowGitHubPoller). Wiring is Task 16; here we only expose it. +export interface WorkflowSourceSyncerShape { + readonly sweep: Effect.Effect<void, never>; + readonly start: () => Effect.Effect<void, never, Scope.Scope>; +} + +export class WorkflowSourceSyncer extends Context.Service< + WorkflowSourceSyncer, + WorkflowSourceSyncerShape +>()("t3/workflow/Services/WorkflowSourceSyncer") {} diff --git a/apps/server/src/workflow/Services/WorkflowTerminalRetentionSweeper.ts b/apps/server/src/workflow/Services/WorkflowTerminalRetentionSweeper.ts new file mode 100644 index 00000000000..eb0c6b15b8a --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowTerminalRetentionSweeper.ts @@ -0,0 +1,19 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface WorkflowTerminalRetentionSweepResult { + readonly candidateCount: number; + readonly deletedCount: number; + readonly failedCount: number; +} + +export interface WorkflowTerminalRetentionSweeperShape { + readonly sweep: () => Effect.Effect<WorkflowTerminalRetentionSweepResult>; + readonly start: () => Effect.Effect<void, never, Scope.Scope>; +} + +export class WorkflowTerminalRetentionSweeper extends Context.Service< + WorkflowTerminalRetentionSweeper, + WorkflowTerminalRetentionSweeperShape +>()("t3/workflow/Services/WorkflowTerminalRetentionSweeper") {} diff --git a/apps/server/src/workflow/Services/WorkflowThreadJanitor.ts b/apps/server/src/workflow/Services/WorkflowThreadJanitor.ts new file mode 100644 index 00000000000..b8ba89b7411 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowThreadJanitor.ts @@ -0,0 +1,29 @@ +import type { BoardId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +/** + * Deletes the hidden orchestration threads created for workflow dispatches + * (agent steps, review panels) when their owning ticket or board is deleted. + * Thread ids must be collected BEFORE the workflow cascade removes the + * outbox rows that know them; deletion runs after, through the real + * thread.delete command path. + */ +export interface WorkflowThreadJanitorShape { + readonly collectBoardThreads: ( + boardId: BoardId, + ) => Effect.Effect<ReadonlyArray<string>, WorkflowEventStoreError>; + readonly collectTicketThreads: ( + ticketId: TicketId, + ) => Effect.Effect<ReadonlyArray<string>, WorkflowEventStoreError>; + readonly deleteThreads: ( + threadIds: ReadonlyArray<string>, + ) => Effect.Effect<void, WorkflowEventStoreError>; +} + +export class WorkflowThreadJanitor extends Context.Service< + WorkflowThreadJanitor, + WorkflowThreadJanitorShape +>()("t3/workflow/Services/WorkflowThreadJanitor") {} diff --git a/apps/server/src/workflow/Services/WorkflowWebhook.ts b/apps/server/src/workflow/Services/WorkflowWebhook.ts new file mode 100644 index 00000000000..567237d92bf --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowWebhook.ts @@ -0,0 +1,85 @@ +import type { BoardId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowWebhookConfigResult { + readonly path: string; + readonly hasToken: boolean; + readonly tokenPrefix?: string; + /** Present only when the token was just created or rotated. */ + readonly token?: string; +} + +export type WorkflowWebhookOutcome = "moved" | "queued" | "noop" | "duplicate"; + +export interface WorkflowExternalEventInput { + readonly boardId: BoardId; + readonly name: string; + readonly ticketId: TicketId; + readonly payload: unknown; + readonly deliveryId?: string; +} + +/** + * Per-board webhook ingress: token issue/verify (sha256 at rest, plaintext + * shown once) and delivery dedupe. Event evaluation itself lives in the + * engine (ingestExternalEvent). + */ +export interface WorkflowWebhookShape { + readonly getConfig: ( + boardId: BoardId, + rotate: boolean, + ) => Effect.Effect<WorkflowWebhookConfigResult, WorkflowEventStoreError>; + readonly verifyToken: ( + boardId: BoardId, + token: string, + ) => Effect.Effect<boolean, WorkflowEventStoreError>; + /** + * Records a delivery id and reports whether it was already seen. Inserts the + * row ON CONFLICT DO NOTHING and returns `false` (fresh — proceed to ingest) + * only when this call actually inserted the row; returns `true` (duplicate — + * skip) when a row already existed. Concurrency-safe: of two concurrent + * deliveries with the same id, exactly one wins the INSERT (gets `false`) and + * the other sees the conflict (gets `true`), so the event is ingested once. + */ + readonly recordDelivery: ( + boardId: BoardId, + deliveryId: string, + ) => Effect.Effect<boolean, WorkflowEventStoreError>; + /** + * Forgets a delivery row after a failed ingest so the sender's retry is + * ingested instead of being answered "duplicate". Best-effort. + */ + readonly releaseDelivery: ( + boardId: BoardId, + deliveryId: string, + ) => Effect.Effect<void, WorkflowEventStoreError>; + /** + * Drops the token and delivery log when a board is deleted, so a recreated + * board with the same id never inherits the old token holder's access. + */ + readonly deleteForBoard: (boardId: BoardId) => Effect.Effect<void, WorkflowEventStoreError>; + /** + * Deletes dedup rows whose `created_at` is older than `beforeIso`, bounded per + * call, returning the number deleted. Dedup rows are only useful within a + * sender's bounded retry window; without time-based pruning the delivery table + * grows unbounded for the life of a board. Caller drives the schedule (see + * `start`). + */ + readonly pruneStaleDeliveries: ( + beforeIso: string, + ) => Effect.Effect<number, WorkflowEventStoreError>; + /** + * Forks a background fiber that periodically prunes stale dedup rows. Scoped: + * the fiber lives for the duration of the provided scope. Wire this at server + * startup alongside the other workflow sweepers. + */ + readonly start: () => Effect.Effect<void, never, Scope.Scope>; +} + +export class WorkflowWebhook extends Context.Service<WorkflowWebhook, WorkflowWebhookShape>()( + "t3/workflow/Services/WorkflowWebhook", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowWorktreeJanitor.ts b/apps/server/src/workflow/Services/WorkflowWorktreeJanitor.ts new file mode 100644 index 00000000000..b8ef98f47cd --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowWorktreeJanitor.ts @@ -0,0 +1,25 @@ +import type { BoardId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +/** + * Everything needed to clean a ticket's git residue after its rows are gone. + * Plans are collected BEFORE the DB cascade (the repo root and ticket list are + * only resolvable while the projections still exist) and executed after it. + */ +export interface WorktreeCleanupPlan { + readonly repoRoot: string; + readonly ticketIds: ReadonlyArray<TicketId>; +} + +export interface WorkflowWorktreeJanitorShape { + readonly collectBoardPlan: (boardId: BoardId) => Effect.Effect<WorktreeCleanupPlan | null>; + readonly collectTicketPlan: (ticketId: TicketId) => Effect.Effect<WorktreeCleanupPlan | null>; + /** Best-effort: removes worktrees, ticket branches, checkpoint refs and lease rows. Never fails. */ + readonly run: (plan: WorktreeCleanupPlan | null) => Effect.Effect<void>; +} + +export class WorkflowWorktreeJanitor extends Context.Service< + WorkflowWorktreeJanitor, + WorkflowWorktreeJanitorShape +>()("t3/workflow/Services/WorkflowWorktreeJanitor") {} diff --git a/apps/server/src/workflow/Services/WorktreeLeaseService.ts b/apps/server/src/workflow/Services/WorktreeLeaseService.ts new file mode 100644 index 00000000000..caec92f1cd4 --- /dev/null +++ b/apps/server/src/workflow/Services/WorktreeLeaseService.ts @@ -0,0 +1,29 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface Lease { + readonly fenceToken: number; +} + +export interface WorktreeLeaseServiceShape { + readonly acquire: ( + worktreeRef: string, + ownerKind: "step" | "user", + ownerId: string, + ) => Effect.Effect<Lease, WorkflowEventStoreError>; + readonly release: ( + worktreeRef: string, + fenceToken: number, + ) => Effect.Effect<void, WorkflowEventStoreError>; + readonly isValid: ( + worktreeRef: string, + fenceToken: number, + ) => Effect.Effect<boolean, WorkflowEventStoreError>; +} + +export class WorktreeLeaseService extends Context.Service< + WorktreeLeaseService, + WorktreeLeaseServiceShape +>()("t3/workflow/Services/WorktreeLeaseService") {} diff --git a/apps/server/src/workflow/Services/WorktreePort.ts b/apps/server/src/workflow/Services/WorktreePort.ts new file mode 100644 index 00000000000..20cedc9622c --- /dev/null +++ b/apps/server/src/workflow/Services/WorktreePort.ts @@ -0,0 +1,24 @@ +import type { TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorktreeHandle { + readonly repoRoot: string; + readonly worktreeRef: string; + readonly path: string; + // Project identity for services that must resolve the project exactly + // (path matching breaks under canonicalization, e.g. /tmp vs /private/tmp). + readonly projectId?: string; +} + +export interface WorktreePortShape { + readonly ensureWorktree: ( + ticketId: TicketId, + ) => Effect.Effect<WorktreeHandle, WorkflowEventStoreError>; +} + +export class WorktreePort extends Context.Service<WorktreePort, WorktreePortShape>()( + "t3/workflow/Services/WorktreePort", +) {} diff --git a/apps/server/src/workflow/WorkflowEngineLive.test.ts b/apps/server/src/workflow/WorkflowEngineLive.test.ts new file mode 100644 index 00000000000..b3fa35aafc1 --- /dev/null +++ b/apps/server/src/workflow/WorkflowEngineLive.test.ts @@ -0,0 +1,70 @@ +import { assert, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { makeStubStepExecutor } from "./Layers/StubStepExecutor.ts"; +import { BoardRegistry } from "./Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "./Services/ScriptCancelRegistry.ts"; +import { WorkflowEngine } from "./Services/WorkflowEngine.ts"; +import { WorkflowReadModel } from "./Services/WorkflowReadModel.ts"; +import { WorkflowEngineCoreLive } from "./WorkflowEngineLive.ts"; + +const definition = { + name: "wf", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }], +}; + +let cryptoByte = 0; +const TestCrypto = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => { + const bytes = new Uint8Array(size); + bytes.fill(cryptoByte); + cryptoByte = (cryptoByte + 1) % 256; + return bytes; + }, + digest: (_algorithm, data) => Effect.succeed(data), + }), +); + +const layer = it.layer( + WorkflowEngineCoreLive.pipe( + Layer.provideMerge(makeStubStepExecutor({ default: { _tag: "completed" } })), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(TestCrypto), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowEngineCoreLive", (it) => { + it.effect("composes the engine core with an injected StepExecutor", () => + Effect.gen(function* () { + cryptoByte = 0; + const registry = yield* BoardRegistry; + yield* registry.register("b-live" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-live" as never, + title: "Live layer", + initialLane: "backlog" as never, + }); + const detail = yield* read.getTicketDetail(ticketId); + + assert.equal(detail?.ticket.title, "Live layer"); + assert.equal(detail?.ticket.currentLaneKey, "backlog"); + }), + ); +}); diff --git a/apps/server/src/workflow/WorkflowEngineLive.ts b/apps/server/src/workflow/WorkflowEngineLive.ts new file mode 100644 index 00000000000..5dfb5901e34 --- /dev/null +++ b/apps/server/src/workflow/WorkflowEngineLive.ts @@ -0,0 +1,23 @@ +import * as Layer from "effect/Layer"; + +import { ApprovalGateLive } from "./Layers/ApprovalGate.ts"; +import { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./Layers/PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./Layers/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngineLayer } from "./Layers/WorkflowEngine.ts"; +import { WorkflowEventCommitterLive } from "./Layers/WorkflowEventCommitter.ts"; +import { WorkflowIdsLive } from "./Layers/WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./Layers/WorkflowRoutingContextBuilder.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; + +export const WorkflowEngineCoreLive = WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(ApprovalGateLive), + // BoardRegistry is also re-exported by WorkflowFoundationLive below; same module export → Effect memoizes one instance. + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(WorkflowIdsLive), + Layer.provideMerge(WorkflowFoundationLive), +); diff --git a/apps/server/src/workflow/WorkflowFoundationLive.test.ts b/apps/server/src/workflow/WorkflowFoundationLive.test.ts new file mode 100644 index 00000000000..a06fdaa1807 --- /dev/null +++ b/apps/server/src/workflow/WorkflowFoundationLive.test.ts @@ -0,0 +1,74 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "./Services/BoardRegistry.ts"; +import { WorkflowEventStore } from "./Services/WorkflowEventStore.ts"; +import { WorkflowProjectionPipeline } from "./Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "./Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; + +// WorkflowFoundationLive already provides AND re-exports BoardRegistry (the read +// model depends on it), so the foundation stack alone satisfies BoardRegistry. +const layer = it.layer( + WorkflowFoundationLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowFoundationLive", (it) => { + it.effect("provides event store and read model together", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const read = yield* WorkflowReadModel; + assert.isDefined(store.append); + assert.isDefined(read.getBoard); + }), + ); + + it.effect("read model resolves lane actions from the same registry boards register into", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const pipeline = yield* WorkflowProjectionPipeline; + const read = yield* WorkflowReadModel; + + // Register the definition via BoardRegistry, then read it back through the + // read model — if these were separate registry instances, getTicketDetail + // would see no definition and fall back to an empty actions array. + yield* registry.register("b-shared-registry" as never, { + name: "Shared registry board", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + actions: [{ label: "Approve", to: "done", hint: "Ship it" }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "shared-registry-a" as never, + ticketId: "t-shared-registry" as never, + streamVersion: 0, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + payload: { + boardId: "b-shared-registry" as never, + title: "Shared" as never, + laneKey: "review" as never, + }, + }); + + const detail = yield* read.getTicketDetail("t-shared-registry" as never); + assert.deepEqual(detail?.ticket.currentLane, { + key: "review", + name: "Review", + actions: [{ label: "Approve", to: "done", hint: "Ship it" }], + }); + }), + ); +}); diff --git a/apps/server/src/workflow/WorkflowFoundationLive.ts b/apps/server/src/workflow/WorkflowFoundationLive.ts new file mode 100644 index 00000000000..079aa4fd4b9 --- /dev/null +++ b/apps/server/src/workflow/WorkflowFoundationLive.ts @@ -0,0 +1,24 @@ +import * as Layer from "effect/Layer"; + +import { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; +import { StepOutputHandoffReaderLive } from "./Layers/StepOutputHandoffReader.ts"; +import { WorkflowAgentSessionStoreLive } from "./Layers/WorkflowAgentSessionStore.ts"; +import { WorkflowEventStoreLive } from "./Layers/WorkflowEventStore.ts"; +import { WorkflowBoardVersionStoreLive } from "./Layers/WorkflowBoardVersionStore.ts"; +import { WorkflowProjectionPipelineLive } from "./Layers/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModelLive } from "./Layers/WorkflowReadModel.ts"; + +// WorkflowReadModelLive resolves current-lane actions from board definitions, +// so it requires BoardRegistry. We provideMerge BoardRegistryLive here: this +// both satisfies the read model's requirement and re-exports BoardRegistry as +// part of the foundation, so the registry boards are registered into is the +// same instance the read model reads. Effect memoizes layers by reference, so +// consumers that also reference BoardRegistryLive share this one instance. +export const WorkflowFoundationLive = Layer.mergeAll( + WorkflowEventStoreLive, + WorkflowBoardVersionStoreLive, + WorkflowAgentSessionStoreLive, + StepOutputHandoffReaderLive, + WorkflowProjectionPipelineLive, + WorkflowReadModelLive, +).pipe(Layer.provideMerge(BoardRegistryLive)); diff --git a/apps/server/src/workflow/WorkflowRuntimeLive.ts b/apps/server/src/workflow/WorkflowRuntimeLive.ts new file mode 100644 index 00000000000..97469dc40d4 --- /dev/null +++ b/apps/server/src/workflow/WorkflowRuntimeLive.ts @@ -0,0 +1,240 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { FetchHttpClient } from "effect/unstable/http"; + +import { ServerConfig } from "../config.ts"; + +import { ProjectionThreadActivityRepositoryLive } from "../persistence/Layers/ProjectionThreadActivities.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepositoryLive } from "../persistence/Layers/ProjectionTurns.ts"; +import { ApprovalGateLive } from "./Layers/ApprovalGate.ts"; +import { BoardDiscoveryLive } from "./Layers/BoardDiscovery.ts"; +import { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; +import { CapturedStepOutputReaderLive } from "./Layers/CapturedStepOutputReader.ts"; +import { DurableApprovalResumeLive } from "./Layers/DurableApprovalResume.ts"; +import { ScriptCancelRegistryLive } from "./Layers/ScriptCancelRegistry.ts"; +import { ScriptCommandRunnerLive } from "./Layers/ScriptCommandRunner.ts"; +import { ScriptStepExecutorLive } from "./Layers/ScriptStepExecutor.ts"; +import { + ProviderDispatchOutboxLive, + ProviderTurnPortLive, +} from "./Layers/ProviderDispatchOutbox.ts"; +import { ProjectScriptTrustLive } from "./Layers/ProjectScriptTrust.ts"; +import { ProviderResponsePortLive } from "./Layers/ProviderResponsePort.ts"; +import { ProjectWorkspaceResolverLive } from "./Layers/ProjectWorkspaceResolver.ts"; +import { PredicateEvaluatorLive } from "./Layers/PredicateEvaluator.ts"; +import { RealStepExecutorLive, WorktreePortLive } from "./Layers/RealStepExecutor.ts"; +import { SetupRunServiceLive, SetupTerminalPortLive } from "./Layers/SetupRunService.ts"; +import { StepOutputHandoffReaderLive } from "./Layers/StepOutputHandoffReader.ts"; +import { StepUsageReaderLive } from "./Layers/StepUsageReader.ts"; +import { TicketCheckpointServiceLive } from "./Layers/TicketCheckpointService.ts"; +import { MergeGitPortLive, TicketMergeServiceLive } from "./Layers/TicketMergeService.ts"; +import { GitHubPortLive } from "./Layers/GitHubPort.ts"; +import { TicketPullRequestServiceLive } from "./Layers/TicketPullRequestService.ts"; +import { WorkflowThreadJanitorLive } from "./Layers/WorkflowThreadJanitor.ts"; +import { WorkflowWebhookLive } from "./Layers/WorkflowWebhook.ts"; +import { WorkflowWorktreeJanitorLive } from "./Layers/WorkflowWorktreeJanitor.ts"; +import { TicketDiffQueryLive, WorktreeDiffPortLive } from "./Layers/TicketDiffQuery.ts"; +import { TurnProjectionPortLive, TurnStateReaderLive } from "./Layers/TurnStateReader.ts"; +import { WorkflowBoardEventsLive } from "./Layers/WorkflowBoardEvents.ts"; +import { WorkflowBoardNotificationDispatcherLive } from "./Layers/WorkflowBoardNotificationDispatcher.ts"; +import { WorkflowBoardNotificationRelayLive } from "./Layers/WorkflowBoardNotificationRelay.ts"; +import { WorkflowBoardSaveLocksLive } from "./Layers/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngineLayer } from "./Layers/WorkflowEngine.ts"; +import { WorkflowEventCommitterLive } from "./Layers/WorkflowEventCommitter.ts"; +import { + WorkflowFileLoaderLive, + WorkflowFilePortLive, + WorkflowProviderInstancePortLive, +} from "./Layers/WorkflowFileLoader.ts"; +import { WorkflowGitHubPollerLive } from "./Layers/WorkflowGitHubPoller.ts"; +import { WorkflowIdsLive } from "./Layers/WorkflowIds.ts"; +import { WorkflowIntakeLive } from "./Layers/WorkflowIntake.ts"; +import { WorkflowRecoveryLive } from "./Layers/WorkflowRecovery.ts"; +import { WorkflowRoutingContextBuilderLive } from "./Layers/WorkflowRoutingContextBuilder.ts"; +import { WorkflowTerminalRetentionSweeperLive } from "./Layers/WorkflowTerminalRetentionSweeper.ts"; +import { AsanaProviderLive } from "./Layers/AsanaProvider.ts"; +import { GithubIssuesProviderLive } from "./Layers/GithubIssuesProvider.ts"; +import { JiraProviderLive } from "./Layers/JiraProvider.ts"; +import { WorkSourceConnectionStoreLive } from "./Layers/WorkSourceConnectionStore.ts"; +import { WorkSourceProviderRegistryLive } from "./Layers/WorkSourceProviderRegistry.ts"; +import { WorkflowSourceCommitterLive } from "./Layers/WorkflowSourceCommitter.ts"; +import { WorkflowSourceSyncerLive } from "./Layers/WorkflowSourceSyncer.ts"; +import { WorkflowOutboundConnectionStoreLive } from "./Layers/WorkflowOutboundConnectionStore.ts"; +import { makeWorkflowOutboundDispatcherLive } from "./Layers/WorkflowOutboundDispatcher.ts"; +import { WorktreeLeaseServiceLive } from "./Layers/WorktreeLeaseService.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; + +// PR steps run through the GitHub port. GitHubPortLive leaks GitHubCli + +// SourceControlProviderRegistry as runtime requirements (mirrors how +// MergeGitPort/WorktreePort leak the git driver stack), satisfied by the +// server's source-control wiring. +const StepExecutionLive = RealStepExecutorLive.pipe( + Layer.provideMerge(TicketMergeServiceLive), + Layer.provideMerge(TicketPullRequestServiceLive), + Layer.provideMerge(GitHubPortLive), +); + +const WorkflowRuntimeCoreBaseLive = Layer.mergeAll( + WorkflowEngineLayer, + WorkflowRecoveryLive.pipe(Layer.provideMerge(WorkflowEngineLayer)), + WorkflowTerminalRetentionSweeperLive.pipe(Layer.provideMerge(WorkflowEngineLayer)), + WorkflowGitHubPollerLive.pipe(Layer.provideMerge(WorkflowEngineLayer)), +).pipe( + Layer.provideMerge(StepExecutionLive), + // Captured/handoff output readers are both SQL-only; merge them into one + // provideMerge to keep this pipe within the 20-argument overload limit. + Layer.provideMerge(Layer.mergeAll(CapturedStepOutputReaderLive, StepOutputHandoffReaderLive)), + Layer.provideMerge(ScriptStepExecutorLive), + Layer.provideMerge(ScriptCommandRunnerLive), + Layer.provideMerge(ScriptCancelRegistryLive), + Layer.provideMerge(ProjectScriptTrustLive), + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge(TurnStateReaderLive), + Layer.provideMerge(SetupRunServiceLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorkflowBoardEventsLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + // BoardRegistry is also re-exported by WorkflowFoundationLive below; same module export → Effect memoizes one instance. + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), +); + +export const WorkflowRuntimeCoreLive = WorkflowRuntimeCoreBaseLive.pipe( + Layer.provideMerge(WorkflowFoundationLive), +); + +export const WorkflowRuntimeLive = WorkflowRuntimeCoreLive.pipe( + Layer.provideMerge(WorkflowIdsLive), + Layer.provideMerge(ProviderTurnPortLive), + Layer.provideMerge(TurnProjectionPortLive), + Layer.provideMerge(SetupTerminalPortLive), + Layer.provideMerge(WorkflowWorktreeJanitorLive), + Layer.provideMerge(WorkflowThreadJanitorLive), + Layer.provideMerge(WorkflowWebhookLive), + Layer.provideMerge( + StepUsageReaderLive.pipe(Layer.provide(ProjectionThreadActivityRepositoryLive)), + ), + Layer.provideMerge(TicketCheckpointServiceLive), + Layer.provideMerge(MergeGitPortLive), + Layer.provideMerge(WorktreePortLive), + Layer.provideMerge(ProviderResponsePortLive), +); + +const WorkflowBoardDiscoverySupportLive = BoardDiscoveryLive.pipe( + Layer.provideMerge(ProjectWorkspaceResolverLive), + Layer.provideMerge(WorkflowFileLoaderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), +); + +export const WorkflowRpcSupportLive = Layer.mergeAll( + WorkflowFileLoaderLive, + TicketDiffQueryLive, + ProjectWorkspaceResolverLive, + WorkflowBoardDiscoverySupportLive, + WorkflowBoardSaveLocksLive, +).pipe( + // BoardRegistry is also re-exported by WorkflowFoundationLive below; same module export → Effect memoizes one instance. + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardEventsLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(WorkflowFilePortLive), + Layer.provideMerge(WorkflowProviderInstancePortLive), + Layer.provideMerge(WorktreeDiffPortLive), +); + +// Board push-notification dispatcher + relay. The relay (HTTP publish client) +// depends on ServerSecretStore + ServerEnvironment + Crypto; the dispatcher +// depends on the relay + WorkflowReadModel + ServerEnvironment + SqlClient. +// WorkflowReadModel is provided inside WorkflowRuntimeLive (via +// WorkflowFoundationLive), so we provideMerge the dispatcher (with the relay +// merged in) over the runtime here; the remaining server-level requirements +// (ServerSecretStore, ServerEnvironment, Crypto, SqlClient) propagate upward +// and are satisfied by the server runtime composition in server.ts. +const WorkflowBoardNotificationLive = WorkflowBoardNotificationDispatcherLive.pipe( + Layer.provideMerge(WorkflowBoardNotificationRelayLive), +); + +// Work-source sync stack ("work arrives by itself"). +// +// The two HTTP providers (GitHub Issues, Asana) require an HttpClient and the +// WorkSourceConnectionStore. We provide FetchHttpClient + the connection store +// to the provider registry so the registry's two provider tags resolve, then +// merge the connection store back out (it is also consumed by the ws.ts +// connection RPCs, so it must be visible at the runtime surface). The committer +// and syncer depend on engine/board-registry/save-locks/sql/ids — all provided +// by WorkflowRuntimeLive below — so those requirements propagate upward and are +// satisfied where WorkSourceLive is merged into the server runtime. Remaining +// server-level deps (SqlClient, ServerSecretStore) propagate to server.ts, the +// same way the notification dispatcher/relay deps do. +// SSRF hardening for Jira: unlike GitHub/Asana (fixed api.github.com / +// app.asana.com hosts), the Jira base URL is user-supplied, so a 3xx from an +// allowed host could otherwise bounce the request to a private/loopback/metadata +// address. Give Jira its own Fetch client with `redirect: "manual"` (mirrors the +// outbound-webhook stack) so a redirect surfaces as a non-2xx status that the +// provider's existing error handling rejects. GitHub/Asana keep the shared +// FetchHttpClient.layer (default redirect behavior) below — unchanged. +// SECURITY CONTROL (not mere config): `redirect: "manual"` is the anti-SSRF +// redirect-pivot guard. Do not relax it to "follow" — that would re-enable the +// bounce-to-internal-host bypass the per-host blocklist alone cannot stop. +const JiraHttpClientLive = FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.RequestInit, { redirect: "manual" })), +); + +const WorkSourceProviderStackLive = WorkSourceProviderRegistryLive.pipe( + Layer.provide(GithubIssuesProviderLive), + Layer.provide(AsanaProviderLive), + Layer.provide(JiraProviderLive.pipe(Layer.provide(JiraHttpClientLive))), + Layer.provide(FetchHttpClient.layer), +); + +const WorkSourceLive = WorkflowSourceSyncerLive.pipe( + // The syncer requires the committer + provider registry; provide (and + // re-export, so both stay visible at the runtime surface) via provideMerge. + Layer.provideMerge(WorkflowSourceCommitterLive), + Layer.provideMerge(WorkSourceProviderStackLive), + Layer.provideMerge(WorkSourceConnectionStoreLive), +); + +// Outbound-webhook stack. The dispatcher drains durable +// `workflow_outbound_delivery` rows and POSTs each rendered payload to its +// connection's target URL. Its `webBaseUrl` (for absolute ticket links in +// Slack buttons) is a plain layer-constructor arg, so we read it from +// ServerConfig here and pass it in — Layer.unwrap defers layer construction +// until ServerConfig is resolvable. The dispatcher requires SqlClient + +// HttpClient + WorkflowOutboundConnectionStore + ServerEnvironment; we provide +// FetchHttpClient + the connection store (and re-export the store via +// provideMerge, because the ws.ts connection RPCs resolve it from the runtime +// surface via Context.getOption). Remaining server-level deps (SqlClient, +// ServerSecretStore, ServerEnvironment) propagate upward to server.ts, the same +// way the notification dispatcher / work-source stack deps do. +const WorkflowOutboundLive = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig; + return makeWorkflowOutboundDispatcherLive({ webBaseUrl: config.webBaseUrl }).pipe( + // SSRF hardening: the outbound POST targets are caller-supplied webhook URLs + // validated up-front by OutboundUrlValidator. `fetch` follows 3xx redirects by + // default, which would let a validated public endpoint bounce the POST to a + // private/loopback/metadata address — defeating the SSRF guard. Provide + // `redirect: "manual"` so a 3xx response is returned as-is (a non-2xx status + // the dispatcher routes to its retryable backoff branch) and never followed. + Layer.provide(FetchHttpClient.layer), + Layer.provide(Layer.succeed(FetchHttpClient.RequestInit, { redirect: "manual" })), + Layer.provideMerge(WorkflowOutboundConnectionStoreLive), + ); + }), +); + +export const WorkflowServerRuntimeLive = WorkflowIntakeLive.pipe( + Layer.provideMerge(WorkflowRpcSupportLive), + Layer.provideMerge(WorkflowBoardNotificationLive), + Layer.provideMerge(WorkSourceLive), + Layer.provideMerge(WorkflowOutboundLive), + Layer.provideMerge(WorkflowRuntimeLive), +); diff --git a/apps/server/src/workflow/agentSessionKey.test.ts b/apps/server/src/workflow/agentSessionKey.test.ts new file mode 100644 index 00000000000..58d383e4c57 --- /dev/null +++ b/apps/server/src/workflow/agentSessionKey.test.ts @@ -0,0 +1,38 @@ +import { assert, describe, it } from "@effect/vitest"; +import { agentKey } from "./agentSessionKey.ts"; + +describe("agentKey", () => { + it("is order-independent over options (sorted by id)", () => { + const a = agentKey("i1", "m1", [ + { id: "effort", value: "high" }, + { id: "tier", value: "x" }, + ]); + const b = agentKey("i1", "m1", [ + { id: "tier", value: "x" }, + { id: "effort", value: "high" }, + ]); + assert.equal(a, b); + }); + + it("differs on instance, model, and option changes", () => { + const base = agentKey("i1", "m1", [{ id: "effort", value: "high" }]); + assert.notEqual(base, agentKey("i2", "m1", [{ id: "effort", value: "high" }])); + assert.notEqual(base, agentKey("i1", "m2", [{ id: "effort", value: "high" }])); + assert.notEqual(base, agentKey("i1", "m1", [{ id: "effort", value: "low" }])); + assert.notEqual(base, agentKey("i1", "m1", [{ id: "tier", value: "high" }])); + assert.notEqual(base, agentKey("i1", "m1", [])); + assert.notEqual(base, agentKey("i1", "m1", undefined)); + }); + + it("is stable for identical input", () => { + const opts = [ + { id: "effort", value: "high" as const }, + { id: "verbose", value: true as const }, + ]; + assert.equal(agentKey("i1", "m1", opts), agentKey("i1", "m1", opts)); + }); + + it("treats missing options the same as empty", () => { + assert.equal(agentKey("i1", "m1", undefined), agentKey("i1", "m1", [])); + }); +}); diff --git a/apps/server/src/workflow/agentSessionKey.ts b/apps/server/src/workflow/agentSessionKey.ts new file mode 100644 index 00000000000..cf304e7c8b2 --- /dev/null +++ b/apps/server/src/workflow/agentSessionKey.ts @@ -0,0 +1,25 @@ +import type { ProviderOptionSelection } from "@t3tools/contracts"; +import { sha256Hex } from "./workflowVersionHash.ts"; + +/** + * Stable, order-independent key identifying an agent's configuration within a + * lane. Used to anchor a per-`(ticket, lane, agentKey)` workflow `threadId` so + * a `continueSession` agent step resumes its own provider session across + * steps/loops. + * + * Order-independent over `options` (canonicalized by sorting on `id`), so two + * agents differing only in the order their options were listed share a key. + * Differs on any change to `instance`, `model`, or any option `id`/`value`. + * Missing/empty options canonicalize identically. + */ +export const agentKey = ( + instance: string, + model: string, + options?: ReadonlyArray<ProviderOptionSelection>, +): string => { + const sortedOptions = [...(options ?? [])] + .map((o) => ({ id: o.id, value: o.value })) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const canonical = JSON.stringify({ instance, model, options: sortedOptions }); + return sha256Hex(canonical); +}; diff --git a/apps/server/src/workflow/blockedHost.test.ts b/apps/server/src/workflow/blockedHost.test.ts new file mode 100644 index 00000000000..eea0183566d --- /dev/null +++ b/apps/server/src/workflow/blockedHost.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { isBlockedHost } from "./blockedHost.ts"; + +describe("isBlockedHost", () => { + it("blocks loopback, link-local, metadata, and private hosts", () => { + const blocked = [ + "localhost", + "127.0.0.1", + "169.254.169.254", + "10.0.0.5", + "192.168.1.1", + "172.16.0.1", + "172.31.255.255", + "::1", + "metadata.google.internal", + // Absolute-FQDN trailing dot (resolves to loopback, must not slip past). + "localhost.", + "127.0.0.1.", + // IPv4-mapped IPv6 — the plain input form… + "::ffff:127.0.0.1", + "::ffff:169.254.169.254", + // …and the exact normalized form Node's URL parser emits (with brackets, + // embedded IPv4 in hex) for `http://[::ffff:169.254.169.254]/`. + "[::ffff:7f00:1]", + "[::ffff:a9fe:a9fe]", + // IPv6 unique-local / link-local LITERALS (contain a colon) stay blocked. + "fc00::1", + "fd00::1", + "fe80::1", + ]; + for (const host of blocked) { + expect(isBlockedHost(host), `${host} should be blocked`).toBe(true); + } + }); + + it("allows ordinary public hosts (including 172.32/8 just outside RFC1918)", () => { + const allowed = [ + "acme.atlassian.net", + "jira.mycompany.com", + "172.32.0.1", + "8.8.8.8", + // DNS names that merely START with fc/fd/fe8.. must NOT be blocked — only + // IPv6 literals (which contain a colon) are. Regression guard for the + // colon-gate on the IPv6 unique-local / link-local checks. + "fdic.gov", + "fd-corp.com", + "fcgroup.com", + "february.example.com", + ]; + for (const host of allowed) { + expect(isBlockedHost(host), `${host} should be allowed`).toBe(false); + } + }); +}); diff --git a/apps/server/src/workflow/blockedHost.ts b/apps/server/src/workflow/blockedHost.ts new file mode 100644 index 00000000000..f07ef1ec29a --- /dev/null +++ b/apps/server/src/workflow/blockedHost.ts @@ -0,0 +1,94 @@ +/** + * blockedHost — literal-blocklist SSRF guard for user-supplied work-source hosts. + * + * Jira is the first work-source provider whose base URL is chosen by the + * connection creator, so the server makes outbound requests to a host that a + * user controls. `isBlockedHost` rejects hostnames that point at the loopback + * interface, link-local/cloud-metadata ranges, or RFC1918 private networks, so + * a malicious base URL cannot pivot the server into internal infrastructure. + * + * ### KNOWN LIMITATION (proportionate mitigation, by design) + * This is a LITERAL, string-based check on the hostname only — it performs NO + * DNS resolution. Therefore it does NOT catch: + * - DNS rebinding (a public name that resolves to 127.0.0.1 at request time) + * - any public hostname that simply has a private/loopback A or AAAA record + * Note: numeric-encoded IPv4 literals (`http://2130706433/`, `0x7f000001`, + * `0177.0.0.1`) are NOT a gap — Node's WHATWG URL parser normalizes them to + * dotted-decimal (`127.0.0.1`) before `.hostname`, so the prefix checks below + * catch them. + * Full DNS-resolution hardening (resolve, then re-check every resolved address, + * and pin the connection to that address) is a deliberate follow-up — the owner + * chose this proportionate level for v1. + */ + +/** Returns true when the hostname must NOT be used for an outbound request. */ +export function isBlockedHost(hostname: string): boolean { + // Normalize: lowercase, strip surrounding IPv6 brackets, and strip an + // absolute-FQDN trailing dot (`localhost.` / `127.0.0.1.` resolve to loopback + // but would otherwise slip past the literal equality / prefix checks below). + const host = hostname + .trim() + .toLowerCase() + .replace(/^\[/u, "") + .replace(/\]$/u, "") + .replace(/\.$/u, ""); + + if (host === "") return true; + + // localhost and any *.localhost label. + if (host === "localhost" || host.endsWith(".localhost")) return true; + + // Unspecified / loopback literals. + if (host === "0.0.0.0" || host === "::" || host === "::1") return true; + + // IPv4-mapped / IPv4-compatible IPv6 (::ffff:x.x.x.x, ::x.x.x.x). These route + // to the embedded IPv4 — including private/loopback/metadata ranges — on + // dual-stack hosts. Node renders the embedded IPv4 in hex (e.g. + // ::ffff:a9fe:a9fe for 169.254.169.254), so a per-range numeric check would + // miss it; block every ::-leading address instead. No legitimate public Jira + // uses one — every ::-prefixed address is non-global. + if (host.startsWith("::")) return true; + + // Cloud metadata service names. + if ( + host === "metadata.google.internal" || + host === "metadata.goog" || + host === "metadata" + ) { + return true; + } + + // IPv4 loopback (127.0.0.0/8). + if (host.startsWith("127.")) return true; + + // IPv4 link-local / cloud metadata (169.254.0.0/16). + if (host.startsWith("169.254.")) return true; + + // RFC1918 private ranges. + if (host.startsWith("10.") || host.startsWith("192.168.")) return true; + // 172.16.0.0/12 → second octet 16..31. + const match172 = /^172\.(\d{1,3})\./u.exec(host); + if (match172) { + const second = Number(match172[1]); + if (second >= 16 && second <= 31) return true; + } + + // IPv6 unique-local (fc00::/7 → fc*/fd*) and link-local (fe80::/10 → fe8*/fe9*/fea*/feb*). + // Gate on ":" so we only match IPv6 literals — an IPv6 address always contains a + // colon, a DNS name never does. Without this gate a legitimate hostname that + // merely starts with these letters (e.g. "fdic.gov", "fd-corp.com", + // "fcgroup.com", "february.example.com") would be wrongly blocked. + if (host.includes(":")) { + if (host.startsWith("fc") || host.startsWith("fd")) return true; + if ( + host.startsWith("fe8") || + host.startsWith("fe9") || + host.startsWith("fea") || + host.startsWith("feb") + ) { + return true; + } + } + + return false; +} diff --git a/apps/server/src/workflow/boardDeletion.test.ts b/apps/server/src/workflow/boardDeletion.test.ts new file mode 100644 index 00000000000..71b99f061a9 --- /dev/null +++ b/apps/server/src/workflow/boardDeletion.test.ts @@ -0,0 +1,531 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import type { ProviderServiceShape } from "../provider/Services/ProviderService.ts"; +import { BoardRegistry } from "./Services/BoardRegistry.ts"; +import type { WorkflowAgentSessionRow } from "./Services/WorkflowAgentSessionStore.ts"; +import { WorkflowBoardVersionStore } from "./Services/WorkflowBoardVersionStore.ts"; +import { WorkflowEventStore } from "./Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "./Services/WorkflowReadModel.ts"; +import { + deleteWorkflowBoardOwnedState, + deleteWorkflowBoardTicketOwnedState, +} from "./boardDeletion.ts"; +import { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; +import { WorkflowBoardVersionStoreLive } from "./Layers/WorkflowBoardVersionStore.ts"; +import { WorkflowEventStoreLive } from "./Layers/WorkflowEventStore.ts"; +import { WorkflowReadModelLive } from "./Layers/WorkflowReadModel.ts"; + +const makeAgentSessionRow = (threadId: string): WorkflowAgentSessionRow => + ({ + ticketId: "ticket-x" as never, + laneKey: "done" as never, + agentKey: "agent-a", + threadId, + createdAt: "2026-06-08T00:00:00.000Z", + lastUsedAt: "2026-06-08T00:00:00.000Z", + }) satisfies WorkflowAgentSessionRow; + +// The cascade only needs `stopSession`, but the dep is typed as a Pick of the +// full provider shape — keep the unused members `die`ing so a mis-wire is loud. +const makeStopOnlyProvider = ( + onStop: (threadId: string) => Effect.Effect<void>, +): Pick<ProviderServiceShape, "stopSession"> => ({ + stopSession: (input) => onStop(input.threadId as string), +}); + +const deletionLayer = Layer.mergeAll( + WorkflowEventStoreLive, + WorkflowReadModelLive, + WorkflowBoardVersionStoreLive, +).pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), +); + +const seedTicketOwnedRows = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const store = yield* WorkflowEventStore; + const now = "2026-06-08T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + ${ticketId}, + 'board-ticket-cascade', + ${ticketId}, + 'done', + 'done', + ${now}, + ${now} + ) + `; + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, + ticket_id, + lane_key, + lane_entry_token, + status, + started_at + ) + VALUES (${`pipeline-${ticketId}`}, ${ticketId}, 'done', ${`token-${ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES (${`step-${ticketId}`}, ${`pipeline-${ticketId}`}, ${ticketId}, 'cleanup', 'script', 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_script_run ( + script_run_id, + step_run_id, + ticket_id, + script_thread_id, + terminal_id, + status, + started_at + ) + VALUES (${`script-${ticketId}`}, ${`step-${ticketId}`}, ${ticketId}, ${`thread-${ticketId}`}, ${`terminal-${ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES (${`dispatch-${ticketId}`}, ${ticketId}, ${`step-${ticketId}`}, ${`thread-${ticketId}`}, 'codex', 'gpt-5.5', 'cleanup', ${`/tmp/${ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES (${`setup-${ticketId}`}, ${ticketId}, ${`worktree-${ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES (${`message-${ticketId}`}, ${ticketId}, ${`step-${ticketId}`}, 'user', 'cleanup', '[]', ${now}) + `; + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, pr_state, updated_at + ) + VALUES ( + ${ticketId}, 1, ${`https://github.com/owner/repo/pull/1`}, + 'ft/branch', 'origin', 'owner/repo', 'open', ${now} + ) + `; + yield* sql` + INSERT INTO workflow_pr_observation ( + observation_id, ticket_id, dedup_key, event_name, payload_json, status, created_at + ) + VALUES ( + ${`obs-${ticketId}`}, ${ticketId}, ${`dedup-${ticketId}`}, + 'ci_check', '{}', 'pending', ${now} + ) + `; + // sequence is UNIQUE across the table; derive a stable per-ticket integer. + const outboxSequence = Array.from(ticketId).reduce((acc, char) => acc + char.charCodeAt(0), 0); + yield* sql` + INSERT INTO workflow_notification_outbox ( + outbox_id, ticket_id, board_id, sequence, status, created_at + ) + VALUES ( + ${`outbox-${ticketId}`}, ${ticketId}, 'board-ticket-cascade', + ${outboxSequence}, 'waiting_on_user', ${now} + ) + `; + yield* store.append({ + type: "TicketCreated", + eventId: `event-${ticketId}` as never, + ticketId: ticketId as never, + occurredAt: now as never, + payload: { + boardId: "board-ticket-cascade" as never, + title: ticketId as never, + laneKey: "done" as never, + }, + }); + }); + +const ticketOwnedRowCount = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_ticket WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM projection_pipeline_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM projection_step_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_script_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_dispatch_outbox WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_setup_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM projection_ticket_message WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_events WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_pr_state WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_pr_observation WHERE ticket_id = ${ticketId} + `; + return rows.reduce((total, row) => total + row.count, 0); + }); + +it.effect("deletes one ticket under the board save lock after cancelling active work", () => + Effect.gen(function* () { + const calls = yield* Ref.make<ReadonlyArray<string>>([]); + const sql = yield* SqlClient.SqlClient; + const record = (call: string) => Ref.update(calls, (current) => [...current, call]); + + yield* deleteWorkflowBoardTicketOwnedState( + { + saveLocks: { + withSaveLock: (boardId, effect) => + Effect.gen(function* () { + yield* record(`lock:${boardId}:enter`); + const result = yield* effect; + yield* record(`lock:${boardId}:exit`); + return result; + }), + }, + engine: { + cancelTicketPipelines: (ticketId) => record(`cancel:${ticketId}`), + }, + eventStore: { + deleteForTicket: (ticketId) => record(`events:${ticketId}`), + }, + readModel: { + deleteTicketState: (ticketId) => record(`read:${ticketId}`), + }, + sql, + }, + "board-ticket-cascade" as never, + "ticket-cascade" as never, + ); + + assert.deepEqual(yield* Ref.get(calls), [ + "lock:board-ticket-cascade:enter", + "cancel:ticket-cascade", + "events:ticket-cascade", + "read:ticket-cascade", + "lock:board-ticket-cascade:exit", + ]); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); + +it.effect("collects hidden dispatch threads before the cascade and deletes them after", () => + Effect.gen(function* () { + const calls = yield* Ref.make<ReadonlyArray<string>>([]); + const sql = yield* SqlClient.SqlClient; + const record = (call: string) => Ref.update(calls, (current) => [...current, call]); + + yield* deleteWorkflowBoardTicketOwnedState( + { + saveLocks: { + withSaveLock: (_boardId, effect) => effect, + }, + engine: { + cancelTicketPipelines: () => Effect.void, + }, + eventStore: { + deleteForTicket: () => record("cascade:events"), + }, + readModel: { + deleteTicketState: () => record("cascade:read"), + }, + sql, + threadJanitor: { + collectTicketThreads: (ticketId) => + record(`collect:${ticketId}`).pipe( + Effect.as(["thread-a", "thread-b"] as ReadonlyArray<string>), + ), + deleteThreads: (threadIds) => record(`delete:${threadIds.join("+")}`), + }, + }, + "board-ticket-cascade" as never, + "ticket-threads" as never, + ); + + assert.deepEqual(yield* Ref.get(calls), [ + "collect:ticket-threads", + "cascade:events", + "cascade:read", + "delete:thread-a+thread-b", + ]); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); + +it.effect("rolls back events and read-model rows when the ticket cascade fails", () => + Effect.gen(function* () { + const eventStore = yield* WorkflowEventStore; + const readModel = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const ticketId = "ticket-cascade-rollback"; + + yield* seedTicketOwnedRows(ticketId); + yield* sql` + CREATE TRIGGER fail_ticket_cascade_step_delete + BEFORE DELETE ON projection_step_run + WHEN OLD.ticket_id = 'ticket-cascade-rollback' + BEGIN + SELECT RAISE(FAIL, 'simulated ticket cascade failure'); + END + `; + + const result = yield* Effect.exit( + deleteWorkflowBoardTicketOwnedState( + { + saveLocks: { + withSaveLock: (_boardId, effect) => effect, + }, + engine: { + cancelTicketPipelines: () => Effect.void, + }, + eventStore, + readModel, + sql, + }, + "board-ticket-cascade" as never, + ticketId as never, + ), + ); + + assert.equal(result._tag, "Failure"); + assert.equal(yield* ticketOwnedRowCount(ticketId), 10); + }).pipe(Effect.provide(deletionLayer)), +); + +it.effect( + "board deletion cascades into workflow_pr_state and workflow_pr_observation, and workflow_notification_outbox", + () => + Effect.gen(function* () { + const eventStore = yield* WorkflowEventStore; + const readModel = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const ticketId = "ticket-pr-cascade"; + + yield* seedTicketOwnedRows(ticketId); + + yield* deleteWorkflowBoardTicketOwnedState( + { + saveLocks: { + withSaveLock: (_boardId, effect) => effect, + }, + engine: { + cancelTicketPipelines: () => Effect.void, + }, + eventStore, + readModel, + sql, + }, + "board-ticket-cascade" as never, + ticketId as never, + ); + + const prStateCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_pr_state WHERE ticket_id = ${ticketId} + `; + const prObsCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_pr_observation WHERE ticket_id = ${ticketId} + `; + const outboxCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_notification_outbox WHERE ticket_id = ${ticketId} + `; + assert.equal(prStateCount[0]?.count, 0); + assert.equal(prObsCount[0]?.count, 0); + assert.equal(outboxCount[0]?.count, 0); + }).pipe(Effect.provide(deletionLayer)), +); + +it.effect( + "board deletion collects threads first, runs DB cascade in a transaction, then cleans up", + () => + Effect.gen(function* () { + const calls = yield* Ref.make<ReadonlyArray<string>>([]); + const realSql = yield* SqlClient.SqlClient; + const record = (call: string) => Ref.update(calls, (current) => [...current, call]); + + yield* deleteWorkflowBoardOwnedState( + { + boardRegistry: { unregister: (boardId) => record(`unregister:${boardId}`) }, + engine: { cancelBoardPipelines: (boardId) => record(`cancel:${boardId}`) }, + eventStore: { deleteForBoard: () => record("db:events") }, + readModel: { + deleteBoardTicketState: () => record("db:ticketState"), + deleteBoard: () => record("db:board"), + }, + versionStore: { deleteForBoard: () => record("db:versions") }, + webhook: { deleteForBoard: () => record("db:webhook") }, + // Wrap the inner cascade so the test can assert all DB deletes ran + // inside one transaction boundary. + sql: { + withTransaction: (effect) => + record("tx:begin").pipe( + Effect.andThen(effect), + Effect.tap(() => record("tx:commit")), + ) as never, + }, + threadJanitor: { + collectBoardThreads: (boardId) => + record(`collect:${boardId}`).pipe( + Effect.as(["thread-a", "thread-b"] as ReadonlyArray<string>), + ), + deleteThreads: (threadIds) => record(`deleteThreads:${threadIds.join("+")}`), + }, + }, + "board-cascade" as never, + ); + + assert.deepEqual(yield* Ref.get(calls), [ + "collect:board-cascade", + "cancel:board-cascade", + "tx:begin", + "db:webhook", + "db:versions", + "db:events", + "db:ticketState", + "db:board", + "tx:commit", + "unregister:board-cascade", + "deleteThreads:thread-a+thread-b", + ]); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); + +it.effect( + "board deletion lists stored agent sessions before the cascade, deletes them, and stops their threads", + () => + Effect.gen(function* () { + const calls = yield* Ref.make<ReadonlyArray<string>>([]); + const realSql = yield* SqlClient.SqlClient; + const record = (call: string) => Ref.update(calls, (current) => [...current, call]); + + yield* deleteWorkflowBoardOwnedState( + { + boardRegistry: { unregister: (boardId) => record(`unregister:${boardId}`) }, + engine: { cancelBoardPipelines: (boardId) => record(`cancel:${boardId}`) }, + eventStore: { deleteForBoard: () => record("db:events") }, + readModel: { + deleteBoardTicketState: () => record("db:ticketState"), + deleteBoard: () => record("db:board"), + }, + versionStore: { deleteForBoard: () => record("db:versions") }, + sql: { + withTransaction: (effect) => + record("tx:begin").pipe( + Effect.andThen(effect), + Effect.tap(() => record("tx:commit")), + ) as never, + }, + // The store join needs projection_ticket, so the rows must be listed + // before the cascade and deleted inside the tx (before deleteBoardTicketState + // clears projection_ticket); stopSession runs after the commit. + agentSessions: { + listByBoard: (boardId) => + record(`agent:list:${boardId}`).pipe( + Effect.as([ + makeAgentSessionRow("agent-thread-a"), + makeAgentSessionRow("agent-thread-b"), + ]), + ), + deleteByBoard: (boardId) => record(`agent:delete:${boardId}`), + }, + provider: makeStopOnlyProvider((threadId) => record(`agent:stop:${threadId}`)), + }, + "board-agent-cascade" as never, + ); + + assert.deepEqual(yield* Ref.get(calls), [ + "agent:list:board-agent-cascade", + "cancel:board-agent-cascade", + "tx:begin", + "db:versions", + "agent:delete:board-agent-cascade", + "db:events", + "db:ticketState", + "db:board", + "tx:commit", + "unregister:board-agent-cascade", + "agent:stop:agent-thread-a", + "agent:stop:agent-thread-b", + ]); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); + +it.effect("rolls back the board DB cascade when a delete fails mid-transaction", () => + Effect.gen(function* () { + const eventStore = yield* WorkflowEventStore; + const readModel = yield* WorkflowReadModel; + const versionStore = yield* WorkflowBoardVersionStore; + const registry = yield* BoardRegistry; + const sql = yield* SqlClient.SqlClient; + const ticketId = "ticket-board-rollback"; + + yield* seedTicketOwnedRows(ticketId); + // Fail mid-cascade (the projection_ticket delete is part of + // deleteBoardTicketState) so the transaction must roll back the already-run + // event-store and version deletes. + yield* sql` + CREATE TRIGGER fail_board_cascade_ticket_delete + BEFORE DELETE ON projection_ticket + WHEN OLD.ticket_id = 'ticket-board-rollback' + BEGIN + SELECT RAISE(FAIL, 'simulated board cascade failure'); + END + `; + + const result = yield* Effect.exit( + deleteWorkflowBoardOwnedState( + { + boardRegistry: registry, + engine: { cancelBoardPipelines: () => Effect.void }, + eventStore, + readModel, + versionStore, + sql, + }, + "board-ticket-cascade" as never, + ), + ); + + assert.equal(result._tag, "Failure"); + // Every owned row survives: the failing delete rolled the whole tx back. + assert.equal(yield* ticketOwnedRowCount(ticketId), 10); + }).pipe(Effect.provide(deletionLayer)), +); diff --git a/apps/server/src/workflow/boardDeletion.ts b/apps/server/src/workflow/boardDeletion.ts new file mode 100644 index 00000000000..98cb1e68d40 --- /dev/null +++ b/apps/server/src/workflow/boardDeletion.ts @@ -0,0 +1,179 @@ +import type { BoardId, ThreadId, TicketId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import type { ProviderServiceShape } from "../provider/Services/ProviderService.ts"; +import type { BoardRegistryShape } from "./Services/BoardRegistry.ts"; +import type { WorkflowAgentSessionStoreShape } from "./Services/WorkflowAgentSessionStore.ts"; +import type { WorkflowBoardSaveLocksShape } from "./Services/WorkflowBoardSaveLocks.ts"; +import type { WorkflowBoardVersionStoreShape } from "./Services/WorkflowBoardVersionStore.ts"; +import type { WorkflowEngineShape } from "./Services/WorkflowEngine.ts"; +import type { WorkflowEventStoreShape } from "./Services/WorkflowEventStore.ts"; +import type { WorkflowReadModelShape } from "./Services/WorkflowReadModel.ts"; +import type { WorkflowThreadJanitorShape } from "./Services/WorkflowThreadJanitor.ts"; +import type { WorkflowWebhookShape } from "./Services/WorkflowWebhook.ts"; +import type { WorkflowWorktreeJanitorShape } from "./Services/WorkflowWorktreeJanitor.ts"; + +export interface WorkflowBoardOwnedStateDeletionDeps { + readonly boardRegistry: Pick<BoardRegistryShape, "unregister">; + readonly engine: Pick<WorkflowEngineShape, "cancelBoardPipelines">; + readonly eventStore: Pick<WorkflowEventStoreShape, "deleteForBoard">; + readonly readModel: Pick<WorkflowReadModelShape, "deleteBoard" | "deleteBoardTicketState">; + readonly versionStore: Pick<WorkflowBoardVersionStoreShape, "deleteForBoard">; + readonly sql: Pick<SqlClient.SqlClient, "withTransaction">; + readonly worktreeJanitor?: Pick<WorkflowWorktreeJanitorShape, "collectBoardPlan" | "run">; + readonly threadJanitor?: Pick< + WorkflowThreadJanitorShape, + "collectBoardThreads" | "deleteThreads" + >; + readonly webhook?: Pick<WorkflowWebhookShape, "deleteForBoard">; + // Per-agent session teardown: both queries join projection_ticket, so the + // threads must be listed BEFORE the cascade and the rows deleted INSIDE the + // transaction (before deleteBoardTicketState clears projection_ticket). + // `stopSession` is a live side effect that runs after the commit, best-effort. + readonly agentSessions?: Pick<WorkflowAgentSessionStoreShape, "listByBoard" | "deleteByBoard">; + readonly provider?: Pick<ProviderServiceShape, "stopSession">; +} + +export interface WorkflowBoardTicketStateDeletionDeps { + readonly saveLocks: Pick<WorkflowBoardSaveLocksShape, "withSaveLock">; + readonly engine: Pick<WorkflowEngineShape, "cancelTicketPipelines">; + readonly eventStore: Pick<WorkflowEventStoreShape, "deleteForTicket">; + readonly readModel: Pick<WorkflowReadModelShape, "deleteTicketState">; + readonly sql: Pick<SqlClient.SqlClient, "withTransaction">; + readonly worktreeJanitor?: Pick<WorkflowWorktreeJanitorShape, "collectTicketPlan" | "run">; + readonly threadJanitor?: Pick< + WorkflowThreadJanitorShape, + "collectTicketThreads" | "deleteThreads" + >; + // Per-agent session teardown for the per-ticket cascade (A8): used by the + // terminal-retention sweep so a swept terminal ticket's stored agent sessions + // are dropped and their live provider sessions stopped (best-effort). + readonly agentSessions?: Pick<WorkflowAgentSessionStoreShape, "listByTicket" | "deleteByTicket">; + readonly provider?: Pick<ProviderServiceShape, "stopSession">; +} + +const noCleanup = Effect.succeed(null); +const noThreads = Effect.succeed([] as ReadonlyArray<string>); + +export const deleteWorkflowBoardOwnedState = ( + deps: WorkflowBoardOwnedStateDeletionDeps, + boardId: BoardId, +) => + Effect.gen(function* () { + // Collected before the cascade — the repo root and ticket list are only + // resolvable while the projections still exist. + const cleanupPlan = yield* deps.worktreeJanitor?.collectBoardPlan(boardId) ?? noCleanup; + const threadIds = yield* deps.threadJanitor?.collectBoardThreads(boardId) ?? noThreads; + // Collected here because listByBoard joins projection_ticket, which the + // cascade below deletes. Best-effort: a failure must not block the cascade. + const agentSessionRows: ReadonlyArray<{ readonly threadId: string }> = + deps.agentSessions === undefined + ? [] + : yield* deps.agentSessions.listByBoard(boardId).pipe(Effect.orElseSucceed(() => [])); + yield* deps.engine.cancelBoardPipelines(boardId); + // The DB cascade runs in one transaction so a mid-cascade SQL/IO failure + // (or SQLITE_BUSY) rolls back instead of leaving orphaned event-store rows + // whose backing projection_ticket rows are gone — mirroring the per-ticket + // path. eventStore.deleteForBoard must precede deleteBoardTicketState, which + // clears the projection_ticket rows the IN-subquery resolves against. + yield* deps.sql.withTransaction( + Effect.gen(function* () { + yield* deps.webhook?.deleteForBoard(boardId) ?? Effect.void; + yield* deps.versionStore.deleteForBoard(boardId); + // Inside the tx, before deleteBoardTicketState clears the + // projection_ticket rows the IN-subquery resolves against. + yield* deps.agentSessions?.deleteByBoard(boardId) ?? Effect.void; + yield* deps.eventStore.deleteForBoard(boardId); + yield* deps.readModel.deleteBoardTicketState(boardId); + yield* deps.readModel.deleteBoard(boardId); + }), + ); + // In-memory registry + git/thread cleanup stay outside the transaction: + // a Ref update and filesystem/provider work cannot be rolled back, and + // unregistering only after the DB commit keeps the in-memory view from + // diverging if the transaction aborts. + yield* deps.boardRegistry.unregister(boardId); + // Best-effort live provider teardown for the now-deleted agent sessions — + // a provider error must never surface from board deletion. + if (deps.provider !== undefined && agentSessionRows.length > 0) { + const provider = deps.provider; + yield* Effect.forEach( + agentSessionRows, + (row) => + provider + .stopSession({ threadId: row.threadId as ThreadId }) + .pipe(Effect.catch(() => Effect.void)), + { discard: true }, + ); + } + yield* deps.worktreeJanitor?.run(cleanupPlan) ?? Effect.void; + yield* deps.threadJanitor?.deleteThreads(threadIds) ?? Effect.void; + }); + +export const deleteWorkflowBoardTicketOwnedStateWhen = <E, R>( + deps: WorkflowBoardTicketStateDeletionDeps, + boardId: BoardId, + ticketId: TicketId, + shouldDelete: Effect.Effect<boolean, E, R>, +) => + Effect.gen(function* () { + const deleted = yield* deps.saveLocks.withSaveLock( + boardId, + Effect.gen(function* () { + const cleanupPlan = yield* deps.worktreeJanitor?.collectTicketPlan(ticketId) ?? noCleanup; + const threadIds = yield* deps.threadJanitor?.collectTicketThreads(ticketId) ?? noThreads; + // Collected before the cascade so the threads survive deleteByTicket and + // can be stopped after the commit. Best-effort: never block the delete. + const agentSessionRows: ReadonlyArray<{ readonly threadId: string }> = + deps.agentSessions === undefined + ? [] + : yield* deps.agentSessions.listByTicket(ticketId).pipe(Effect.orElseSucceed(() => [])); + const deleted = yield* deps.sql.withTransaction( + Effect.gen(function* () { + if (!(yield* shouldDelete)) { + return false; + } + + yield* deps.engine.cancelTicketPipelines(ticketId); + yield* ( + deps.agentSessions?.deleteByTicket(ticketId).pipe(Effect.catch(() => Effect.void)) ?? + Effect.void + ); + yield* deps.eventStore.deleteForTicket(ticketId); + yield* deps.readModel.deleteTicketState(ticketId); + return true; + }), + ); + if (deleted) { + // Git/filesystem cleanup stays outside the DB transaction but under + // the board save lock so a concurrent re-create of the same ticket + // worktree cannot interleave with its removal. + if (deps.provider !== undefined && agentSessionRows.length > 0) { + const provider = deps.provider; + yield* Effect.forEach( + agentSessionRows, + (row) => + provider + .stopSession({ threadId: row.threadId as ThreadId }) + .pipe(Effect.catch(() => Effect.void)), + { discard: true }, + ); + } + yield* deps.worktreeJanitor?.run(cleanupPlan) ?? Effect.void; + yield* deps.threadJanitor?.deleteThreads(threadIds) ?? Effect.void; + } + return deleted; + }), + ); + return deleted; + }); + +export const deleteWorkflowBoardTicketOwnedState = ( + deps: WorkflowBoardTicketStateDeletionDeps, + boardId: BoardId, + ticketId: TicketId, +) => + deleteWorkflowBoardTicketOwnedStateWhen(deps, boardId, ticketId, Effect.succeed(true)).pipe( + Effect.asVoid, + ); diff --git a/apps/server/src/workflow/boardSlug.test.ts b/apps/server/src/workflow/boardSlug.test.ts new file mode 100644 index 00000000000..34e7a0b9b3e --- /dev/null +++ b/apps/server/src/workflow/boardSlug.test.ts @@ -0,0 +1,16 @@ +import { assert, describe, it } from "@effect/vitest"; +import { slugifyBoardName, uniqueBoardSlug } from "./boardSlug.ts"; + +describe("boardSlug", () => { + it("slugifies names", () => { + assert.equal(slugifyBoardName("Workflow Board"), "workflow-board"); + assert.equal(slugifyBoardName(" A/B board!! "), "a-b-board"); + assert.equal(slugifyBoardName("!!!"), "board"); + }); + + it("uniquifies against existing slugs", () => { + const existing = new Set(["workflow-board", "workflow-board-2"]); + assert.equal(uniqueBoardSlug("workflow-board", existing), "workflow-board-3"); + assert.equal(uniqueBoardSlug("fresh", existing), "fresh"); + }); +}); diff --git a/apps/server/src/workflow/boardSlug.ts b/apps/server/src/workflow/boardSlug.ts new file mode 100644 index 00000000000..07d999fe857 --- /dev/null +++ b/apps/server/src/workflow/boardSlug.ts @@ -0,0 +1,15 @@ +export const slugifyBoardName = (name: string): string => { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug.length > 0 ? slug : "board"; +}; + +export const uniqueBoardSlug = (base: string, existing: ReadonlySet<string>): string => { + if (!existing.has(base)) return base; + let n = 2; + while (existing.has(`${base}-${n}`)) n += 1; + return `${base}-${n}`; +}; diff --git a/apps/server/src/workflow/boardTemplates.test.ts b/apps/server/src/workflow/boardTemplates.test.ts new file mode 100644 index 00000000000..d25f6f2fc18 --- /dev/null +++ b/apps/server/src/workflow/boardTemplates.test.ts @@ -0,0 +1,167 @@ +import type { ProviderOptionSelection, WorkflowDefinition } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import { defaultBoardDefinition } from "./defaultBoard.ts"; +import { BOARD_TEMPLATES, listBoardTemplateSummaries } from "./boardTemplates.ts"; +import { lintWorkflowDefinition } from "./workflowFile.ts"; + +const baseAgent = { instance: "i", model: "m" } as const; + +const lintErrors = (def: WorkflowDefinition) => + lintWorkflowDefinition(def, { + providerInstanceExists: () => true, + instructionFileExists: () => true, + }); + +describe("BOARD_TEMPLATES", () => { + it("registers the full-sdlc and lite-agent-loop templates", () => { + assert.deepEqual( + BOARD_TEMPLATES.map((t) => t.id), + ["full-sdlc", "lite-agent-loop", "design-board", "design-board-full"], + ); + for (const template of BOARD_TEMPLATES) { + assert.equal(template.requiresAgent, true); + } + }); + + for (const template of BOARD_TEMPLATES) { + describe(template.id, () => { + const def = template.build({ name: "X", agent: baseAgent }); + + it("builds a lint-clean WorkflowDefinition", () => { + assert.equal(def.name, "X"); + assert.deepEqual(lintErrors(def), []); + }); + + it("has every transition/on/action `to` target among the lane keys", () => { + const laneKeys = new Set(def.lanes.map((lane) => lane.key as string)); + for (const lane of def.lanes) { + for (const action of lane.actions ?? []) { + assert.ok(laneKeys.has(action.to as string), `action ${action.to}`); + } + for (const transition of lane.transitions ?? []) { + assert.ok(laneKeys.has(transition.to as string), `transition ${transition.to}`); + } + if (lane.on) { + for (const target of [lane.on.success, lane.on.failure, lane.on.blocked]) { + if (target !== undefined) { + assert.ok(laneKeys.has(target as string), `on ${target}`); + } + } + } + } + }); + }); + } + + it("lite-agent-loop bounds its review self-loop with lane.runCount", () => { + const def = BOARD_TEMPLATES.find((t) => t.id === "lite-agent-loop")!.build({ + name: "X", + agent: baseAgent, + }); + const inProgress = def.lanes.find((lane) => (lane.key as string) === "in-progress"); + assert.ok(inProgress); + const transitions = inProgress.transitions ?? []; + assert.ok(transitions.length >= 1); + const loopTransition = transitions.find((t) => (t.to as string) === "in-progress"); + assert.ok(loopTransition, "expected a self-loop transition back to in-progress"); + assert.ok(JSON.stringify(loopTransition.when).includes("lane.runCount")); + }); + + it("full-sdlc.build deep-equals defaultBoardDefinition", () => { + const fromTemplate = BOARD_TEMPLATES.find((t) => t.id === "full-sdlc")!.build({ + name: "X", + agent: baseAgent, + }); + assert.deepEqual(fromTemplate, defaultBoardDefinition({ name: "X", agent: baseAgent })); + }); + + it("threads agent.options through every agent step in BOTH templates", () => { + const options: ReadonlyArray<ProviderOptionSelection> = [ + { id: "reasoning_effort", value: "high" }, + ]; + for (const template of BOARD_TEMPLATES) { + const def = template.build({ + name: "X", + agent: { instance: "i", model: "m", options }, + }); + let agentStepCount = 0; + for (const lane of def.lanes) { + for (const step of lane.pipeline ?? []) { + if (step.type === "agent") { + agentStepCount += 1; + assert.deepEqual(step.agent.options, options, `${template.id} ${step.key}`); + } + } + } + assert.ok(agentStepCount > 0, `${template.id} should have agent steps`); + } + }); + + const stepKeys = (def: WorkflowDefinition) => + def.lanes.flatMap((l) => (l.pipeline ?? []).map((s) => s.key as string)); + + it("design-board has no AI review steps", () => { + const def = BOARD_TEMPLATES.find((t) => t.id === "design-board")!.build({ + name: "X", + agent: baseAgent, + }); + assert.deepEqual( + stepKeys(def).filter((k) => k.endsWith("-review")), + [], + ); + }); + + it("design-board-full has exactly the three review steps", () => { + const def = BOARD_TEMPLATES.find((t) => t.id === "design-board-full")!.build({ + name: "X", + agent: baseAgent, + }); + assert.deepEqual( + stepKeys(def) + .filter((k) => k.endsWith("-review")) + .sort(), + ["build-review", "plan-review", "spec-review"], + ); + }); + + it("design-board-full build lane guards the loop before the unguarded revise", () => { + const def = BOARD_TEMPLATES.find((t) => t.id === "design-board-full")!.build({ + name: "X", + agent: baseAgent, + }); + const build = def.lanes.find((l) => (l.key as string) === "build")!; + // first transition must be the lane.runCount-guarded one + assert.ok(JSON.stringify(build.transitions![0]).includes("lane.runCount")); + }); +}); + +describe("listBoardTemplateSummaries", () => { + it("returns exactly the four template summaries", () => { + assert.deepEqual(listBoardTemplateSummaries(), [ + { + id: "full-sdlc", + name: "Full SDLC", + description: "Plan → spec → implement → review pipeline with a revision loop.", + requiresAgent: true, + }, + { + id: "lite-agent-loop", + name: "Lite agent loop", + description: "To do → In progress (implement→review, loops on changes) → Done.", + requiresAgent: true, + }, + { + id: "design-board", + name: "Design board", + description: "Idea → brainstorm → plan → build, with human approval gates.", + requiresAgent: true, + }, + { + id: "design-board-full", + name: "Design board (with AI review)", + description: "Adds AI spec/plan/build reviews before each gate. Needs a capable agent.", + requiresAgent: true, + }, + ]); + }); +}); diff --git a/apps/server/src/workflow/boardTemplates.ts b/apps/server/src/workflow/boardTemplates.ts new file mode 100644 index 00000000000..418eb9e3282 --- /dev/null +++ b/apps/server/src/workflow/boardTemplates.ts @@ -0,0 +1,361 @@ +import type { AgentSelection, BoardTemplateSummary, WorkflowDefinition } from "@t3tools/contracts"; +import { WorkflowDefinition as WorkflowDefinitionSchema } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { defaultBoardDefinition } from "./defaultBoard.ts"; + +const decodeWorkflowDefinition = Schema.decodeUnknownSync(WorkflowDefinitionSchema); + +const IMPLEMENT_INSTRUCTION = `Implement ticket "{{ticket.title}}" in this worktree. + +Ticket {{ticket.id}} description: +{{ticket.description}} + +If a .t3/ticket/{{ticket.id}}/REVIEW.md file exists at the repo root, a previous +review requested changes: address every issue listed there first, then delete +.t3/ticket/{{ticket.id}}/REVIEW.md. Run the relevant tests/checks and fix what you +break. Keep the change focused on the ticket.`; + +const REVIEW_INSTRUCTION = `Review the accumulated work for ticket "{{ticket.title}}". + +Diff the worktree against {{ticket.baseRef}} and judge whether it correctly +implements the ticket. Look for blocking correctness, reliability, or +integration issues — ignore style nits. + +If changes are required, write the specific, actionable issues to +.t3/ticket/{{ticket.id}}/REVIEW.md at the repo root (overwrite it) so the next +implementation pass can address them. If the work is ready, make sure no +.t3/ticket/{{ticket.id}}/REVIEW.md file remains.`; + +const REVIEW_OUTPUT_HINT = `Your result object must be {"verdict": "approve"} or {"verdict": "revise"}.`; + +const DESIGN_DIR = ".t3/ticket/{{ticket.id}}/design"; + +const BRAINSTORM_INSTRUCTION = `You are brainstorming the design for ticket "{{ticket.title}}". + +Seed idea (ticket {{ticket.id}} description): +{{ticket.description}} + +Work like a thoughtful collaborator: ask the user ONE clarifying question at a +time and wait for their answer before asking the next. Cover purpose, +constraints, success criteria, and 2-3 approaches with a recommendation. + +When you understand what to build, write the design spec as Markdown to +${DESIGN_DIR}/SPEC.md (create the directory if needed) and stop. The spec is the +only artifact that matters — make it complete and self-contained.`; + +const SPEC_REVIEW_INSTRUCTION = `Adversarially review the design spec at +${DESIGN_DIR}/SPEC.md for ticket "{{ticket.title}}". + +Look for missing requirements, contradictions, unjustified scope, and anything +that would break if built as written. Write your critique to +${DESIGN_DIR}/SPEC-REVIEW.md (overwrite it). If the spec is sound, say so there.`; + +const PLAN_INSTRUCTION = `Write an implementation plan for ticket "{{ticket.title}}". + +Read the approved design spec at ${DESIGN_DIR}/SPEC.md. Produce a concrete, +bite-sized, test-driven implementation plan and write it as Markdown to +${DESIGN_DIR}/PLAN.md (overwrite it). Exact file paths, real code, real test +commands — assume the implementer has no prior context.`; + +const PLAN_REVIEW_INSTRUCTION = `Adversarially review the implementation plan at +${DESIGN_DIR}/PLAN.md against the spec at ${DESIGN_DIR}/SPEC.md for ticket +"{{ticket.title}}". + +Check spec coverage, placeholder/hand-wavy steps, and type/name consistency. +Write your critique to ${DESIGN_DIR}/PLAN-REVIEW.md (overwrite it).`; + +const BUILD_INSTRUCTION = `Implement the plan for ticket "{{ticket.title}}" in this worktree. + +Follow ${DESIGN_DIR}/PLAN.md (which implements ${DESIGN_DIR}/SPEC.md). If a +${DESIGN_DIR}/BUILD-REVIEW.md file exists, a previous review requested changes: +address every issue there first, then delete ${DESIGN_DIR}/BUILD-REVIEW.md. Run +the relevant tests/checks and fix what you break. Keep the change focused.`; + +const BUILD_REVIEW_INSTRUCTION = `Review the accumulated work for ticket "{{ticket.title}}". + +Diff the worktree against {{ticket.baseRef}} and judge whether it correctly +implements ${DESIGN_DIR}/PLAN.md. Look for blocking correctness, reliability, or +integration issues — ignore style nits. If changes are required, write specific, +actionable issues to ${DESIGN_DIR}/BUILD-REVIEW.md (overwrite it) so the next +build pass can address them. If the work is ready, ensure no +${DESIGN_DIR}/BUILD-REVIEW.md remains.`; + +/** + * Lite agent loop: To do → In progress (implement → review, looping back on a + * "revise" verdict while the lane.runCount budget lasts, then parking in Needs + * attention) → Done. A minimal agent-driven board for small tickets that do not + * need the full plan/spec scaffolding of the default SDLC board. + */ +const liteAgentLoopDefinition = (input: { + readonly name: string; + readonly agent: AgentSelection; +}): WorkflowDefinition => { + const agent = { + instance: input.agent.instance, + model: input.agent.model, + ...(input.agent.options === undefined ? {} : { options: input.agent.options }), + }; + return decodeWorkflowDefinition({ + name: input.name, + lanes: [ + { + key: "to-do", + name: "To do", + entry: "manual", + actions: [ + { + label: "Start work", + to: "in-progress", + hint: "The agent implements and reviews the ticket.", + }, + ], + }, + { + key: "in-progress", + name: "In progress", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent, + instruction: IMPLEMENT_INSTRUCTION, + retry: { maxAttempts: 2 }, + }, + { + key: "review", + type: "agent", + agent, + instruction: `${REVIEW_INSTRUCTION}\n\n${REVIEW_OUTPUT_HINT}`, + captureOutput: true, + }, + ], + transitions: [ + { + when: { + and: [ + { "==": [{ var: "steps.review.output.verdict" }, "revise"] }, + { "<": [{ var: "lane.runCount" }, 3] }, + ], + }, + to: "in-progress", + }, + { + when: { "==": [{ var: "steps.review.output.verdict" }, "revise"] }, + to: "needs-attention", + }, + { + when: { "==": [{ var: "steps.review.output.verdict" }, "approve"] }, + to: "done", + }, + ], + // No transition matched means the review verdict was malformed or + // missing — that needs eyes. + on: { success: "needs-attention", failure: "needs-attention", blocked: "needs-attention" }, + }, + { + key: "needs-attention", + name: "Needs attention", + entry: "manual", + actions: [ + { + label: "Retry", + to: "in-progress", + hint: "Run another implement + review pass.", + }, + { + label: "Back to to-do", + to: "to-do", + hint: "Park the ticket.", + }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true, retention: "14 days" }, + ], + }); +}; + +/** + * Design board: Idea → Brainstorm → spec gate → Plan → plan gate → Build → Done, + * encoding the brainstorm → review → plan → review → build → review loop using + * only existing engine primitives. Artifacts flow through files in the per-ticket + * worktree (`.t3/ticket/<id>/design/SPEC.md` → `PLAN.md` → diff). AI review steps + * are spliced into the producer pipelines only when `withAiReview` is set. + */ +const designBoardDefinition = (input: { + readonly name: string; + readonly agent: AgentSelection; + readonly withAiReview: boolean; +}): WorkflowDefinition => { + const agent = { + instance: input.agent.instance, + model: input.agent.model, + ...(input.agent.options === undefined ? {} : { options: input.agent.options }), + }; + const reviewStep = (key: string, instruction: string) => ({ + key, + type: "agent", + agent, + instruction: `${instruction}\n\n${REVIEW_OUTPUT_HINT}`, + captureOutput: true, + }); + const producer = (key: string, instruction: string) => ({ + key, + type: "agent", + agent, + instruction, + retry: { maxAttempts: 2 }, + }); + return decodeWorkflowDefinition({ + name: input.name, + lanes: [ + { + key: "idea", + name: "Idea", + entry: "manual", + actions: [ + { + label: "Start brainstorm", + to: "brainstorm", + hint: "The agent asks clarifying questions, then writes the spec.", + }, + ], + }, + { + key: "brainstorm", + name: "Brainstorm", + entry: "auto", + pipeline: [ + producer("brainstorm", BRAINSTORM_INSTRUCTION), + ...(input.withAiReview ? [reviewStep("spec-review", SPEC_REVIEW_INSTRUCTION)] : []), + ], + on: { success: "spec-gate", failure: "needs-attention", blocked: "needs-attention" }, + }, + { + key: "spec-gate", + name: "Spec review", + entry: "manual", + actions: [ + { label: "Approve spec", to: "plan", hint: "Read SPEC.md, then continue to planning." }, + { label: "Request changes", to: "brainstorm", hint: "Send it back for another pass." }, + ], + }, + { + key: "plan", + name: "Plan", + entry: "auto", + pipeline: [ + producer("plan", PLAN_INSTRUCTION), + ...(input.withAiReview ? [reviewStep("plan-review", PLAN_REVIEW_INSTRUCTION)] : []), + ], + on: { success: "plan-gate", failure: "needs-attention", blocked: "needs-attention" }, + }, + { + key: "plan-gate", + name: "Plan review", + entry: "manual", + actions: [ + { label: "Approve plan", to: "build", hint: "Read PLAN.md, then build." }, + { label: "Request changes", to: "plan", hint: "Send it back for another pass." }, + ], + }, + { + key: "build", + name: "Build", + entry: "auto", + pipeline: [ + producer("build", BUILD_INSTRUCTION), + ...(input.withAiReview ? [reviewStep("build-review", BUILD_REVIEW_INSTRUCTION)] : []), + ], + ...(input.withAiReview + ? { + transitions: [ + { + when: { + and: [ + { "==": [{ var: "steps.build-review.output.verdict" }, "revise"] }, + { "<": [{ var: "lane.runCount" }, 3] }, + ], + }, + to: "build", + }, + { + when: { "==": [{ var: "steps.build-review.output.verdict" }, "revise"] }, + to: "needs-attention", + }, + { + when: { "==": [{ var: "steps.build-review.output.verdict" }, "approve"] }, + to: "done", + }, + ], + on: { + success: "needs-attention", + failure: "needs-attention", + blocked: "needs-attention", + }, + } + : { on: { success: "done", failure: "needs-attention", blocked: "needs-attention" } }), + }, + { + key: "needs-attention", + name: "Needs attention", + entry: "manual", + actions: [ + { label: "Retry", to: "build", hint: "Run another build + review pass." }, + { label: "Back to idea", to: "idea", hint: "Park the ticket." }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true, retention: "14 days" }, + ], + }); +}; + +/** + * The wizard's board templates. Each entry builds a concrete + * {@link WorkflowDefinition} from a name + agent selection. `full-sdlc` is the + * existing default board; `lite-agent-loop` is a minimal implement→review loop. + */ +export const BOARD_TEMPLATES = [ + { + id: "full-sdlc", + name: "Full SDLC", + description: "Plan → spec → implement → review pipeline with a revision loop.", + requiresAgent: true, + build: (input: { readonly name: string; readonly agent: AgentSelection }): WorkflowDefinition => + defaultBoardDefinition(input), + }, + { + id: "lite-agent-loop", + name: "Lite agent loop", + description: "To do → In progress (implement→review, loops on changes) → Done.", + requiresAgent: true, + build: (input: { readonly name: string; readonly agent: AgentSelection }): WorkflowDefinition => + liteAgentLoopDefinition(input), + }, + { + id: "design-board", + name: "Design board", + description: "Idea → brainstorm → plan → build, with human approval gates.", + requiresAgent: true, + build: (input: { readonly name: string; readonly agent: AgentSelection }): WorkflowDefinition => + designBoardDefinition({ ...input, withAiReview: false }), + }, + { + id: "design-board-full", + name: "Design board (with AI review)", + description: "Adds AI spec/plan/build reviews before each gate. Needs a capable agent.", + requiresAgent: true, + build: (input: { readonly name: string; readonly agent: AgentSelection }): WorkflowDefinition => + designBoardDefinition({ ...input, withAiReview: true }), + }, +] as const; + +/** Pure summary projection of {@link BOARD_TEMPLATES} for the listBoardTemplates RPC. */ +export const listBoardTemplateSummaries = (): ReadonlyArray<BoardTemplateSummary> => + BOARD_TEMPLATES.map((template) => ({ + id: template.id, + name: template.name, + description: template.description, + requiresAgent: template.requiresAgent, + })); diff --git a/apps/server/src/workflow/createWizard/createWorkflowPrompt.test.ts b/apps/server/src/workflow/createWizard/createWorkflowPrompt.test.ts new file mode 100644 index 00000000000..3f7bcddff07 --- /dev/null +++ b/apps/server/src/workflow/createWizard/createWorkflowPrompt.test.ts @@ -0,0 +1,190 @@ +import type { AgentSelection } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; + +import { + buildCreatePrompt, + containsForbiddenStepType, + injectAgentIntoSteps, +} from "./createWorkflowPrompt.ts"; + +const agent: AgentSelection = { instance: "claude_main", model: "sonnet" }; + +describe("buildCreatePrompt", () => { + it("includes the board name, the description, and the fenced-json output instruction", () => { + const prompt = buildCreatePrompt({ + name: "My Board", + description: "I triage bugs then fix them.", + agent, + }); + assert.include(prompt, "My Board"); + assert.include(prompt, "I triage bugs then fix them."); + assert.include(prompt, '```json block with `{ "proposedDefinition"'); + assert.include(prompt, "rationale"); + }); + + it("FORBIDS executable step types in the instruction text", () => { + const prompt = buildCreatePrompt({ name: "B", description: "d", agent }); + assert.include(prompt, "script"); + assert.include(prompt, "merge"); + assert.include(prompt, "pullRequest"); + }); + + it("teaches the exact shape: the strict enums + required fields the decoder enforces", () => { + const prompt = buildCreatePrompt({ name: "B", description: "d", agent }); + // The two strict enums GPT most often guesses wrong. + assert.include(prompt, '"auto" or "manual"'); + assert.include(prompt, '"agent" or "approval"'); + // The required keys + the reachable-terminal rule + the loop guard. + assert.include(prompt, "terminal"); + assert.include(prompt, "lane.runCount"); + }); + + it("includes a complete worked example board the model can pattern-match", () => { + const prompt = buildCreatePrompt({ name: "B", description: "d", agent }); + assert.include(prompt, "Worked example"); + assert.include(prompt, '"entry": "auto"'); + assert.include(prompt, '"type": "agent"'); + assert.include(prompt, '"terminal": true'); + }); + + it("redacts a high-entropy token seeded into the description (defence-in-depth)", () => { + // sk- pattern triggers the OpenAI key redaction in redactSensitiveText. + const token = "sk-" + "ABCdef0123456789ABCdef0123456789"; + const prompt = buildCreatePrompt({ + name: "Board", + description: `My agent uses ${token} to do things`, + agent, + }); + assert.notInclude(prompt, token); + assert.include(prompt, "[redacted]"); + }); +}); + +describe("injectAgentIntoSteps", () => { + it("injects the chosen agent into an agent step that omits `agent` entirely", () => { + const raw = { + name: "B", + lanes: [ + { + key: "work", + pipeline: [{ key: "code", type: "agent", instruction: "do it" }], + }, + ], + }; + const out = injectAgentIntoSteps(raw, agent) as typeof raw; + const step = out.lanes[0]!.pipeline[0] as unknown as { readonly agent: unknown }; + assert.deepEqual(step.agent, { instance: "claude_main", model: "sonnet" }); + }); + + it("OVERWRITES a different agent the model emitted", () => { + const raw = { + lanes: [ + { + key: "work", + pipeline: [ + { + key: "code", + type: "agent", + instruction: "do it", + agent: { instance: "other_inst", model: "opus" }, + }, + ], + }, + ], + }; + const out = injectAgentIntoSteps(raw, agent) as typeof raw; + assert.deepEqual(out.lanes[0]!.pipeline[0]!.agent, { + instance: "claude_main", + model: "sonnet", + }); + }); + + it("threads options through and removes retry.escalate", () => { + const withOptions: AgentSelection = { + instance: "claude_main", + model: "sonnet", + options: [{ optionId: "reasoning", valueId: "high" }] as never, + }; + const raw = { + lanes: [ + { + key: "work", + pipeline: [ + { + key: "code", + type: "agent", + instruction: "do it", + agent: { instance: "x", model: "y" }, + retry: { maxAttempts: 3, escalate: { instance: "esc", model: "opus" } }, + }, + ], + }, + ], + }; + const out = injectAgentIntoSteps(raw, withOptions) as { + lanes: Array<{ + pipeline: Array<{ + agent: { instance: string; model: string; options?: unknown }; + retry: Record<string, unknown>; + }>; + }>; + }; + const step = out.lanes[0]!.pipeline[0]!; + assert.equal(step.agent.instance, "claude_main"); + assert.deepEqual(step.agent.options, withOptions.options); + assert.equal(step.retry.maxAttempts, 3); + assert.notProperty(step.retry, "escalate"); + }); + + it("leaves approval steps and manual lanes untouched", () => { + const raw = { + lanes: [ + { key: "backlog", entry: "manual" }, + { + key: "review", + pipeline: [{ key: "approve", type: "approval", prompt: "ok?" }], + }, + ], + }; + const out = injectAgentIntoSteps(raw, agent) as { + lanes: Array<{ key: string; entry?: string; pipeline?: Array<Record<string, unknown>> }>; + }; + assert.notProperty(out.lanes[1]!.pipeline![0]!, "agent"); + assert.deepEqual(out.lanes[0], { key: "backlog", entry: "manual" }); + }); + + it("returns non-object / malformed input unchanged without throwing", () => { + assert.equal(injectAgentIntoSteps(null, agent), null); + assert.equal(injectAgentIntoSteps(42, agent), 42); + assert.deepEqual(injectAgentIntoSteps({ lanes: "nope" }, agent), { lanes: "nope" }); + assert.deepEqual(injectAgentIntoSteps({}, agent), {}); + }); +}); + +describe("containsForbiddenStepType", () => { + for (const forbidden of ["script", "merge", "pullRequest"] as const) { + it(`returns true when a step has type "${forbidden}"`, () => { + const raw = { + lanes: [{ key: "w", pipeline: [{ key: "s", type: forbidden }] }], + }; + assert.isTrue(containsForbiddenStepType(raw)); + }); + } + + it("returns false for an agent+approval-only def", () => { + const raw = { + lanes: [ + { key: "w", pipeline: [{ key: "a", type: "agent" }] }, + { key: "r", pipeline: [{ key: "b", type: "approval" }] }, + ], + }; + assert.isFalse(containsForbiddenStepType(raw)); + }); + + it("is defensive on junk input", () => { + assert.isFalse(containsForbiddenStepType(null)); + assert.isFalse(containsForbiddenStepType(42)); + assert.isFalse(containsForbiddenStepType({ lanes: "nope" })); + assert.isFalse(containsForbiddenStepType({})); + }); +}); diff --git a/apps/server/src/workflow/createWizard/createWorkflowPrompt.ts b/apps/server/src/workflow/createWizard/createWorkflowPrompt.ts new file mode 100644 index 00000000000..4c998605bfb --- /dev/null +++ b/apps/server/src/workflow/createWizard/createWorkflowPrompt.ts @@ -0,0 +1,211 @@ +/** + * Pure prompt builder + RAW-JSON transforms for the "agent-assisted" create + * workflow wizard. A no-tool LLM op drafts a board from the user's free-text + * description; this module assembles the prompt and post-processes the parsed + * output BEFORE schema decode so that: + * - the user's chosen agent is FORCED into every agent step + * (`injectAgentIntoSteps`), and + * - executable step types are detected for rejection + * (`containsForbiddenStepType`). + * + * Pure module — no Effect, no I/O. The transforms operate on the RAW parsed + * JSON (an `unknown`), are total/defensive (never throw on weird input), and + * mutate only a value the caller owns. + */ + +import type { AgentSelection } from "@t3tools/contracts"; + +import { redactSensitiveText } from "../redactSensitiveText.ts"; + +const FORBIDDEN_STEP_TYPES = new Set(["script", "merge", "pullRequest"]); + +const OUTPUT_INSTRUCTION = [ + "Output a single fenced", + '```json block with `{ "proposedDefinition": <string>, "rationale": <string> }`, where', + "proposedDefinition is the full WorkflowDefinition serialized as a JSON string", + "(i.e. JSON.stringify of the definition object), not a nested object.", +].join(" "); + +// EXACT shape the definition object must follow. The decoder is strict about +// enums and required fields (it ignores unknown fields), so the most common +// failure is an out-of-vocabulary `entry`/`type` or a missing required key. +const SHAPE_SPEC = [ + "## Exact JSON shape (the decoder is strict — follow it precisely)", + 'The definition object is `{ "name": string, "lanes": [ Lane, ... ] }`.', + "Each Lane is an object with these fields:", + '- `key` (REQUIRED, non-empty string, unique per lane — e.g. "to-do", "in-progress")', + '- `name` (REQUIRED, non-empty string — the human label, e.g. "To do")', + '- `entry` (REQUIRED, EXACTLY one of the two strings "auto" or "manual" — no other value)', + '- `pipeline` (optional array of Step; ONLY meaningful on an "auto" lane)', + '- `transitions` (optional array of `{ "when": <json-logic>, "to": "<lane key>" }`)', + '- `actions` (optional array of `{ "label": string (≤48 chars), "to": "<lane key>" }` — buttons on a manual lane)', + '- `on` (optional `{ "success": "<lane key>", "failure": "<lane key>", "blocked": "<lane key>" }` — where the pipeline routes by outcome)', + "- `terminal` (optional boolean; set `true` on the lane(s) where tickets are done)", + "Each Step in a `pipeline` is an object with:", + '- `key` (REQUIRED, non-empty string, unique per pipeline — e.g. "implement", "review")', + '- `type` (REQUIRED, EXACTLY "agent" or "approval" — script/merge/pullRequest are forbidden)', + '- for `type:"agent"`: `instruction` (REQUIRED string — what the agent should do; use {{ticket.title}}/{{ticket.description}} placeholders). Do NOT include an `agent` field; the server injects it.', + '- optional `captureOutput` (boolean) — set `true` on a step whose JSON output a later transition reads via `{ "var": "steps.<stepKey>.output.<field>" }`', + "Rules: every `to`/`on` target must be a `key` of a lane you define; at least one lane MUST have `terminal: true` and be reachable.", + "A bounded review loop (run a step again until a budget is hit) uses this transition (note the `lane.runCount` guard, REQUIRED for a self-loop so it terminates):", + '`{ "when": { "and": [ { "==": [{ "var": "steps.review.output.verdict" }, "revise"] }, { "<": [{ "var": "lane.runCount" }, 3] } ] }, "to": "<same auto lane>" }`', +].join("\n"); + +// A complete, decode-valid, lint-clean worked example the model can pattern-match. +const WORKED_EXAMPLE = `## Worked example of a valid definition object +\`\`\`json +{ + "name": "Example board", + "lanes": [ + { + "key": "backlog", + "name": "Backlog", + "entry": "manual", + "actions": [{ "label": "Start work", "to": "working" }] + }, + { + "key": "working", + "name": "Working", + "entry": "auto", + "pipeline": [ + { "key": "implement", "type": "agent", "instruction": "Implement the ticket described in {{ticket.title}}: {{ticket.description}}. Keep the change focused." }, + { "key": "review", "type": "agent", "instruction": "Review the implementation for ticket {{ticket.title}}. Your result object must be {\\"verdict\\": \\"approve\\"} or {\\"verdict\\": \\"revise\\"}.", "captureOutput": true } + ], + "transitions": [ + { "when": { "and": [{ "==": [{ "var": "steps.review.output.verdict" }, "revise"] }, { "<": [{ "var": "lane.runCount" }, 3] }] }, "to": "working" }, + { "when": { "==": [{ "var": "steps.review.output.verdict" }, "revise"] }, "to": "needs-attention" }, + { "when": { "==": [{ "var": "steps.review.output.verdict" }, "approve"] }, "to": "done" } + ], + "on": { "success": "needs-attention", "failure": "needs-attention", "blocked": "needs-attention" } + }, + { + "key": "needs-attention", + "name": "Needs attention", + "entry": "manual", + "actions": [{ "label": "Retry", "to": "working" }] + }, + { "key": "done", "name": "Done", "entry": "manual", "terminal": true } + ] +} +\`\`\``; + +/** + * Assemble a from-scratch board-authoring prompt from the board name + the + * user's description of how they work, then redact any credential-shaped + * strings that leaked into the free text (defence-in-depth). + */ +export const buildCreatePrompt = ({ + name, + description, + agent: _agent, +}: { + readonly name: string; + readonly description: string; + readonly agent: AgentSelection; +}): string => { + const assembled = [ + "You are designing a brand-new t3 workflow board from scratch.", + "", + "A t3 workflow board is a state machine: tickets flow between lanes. Each lane", + 'either accepts tickets manually (`entry: "manual"`) or runs an automated', + 'pipeline of steps when a ticket enters it (`entry: "auto"`). Routing on a', + "step's outcome (success/failure/blocked) moves the ticket to another lane.", + "", + "## Board name", + name, + "", + "## How the user works with their agent (their words)", + description, + "", + "## Task", + "Design the lanes and an agent pipeline that matches how this user works.", + 'Agent steps (`type: "agent"`) run a SPECIFIC configured agent that the user', + "has already chosen — you do NOT need to specify the agent's instance or model;", + "the server injects them. Just describe each agent step's instruction and", + "routing.", + "", + "ALLOWED step types: only `agent` and `approval`. Manual lanes and manual", + "actions are also allowed.", + "FORBIDDEN step types: do NOT emit any `script`, `merge`, or `pullRequest`", + "steps — the board will be rejected if it contains them.", + "", + "The board MUST have at least one reachable terminal lane (a lane marked", + "`terminal: true`) so tickets can complete.", + "", + SHAPE_SPEC, + "", + WORKED_EXAMPLE, + "", + OUTPUT_INSTRUCTION, + ].join("\n"); + + // Defence-in-depth: the description is free text and may contain a pasted + // token / secret. Strip credential-shaped strings before sending to the LLM. + return redactSensitiveText(assembled); +}; + +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * Force the user's chosen agent into every agent step of a RAW parsed + * definition (BEFORE schema decode), overwriting whatever the model emitted. + * Total/defensive: returns the input unchanged on any non-object/non-array + * shape (decode will reject it later). Mutates the passed object in place and + * returns it. + * + * `retry.escalate`: rather than re-normalize an LLM-invented escalation target, + * we simply DELETE the `escalate` key — simpler and safe. The retry policy's + * `maxAttempts` (and any other fields) are preserved. + */ +export const injectAgentIntoSteps = (rawDef: unknown, agent: AgentSelection): unknown => { + if (!isRecord(rawDef)) return rawDef; + const lanes = rawDef.lanes; + if (!Array.isArray(lanes)) return rawDef; + + const injectedAgent: Record<string, unknown> = { + instance: agent.instance, + model: agent.model, + ...(agent.options === undefined ? {} : { options: agent.options }), + }; + + for (const lane of lanes) { + if (!isRecord(lane)) continue; + const pipeline = lane.pipeline; + if (!Array.isArray(pipeline)) continue; + for (const step of pipeline) { + if (!isRecord(step)) continue; + if (step.type !== "agent") continue; + // Overwrite (or set) the agent with a fresh object the caller owns. + step.agent = { ...injectedAgent }; + if (isRecord(step.retry) && "escalate" in step.retry) { + delete step.retry.escalate; + } + } + } + + return rawDef; +}; + +/** + * True if any step in any lane pipeline has a forbidden executable type + * (`script` / `merge` / `pullRequest`). Defensive on non-object/non-array + * shapes (returns false). Operates on the RAW object. + */ +export const containsForbiddenStepType = (rawDef: unknown): boolean => { + if (!isRecord(rawDef)) return false; + const lanes = rawDef.lanes; + if (!Array.isArray(lanes)) return false; + for (const lane of lanes) { + if (!isRecord(lane)) continue; + const pipeline = lane.pipeline; + if (!Array.isArray(pipeline)) continue; + for (const step of pipeline) { + if (!isRecord(step)) continue; + if (typeof step.type === "string" && FORBIDDEN_STEP_TYPES.has(step.type)) { + return true; + } + } + } + return false; +}; diff --git a/apps/server/src/workflow/defaultBoard.test.ts b/apps/server/src/workflow/defaultBoard.test.ts new file mode 100644 index 00000000000..9ef432b423d --- /dev/null +++ b/apps/server/src/workflow/defaultBoard.test.ts @@ -0,0 +1,83 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; +import { WorkflowDefinition } from "@t3tools/contracts"; +import { defaultBoardDefinition } from "./defaultBoard.ts"; +import { encodeWorkflowDefinitionJson, lintWorkflowDefinition } from "./workflowFile.ts"; + +const decodeWorkflowDefinitionJson = Schema.decodeSync(Schema.fromJsonString(WorkflowDefinition)); + +describe("defaultBoardDefinition", () => { + const def = defaultBoardDefinition({ + name: "My board", + agent: { instance: "codex", model: "gpt-5.4" }, + }); + + it("round-trips through the board file encoder", () => { + const decoded = decodeWorkflowDefinitionJson(encodeWorkflowDefinitionJson(def)); + assert.equal(decoded.name, "My board"); + assert.deepEqual( + decoded.lanes.map((lane) => lane.key as string), + [ + "backlog", + "planning", + "specifying", + "planning_issues", + "implementation", + "owner_review", + "land", + "manual_review", + "implementation_issues", + "done", + ], + ); + }); + + it("passes the linter for a known agent instance", () => { + const errors = lintWorkflowDefinition(def, { + providerInstanceExists: (id) => id === "codex", + instructionFileExists: () => true, + }); + assert.deepEqual(errors, []); + }); + + it("bakes the agent into every agent step", () => { + for (const lane of def.lanes) { + for (const step of lane.pipeline ?? []) { + if (step.type === "agent") { + assert.equal(step.agent.instance, "codex"); + assert.equal(step.agent.model, "gpt-5.4"); + } + } + } + }); + + it("bounds the implementation review loop and escalates to manual review", () => { + const implementation = def.lanes.find((lane) => (lane.key as string) === "implementation"); + assert.ok(implementation); + const transitions = implementation.transitions ?? []; + assert.equal(transitions.length, 3); + assert.equal(transitions[0]?.to, "implementation"); + assert.equal(transitions[1]?.to, "manual_review"); + assert.equal(transitions[2]?.to, "owner_review"); + const loopRule = JSON.stringify(transitions[0]?.when); + assert.ok(loopRule.includes("lane.runCount")); + const review = implementation.pipeline?.find((step) => (step.key as string) === "review"); + assert.ok(review?.type === "agent" && review.captureOutput === true); + }); + + it("uses retry policies on the agent work steps and retention on done", () => { + for (const stepKey of ["plan", "spec", "implement"]) { + const step = def.lanes + .flatMap((lane) => lane.pipeline ?? []) + .find((candidate) => (candidate.key as string) === stepKey); + assert.ok( + step?.type === "agent" && step.retry?.maxAttempts === 2, + `step ${stepKey} should retry`, + ); + } + const done = def.lanes.find((lane) => (lane.key as string) === "done"); + assert.ok(done?.terminal === true && done.retention !== undefined); + const land = def.lanes.find((lane) => (lane.key as string) === "land"); + assert.equal(land?.pipeline?.[0]?.type, "merge"); + }); +}); diff --git a/apps/server/src/workflow/defaultBoard.ts b/apps/server/src/workflow/defaultBoard.ts new file mode 100644 index 00000000000..a47d551fb53 --- /dev/null +++ b/apps/server/src/workflow/defaultBoard.ts @@ -0,0 +1,250 @@ +import type { ProviderOptionSelection } from "@t3tools/contracts"; +import { WorkflowDefinition } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export interface DefaultBoardAgent { + readonly instance: string; + readonly model: string; + readonly options?: ReadonlyArray<ProviderOptionSelection> | undefined; +} + +const decodeWorkflowDefinition = Schema.decodeUnknownSync(WorkflowDefinition); + +const PLAN_INSTRUCTION = `You are planning the ticket "{{ticket.title}}". + +Ticket description: +{{ticket.description}} + +Investigate the codebase and write a short, concrete implementation plan to a +file named .t3/ticket/{{ticket.id}}/PLAN.md at the repo root of this worktree: the goal, the files you +expect to touch, the approach, and the main risks. Do not implement anything +yet. Keep the plan under a page.`; + +const SPEC_INSTRUCTION = `Turn the plan in .t3/ticket/{{ticket.id}}/PLAN.md for ticket "{{ticket.title}}" into a concrete spec. + +Write .t3/ticket/{{ticket.id}}/SPEC.md at the repo root of this worktree containing: the exact behavior +to build, edge cases to handle, and a checklist of verifiable acceptance +criteria (including which tests or checks must pass). Adjust .t3/ticket/{{ticket.id}}/PLAN.md if your +investigation contradicts it. Do not implement anything yet.`; + +const IMPLEMENT_INSTRUCTION = `Implement ticket "{{ticket.title}}" in this worktree according to .t3/ticket/{{ticket.id}}/SPEC.md. + +If a .t3/ticket/{{ticket.id}}/REVIEW.md file exists at the repo root, a previous review requested +changes: address every issue listed there first, then delete .t3/ticket/{{ticket.id}}/REVIEW.md. + +Satisfy each acceptance criterion in .t3/ticket/{{ticket.id}}/SPEC.md, run the relevant tests/checks, +and fix what you break. Keep the change focused on the ticket.`; + +const REVIEW_INSTRUCTION = `Review the accumulated work for ticket "{{ticket.title}}". + +Diff the worktree against {{ticket.baseRef}} and judge it against .t3/ticket/{{ticket.id}}/SPEC.md. +Look for blocking correctness, reliability, or integration issues and unmet +acceptance criteria — ignore style nits. + +If changes are required, write the specific, actionable issues to .t3/ticket/{{ticket.id}}/REVIEW.md at +the repo root (overwrite it) so the next implementation pass can address them. +If the work is ready, make sure no .t3/ticket/{{ticket.id}}/REVIEW.md file remains.`; + +const REVIEW_OUTPUT_HINT = `Your result object must be {"verdict": "approve"} or {"verdict": "revise"}.`; + +/** + * Default board: Backlog → Planning → Specifying → Implementation (with an + * implement/review loop bounded by lane.runCount) → Owner Review → Land → + * Done. Failures park in a phase-specific issues lane — Planning Issues for + * plan/spec problems, Implementation Issues for build/land problems — and + * Manual Review holds tickets whose review loop budget is exhausted. The + * loop budget is the "3" in the Implementation transitions — edit it in the + * workflow editor to allow more or fewer passes. + */ +export const defaultBoardDefinition = (input: { + readonly name: string; + readonly agent: DefaultBoardAgent; +}): WorkflowDefinition => { + const agent = { + instance: input.agent.instance, + model: input.agent.model, + ...(input.agent.options === undefined ? {} : { options: input.agent.options }), + }; + return decodeWorkflowDefinition({ + name: input.name, + settings: { maxConcurrentTickets: 3 }, + lanes: [ + { + key: "backlog", + name: "Backlog", + entry: "manual", + actions: [ + { + label: "Start work", + to: "planning", + hint: "The agent plans, specs, implements and reviews the ticket.", + }, + ], + }, + { + key: "planning", + name: "Planning", + entry: "auto", + pipeline: [ + { + key: "plan", + type: "agent", + agent, + instruction: PLAN_INSTRUCTION, + retry: { maxAttempts: 2 }, + }, + ], + on: { success: "specifying", failure: "planning_issues", blocked: "planning_issues" }, + }, + { + key: "specifying", + name: "Specifying", + entry: "auto", + pipeline: [ + { + key: "spec", + type: "agent", + agent, + instruction: SPEC_INSTRUCTION, + retry: { maxAttempts: 2 }, + }, + ], + on: { success: "implementation", failure: "planning_issues", blocked: "planning_issues" }, + }, + { + key: "planning_issues", + name: "Planning Issues", + entry: "manual", + actions: [ + { + label: "Retry planning", + to: "planning", + hint: "Run planning and specification again.", + }, + { + label: "Back to backlog", + to: "backlog", + hint: "Park the ticket; nothing runs until you start it again.", + }, + ], + }, + { + key: "implementation", + name: "Implementation", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent, + instruction: IMPLEMENT_INSTRUCTION, + retry: { maxAttempts: 2 }, + }, + { + key: "review", + type: "agent", + agent, + instruction: `${REVIEW_INSTRUCTION}\n\n${REVIEW_OUTPUT_HINT}`, + captureOutput: true, + }, + ], + transitions: [ + { + when: { + and: [ + { "==": [{ var: "steps.review.output.verdict" }, "revise"] }, + { "<": [{ var: "lane.runCount" }, 3] }, + ], + }, + to: "implementation", + }, + { + when: { "==": [{ var: "steps.review.output.verdict" }, "revise"] }, + to: "manual_review", + }, + { + when: { "==": [{ var: "steps.review.output.verdict" }, "approve"] }, + to: "owner_review", + }, + ], + // No transition matched means the review verdict was malformed or + // missing — that needs eyes, not an owner-review rubber stamp. + on: { + success: "implementation_issues", + failure: "implementation_issues", + blocked: "implementation_issues", + }, + }, + { + key: "owner_review", + name: "Owner Review", + entry: "manual", + actions: [ + { + label: "Approve & land", + to: "land", + hint: "Merge the ticket's work into the branch checked out in your repo.", + }, + { + label: "Send back", + to: "implementation", + hint: "Run another implement + review pass.", + }, + ], + }, + { + key: "land", + name: "Land", + entry: "manual", + pipeline: [ + { + key: "merge", + type: "merge", + cleanupPaths: [".t3/ticket/{{ticket.id}}"], + }, + ], + on: { success: "done", failure: "implementation_issues", blocked: "implementation_issues" }, + }, + { + key: "manual_review", + name: "Manual Review", + entry: "manual", + actions: [ + { + label: "Approve & land", + to: "land", + hint: "Merge the ticket's work into the branch checked out in your repo.", + }, + { + label: "Send back", + to: "implementation", + hint: "Run another implement + review pass with a fresh loop budget.", + }, + ], + }, + { + key: "implementation_issues", + name: "Implementation Issues", + entry: "manual", + actions: [ + { + label: "Retry implementation", + to: "implementation", + hint: "Run the implement + review pipeline again.", + }, + { + label: "Re-plan", + to: "planning", + hint: "Start over from planning with what you learned.", + }, + { + label: "Back to backlog", + to: "backlog", + hint: "Park the ticket; nothing runs until you start it again.", + }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true, retention: "14 days" }, + ], + }); +}; diff --git a/apps/server/src/workflow/definitionCaps.ts b/apps/server/src/workflow/definitionCaps.ts new file mode 100644 index 00000000000..9926b2c47a7 --- /dev/null +++ b/apps/server/src/workflow/definitionCaps.ts @@ -0,0 +1,44 @@ +/** + * Shared size caps for a persisted/loaded workflow definition. A pure DoS + * backstop: generous enough that any realistically-authored board round-trips + * (export → re-import, edit → save, recovery/discovery load from disk), but + * bounding memory/CPU so neither an untrusted import, an operate-scoped save, + * NOR a hand-edited on-disk definition can register an arbitrarily large board. + * + * Imported by BOTH the import/save path (WorkflowRpcHandlers) and the disk + * load path (WorkflowFileLoader.loadAndRegister) so the two never diverge. + * Deliberately decoupled from dryRunBoard's tighter MAX_DRY_RUN_* limits. + */ + +import type { WorkflowDefinition } from "@t3tools/contracts"; + +export const MAX_IMPORT_DEFINITION_CHARS = 2_000_000; +export const MAX_IMPORT_LANES = 1000; +export const MAX_IMPORT_PER_LANE = 1000; + +/** + * Returns a human-readable violation message when `definition` (already decoded) + * exceeds the lane / per-lane caps, or `null` when it is within bounds. Does NOT + * check the byte/char cap — that is applied on the raw payload/file string by the + * caller before decode (see `exceedsDefinitionCharCap`). + */ +export const definitionLaneCapViolation = (definition: WorkflowDefinition): string | null => { + if (definition.lanes.length > MAX_IMPORT_LANES) { + return `Board definition is too large (exceeds ${MAX_IMPORT_LANES} lanes)`; + } + if ( + definition.lanes.some( + (lane) => + (lane.pipeline?.length ?? 0) > MAX_IMPORT_PER_LANE || + (lane.transitions?.length ?? 0) > MAX_IMPORT_PER_LANE || + (lane.onEvent?.length ?? 0) > MAX_IMPORT_PER_LANE, + ) + ) { + return `Board definition is too large (a lane exceeds ${MAX_IMPORT_PER_LANE} pipeline steps, transitions, or event handlers)`; + } + return null; +}; + +/** True when the raw definition text exceeds the byte/char cap. */ +export const exceedsDefinitionCharCap = (rawLength: number): boolean => + rawLength > MAX_IMPORT_DEFINITION_CHARS; diff --git a/apps/server/src/workflow/dryRun.test.ts b/apps/server/src/workflow/dryRun.test.ts new file mode 100644 index 00000000000..ab3815f374c --- /dev/null +++ b/apps/server/src/workflow/dryRun.test.ts @@ -0,0 +1,273 @@ +import type { WorkflowDefinition } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { PredicateEvaluatorLive } from "./Layers/PredicateEvaluator.ts"; +import { PredicateEvaluator } from "./Services/PredicateEvaluator.ts"; +import { simulateBoardRoute } from "./dryRun.ts"; + +const definition = { + name: "Dry run", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + on: { success: "review", blocked: "stuck" }, + }, + ], + on: { failure: "stuck" }, + }, + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [ + { + key: "check", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "review it", + }, + ], + // Self-loop twice (streak grows while runs stay in this lane), then + // fall through to done. + transitions: [{ when: { "<": [{ var: "lane.runCount" }, 3] }, to: "review" }], + on: { success: "done" }, + }, + { key: "stuck", name: "Stuck", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +} as unknown as WorkflowDefinition; + +const layer = it.layer(PredicateEvaluatorLive); + +layer("simulateBoardRoute", (it) => { + it.effect("walks step routes and bounded self-loop transitions to the terminal lane", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const run = yield* simulateBoardRoute({ + definition, + startLane: "work" as never, + scenario: "success", + evaluator, + }); + + // work →(step.on) review →(self-loop ×2 while runCount < 3) →(lane.on) done + assert.equal(run.end, "terminal"); + assert.equal(run.endLane, "done"); + assert.deepEqual( + run.hops.map((hop) => `${hop.fromLane}>${hop.toLane}:${hop.source}`), + [ + "work>review:step_on", + "review>review:lane_transition", + "review>review:lane_transition", + "review>done:lane_on", + ], + ); + assert.equal(run.hops[0]?.viaStepKey, "code"); + assert.equal(run.hops[1]?.matchedTransitionIndex, 0); + assert.lengthOf(run.notes, 0); + }), + ); + + it.effect("lane.runCount resets when another lane runs, exactly like the engine", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + // Review bounces back to work, so review's streak never exceeds 1 and + // `runCount < 3` matches forever — the live engine loops unboundedly, + // and the dry run must say so instead of claiming a bounded loop. + const alternating = { + ...definition, + lanes: (definition.lanes as ReadonlyArray<Record<string, unknown>>).map((lane) => + lane["key"] === "review" + ? { + ...lane, + transitions: [{ when: { "<": [{ var: "lane.runCount" }, 3] }, to: "work" }], + } + : lane, + ), + } as unknown as WorkflowDefinition; + const run = yield* simulateBoardRoute({ + definition: alternating, + startLane: "work" as never, + scenario: "success", + evaluator, + }); + assert.equal(run.end, "cycle_cap"); + assert.equal(run.hops.length, 25); + }), + ); + + it.effect("failure scenario falls through lane.on into a manual lane", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const run = yield* simulateBoardRoute({ + definition, + startLane: "work" as never, + scenario: "failure", + evaluator, + }); + assert.equal(run.end, "manual"); + assert.equal(run.endLane, "stuck"); + assert.deepEqual( + run.hops.map((hop) => `${hop.fromLane}>${hop.toLane}:${hop.source}`), + ["work>stuck:lane_on"], + ); + }), + ); + + it.effect("blocked scenario uses the step's blocked route", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const run = yield* simulateBoardRoute({ + definition, + startLane: "work" as never, + scenario: "blocked", + evaluator, + }); + assert.equal(run.hops[0]?.toLane, "stuck"); + assert.equal(run.hops[0]?.source, "step_on"); + assert.equal(run.end, "manual"); + }), + ); + + it.effect("a manual start lane without a pipeline ends immediately", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const run = yield* simulateBoardRoute({ + definition, + startLane: "backlog" as never, + scenario: "success", + evaluator, + }); + assert.equal(run.end, "manual"); + assert.equal(run.endLane, "backlog"); + assert.lengthOf(run.hops, 0); + }), + ); + + it.effect("an empty auto lane never routes, exactly like the engine", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + // The engine returns before starting a pipeline when there are no + // steps, so the lane.on fallback must NOT fire in the dry run either. + const noSteps = { + name: "No steps", + lanes: [ + { key: "only", name: "Only", entry: "auto", pipeline: [], on: { success: "done" } }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + } as unknown as WorkflowDefinition; + const run = yield* simulateBoardRoute({ + definition: noSteps, + startLane: "only" as never, + scenario: "success", + evaluator, + }); + assert.equal(run.end, "no_route"); + assert.equal(run.endLane, "only"); + assert.isTrue(run.notes.some((note) => note.includes("has no steps"))); + }), + ); + + it.effect("an unbounded loop stops at the hop cap", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const step = (key: string) => ({ key, type: "script", run: "true" }); + const looping = { + name: "Loop", + lanes: [ + { key: "a", name: "A", entry: "auto", pipeline: [step("sa")], on: { success: "b" } }, + { key: "b", name: "B", entry: "auto", pipeline: [step("sb")], on: { success: "a" } }, + ], + } as unknown as WorkflowDefinition; + const run = yield* simulateBoardRoute({ + definition: looping, + startLane: "a" as never, + scenario: "success", + evaluator, + }); + assert.equal(run.end, "cycle_cap"); + assert.equal(run.hops.length, 25); + }), + ); + + it.effect("notes when predicates read the approximated ticket status", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const statusBoard = { + name: "Status", + lanes: [ + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [{ key: "s", type: "script", run: "true" }], + transitions: [{ when: { "==": [{ var: "status" }, "running"] }, to: "done" }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + } as unknown as WorkflowDefinition; + const run = yield* simulateBoardRoute({ + definition: statusBoard, + startLane: "work" as never, + scenario: "success", + evaluator, + }); + assert.equal(run.end, "terminal"); + assert.isTrue(run.notes.some((note) => note.includes("approximates it"))); + }), + ); + + it.effect("does not strand a lane whose only exit gates on captured step output", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const outputBoard = { + name: "Output gated", + lanes: [ + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "review", + captureOutput: true, + }, + ], + // The ONLY way out is an output-conditioned transition: a dry run + // reads `steps.review.output.verdict` as null, so without the fix + // this lane falsely reports as a dead end (no_route). + transitions: [ + { + when: { "==": [{ var: "steps.review.output.verdict" }, "approve"] }, + to: "done", + }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + } as unknown as WorkflowDefinition; + const run = yield* simulateBoardRoute({ + definition: outputBoard, + startLane: "review" as never, + scenario: "success", + evaluator, + }); + assert.notEqual(run.end, "no_route"); + assert.equal(run.endLane, "done"); + assert.isTrue(run.notes.some((note) => note.includes("captured step output"))); + }), + ); +}); diff --git a/apps/server/src/workflow/dryRun.ts b/apps/server/src/workflow/dryRun.ts new file mode 100644 index 00000000000..95dc813e2db --- /dev/null +++ b/apps/server/src/workflow/dryRun.ts @@ -0,0 +1,237 @@ +import type { + LaneKey, + WorkflowDefinition, + WorkflowDryRunHop, + WorkflowDryRunResult, + WorkflowDryRunScenario, + WorkflowLane, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { inspectJsonLogicRule } from "./jsonLogicRule.ts"; +import type { PredicateEvaluatorShape } from "./Services/PredicateEvaluator.ts"; + +/** + * Simulates a hypothetical ticket's path through a board definition without + * touching any real state. Every agent/script/approval step is assumed to end + * with the chosen scenario outcome; routing then follows the engine's real + * precedence (step.on → lane transitions → lane.on). Transition predicates are + * evaluated against a synthetic context that mirrors the engine's: + * `lane.runCount` is the consecutive streak of pipeline runs in the lane + * (reset by a run elsewhere, exactly like `countLanePipelineRuns`), and data a + * dry run cannot know (captured outputs, ticket fields) reads as null — the + * same as missing data in the engine. + */ + +const MAX_HOPS = 25; + +export type DryRunPredicateEvaluator = Pick<PredicateEvaluatorShape, "evaluate">; + +const stepStatusForResult = (result: WorkflowDryRunScenario): string => + result === "success" ? "completed" : result === "failure" ? "failed" : "blocked"; + +// What the routing-context builder would read from the projection at decision +// time: tickets run as "running"; step failures and blocks project the ticket +// as "blocked" before the route is decided. +const ticketStatusForResult = (result: WorkflowDryRunScenario): string => + result === "success" ? "running" : "blocked"; + +export const simulateBoardRoute = ({ + definition, + startLane, + scenario, + evaluator, +}: { + readonly definition: WorkflowDefinition; + readonly startLane: LaneKey; + readonly scenario: WorkflowDryRunScenario; + readonly evaluator: DryRunPredicateEvaluator; +}): Effect.Effect<WorkflowDryRunResult, never> => + Effect.gen(function* () { + const laneByKey = new Map<string, WorkflowLane>( + definition.lanes.map((lane) => [lane.key as string, lane]), + ); + const hops: Array<WorkflowDryRunHop> = []; + const notes: Array<string> = []; + const pushNote = (note: string) => { + if (!notes.includes(note)) { + notes.push(note); + } + }; + const finish = (end: WorkflowDryRunResult["end"], endLane: LaneKey): WorkflowDryRunResult => ({ + startLane, + scenario, + hops, + end, + endLane, + notes, + }); + + // Mirrors countLanePipelineRuns: the streak only grows while consecutive + // pipeline runs stay in the same lane; a run elsewhere resets it. + let streakLane: string | null = null; + let streakCount = 0; + + let currentKey = startLane; + for (let hop = 0; hop <= MAX_HOPS; hop += 1) { + const lane = laneByKey.get(currentKey as string); + if (lane === undefined) { + pushNote(`Lane "${currentKey as string}" does not exist — the walk cannot continue.`); + return finish("no_route", currentKey); + } + if (lane.terminal === true) { + return finish("terminal", currentKey); + } + const isStart = hops.length === 0; + // A manual lane parks the ticket until a human acts. The start lane is + // the exception: simulate it as if the user pressed "Run lane". + if (lane.entry !== "auto" && !isStart) { + return finish("manual", currentKey); + } + const steps = lane.pipeline ?? []; + if (steps.length === 0) { + if (lane.entry !== "auto") { + return finish("manual", currentKey); + } + // The engine returns before starting a pipeline for a lane with no + // steps, so transitions and fallbacks are never evaluated. + pushNote( + `Auto lane "${currentKey as string}" has no steps — its pipeline never runs, so nothing routes out of it.`, + ); + return finish("no_route", currentKey); + } + if (hop === MAX_HOPS) { + return finish("cycle_cap", currentKey); + } + + streakCount = streakLane === (currentKey as string) ? streakCount + 1 : 1; + streakLane = currentKey as string; + + // Mirror of the engine's pipeline walk: each step ends with the + // scenario outcome; a step.on match (or a non-success) stops the run. + const stepsContext: Record<string, unknown> = {}; + let result: WorkflowDryRunScenario = "success"; + let decision: WorkflowDryRunHop | null = null; + for (const step of steps) { + result = scenario; + stepsContext[step.key as string] = { + exitCode: result === "success" ? 0 : 1, + status: stepStatusForResult(result), + output: null, + }; + const target = step.on?.[result]; + if (target !== undefined) { + decision = { + fromLane: currentKey, + toLane: target, + source: "step_on", + viaStepKey: step.key, + result, + }; + break; + } + if (result !== "success") { + break; + } + } + + if (decision === null) { + const status = ticketStatusForResult(result); + const context = { + pipeline: { result }, + lane: { runCount: streakCount }, + status, + steps: stepsContext, + }; + // A dry run models every captured step output as null, so a transition + // that gates ONLY on `steps.<key>.output.*` can never match here even + // though it would route live. Track the first such transition so a lane + // whose only way out is output-conditioned is reported as an + // (indeterminate) route rather than a false "strands tickets" dead end. + let outputGatedFallback: { + readonly toLane: LaneKey; + readonly index: number; + } | null = null; + for (const [index, transition] of (lane.transitions ?? []).entries()) { + const paths = inspectJsonLogicRule(transition.when).variablePaths; + if (paths.includes("status")) { + pushNote( + `Transition predicates read the ticket status — the dry run approximates it as "${status}".`, + ); + } + const isOutputPath = (path: string) => /^steps\.[^.]+\.output(\.|$)/.test(path); + // Only treat a transition as "indeterminate because captured output is + // unavailable" when EVERY variable it reads is captured output. A mixed + // predicate that also reads a deterministic input (e.g. `status`) and + // fails on that part is a REAL non-match, not a dry-run blind spot — so + // it must not be optimistically followed. + const onlyOutputGated = paths.length > 0 && paths.every(isOutputPath); + const evaluation = yield* evaluator + .evaluate(transition.when, context) + .pipe(Effect.orElseSucceed(() => null)); + if (evaluation === null) { + // The engine fails the whole routing path on a predicate error; + // there is nothing meaningful to simulate past this point. A + // transition gated on captured output evaluates against null here, + // which is a known dry-run blind spot rather than a real predicate + // error — skip it so it doesn't masquerade as a routing fault. + if (onlyOutputGated) { + if (outputGatedFallback === null) { + outputGatedFallback = { toLane: transition.to, index }; + } + continue; + } + pushNote( + `Lane "${currentKey as string}" transition #${index + 1} predicate failed to evaluate — live routing would error here.`, + ); + return finish("no_route", currentKey); + } + if (evaluation.result) { + decision = { + fromLane: currentKey, + toLane: transition.to, + source: "lane_transition", + matchedTransitionIndex: index, + result, + }; + break; + } + // Didn't match — but if it could only match on captured output the + // dry run cannot know, remember it as a possible exit. + if (onlyOutputGated && outputGatedFallback === null) { + outputGatedFallback = { toLane: transition.to, index }; + } + } + + // No concrete transition matched and no lane.on fallback will be tried + // below: rather than mislabel an output-only routed lane as a dead end, + // optimistically follow the first output-gated transition and flag it. + if (decision === null && outputGatedFallback !== null && lane.on?.[result] === undefined) { + pushNote( + `Lane "${currentKey as string}" routes out only via captured step output the dry run cannot evaluate — assuming transition #${outputGatedFallback.index + 1} can match.`, + ); + decision = { + fromLane: currentKey, + toLane: outputGatedFallback.toLane, + source: "lane_transition", + matchedTransitionIndex: outputGatedFallback.index, + result, + }; + } + } + + if (decision === null) { + const target = lane.on?.[result]; + if (target !== undefined) { + decision = { fromLane: currentKey, toLane: target, source: "lane_on", result }; + } + } + + if (decision === null) { + return finish("no_route", currentKey); + } + hops.push(decision); + currentKey = decision.toLane; + } + return finish("cycle_cap", currentKey); + }); diff --git a/apps/server/src/workflow/emptyBoard.test.ts b/apps/server/src/workflow/emptyBoard.test.ts new file mode 100644 index 00000000000..a7df71092ff --- /dev/null +++ b/apps/server/src/workflow/emptyBoard.test.ts @@ -0,0 +1,66 @@ +import { assert, describe, it } from "@effect/vitest"; +import { emptyBoardDefinition } from "./emptyBoard.ts"; +import { lintWorkflowDefinition } from "./workflowFile.ts"; + +describe("emptyBoardDefinition", () => { + const def = emptyBoardDefinition({ name: "X" }); + + it("produces a valid WorkflowDefinition (schema decode)", () => { + assert.ok(def); + assert.equal(def.name, "X"); + }); + + it("has exactly 3 lanes with keys to-do, in-progress, done", () => { + assert.equal(def.lanes.length, 3); + assert.deepEqual( + def.lanes.map((lane) => lane.key as string), + ["to-do", "in-progress", "done"], + ); + }); + + it("all lanes have entry: manual", () => { + for (const lane of def.lanes) { + assert.equal(lane.entry, "manual"); + } + }); + + it("done lane has terminal === true", () => { + const done = def.lanes.find((lane) => (lane.key as string) === "done"); + assert.ok(done); + assert.equal(done.terminal, true); + }); + + it("no lane has a pipeline", () => { + for (const lane of def.lanes) { + assert.ok(lane.pipeline === undefined || lane.pipeline.length === 0); + } + }); + + it("to-do has an action pointing to in-progress (no id field)", () => { + const toDo = def.lanes.find((lane) => (lane.key as string) === "to-do"); + assert.ok(toDo); + const actions = toDo.actions ?? []; + assert.ok(actions.length > 0); + const action = actions.find((a) => a.to === "in-progress"); + assert.ok(action, "expected an action with to === 'in-progress'"); + assert.ok("id" in action === false, "actions must not have an id field"); + }); + + it("in-progress has an action pointing to done (no id field)", () => { + const inProgress = def.lanes.find((lane) => (lane.key as string) === "in-progress"); + assert.ok(inProgress); + const actions = inProgress.actions ?? []; + assert.ok(actions.length > 0); + const action = actions.find((a) => a.to === "done"); + assert.ok(action, "expected an action with to === 'done'"); + assert.ok("id" in action === false, "actions must not have an id field"); + }); + + it("passes the linter with no errors", () => { + const errors = lintWorkflowDefinition(def, { + providerInstanceExists: () => true, + instructionFileExists: () => true, + }); + assert.deepEqual(errors, []); + }); +}); diff --git a/apps/server/src/workflow/emptyBoard.ts b/apps/server/src/workflow/emptyBoard.ts new file mode 100644 index 00000000000..6833a11cb8e --- /dev/null +++ b/apps/server/src/workflow/emptyBoard.ts @@ -0,0 +1,24 @@ +import { WorkflowDefinition } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const decodeWorkflowDefinition = Schema.decodeUnknownSync(WorkflowDefinition); + +export const emptyBoardDefinition = (input: { name: string }): WorkflowDefinition => + decodeWorkflowDefinition({ + name: input.name, + lanes: [ + { + key: "to-do", + name: "To do", + entry: "manual", + actions: [{ label: "Start", to: "in-progress" }], + }, + { + key: "in-progress", + name: "In progress", + entry: "manual", + actions: [{ label: "Mark done", to: "done" }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); diff --git a/apps/server/src/workflow/externalEvent.ts b/apps/server/src/workflow/externalEvent.ts new file mode 100644 index 00000000000..d440e86671e --- /dev/null +++ b/apps/server/src/workflow/externalEvent.ts @@ -0,0 +1,49 @@ +/** + * Bound an inbound webhook payload before it enters predicates and the + * route-decision snapshot: JSON-aware (never truncated JSON strings), depth + * and breadth capped, long strings clipped. + */ +const MAX_DEPTH = 6; +const MAX_KEYS = 100; +const MAX_ARRAY = 100; +const MAX_STRING = 2_000; + +export const sanitizeExternalEventPayload = (value: unknown, depth = 0): unknown => { + if (value === null || typeof value === "boolean" || typeof value === "number") { + return value; + } + if (typeof value === "string") { + return value.length > MAX_STRING ? value.slice(0, MAX_STRING) : value; + } + if (depth >= MAX_DEPTH) { + return undefined; + } + if (Array.isArray(value)) { + return value + .slice(0, MAX_ARRAY) + .map((entry) => sanitizeExternalEventPayload(entry, depth + 1)) + .filter((entry) => entry !== undefined); + } + if (typeof value === "object") { + const out: Record<string, unknown> = {}; + let keys = 0; + for (const [key, entry] of Object.entries(value)) { + if (keys >= MAX_KEYS) { + break; + } + // "__proto__" as an own key would mutate the prototype on assignment, + // letting predicates see values absent from the persisted snapshot. + if (key === "__proto__" || key === "prototype" || key === "constructor") { + continue; + } + const sanitized = sanitizeExternalEventPayload(entry, depth + 1); + if (sanitized !== undefined) { + out[key.slice(0, MAX_STRING)] = sanitized; + keys += 1; + } + } + return out; + } + // Functions, symbols, undefined — not representable. + return undefined; +}; diff --git a/apps/server/src/workflow/instructionPath.ts b/apps/server/src/workflow/instructionPath.ts new file mode 100644 index 00000000000..1e8a119f58e --- /dev/null +++ b/apps/server/src/workflow/instructionPath.ts @@ -0,0 +1,24 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +export const unsafeWorkflowInstructionPathMessage = (repoRelativePath: string): string => + `Instruction file path must be relative and stay within the project root: "${repoRelativePath}"`; + +export const isSafeWorkflowInstructionPath = (repoRelativePath: string): boolean => { + if (path.isAbsolute(repoRelativePath) || path.win32.isAbsolute(repoRelativePath)) { + return false; + } + + return !repoRelativePath.split(/[\\/]+/).some((segment) => segment === ".."); +}; + +export const resolveWorkflowInstructionPath = ( + repoRoot: string, + repoRelativePath: string, +): string | null => + isSafeWorkflowInstructionPath(repoRelativePath) ? path.resolve(repoRoot, repoRelativePath) : null; + +export const containsRealPath = (realRoot: string, realTarget: string): boolean => { + const relative = path.relative(realRoot, realTarget); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +}; diff --git a/apps/server/src/workflow/instructionTemplate.test.ts b/apps/server/src/workflow/instructionTemplate.test.ts new file mode 100644 index 00000000000..ecf1d97cada --- /dev/null +++ b/apps/server/src/workflow/instructionTemplate.test.ts @@ -0,0 +1,286 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + applyInstructionTemplate, + applyInstructionTemplateExcept, + descriptionSpillPath, + descriptionSpillReference, + findHandoffReferences, + handoffSpillPath, + handoffSpillReference, + instructionBodyBudget, + NO_PRIOR_OUTPUT_NOTE, + providerInputBudget, + renderTicketDiscussion, + safeStepKey, + stringifyHandoffOutput, + ticketScratchDir, + unknownTicketPlaceholders, + type DiscussionMessage, +} from "./instructionTemplate.ts"; + +const vars = { + title: "Fix login bug", + description: "Users get logged out", + id: "ticket-42", + baseRef: "refs/t3/tickets/abc/base", + discussion: "(no discussion yet)", +}; + +describe("applyInstructionTemplate", () => { + it("substitutes known ticket placeholders", () => { + const result = applyInstructionTemplate( + "Review {{ticket.title}} ({{ticket.id}}): diff against {{ ticket.baseRef }}.", + vars, + ); + assert.equal( + result, + "Review Fix login bug (ticket-42): diff against refs/t3/tickets/abc/base.", + ); + }); + + it("substitutes description and tolerates repeated placeholders", () => { + const result = applyInstructionTemplate( + "{{ticket.description}} / {{ticket.description}}", + vars, + ); + assert.equal(result, "Users get logged out / Users get logged out"); + }); + + it("leaves unknown ticket placeholders literal", () => { + const result = applyInstructionTemplate("Check {{ticket.priority}}", vars); + assert.equal(result, "Check {{ticket.priority}}"); + }); + + it("ignores non-ticket handlebars text", () => { + const result = applyInstructionTemplate("Use {{value}} and {{ other.thing }}", vars); + assert.equal(result, "Use {{value}} and {{ other.thing }}"); + }); +}); + +describe("applyInstructionTemplate discussion", () => { + it("substitutes the discussion placeholder", () => { + const result = applyInstructionTemplate("Context:\n{{ticket.discussion}}", vars); + assert.equal(result, "Context:\n(no discussion yet)"); + }); +}); + +const message = (overrides: Partial<DiscussionMessage>): DiscussionMessage => ({ + author: "user", + body: "Looks good", + createdAt: "2026-06-09T10:00:00.000Z", + attachmentCount: 0, + ...overrides, +}); + +describe("renderTicketDiscussion", () => { + it("renders an empty string for no messages", () => { + assert.equal(renderTicketDiscussion([]), ""); + }); + + it("renders authors, timestamps, and bodies in order", () => { + const rendered = renderTicketDiscussion([ + message({ + author: "user", + body: "Use the retry helper", + createdAt: "2026-06-09T10:00:00.000Z", + }), + message({ author: "agent", body: "Will do", createdAt: "2026-06-09T10:05:00.000Z" }), + ]); + assert.equal( + rendered, + [ + "### User — 2026-06-09T10:00:00.000Z", + "Use the retry helper", + "", + "### Agent — 2026-06-09T10:05:00.000Z", + "Will do", + ].join("\n"), + ); + }); + + it("notes attachments without inlining them", () => { + const rendered = renderTicketDiscussion([ + message({ body: "See screenshot", attachmentCount: 2 }), + ]); + assert.include(rendered, "See screenshot"); + assert.include(rendered, "[2 attachments omitted]"); + }); + + it("notes a single attachment with singular wording", () => { + const rendered = renderTicketDiscussion([message({ attachmentCount: 1 })]); + assert.include(rendered, "[1 attachment omitted]"); + }); + + it("keeps only the newest messages past the message cap and flags truncation", () => { + const messages = Array.from({ length: 35 }, (_, index) => + message({ + body: `note ${index}`, + createdAt: `2026-06-09T10:00:${String(index).padStart(2, "0")}.000Z`, + }), + ); + const rendered = renderTicketDiscussion(messages); + assert.include(rendered, "_(earlier messages omitted)_"); + assert.notInclude(rendered, "note 4\n"); + assert.include(rendered, "note 34"); + assert.include(rendered, "note 5"); + }); + + it("keeps only the newest messages within the character budget", () => { + const big = "x".repeat(5000); + const messages = Array.from({ length: 6 }, (_, index) => + message({ body: `${big} tail-${index}`, createdAt: `2026-06-09T10:0${index}:00.000Z` }), + ); + const rendered = renderTicketDiscussion(messages); + assert.isAtMost(rendered.length, 13_000); + assert.include(rendered, "_(earlier messages omitted)_"); + assert.include(rendered, "tail-5"); + assert.notInclude(rendered, "tail-0"); + }); +}); + +describe("findHandoffReferences", () => { + it("finds prev.output references", () => { + const refs = findHandoffReferences("Use {{prev.output}} then {{ prev.output }}."); + assert.deepEqual( + refs.map((ref) => ({ kind: ref.kind, stepKey: ref.stepKey, raw: ref.raw })), + [ + { kind: "prev", stepKey: undefined, raw: "{{prev.output}}" }, + { kind: "prev", stepKey: undefined, raw: "{{ prev.output }}" }, + ], + ); + }); + + it("finds step.<key>.output references and captures the key", () => { + const refs = findHandoffReferences("Read {{step.review.output}} and {{ step.spec-1.output }}."); + assert.deepEqual( + refs.map((ref) => ({ kind: ref.kind, stepKey: ref.stepKey })), + [ + { kind: "step", stepKey: "review" }, + { kind: "step", stepKey: "spec-1" }, + ], + ); + }); + + it("ignores ticket placeholders and unrelated braces", () => { + assert.deepEqual(findHandoffReferences("{{ticket.title}} {{other}} {{step.output}}"), []); + }); +}); + +describe("safeStepKey", () => { + it("passes path-safe keys through unchanged", () => { + assert.equal(safeStepKey("review"), "review"); + assert.equal(safeStepKey("implement_2-b"), "implement_2-b"); + }); + + it("encodes non-path-safe keys deterministically and path-safely", () => { + const encoded = safeStepKey("re view/../etc"); + assert.match(encoded, /^[A-Za-z0-9_-]+$/); + assert.equal(encoded, safeStepKey("re view/../etc")); + assert.notEqual(encoded, safeStepKey("review")); + }); +}); + +describe("stringifyHandoffOutput", () => { + it("passes strings through unchanged", () => { + assert.equal(stringifyHandoffOutput("hello"), "hello"); + }); + + it("JSON-stringifies non-string output", () => { + assert.equal(stringifyHandoffOutput({ verdict: "approve" }), '{"verdict":"approve"}'); + assert.equal(stringifyHandoffOutput(null), "null"); + }); +}); + +describe("handoffSpillPath / handoffSpillReference", () => { + it("builds a per-ticket handoff scratch path under .t3/ticket", () => { + assert.equal(handoffSpillPath("ticket-42", "review"), ".t3/ticket/ticket-42/handoff/review.md"); + }); + + it("renders a path reference message pointing at the spilled file", () => { + const reference = handoffSpillReference(".t3/ticket/ticket-42/handoff/review.md"); + assert.include(reference, ".t3/ticket/ticket-42/handoff/review.md"); + assert.include(reference, "read"); + }); +}); + +describe("providerInputBudget", () => { + it("falls back to the 120k cap when undefined", () => { + assert.equal(providerInputBudget(undefined), 120000); + }); + + it("uses a tighter provider limit when smaller than the cap", () => { + assert.equal(providerInputBudget(3000), 3000); + }); + + it("clamps an over-cap provider limit to the 120k cap", () => { + assert.equal(providerInputBudget(999999), 120000); + }); +}); + +describe("instructionBodyBudget", () => { + it("subtracts the discussion block and capture reserve for capture steps", () => { + assert.equal(instructionBodyBudget(3000, 500, true), 1988); + }); + + it("floors at 0 when the appended block exceeds the budget", () => { + assert.equal(instructionBodyBudget(3000, 12000, true), 0); + }); + + it("omits the capture reserve for non-capture steps", () => { + assert.equal(instructionBodyBudget(3000, 500, false), 2500); + }); +}); + +describe("applyInstructionTemplateExcept", () => { + it("substitutes included fields and leaves excluded placeholders literal", () => { + assert.equal( + applyInstructionTemplateExcept("{{ticket.title}}|{{ ticket.description }}", { title: "T" }, [ + "description", + ]), + "T|{{ ticket.description }}", + ); + }); +}); + +describe("ticketScratchDir / descriptionSpillPath", () => { + it("builds a per-ticket scratch dir under .t3/ticket", () => { + assert.equal(ticketScratchDir("ticket-1"), ".t3/ticket/ticket-1"); + }); + + it("rejects a non-path-safe ticket id", () => { + assert.throws(() => ticketScratchDir("../evil")); + }); + + it("builds the DESCRIPTION.md spill path under the scratch dir", () => { + assert.equal(descriptionSpillPath("ticket-1"), ".t3/ticket/ticket-1/DESCRIPTION.md"); + }); + + it("renders a path reference pointing at the spilled description", () => { + const reference = descriptionSpillReference(".t3/ticket/ticket-1/DESCRIPTION.md"); + assert.include(reference, ".t3/ticket/ticket-1/DESCRIPTION.md"); + assert.include(reference, "read"); + }); +}); + +describe("NO_PRIOR_OUTPUT_NOTE", () => { + it("is the explicit forward-reference marker", () => { + assert.equal(NO_PRIOR_OUTPUT_NOTE, "(no prior output yet)"); + }); +}); + +describe("unknownTicketPlaceholders", () => { + it("reports unknown ticket fields once each", () => { + const unknown = unknownTicketPlaceholders( + "{{ticket.title}} {{ticket.priority}} {{ticket.priority}} {{ticket.owner.name}}", + ); + assert.deepEqual([...unknown].sort(), ["owner.name", "priority"]); + }); + + it("reports nothing for known fields or non-ticket braces", () => { + assert.deepEqual( + unknownTicketPlaceholders("{{ticket.title}} {{ticket.baseRef}} {{whatever}}"), + [], + ); + }); +}); diff --git a/apps/server/src/workflow/instructionTemplate.ts b/apps/server/src/workflow/instructionTemplate.ts new file mode 100644 index 00000000000..e98587a5207 --- /dev/null +++ b/apps/server/src/workflow/instructionTemplate.ts @@ -0,0 +1,220 @@ +/** + * Ticket-context placeholders usable inside agent step instructions. + * + * Only `{{ticket.<field>}}` tokens participate in templating; any other + * `{{...}}` text is left untouched so instructions can freely contain + * handlebars-style examples. Unknown `ticket.*` fields are left literal at + * runtime and surfaced as lint errors at save time. + */ +export const TICKET_TEMPLATE_FIELDS = [ + "title", + "description", + "id", + "baseRef", + "discussion", +] as const; +export type TicketTemplateField = (typeof TICKET_TEMPLATE_FIELDS)[number]; + +export type TicketTemplateVars = Readonly<Record<TicketTemplateField, string>>; + +const PLACEHOLDER_PATTERN = /\{\{\s*ticket\.([A-Za-z0-9_.]+)\s*\}\}/g; + +const isTemplateField = (field: string): field is TicketTemplateField => + (TICKET_TEMPLATE_FIELDS as ReadonlyArray<string>).includes(field); + +export const applyInstructionTemplate = (instruction: string, vars: TicketTemplateVars): string => + instruction.replace(PLACEHOLDER_PATTERN, (match, field: string) => + isTemplateField(field) ? vars[field] : match, + ); + +/** + * Like {@link applyInstructionTemplate} but leaves the placeholders of any + * `exclude`d fields literal, and tolerates a partial `vars` map (a placeholder + * whose value is `undefined` is left literal). Used to substitute the short + * ticket fields while deferring `{{ticket.description}}` for a budget-aware + * spill decision. + */ +export const applyInstructionTemplateExcept = ( + instruction: string, + vars: Partial<TicketTemplateVars>, + exclude: ReadonlyArray<TicketTemplateField>, +): string => + instruction.replace(PLACEHOLDER_PATTERN, (match, field: string) => + isTemplateField(field) && !exclude.includes(field) && vars[field] !== undefined + ? vars[field]! + : match, + ); + +export interface DiscussionMessage { + readonly author: "agent" | "user"; + readonly body: string; + readonly createdAt: string; + readonly attachmentCount: number; +} + +export const DISCUSSION_MESSAGE_CAP = 30; +const DISCUSSION_CHAR_BUDGET = 12_000; +const DISCUSSION_TRUNCATION_NOTE = "_(earlier messages omitted)_"; + +const renderDiscussionMessage = (message: DiscussionMessage): string => { + const author = message.author === "user" ? "User" : "Agent"; + const attachmentNote = + message.attachmentCount > 0 + ? `\n[${message.attachmentCount} attachment${message.attachmentCount === 1 ? "" : "s"} omitted]` + : ""; + return `### ${author} — ${message.createdAt}\n${message.body}${attachmentNote}`; +}; + +/** + * Render a ticket's message thread as a markdown transcript for agent + * instructions. Keeps the newest messages within a message count and + * character budget; attachments are noted, never inlined (they are data + * URLs). Returns the empty string when there is nothing to show. + */ +export const renderTicketDiscussion = (messages: ReadonlyArray<DiscussionMessage>): string => { + if (messages.length === 0) { + return ""; + } + const kept: string[] = []; + let used = 0; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const source = messages[index]; + const entry = source === undefined ? "" : renderDiscussionMessage(source); + if ( + kept.length >= DISCUSSION_MESSAGE_CAP || + (kept.length > 0 && used + entry.length > DISCUSSION_CHAR_BUDGET) + ) { + kept.unshift(DISCUSSION_TRUNCATION_NOTE); + break; + } + kept.unshift(entry); + used += entry.length + 2; + } + return kept.join("\n\n"); +}; + +export const hasDiscussionPlaceholder = (instruction: string): boolean => + /\{\{\s*ticket\.discussion\s*\}\}/.test(instruction); + +// --------------------------------------------------------------------------- +// Inter-agent handoff placeholders: {{prev.output}} / {{step.<key>.output}} +// --------------------------------------------------------------------------- + +/** Marker substituted for a forward handoff reference with nothing captured yet. */ +export const NO_PRIOR_OUTPUT_NOTE = "(no prior output yet)"; + +// The global schema cap on a single turn's input chars (`provider.ts:69-71`). +const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; +// Reserve room on the FINAL assembled instruction for the capture-output suffix +// the executor may append (only for capture steps), so an inlined body never +// blows the provider input cap. This is a per-render reserve, not a per-output cap. +const CAPTURE_OUTPUT_SUFFIX_RESERVE = 512; + +/** + * The active provider's per-turn input budget: the adapter's declared + * `maxInputChars` clamped to the global 120k schema cap, or the cap itself when + * the adapter declares no tighter limit. + */ +export const providerInputBudget = (maxInputChars: number | undefined): number => + Math.min(maxInputChars ?? PROVIDER_SEND_TURN_MAX_INPUT_CHARS, PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + +/** + * Room for the instruction BODY (template text + inlined description + inlined + * handoff), reserving the EXACT trailing blocks appended after the body: the + * discussion block and (only for capture steps) the capture suffix. Floored at 0 + * so the math never underflows on a tight provider. + */ +export const instructionBodyBudget = ( + providerBudget: number, + appendedDiscussionBlockLength: number, + capturesOutput: boolean, +): number => + Math.max( + 0, + providerBudget - + appendedDiscussionBlockLength - + (capturesOutput ? CAPTURE_OUTPUT_SUFFIX_RESERVE : 0), + ); + +// `{{prev.output}}` or `{{step.<key>.output}}`. The step key allows the full +// trimmed-non-empty step-key alphabet (keys are NOT restricted to path-safe). +const HANDOFF_PLACEHOLDER_PATTERN = /\{\{\s*(?:prev\.output|step\.([^.{}]+?)\.output)\s*\}\}/g; + +export interface HandoffReference { + /** The exact placeholder text to replace, e.g. `{{ step.review.output }}`. */ + readonly raw: string; + readonly kind: "prev" | "step"; + /** Present only for `step.<key>.output` references. */ + readonly stepKey: string | undefined; +} + +/** Parse all `{{prev.output}}` / `{{step.<key>.output}}` placeholders in order. */ +export const findHandoffReferences = (instruction: string): ReadonlyArray<HandoffReference> => { + const refs: HandoffReference[] = []; + for (const match of instruction.matchAll(HANDOFF_PLACEHOLDER_PATTERN)) { + const stepKey = match[1]; + refs.push( + stepKey === undefined + ? { raw: match[0], kind: "prev", stepKey: undefined } + : { raw: match[0], kind: "step", stepKey }, + ); + } + return refs; +}; + +const PATH_SAFE_STEP_KEY = /^[A-Za-z0-9_-]+$/; + +/** + * A filename-safe form of a step key for the spill path. Path-safe keys pass + * through unchanged; anything else is base64url-encoded (deterministic, + * collision-free, and matching `[A-Za-z0-9_-]+`) so it never smuggles path + * segments into the scratch tree. + */ +export const safeStepKey = (stepKey: string): string => + PATH_SAFE_STEP_KEY.test(stepKey) ? stepKey : Buffer.from(stepKey, "utf8").toString("base64url"); + +/** Render a handoff output value as text for inlining or spilling. */ +export const stringifyHandoffOutput = (output: unknown): string => + typeof output === "string" ? output : JSON.stringify(output); + +const PATH_SAFE_TICKET_ID = /^[A-Za-z0-9_-]+$/; + +/** + * The worktree-relative per-ticket scratch directory `.t3/ticket/<id>` that + * holds all pipeline scratch (description spill, handoff outputs, design docs). + * Validates the ticket id against a path-safe pattern so it can never smuggle + * path segments into the scratch tree. + */ +export const ticketScratchDir = (ticketId: string): string => { + if (!PATH_SAFE_TICKET_ID.test(ticketId)) { + throw new Error(`unsafe ticket id for scratch path: ${ticketId}`); + } + return `.t3/ticket/${ticketId}`; +}; + +/** The worktree-relative scratch path a spilled handoff output is written to. */ +export const handoffSpillPath = (ticketId: string, stepKey: string): string => + `${ticketScratchDir(ticketId)}/handoff/${safeStepKey(stepKey)}.md`; + +/** The placeholder replacement pointing an agent at a spilled handoff file. */ +export const handoffSpillReference = (spillPath: string): string => + `the prior step's full output is in \`${spillPath}\` — read that file`; + +/** The worktree-relative scratch path a spilled ticket description is written to. */ +export const descriptionSpillPath = (ticketId: string): string => + `${ticketScratchDir(ticketId)}/DESCRIPTION.md`; + +/** The placeholder replacement pointing an agent at a spilled description file. */ +export const descriptionSpillReference = (spillPath: string): string => + `The full ticket description is in \`${spillPath}\` — read that file before starting.`; + +export const unknownTicketPlaceholders = (instruction: string): ReadonlyArray<string> => { + const unknown = new Set<string>(); + for (const match of instruction.matchAll(PLACEHOLDER_PATTERN)) { + const field = match[1]; + if (field !== undefined && !isTemplateField(field)) { + unknown.add(field); + } + } + return [...unknown]; +}; diff --git a/apps/server/src/workflow/jsonLogicRule.ts b/apps/server/src/workflow/jsonLogicRule.ts new file mode 100644 index 00000000000..496c966b31e --- /dev/null +++ b/apps/server/src/workflow/jsonLogicRule.ts @@ -0,0 +1,96 @@ +export const ALLOWED_JSON_LOGIC_OPERATORS = new Set([ + "==", + "!=", + ">", + ">=", + "<", + "<=", + "and", + "or", + "!", + "var", + "in", +] as const); + +/** Maximum nesting depth for a JSON-logic predicate tree. + * Generous enough for any real predicate; well below the JS call-stack limit. + * Predicates deeper than this are rejected with an `invalid_json_logic` lint error. */ +export const MAX_PREDICATE_DEPTH = 12; + +export interface JsonLogicRuleIssue { + readonly message: string; +} + +export interface JsonLogicRuleInspection { + readonly variablePaths: ReadonlyArray<string>; + readonly issues: ReadonlyArray<JsonLogicRuleIssue>; +} + +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const inspectNode = ( + node: unknown, + variablePaths: string[], + seenPaths: Set<string>, + issues: JsonLogicRuleIssue[], + depth: number = 0, +): void => { + if (depth > MAX_PREDICATE_DEPTH) { + issues.push({ + message: `JSONLogic predicate exceeds maximum nesting depth of ${MAX_PREDICATE_DEPTH}`, + }); + return; + } + + if (Array.isArray(node)) { + for (const item of node) { + inspectNode(item, variablePaths, seenPaths, issues, depth + 1); + } + return; + } + if (!isRecord(node)) { + return; + } + + const entries = Object.entries(node); + if (entries.length !== 1) { + issues.push({ message: "JSONLogic rule objects must contain exactly one operator" }); + for (const value of Object.values(node)) { + inspectNode(value, variablePaths, seenPaths, issues, depth + 1); + } + return; + } + + const entry = entries[0]; + if (entry === undefined) { + return; + } + const [operator, operand] = entry; + if (!ALLOWED_JSON_LOGIC_OPERATORS.has(operator as never)) { + issues.push({ message: `unsupported JSONLogic operator: ${operator}` }); + inspectNode(operand, variablePaths, seenPaths, issues, depth + 1); + return; + } + + if (operator === "var") { + if (typeof operand !== "string") { + issues.push({ message: "JSONLogic var must be a string path without a default" }); + return; + } + if (!seenPaths.has(operand)) { + seenPaths.add(operand); + variablePaths.push(operand); + } + return; + } + + inspectNode(operand, variablePaths, seenPaths, issues, depth + 1); +}; + +export const inspectJsonLogicRule = (rule: unknown): JsonLogicRuleInspection => { + const variablePaths: string[] = []; + const issues: JsonLogicRuleIssue[] = []; + inspectNode(rule, variablePaths, new Set(), issues); + return { variablePaths, issues }; +}; diff --git a/apps/server/src/workflow/outbound/OutboundUrlValidator.test.ts b/apps/server/src/workflow/outbound/OutboundUrlValidator.test.ts new file mode 100644 index 00000000000..114036dc80f --- /dev/null +++ b/apps/server/src/workflow/outbound/OutboundUrlValidator.test.ts @@ -0,0 +1,167 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { OutboundUrlValidator } from "./OutboundUrlValidator.ts"; + +const validateWith = (url: string, addrs: ReadonlyArray<string>) => + Effect.exit(OutboundUrlValidator.validate(url, { lookup: () => Effect.succeed(addrs) })); + +describe("OutboundUrlValidator", () => { + it.effect("accepts a public https host", () => + Effect.gen(function* () { + assert.equal( + (yield* validateWith("https://hooks.slack.com/services/x", ["140.82.112.3"]))._tag, + "Success", + ); + }), + ); + it.effect("rejects http", () => + Effect.gen(function* () { + assert.equal( + (yield* validateWith("http://hooks.slack.com/x", ["140.82.112.3"]))._tag, + "Failure", + ); + }), + ); + it.effect("rejects loopback", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://localhost/x", ["127.0.0.1"]))._tag, "Failure"); + }), + ); + it.effect("rejects the cloud-metadata address", () => + Effect.gen(function* () { + assert.equal( + (yield* validateWith("https://metadata.internal/x", ["169.254.169.254"]))._tag, + "Failure", + ); + }), + ); + it.effect("rejects private 10/8", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://internal.svc/x", ["10.1.2.3"]))._tag, "Failure"); + }), + ); + it.effect("rejects 172.16/12 and accepts 172.32 (outside range)", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["172.16.0.1"]))._tag, "Failure"); + assert.equal((yield* validateWith("https://x/y", ["172.31.255.255"]))._tag, "Failure"); + assert.equal((yield* validateWith("https://x/y", ["172.32.0.1"]))._tag, "Success"); + }), + ); + it.effect("rejects 192.168/16", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["192.168.1.1"]))._tag, "Failure"); + }), + ); + it.effect("rejects CGNAT 100.64/10 but accepts adjacent public 100.x", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["100.64.0.1"]))._tag, "Failure"); + assert.equal((yield* validateWith("https://x/y", ["100.127.255.255"]))._tag, "Failure"); + assert.equal((yield* validateWith("https://x/y", ["100.63.255.255"]))._tag, "Success"); + assert.equal((yield* validateWith("https://x/y", ["100.128.0.1"]))._tag, "Success"); + }), + ); + it.effect("rejects benchmarking 198.18/15 but accepts adjacent public 198.x", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["198.18.0.1"]))._tag, "Failure"); + assert.equal((yield* validateWith("https://x/y", ["198.19.255.255"]))._tag, "Failure"); + assert.equal((yield* validateWith("https://x/y", ["198.17.0.1"]))._tag, "Success"); + assert.equal((yield* validateWith("https://x/y", ["198.20.0.1"]))._tag, "Success"); + }), + ); + it.effect("rejects documentation / protocol / 6to4 special ranges", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["192.0.0.1"]))._tag, "Failure"); // 192.0.0/24 + assert.equal((yield* validateWith("https://x/y", ["192.0.2.5"]))._tag, "Failure"); // TEST-NET-1 + assert.equal((yield* validateWith("https://x/y", ["198.51.100.5"]))._tag, "Failure"); // TEST-NET-2 + assert.equal((yield* validateWith("https://x/y", ["203.0.113.5"]))._tag, "Failure"); // TEST-NET-3 + assert.equal((yield* validateWith("https://x/y", ["192.88.99.1"]))._tag, "Failure"); // 6to4 relay + // a neighbouring public address in the same first octet still resolves OK + assert.equal((yield* validateWith("https://x/y", ["192.0.1.1"]))._tag, "Success"); + assert.equal((yield* validateWith("https://x/y", ["203.0.114.1"]))._tag, "Success"); + }), + ); + it.effect("rejects multicast, reserved/future, and broadcast", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["224.0.0.1"]))._tag, "Failure"); // multicast + assert.equal((yield* validateWith("https://x/y", ["239.255.255.255"]))._tag, "Failure"); + assert.equal((yield* validateWith("https://x/y", ["240.0.0.1"]))._tag, "Failure"); // reserved + assert.equal((yield* validateWith("https://x/y", ["255.255.255.255"]))._tag, "Failure"); // broadcast + assert.equal((yield* validateWith("https://x/y", ["223.255.255.255"]))._tag, "Success"); // last public + }), + ); + it.effect("rejects IPv6 loopback + link-local + unique-local", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["::1"]))._tag, "Failure"); + assert.equal((yield* validateWith("https://x/y", ["fe80::1"]))._tag, "Failure"); + assert.equal((yield* validateWith("https://x/y", ["fc00::1"]))._tag, "Failure"); + }), + ); + it.effect("rejects the full fe80::/10 link-local range (boundaries fe80/fe90/febf)", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["fe80::1"]))._tag, "Failure"); + assert.equal((yield* validateWith("https://x/y", ["fe90::1"]))._tag, "Failure"); + assert.equal((yield* validateWith("https://x/y", ["febf::1"]))._tag, "Failure"); + }), + ); + it.effect("rejects the full fc00::/7 unique-local range (boundaries fc00/fdff)", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["fc00::1"]))._tag, "Failure"); + assert.equal((yield* validateWith("https://x/y", ["fdff::1"]))._tag, "Failure"); + }), + ); + it.effect("rejects the ff00::/8 multicast range (link-local + boundaries)", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["ff02::1"]))._tag, "Failure"); // link-local all-nodes + assert.equal((yield* validateWith("https://x/y", ["ff00::1"]))._tag, "Failure"); // boundary low + assert.equal((yield* validateWith("https://x/y", ["ffff::1"]))._tag, "Failure"); // boundary high + }), + ); + it.effect("rejects IPv4-mapped IPv6 private (::ffff:10.0.0.1)", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["::ffff:10.0.0.1"]))._tag, "Failure"); + }), + ); + it.effect("rejects IPv4-mapped IPv6 in hex/expanded/compatible form (SSRF bypass)", () => + Effect.gen(function* () { + // ::ffff:7f00:1 === ::ffff:127.0.0.1 in hex-hextet form + assert.equal((yield* validateWith("https://x/y", ["::ffff:7f00:1"]))._tag, "Failure"); + // fully-expanded IPv4-mapped loopback + assert.equal((yield* validateWith("https://x/y", ["0:0:0:0:0:ffff:7f00:1"]))._tag, "Failure"); + // IPv4-mapped cloud-metadata (169.254.169.254 === a9fe:a9fe) + assert.equal((yield* validateWith("https://x/y", ["::ffff:a9fe:a9fe"]))._tag, "Failure"); + // deprecated IPv4-compatible loopback + assert.equal((yield* validateWith("https://x/y", ["::7f00:1"]))._tag, "Failure"); + }), + ); + it.effect("rejects NAT64-embedded private/metadata IPv4 (64:ff9b::/96)", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["64:ff9b::7f00:1"]))._tag, "Failure"); // 127.0.0.1 + assert.equal((yield* validateWith("https://x/y", ["64:ff9b::a9fe:a9fe"]))._tag, "Failure"); // 169.254.169.254 + }), + ); + it.effect("still accepts genuinely public IPv6 and public-embedded IPv4", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", ["2606:4700:4700::1111"]))._tag, "Success"); // Cloudflare + assert.equal((yield* validateWith("https://x/y", ["::ffff:8.8.8.8"]))._tag, "Success"); // public mapped + assert.equal((yield* validateWith("https://x/y", ["64:ff9b::808:808"]))._tag, "Success"); // NAT64 of 8.8.8.8 + }), + ); + it.effect("rejects when ANY resolved address is private (mixed)", () => + Effect.gen(function* () { + assert.equal( + (yield* validateWith("https://x/y", ["140.82.112.3", "10.0.0.1"]))._tag, + "Failure", + ); + }), + ); + it.effect("fails when the host does not resolve (empty)", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("https://x/y", []))._tag, "Failure"); + }), + ); + it.effect("fails on a malformed URL", () => + Effect.gen(function* () { + assert.equal((yield* validateWith("not a url", ["1.2.3.4"]))._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/workflow/outbound/OutboundUrlValidator.ts b/apps/server/src/workflow/outbound/OutboundUrlValidator.ts new file mode 100644 index 00000000000..53bfdbc4c9f --- /dev/null +++ b/apps/server/src/workflow/outbound/OutboundUrlValidator.ts @@ -0,0 +1,193 @@ +/** + * SSRF-aware outbound URL validator. + * + * Honest limitation / DNS-rebinding caveat: + * The HTTP stack used at delivery time (global `fetch`) cannot be pinned to a + * specific pre-resolved IP address. Re-validating at delivery time (TOCTOU + * mitigation) significantly raises the bar for a rebinding attack, but a + * determined attacker who controls DNS could still swap the record between + * the validation check and the subsequent `fetch`. Full prevention would + * require a custom HTTP client that connects to the IP returned by our own + * resolver. This is a known, documented limitation — not a silent bug. + */ + +import { Data, Effect } from "effect"; +import * as dns from "node:dns"; + +export class OutboundUrlError extends Data.TaggedError("OutboundUrlError")<{ + readonly reason: string; +}> {} + +export interface UrlValidatorDeps { + readonly lookup: (host: string) => Effect.Effect<ReadonlyArray<string>, OutboundUrlError>; +} + +const defaultLookup = (host: string): Effect.Effect<ReadonlyArray<string>, OutboundUrlError> => + Effect.tryPromise({ + try: async () => { + const records = await dns.promises.lookup(host, { all: true }); + return records.map((r) => r.address); + }, + catch: (error) => { + const code = (error as { code?: unknown })?.code; + const suffix = typeof code === "string" ? ` (${code})` : ""; + return new OutboundUrlError({ reason: `DNS resolution failed for ${host}${suffix}` }); + }, + }); + +// INVARIANT: only ever called on canonical dotted-decimal — the host from +// `new URL(...)` (already normalized) or an address string from `dns.lookup` +// output. Because of that, JS `Number()`'s octal/hex leniency +// (e.g. `Number("0177") === 177`) is NOT reachable here, and this MUST stay +// true: never call `isBlocked`/`ipv4Bytes` on a raw, un-normalized host string. +const ipv4Bytes = (ip: string): ReadonlyArray<number> | null => { + if (!ip.includes(".") || ip.includes(":")) return null; + const parts = ip.split(".").map((p) => Number(p)); + if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) + return null; + return parts; +}; + +// Blocks every IPv4 range that is NOT globally-routable unicast, per the IANA +// special-purpose registry (RFC 6890 and friends). User-configurable outbound +// webhooks must only reach public hosts, so we deny private, shared, loopback, +// link-local, protocol-assignment, documentation/TEST-NET, benchmarking, 6to4 +// relay anycast, multicast, reserved/future, and broadcast space. +const isDisallowedV4 = (b: ReadonlyArray<number>): boolean => { + const a = b[0] ?? -1; + const second = b[1] ?? -1; + const third = b[2] ?? -1; + if (a === 0) return true; // 0.0.0.0/8 "this network" + if (a === 10) return true; // 10/8 private + if (a === 127) return true; // 127/8 loopback + if (a === 100 && second >= 64 && second <= 127) return true; // 100.64/10 CGNAT (shared) + if (a === 169 && second === 254) return true; // 169.254/16 link-local (incl. cloud-metadata 169.254.169.254) + if (a === 172 && second >= 16 && second <= 31) return true; // 172.16/12 private + if (a === 192 && second === 0 && third === 0) return true; // 192.0.0/24 IETF protocol assignments + if (a === 192 && second === 0 && third === 2) return true; // 192.0.2/24 TEST-NET-1 (documentation) + if (a === 192 && second === 88 && third === 99) return true; // 192.88.99/24 6to4 relay anycast + if (a === 192 && second === 168) return true; // 192.168/16 private + if (a === 198 && (second === 18 || second === 19)) return true; // 198.18/15 benchmarking + if (a === 198 && second === 51 && third === 100) return true; // 198.51.100/24 TEST-NET-2 + if (a === 203 && second === 0 && third === 113) return true; // 203.0.113/24 TEST-NET-3 + if (a >= 224) return true; // 224/4 multicast + 240/4 reserved/future + 255.255.255.255 broadcast + return false; +}; + +// Expand any IPv6 textual form (compressed `::`, embedded dotted-IPv4 suffix, +// zone id) into its canonical 16 bytes. Returns null for anything that does not +// parse as IPv6 — callers MUST fail closed on null. A string-regex approach +// (matching only `::ffff:1.2.3.4`) silently misses the hex-hextet form +// (`::ffff:7f00:1`), the fully-expanded form, IPv4-compatible (`::7f00:1`), and +// NAT64 (`64:ff9b::`), all of which route to the same internal IPv4. +const ipv6Bytes = (raw: string): ReadonlyArray<number> | null => { + let ip = raw.toLowerCase().replace(/^\[|\]$/g, ""); + const zone = ip.indexOf("%"); + if (zone !== -1) ip = ip.slice(0, zone); + if (!ip.includes(":")) return null; + // Convert a trailing dotted-quad (e.g. `::ffff:127.0.0.1`) into two hextets. + const lastColon = ip.lastIndexOf(":"); + const tail = ip.slice(lastColon + 1); + if (tail.includes(".")) { + const v4 = ipv4Bytes(tail); + if (!v4) return null; + const hi = (((v4[0] ?? 0) << 8) | (v4[1] ?? 0)).toString(16); + const lo = (((v4[2] ?? 0) << 8) | (v4[3] ?? 0)).toString(16); + ip = `${ip.slice(0, lastColon + 1)}${hi}:${lo}`; + } + const halves = ip.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tailParts = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : null; + let hextets: ReadonlyArray<string>; + if (tailParts === null) { + hextets = head; + } else { + const fill = 8 - head.length - tailParts.length; + if (fill < 0) return null; + hextets = [...head, ...Array(fill).fill("0"), ...tailParts]; + } + if (hextets.length !== 8) return null; + const bytes: number[] = []; + for (const h of hextets) { + if (!/^[0-9a-f]{1,4}$/.test(h)) return null; + const n = parseInt(h, 16); + bytes.push((n >> 8) & 0xff, n & 0xff); + } + return bytes; +}; + +// If these 16 bytes embed an IPv4 address (mapped, compatible, NAT64, or 6to4), +// return that IPv4's bytes so the v4 allow/deny rules apply to it. +const embeddedV4 = (b: ReadonlyArray<number>): ReadonlyArray<number> | null => { + const zerosThrough = (n: number): boolean => b.slice(0, n).every((x) => x === 0); + // ::ffff:0:0/96 IPv4-mapped + if (zerosThrough(10) && b[10] === 0xff && b[11] === 0xff) return b.slice(12, 16); + // 64:ff9b::/96 well-known NAT64 + if ( + b[0] === 0x00 && + b[1] === 0x64 && + b[2] === 0xff && + b[3] === 0x9b && + b.slice(4, 12).every((x) => x === 0) + ) + return b.slice(12, 16); + // ::/96 IPv4-compatible (deprecated). :: and ::1 are handled separately. + if (zerosThrough(12)) return b.slice(12, 16); + // 2002::/16 6to4 — embedded v4 in bytes 2..5 + if (b[0] === 0x20 && b[1] === 0x02) return b.slice(2, 6); + return null; +}; + +const isPrivateV6 = (raw: string): boolean => { + const b = ipv6Bytes(raw); + // Fail closed: an address we cannot canonicalise is not provably public. + if (!b) return true; + // ::1 loopback / :: unspecified + if (b.slice(0, 15).every((x) => x === 0) && (b[15] === 0 || b[15] === 1)) return true; + const first = b[0] ?? 0; + const second = b[1] ?? 0; + if (first === 0xfe && (second & 0xc0) === 0x80) return true; // fe80::/10 link-local + if ((first & 0xfe) === 0xfc) return true; // fc00::/7 unique-local + if (first === 0xff) return true; // ff00::/8 multicast (matches IPv4 224/4 block) + const v4 = embeddedV4(b); + if (v4) return isDisallowedV4(v4); + return false; +}; + +const isBlocked = (ip: string): boolean => { + const v4 = ipv4Bytes(ip); + if (v4) return isDisallowedV4(v4); + return isPrivateV6(ip); +}; + +export const OutboundUrlValidator = { + validate: ( + rawUrl: string, + deps: UrlValidatorDeps = { lookup: defaultLookup }, + ): Effect.Effect<URL, OutboundUrlError> => + Effect.gen(function* () { + let parsed: URL; + // @effect-diagnostics-next-line tryCatchInEffectGen:off -- synchronous URL parse guard; not an Effect failure + try { + parsed = new URL(rawUrl); + } catch { + return yield* new OutboundUrlError({ reason: "Malformed URL" }); + } + if (parsed.protocol !== "https:") { + return yield* new OutboundUrlError({ reason: "Only https:// targets are allowed" }); + } + const addrs = yield* deps.lookup(parsed.hostname); + if (addrs.length === 0) { + return yield* new OutboundUrlError({ reason: "Host did not resolve" }); + } + for (const addr of addrs) { + if (isBlocked(addr)) { + return yield* new OutboundUrlError({ + reason: `Resolved to a disallowed address (${addr})`, + }); + } + } + return parsed; + }), +}; diff --git a/apps/server/src/workflow/outbound/outboundEventContext.test.ts b/apps/server/src/workflow/outbound/outboundEventContext.test.ts new file mode 100644 index 00000000000..d94ca982bcb --- /dev/null +++ b/apps/server/src/workflow/outbound/outboundEventContext.test.ts @@ -0,0 +1,184 @@ +import type { OutboundTrigger } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; +import { + OUTBOUND_EVENT_TYPES, + buildOutboundContext, + contextForRule, + matchesTrigger, +} from "./outboundEventContext.ts"; + +describe("outbound event context", () => { + it("gates exactly the outbound-relevant event types", () => { + expect(OUTBOUND_EVENT_TYPES.has("StepAwaitingUser")).toBe(true); + expect(OUTBOUND_EVENT_TYPES.has("TicketBlocked")).toBe(true); + expect(OUTBOUND_EVENT_TYPES.has("TicketMovedToLane")).toBe(true); + expect(OUTBOUND_EVENT_TYPES.has("TicketAdmitted")).toBe(true); + // StepFailed is NOT a first-class outbound event: step failures surface via the board's + // retry/fail path → a TicketBlocked (`blocked`) or a failure-route TicketMovedToLane + // (`lane_entered`), so gating StepFailed directly would double-fire. + expect(OUTBOUND_EVENT_TYPES.has("StepFailed")).toBe(false); + }); + it("every gated event type maps to an explicit (non-fallback) trigger", () => { + const expected: Record<string, OutboundTrigger> = { + StepAwaitingUser: "needs_attention", + TicketBlocked: "blocked", + TicketMovedToLane: "lane_entered", + TicketAdmitted: "lane_entered", + }; + for (const eventType of OUTBOUND_EVENT_TYPES) { + expect(expected[eventType], `missing expected trigger for ${eventType}`).toBeDefined(); + const ctx = buildOutboundContext({ + eventType, + ticketId: "t", + boardId: "b", + title: "x", + fromLane: null, + toLane: null, + postStatus: "s", + isTerminal: false, + reason: undefined, + occurredAt: "2026-06-14T00:00:00.000Z", + }); + expect(ctx.trigger).toBe(expected[eventType]); + } + }); + it("StepAwaitingUser → needs_attention", () => { + const ctx = buildOutboundContext({ + eventType: "StepAwaitingUser", + ticketId: "t1", + boardId: "b1", + title: "Fix", + fromLane: "in-progress", + toLane: null, + postStatus: "waiting_on_user", + isTerminal: false, + reason: undefined, + occurredAt: "2026-06-14T00:00:00.000Z", + }); + expect(ctx.trigger).toBe("needs_attention"); + expect(ctx.occurredAt).toBe("2026-06-14T00:00:00.000Z"); + expect(matchesTrigger({ on: "needs_attention" }, ctx)).toBe(true); + expect(matchesTrigger({ on: "blocked" }, ctx)).toBe(false); + }); + it("TicketBlocked → blocked, carries reason", () => { + const ctx = buildOutboundContext({ + eventType: "TicketBlocked", + ticketId: "t1", + boardId: "b1", + title: "Fix", + fromLane: "in-progress", + toLane: null, + postStatus: "blocked", + isTerminal: false, + reason: "3 retries failed", + occurredAt: "2026-06-14T00:00:00.000Z", + }); + expect(ctx.trigger).toBe("blocked"); + expect(ctx.reason).toBe("3 retries failed"); + expect(matchesTrigger({ on: "blocked" }, ctx)).toBe(true); + }); + it("TicketMovedToLane into a terminal lane → done AND lane_entered both match", () => { + const ctx = buildOutboundContext({ + eventType: "TicketMovedToLane", + ticketId: "t1", + boardId: "b1", + title: "Fix", + fromLane: "review", + toLane: "shipped", + postStatus: "idle", + isTerminal: true, + reason: undefined, + occurredAt: "2026-06-14T00:00:00.000Z", + }); + expect(ctx.isTerminal).toBe(true); + expect(matchesTrigger({ on: "done" }, ctx)).toBe(true); + expect(matchesTrigger({ on: "lane_entered" }, ctx)).toBe(true); + }); + it("TicketMovedToLane into a non-terminal lane → lane_entered only, not done", () => { + const ctx = buildOutboundContext({ + eventType: "TicketMovedToLane", + ticketId: "t1", + boardId: "b1", + title: "Fix", + fromLane: "todo", + toLane: "in-progress", + postStatus: "running", + isTerminal: false, + reason: undefined, + occurredAt: "2026-06-14T00:00:00.000Z", + }); + expect(matchesTrigger({ on: "done" }, ctx)).toBe(false); + expect(matchesTrigger({ on: "lane_entered" }, ctx)).toBe(true); + }); + it("TicketAdmitted → lane_entered, fromLane null", () => { + const ctx = buildOutboundContext({ + eventType: "TicketAdmitted", + ticketId: "t1", + boardId: "b1", + title: "Fix", + fromLane: null, + toLane: "todo", + postStatus: "queued", + isTerminal: false, + reason: undefined, + occurredAt: "2026-06-14T00:00:00.000Z", + }); + expect(ctx.trigger).toBe("lane_entered"); + expect(ctx.fromLane).toBeNull(); + expect(matchesTrigger({ on: "lane_entered" }, ctx)).toBe(true); + expect(matchesTrigger({ on: "done" }, ctx)).toBe(false); + }); + describe("contextForRule", () => { + it("a done rule on a terminal ctx → trigger becomes done", () => { + const ctx = buildOutboundContext({ + eventType: "TicketMovedToLane", + ticketId: "t1", + boardId: "b1", + title: "Fix", + fromLane: "review", + toLane: "shipped", + postStatus: "idle", + isTerminal: true, + reason: undefined, + occurredAt: "2026-06-14T00:00:00.000Z", + }); + expect(ctx.trigger).toBe("lane_entered"); + const ruleCtx = contextForRule({ on: "done" }, ctx); + expect(ruleCtx.trigger).toBe("done"); + // Everything else is preserved. + expect(ruleCtx.isTerminal).toBe(true); + expect(ruleCtx.toLane).toBe("shipped"); + expect(ruleCtx.fromLane).toBe("review"); + }); + it("a lane_entered rule → returns the base ctx unchanged", () => { + const ctx = buildOutboundContext({ + eventType: "TicketMovedToLane", + ticketId: "t1", + boardId: "b1", + title: "Fix", + fromLane: "review", + toLane: "shipped", + postStatus: "idle", + isTerminal: true, + reason: undefined, + occurredAt: "2026-06-14T00:00:00.000Z", + }); + expect(contextForRule({ on: "lane_entered" }, ctx)).toBe(ctx); + }); + it("a blocked rule → returns the base ctx unchanged", () => { + const ctx = buildOutboundContext({ + eventType: "TicketBlocked", + ticketId: "t1", + boardId: "b1", + title: "Fix", + fromLane: "in-progress", + toLane: null, + postStatus: "blocked", + isTerminal: false, + reason: "boom", + occurredAt: "2026-06-14T00:00:00.000Z", + }); + expect(contextForRule({ on: "blocked" }, ctx)).toBe(ctx); + }); + }); +}); diff --git a/apps/server/src/workflow/outbound/outboundEventContext.ts b/apps/server/src/workflow/outbound/outboundEventContext.ts new file mode 100644 index 00000000000..f211fec6210 --- /dev/null +++ b/apps/server/src/workflow/outbound/outboundEventContext.ts @@ -0,0 +1,88 @@ +import type { OutboundEventContext, OutboundTrigger } from "@t3tools/contracts"; +import { redactSensitiveText } from "../redactSensitiveText.ts"; + +// INVARIANT: every member here must have an explicit case in `primaryTrigger` +// (pinned by the "every gated event type maps to an explicit (non-fallback) trigger" +// coverage test). Adding a tag without updating the switch would silently get the +// `lane_entered` fallback and fire `lane_entered` rules spuriously. +export const OUTBOUND_EVENT_TYPES = new Set<string>([ + "StepAwaitingUser", + "TicketBlocked", + "TicketMovedToLane", + "TicketAdmitted", +]); + +export interface OutboundContextInput { + readonly eventType: string; + readonly ticketId: string; + readonly boardId: string; + readonly title: string; + readonly fromLane: string | null; + readonly toLane: string | null; + readonly postStatus: string; + readonly isTerminal: boolean; + readonly reason: string | undefined; + readonly occurredAt: string; +} + +// The PRIMARY trigger label the event maps to (ctx.trigger). `done` is NOT a primary label — +// it's computed in matchesTrigger as lane_entered && isTerminal so a `when` on `trigger` is predictable. +const primaryTrigger = (eventType: string): OutboundTrigger => { + switch (eventType) { + case "StepAwaitingUser": + return "needs_attention"; + case "TicketBlocked": + return "blocked"; + case "TicketMovedToLane": + case "TicketAdmitted": + return "lane_entered"; + default: + return "lane_entered"; + } +}; + +export const buildOutboundContext = (input: OutboundContextInput): OutboundEventContext => ({ + trigger: primaryTrigger(input.eventType), + ticketId: input.ticketId, + boardId: input.boardId, + title: input.title, + status: input.postStatus, + fromLane: input.fromLane, + toLane: input.toLane, + isTerminal: input.isTerminal, + // Redact secrets from `reason` before it is persisted in context_json and + // POSTed to a user-configured third-party endpoint. `reason` can carry step + // stderr or `Cause.pretty(...)` output, exactly where tokens surface — the + // internal push path redacts the same field, so outbound must too. + ...(input.reason !== undefined ? { reason: redactSensitiveText(input.reason) } : {}), + occurredAt: input.occurredAt, +}); + +// Rule-specific context used for `when` evaluation, storage (context_json) and +// later rendering. `matchesTrigger` deliberately gates `done` against the BASE +// ctx (lane_entered && isTerminal); once a rule has MATCHED as `done`, the +// context it carries forward must report `trigger: "done"` so that a +// `{"==":[{"var":"trigger"},"done"]}` predicate (which the board editor suggests) +// matches and the rendered payload shows "Done" rather than the "Moved" label. +export const contextForRule = ( + rule: { on: OutboundTrigger }, + ctx: OutboundEventContext, +): OutboundEventContext => (rule.on === "done" ? { ...ctx, trigger: "done" } : ctx); + +export const matchesTrigger = ( + rule: { on: OutboundTrigger }, + ctx: OutboundEventContext, +): boolean => { + switch (rule.on) { + case "needs_attention": + return ctx.trigger === "needs_attention"; + case "blocked": + return ctx.trigger === "blocked"; + case "lane_entered": + return ctx.trigger === "lane_entered"; + case "done": + return ctx.trigger === "lane_entered" && ctx.isTerminal; + default: + return false; + } +}; diff --git a/apps/server/src/workflow/outbound/outboundFormatters.test.ts b/apps/server/src/workflow/outbound/outboundFormatters.test.ts new file mode 100644 index 00000000000..4ec6a8ebdc7 --- /dev/null +++ b/apps/server/src/workflow/outbound/outboundFormatters.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { renderOutbound } from "./outboundFormatters.ts"; + +const ctx = { + trigger: "blocked", + ticketId: "t1", + boardId: "b1", + title: "Fix login", + status: "blocked", + fromLane: "in-progress", + toLane: "in-progress", + isTerminal: false, + reason: "3 retries failed", + occurredAt: "2026-06-14T00:00:00.000Z", +} as const; + +const ABS_URL = "https://app.example.com/tickets/env1/b1/t1"; + +describe("outbound formatters", () => { + it("generic envelope is stable JSON with event + ticket; url is the absolute ticketUrl when provided", () => { + const { body, contentType } = renderOutbound("generic", ctx, { + connection: { kind: "webhook", url: "https://x/y" }, + ticketUrl: ABS_URL, + }); + expect(contentType).toBe("application/json"); + const p = JSON.parse(body); + expect(p.event).toBe("blocked"); + expect(p.ticket.id).toBe("t1"); + expect(p.ticket.title).toBe("Fix login"); + expect(p.ticket.url).toBe(ABS_URL); + }); + it("generic envelope ticket.url is null when no ticketUrl is provided", () => { + const p = JSON.parse( + renderOutbound("generic", ctx, { connection: { kind: "webhook", url: "https://x/y" } }).body, + ); + expect(p.ticket.url).toBeNull(); + // The full context is still embedded so consumers can build their own link. + expect(p.context.boardId).toBe("b1"); + expect(p.context.ticketId).toBe("t1"); + }); + it("slack body has header+section blocks and a text fallback", () => { + const { body } = renderOutbound("slack", ctx, { + connection: { kind: "slack", url: "https://hooks.slack.com/x" }, + ticketUrl: ABS_URL, + }); + const p = JSON.parse(body); + expect(Array.isArray(p.blocks)).toBe(true); + expect(typeof p.text).toBe("string"); + expect(p.text).toContain("Fix login"); + expect(p.blocks.some((b: { type: string }) => b.type === "header")).toBe(true); + expect(p.blocks.some((b: { type: string }) => b.type === "section")).toBe(true); + }); + it("slack WITH ticketUrl has an actions block whose button url is the absolute url", () => { + const p = JSON.parse( + renderOutbound("slack", ctx, { + connection: { kind: "slack", url: "https://hooks.slack.com/x" }, + ticketUrl: ABS_URL, + }).body, + ); + const actions = p.blocks.find((b: { type: string }) => b.type === "actions"); + expect(actions).toBeDefined(); + expect(actions.elements[0].type).toBe("button"); + expect(actions.elements[0].url).toBe(ABS_URL); + }); + it("slack WITHOUT ticketUrl omits the actions block but keeps header+section+text", () => { + const p = JSON.parse( + renderOutbound("slack", ctx, { + connection: { kind: "slack", url: "https://hooks.slack.com/x" }, + }).body, + ); + expect(p.blocks.every((b: { type: string }) => b.type !== "actions")).toBe(true); + expect(p.blocks.some((b: { type: string }) => b.type === "header")).toBe(true); + expect(p.blocks.some((b: { type: string }) => b.type === "section")).toBe(true); + expect(typeof p.text).toBe("string"); + expect(p.text).toContain("Fix login"); + }); + it("slack section includes reason when present and omits it when absent", () => { + const withReason = JSON.parse( + renderOutbound("slack", ctx, { connection: { kind: "slack", url: "https://x" } }).body, + ); + expect(JSON.stringify(withReason)).toContain("3 retries failed"); + const noReason = JSON.parse( + renderOutbound( + "slack", + { ...ctx, reason: undefined }, + { + connection: { kind: "slack", url: "https://x" }, + }, + ).body, + ); + expect(JSON.stringify(noReason)).not.toContain("Reason:"); + }); + it("generic ticket.lane reflects toLane", () => { + const p = JSON.parse( + renderOutbound( + "generic", + { ...ctx, toLane: "done" }, + { + connection: { kind: "webhook", url: "https://x" }, + }, + ).body, + ); + expect(p.ticket.lane).toBe("done"); + }); +}); diff --git a/apps/server/src/workflow/outbound/outboundFormatters.ts b/apps/server/src/workflow/outbound/outboundFormatters.ts new file mode 100644 index 00000000000..457f5a8181d --- /dev/null +++ b/apps/server/src/workflow/outbound/outboundFormatters.ts @@ -0,0 +1,97 @@ +/** + * Pure outbound payload formatters. + * + * Turns a normalized OutboundEventContext into an HTTP body ready for the + * outbound dispatcher to POST. No IO — purely deterministic string + * construction. + * + * Link-agnostic by design: the formatter NEVER builds a route. The dispatcher + * (Task 12) owns base-URL config + the runtime environmentId, so it constructs + * the absolute ticket URL and passes it in via RenderOptions.ticketUrl. This + * matters for Slack: a Block Kit button `url` MUST be an absolute http(s) URL — + * a relative path makes Slack reject the entire message (HTTP 400 + * invalid_blocks). When no absolute URL is available, the Slack actions block is + * omitted entirely (a still-valid, deliverable message), and the generic + * envelope's ticket.url is null. + */ + +import type { OutboundEventContext, OutboundFormatter } from "@t3tools/contracts"; + +export interface RenderedDelivery { + readonly body: string; + readonly contentType: string; +} + +export interface RenderOptions { + readonly connection: { readonly kind: string; readonly url: string }; + readonly ticketUrl?: string; // a fully-formed ABSOLUTE url, or undefined if none available +} + +const TRIGGER_LABEL: Record<string, string> = { + needs_attention: "Needs attention", + blocked: "Blocked", + done: "Done", + lane_entered: "Moved", +}; + +const generic = (ctx: OutboundEventContext, ticketUrl: string | undefined): RenderedDelivery => ({ + contentType: "application/json", + body: JSON.stringify({ + event: ctx.trigger, + board: { id: ctx.boardId }, + ticket: { + id: ctx.ticketId, + title: ctx.title, + status: ctx.status, + lane: ctx.toLane, + url: ticketUrl ?? null, + }, + occurredAt: ctx.occurredAt, + context: ctx, + }), +}); + +const slack = (ctx: OutboundEventContext, ticketUrl: string | undefined): RenderedDelivery => { + const header = TRIGGER_LABEL[ctx.trigger] ?? ctx.trigger; + const fallback = `${header}: ${ctx.title} (${ctx.status})`; + const sectionText = [ + `*${ctx.title}*`, + `Status: ${ctx.status}`, + ctx.toLane ? `Lane: ${ctx.toLane}` : null, + ctx.reason ? `Reason: ${ctx.reason}` : null, + ] + .filter(Boolean) + .join("\n"); + + const blocks: Array<Record<string, unknown>> = [ + { type: "header", text: { type: "plain_text", text: header } }, + { type: "section", text: { type: "mrkdwn", text: sectionText } }, + ]; + + // Slack rejects a button with a relative/empty url (HTTP 400 invalid_blocks), + // so only attach the "View ticket" action when we have an absolute URL. + if (ticketUrl !== undefined) { + blocks.push({ + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "View ticket" }, + url: ticketUrl, + }, + ], + }); + } + + return { + contentType: "application/json", + body: JSON.stringify({ text: fallback, blocks }), + }; +}; + +export const renderOutbound = ( + formatter: OutboundFormatter, + ctx: OutboundEventContext, + options: RenderOptions, +): RenderedDelivery => + formatter === "slack" ? slack(ctx, options.ticketUrl) : generic(ctx, options.ticketUrl); diff --git a/apps/server/src/workflow/redactSensitiveText.test.ts b/apps/server/src/workflow/redactSensitiveText.test.ts new file mode 100644 index 00000000000..827a7901ffa --- /dev/null +++ b/apps/server/src/workflow/redactSensitiveText.test.ts @@ -0,0 +1,254 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + redactSensitiveText, + truncateKeepingHead, + truncateKeepingTail, +} from "./redactSensitiveText.ts"; + +describe("redactSensitiveText", () => { + describe("GitHub tokens", () => { + it("redacts ghp_ tokens", () => { + assert.equal( + redactSensitiveText("token: ghp_" + "abcdefghijklmnopqrstu1234567890"), + "token: [redacted]", + ); + }); + + it("redacts gho_ tokens via the gh prefix pattern (not high-entropy)", () => { + // All lowercase+digits, no uppercase: HIGH_ENTROPY's uppercase lookahead + // cannot fire, so redaction here proves the gh[pousr]_ pattern matched. + assert.equal(redactSensitiveText("auth gho_" + "abcdefghij1234567890ab"), "auth [redacted]"); + }); + + it("does not redact a too-short gho_ token", () => { + const text = "gho_abc"; + assert.equal(redactSensitiveText(text), text); + }); + + it("redacts github_pat_ tokens", () => { + assert.equal( + redactSensitiveText("github_pat_" + "ABCDEFGHIJKLMNOPQRSTUVWXYZabc1234567890"), + "[redacted]", + ); + }); + }); + + describe("OpenAI tokens", () => { + it("redacts sk- tokens", () => { + assert.equal( + redactSensitiveText("key=sk-" + "ABCDEFGHIJKLMNOPQRSTUVWXYZabc123"), + "key=[redacted]", + ); + }); + }); + + describe("Stripe-style keys", () => { + it("redacts an all-lowercase sk_live_ key the high-entropy sweep misses", () => { + // No uppercase + underscore prefix: neither the OpenAI `sk-` rule nor the + // uppercase-requiring high-entropy sweep would catch this. + assert.equal( + redactSensitiveText("key=sk_live_" + "4ec39hqlyjwdarjtt1zdp7dc"), + "key=[redacted]", + ); + }); + + it("redacts restricted rk_live_ keys", () => { + assert.equal(redactSensitiveText("rk_live_" + "abcdefghijklmnop1234567890"), "[redacted]"); + }); + + it("redacts sk_test_ keys", () => { + assert.equal( + redactSensitiveText("STRIPE=sk_test_" + "abcdefghijklmnopqrstuvwx"), + "STRIPE=[redacted]", + ); + }); + }); + + describe("Bearer tokens", () => { + it("redacts Bearer header values", () => { + assert.equal( + redactSensitiveText("Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + "Authorization: [redacted]", + ); + }); + + it("does not redact very short Bearer values", () => { + const text = "Bearer short"; + // "short" is only 5 chars, well below 16 + assert.equal(redactSensitiveText(text), text); + }); + }); + + describe("AWS keys", () => { + it("redacts AKIA keys", () => { + assert.equal(redactSensitiveText("key: AKIA" + "IOSFODNN7EXAMPLE"), "key: [redacted]"); + }); + + it("does not redact AKIA with wrong length", () => { + // Only 15 chars after AKIA (needs 16) + const text = "AKIAIOSFODNN7EXA"; + assert.equal(redactSensitiveText(text), text); + }); + }); + + describe("NAME=value / NAME: value lines", () => { + it("redacts TOKEN= assignments", () => { + assert.equal(redactSensitiveText("MY_TOKEN=supersecretvalue123"), "MY_TOKEN=[redacted]"); + }); + + it("redacts SECRET: assignments", () => { + assert.equal(redactSensitiveText("APP_SECRET: mysecretvalue"), "APP_SECRET=[redacted]"); + }); + + it("redacts PASSWORD= assignments", () => { + assert.equal(redactSensitiveText("DB_PASSWORD=hunter2"), "DB_PASSWORD=[redacted]"); + }); + + it("redacts KEY= assignments", () => { + assert.equal(redactSensitiveText("API_KEY=abcdef123456"), "API_KEY=[redacted]"); + }); + + it("redacts CREDENTIAL= assignments", () => { + assert.equal( + redactSensitiveText("AWS_CREDENTIAL=some_cred_value"), + "AWS_CREDENTIAL=[redacted]", + ); + }); + + it("redacts case-insensitively (lowercase token)", () => { + assert.equal(redactSensitiveText("access_token=abc123xyz"), "access_token=[redacted]"); + }); + + it("handles multiline input, only redacts matching lines", () => { + const input = `HOST=localhost\nAPI_KEY=supersecret\nPORT=3000`; + const output = redactSensitiveText(input); + assert.include(output, "HOST=localhost"); + assert.include(output, "API_KEY=[redacted]"); + assert.include(output, "PORT=3000"); + }); + + it("does not redact KEYBOARD= (no matching sensitive word)", () => { + const text = "KEYBOARD=qwerty"; + assert.equal(redactSensitiveText(text), text); + }); + + it("does not trip on ordinary sentence with KEYBOARD word not followed by =", () => { + const text = "the KEYBOARD shortcut is ctrl+c"; + assert.equal(redactSensitiveText(text), text); + }); + }); + + describe("high-entropy strings", () => { + it("redacts high-entropy 32+ char strings", () => { + // 32-char string with upper, lower, digit + const secret = "aBcDeFgH1234567890ABCDEFGHIJKLMN"; + assert.equal(redactSensitiveText(secret), "[redacted]"); + }); + + it("does not redact short strings even if mixed-case", () => { + const text = "Hello World 123"; + assert.equal(redactSensitiveText(text), text); + }); + + it("does not redact long all-lowercase strings (no uppercase)", () => { + const text = "abcdefghijklmnopqrstuvwxyz1234567890abc"; + assert.equal(redactSensitiveText(text), text); + }); + + it("does not redact long all-uppercase strings (no lowercase)", () => { + const text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABC"; + assert.equal(redactSensitiveText(text), text); + }); + + it("does not redact long strings with no digit", () => { + const text = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijkl"; + assert.equal(redactSensitiveText(text), text); + }); + }); + + describe("leaves ordinary prose untouched", () => { + it("does not redact build failure message", () => { + const text = "the build failed at line 42"; + assert.equal(redactSensitiveText(text), text); + }); + + it("does not redact import statement", () => { + const text = "import { foo } from 'bar'"; + assert.equal(redactSensitiveText(text), text); + }); + + it("does not redact normal sentence", () => { + const text = "Error: could not find module at path /home/user/app"; + assert.equal(redactSensitiveText(text), text); + }); + }); +}); + +describe("truncateKeepingTail", () => { + it("returns text unchanged when length <= max", () => { + assert.equal(truncateKeepingTail("hello", 10), "hello"); + assert.equal(truncateKeepingTail("hello", 5), "hello"); + }); + + it("truncates with the marker INCLUDED in the budget (result length <= max)", () => { + const marker = "…[truncated]\n"; + const text = "abcdefghijklmnopqrstuvwxyz"; + const result = truncateKeepingTail(text, 20); + // The result must fit within max — the marker is part of the budget. + assert.isTrue(result.length <= 20); + assert.isTrue(result.startsWith(marker)); + // Tail is the last (max - markerLength) chars of the source. + assert.equal(result.slice(marker.length), text.slice(text.length - (20 - marker.length))); + }); + + it("never exceeds max for a large body capped at the ticket body limit", () => { + const text = "y".repeat(20_000); + const max = 8_000; + const result = truncateKeepingTail(text, max); + assert.isTrue(result.length <= max); + assert.isTrue(result.startsWith("…[truncated]\n")); + }); + + it("handles empty string", () => { + assert.equal(truncateKeepingTail("", 10), ""); + }); + + it("handles max smaller than the marker without exceeding max", () => { + // text longer than max forces truncation; max < marker length (13). + const result = truncateKeepingTail("hello world", 5); + assert.isTrue(result.length <= 5); + }); +}); + +describe("truncateKeepingHead", () => { + it("returns text unchanged when length <= max", () => { + assert.equal(truncateKeepingHead("hello", 10), "hello"); + assert.equal(truncateKeepingHead("hello", 5), "hello"); + }); + + it("keeps the START and appends the marker within the budget", () => { + const marker = "…[truncated]"; + const text = "actionable summary first, then trailing log noise goes here"; + const result = truncateKeepingHead(text, 30); + assert.isTrue(result.length <= 30); + assert.isTrue(result.endsWith(marker)); + // The kept prefix is the first (max - markerLength) chars of the source. + assert.equal(result.slice(0, result.length - marker.length), text.slice(0, 30 - marker.length)); + }); + + it("never exceeds max for a large body", () => { + const text = "y".repeat(20_000); + const result = truncateKeepingHead(text, 240); + assert.isTrue(result.length <= 240); + assert.isTrue(result.endsWith("…[truncated]")); + }); + + it("handles empty string", () => { + assert.equal(truncateKeepingHead("", 10), ""); + }); + + it("handles max smaller than the marker without exceeding max", () => { + const result = truncateKeepingHead("hello world", 5); + assert.isTrue(result.length <= 5); + }); +}); diff --git a/apps/server/src/workflow/redactSensitiveText.ts b/apps/server/src/workflow/redactSensitiveText.ts new file mode 100644 index 00000000000..2f1ae0e6183 --- /dev/null +++ b/apps/server/src/workflow/redactSensitiveText.ts @@ -0,0 +1,127 @@ +/** + * Redacts sensitive strings (tokens, secrets, high-entropy values) from text, + * and provides a tail-keeping truncation utility. + * + * Pure module — no Effect, no external dependencies. + */ + +/** + * Pattern that tests whether a variable name contains a sensitive word as a + * complete underscore-delimited segment (e.g. MY_TOKEN, API_KEY, access_token) + * but NOT as an arbitrary substring (e.g. KEYBOARD is not sensitive). + */ +const SENSITIVE_NAME = /(?:^|_)(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL)(?:_|$)/i; + +/** + * Build each regex fresh per call so we never have lastIndex statefulness + * issues from module-level shared regexes used with repeated replacements. + * + * Order matters: specific token patterns run before the NAME=value sweep so + * that tokens embedded in `name: <token>` lines are redacted by the token + * pattern first, leaving `name: [redacted]`. The NAME=value pattern then uses + * a negative lookahead to skip lines whose value is already `[redacted]`. + */ +const buildPatterns = (): Array<(text: string) => string> => [ + // GitHub tokens: ghp_, gho_, ghu_, ghs_, ghr_ + (t) => t.replace(/gh[pousr]_[A-Za-z0-9_]{20,}/g, "[redacted]"), + // GitHub fine-grained PATs + (t) => t.replace(/github_pat_[A-Za-z0-9_]{20,}/g, "[redacted]"), + // OpenAI API keys + (t) => t.replace(/sk-[A-Za-z0-9_-]{20,}/g, "[redacted]"), + // Stripe-style secret / restricted keys (sk_live_, sk_test_, rk_live_, + // rk_test_). These are all-lowercase+digit after the prefix, so the OpenAI + // `sk-` rule (hyphen) and the uppercase-requiring high-entropy sweep both + // miss them; match the explicit prefix instead. + (t) => t.replace(/\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g, "[redacted]"), + // Bearer tokens (HTTP Authorization header values, ≥16 chars) + (t) => t.replace(/\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/g, "[redacted]"), + // AWS access key IDs (AKIA + exactly 16 uppercase alnum chars) + (t) => t.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]"), + // NAME=value or NAME: value lines where NAME contains a sensitive word as a + // complete _ segment. Only fires when the value is NOT already `[redacted]` + // (prevents double-processing lines already handled by a specific pattern). + (t) => + t.replace(/^([A-Za-z_]+)\s*[=:]\s*(?!\[redacted\])(\S+)$/gim, (_, name: string) => + SENSITIVE_NAME.test(name) ? `${name}=[redacted]` : _, + ), +]; + +/** + * High-entropy string pattern: ≥32 non-whitespace chars that contain at least + * one uppercase letter, one lowercase letter, and one digit. + * Applied last — already-replaced `[redacted]` markers are 10 chars and won't + * match, so there's no risk of double-processing. + * + * Known limitation (over-redaction): this also catches benign mixed-case + * ≥32-char tokens that commonly appear in CI logs — e.g. npm SRI integrity + * hashes (`sha512-...`), JWTs, and git/content hashes. These are reproducible + * and non-damaging, so over-redacting them is acceptable. + * + * Known limitation (under-redaction): the uppercase+lowercase+digit gate is + * DELIBERATE — it avoids redacting all-lowercase-hex git SHAs and similar IDs. + * The cost is that a purely-lowercase-hex secret (32+ hex chars, no uppercase) + * is NOT swept here. Prefixed secrets are covered by the explicit patterns above + * (gh*_, github_pat_, sk-/[rs]k_(live|test)_, Bearer, AKIA); a generic + * lowercase-hex sweep is intentionally omitted to keep the git-SHA false-positive + * rate low. Add a contextual pattern above if a specific lowercase-only secret + * format needs coverage. + */ +const HIGH_ENTROPY_RE = /\b(?=[^\s]*[A-Z])(?=[^\s]*[a-z])(?=[^\s]*\d)[A-Za-z0-9+/_=-]{32,}\b/g; + +/** + * Redacts known credential patterns and high-entropy strings from `text`. + * Returns the sanitised copy; the original is not mutated. + */ +export const redactSensitiveText = (text: string): string => { + let out = text; + for (const apply of buildPatterns()) out = apply(out); + // High-entropy sweep runs after all known patterns. + out = out.replace(HIGH_ENTROPY_RE, "[redacted]"); + return out; +}; + +const TRUNCATION_MARKER = "…[truncated]\n"; + +/** + * If `text` is longer than `max` characters, returns a string of length ≤ `max` + * that starts with the marker line "…[truncated]\n" followed by the LAST chars + * of `text`. The marker is INCLUDED in the budget, so the result never exceeds + * `max` — callers can pass a hard limit (e.g. the ticket message body cap) and + * rely on the output fitting under it. Otherwise returns `text` unchanged. + * + * When `max` is smaller than the marker itself, the marker is truncated to fit + * (degenerate but bounded) so the contract — result length ≤ max — always holds. + */ +export const truncateKeepingTail = (text: string, max: number): string => { + if (text.length <= max) return text; + if (max <= TRUNCATION_MARKER.length) { + return TRUNCATION_MARKER.slice(0, Math.max(0, max)); + } + const tailBudget = max - TRUNCATION_MARKER.length; + return `${TRUNCATION_MARKER}${text.slice(text.length - tailBudget)}`; +}; + +const HEAD_TRUNCATION_MARKER = "…[truncated]"; + +/** + * If `text` is longer than `max`, returns a string of length ≤ `max` that keeps + * the START of `text` followed by the "…[truncated]" marker. The marker is + * INCLUDED in the budget so the result never exceeds `max`. Otherwise returns + * `text` unchanged. + * + * Use this (not `truncateKeepingTail`) where the meaningful content is at the + * front — e.g. a one-line push-notification preview of an attention reason, + * whose actionable summary leads and whose trailing noise (logs/stack frames) + * is the part safe to drop. + * + * When `max` is smaller than the marker itself, the marker is truncated to fit + * (degenerate but bounded) so the contract — result length ≤ max — always holds. + */ +export const truncateKeepingHead = (text: string, max: number): string => { + if (text.length <= max) return text; + if (max <= HEAD_TRUNCATION_MARKER.length) { + return HEAD_TRUNCATION_MARKER.slice(0, Math.max(0, max)); + } + const headBudget = max - HEAD_TRUNCATION_MARKER.length; + return `${text.slice(0, headBudget)}${HEAD_TRUNCATION_MARKER}`; +}; diff --git a/apps/server/src/workflow/sampleBoardFile.test.ts b/apps/server/src/workflow/sampleBoardFile.test.ts new file mode 100644 index 00000000000..2d124dec0ad --- /dev/null +++ b/apps/server/src/workflow/sampleBoardFile.test.ts @@ -0,0 +1,57 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { WorkflowDefinition } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { lintWorkflowDefinition } from "./workflowFile.ts"; + +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); + +it.layer(NodeServices.layer)("sample delivery board", (it) => { + it.effect("decodes and lints for the default codex provider", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = path.join(process.cwd(), "../.."); + const raw = yield* fileSystem.readFileString(path.join(repoRoot, ".t3/boards/delivery.json")); + const definition = yield* decodeWorkflowDefinitionJson(raw); + const lintErrors = lintWorkflowDefinition(definition, { + providerInstanceExists: (instanceId) => instanceId === "codex", + instructionFileExists: () => true, + }); + + assert.equal(definition.name, "Standard delivery"); + assert.deepEqual( + lintErrors.map((error) => error.code), + [], + ); + }), + ); +}); + +it.layer(NodeServices.layer)("github-flow example board", (it) => { + it.effect("decodes and lints with no errors", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = path.join(process.cwd(), "../.."); + const raw = yield* fileSystem.readFileString( + path.join(repoRoot, "docs/workflow-boards/github-flow-example.json"), + ); + const definition = yield* decodeWorkflowDefinitionJson(raw); + const lintErrors = lintWorkflowDefinition(definition, { + providerInstanceExists: (instanceId) => instanceId === "codex", + instructionFileExists: () => true, + }); + + assert.equal(definition.name, "GitHub flow"); + assert.deepEqual( + lintErrors.map((error) => error.code), + [], + ); + }), + ); +}); diff --git a/apps/server/src/workflow/scanSource.test.ts b/apps/server/src/workflow/scanSource.test.ts new file mode 100644 index 00000000000..0aba883fbff --- /dev/null +++ b/apps/server/src/workflow/scanSource.test.ts @@ -0,0 +1,77 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { + scanSource, + chunkArray, + MAX_ITEMS_PER_SOURCE_TICK, + MAX_DELTAS_PER_RECONCILE_CHUNK, +} from "./scanSource.ts"; +import type { + ExternalWorkItem, + WorkSourcePage, + WorkSourceProvider, +} from "./Services/WorkSourceProvider.ts"; + +const item = (id: string): ExternalWorkItem => ({ + provider: "github", + externalId: id, + url: `https://x/${id}`, + lifecycle: "open", + version: {}, + fields: { title: id }, +}); +const stubProvider = (pages: Array<WorkSourcePage>): WorkSourceProvider => + ({ + provider: "github", + selectorSchema: {} as never, + listPage: () => Effect.succeed(pages.shift() ?? { items: [] }), + }) as unknown as WorkSourceProvider; +const source = { + id: "s", + provider: "github", + connectionRef: "c", + selector: {}, + destinationLane: "inbox", + closedLane: "done", + enabled: true, +} as never; + +describe("scanSource", () => { + it("returns all items and scanCompleted=true when the last page has no nextPageToken", () => + Effect.gen(function* () { + const r = yield* scanSource( + stubProvider([{ items: [item("1"), item("2")] }]), + source, + undefined, + ); + assert.deepEqual( + r.items.map((i) => i.externalId), + ["1", "2"], + ); + assert.equal(r.scanCompleted, true); + })); + + it("consumes the whole page before the cap check; scanCompleted=false when a token remains", () => + Effect.gen(function* () { + const big = Array.from({ length: MAX_ITEMS_PER_SOURCE_TICK }, (_, i) => item(String(i))); + const r = yield* scanSource( + stubProvider([{ items: big, nextPageToken: "more" }, { items: [item("x")] }]), + source, + undefined, + ); + assert.equal(r.items.length, MAX_ITEMS_PER_SOURCE_TICK); + assert.equal(r.scanCompleted, false); + })); +}); + +describe("chunkArray", () => { + it("splits into chunks of the reconcile size", () => { + const chunks = chunkArray( + Array.from({ length: MAX_DELTAS_PER_RECONCILE_CHUNK + 1 }, (_, i) => i), + MAX_DELTAS_PER_RECONCILE_CHUNK, + ); + assert.equal(chunks.length, 2); + assert.equal(chunks[0]!.length, MAX_DELTAS_PER_RECONCILE_CHUNK); + assert.equal(chunks[1]!.length, 1); + }); +}); diff --git a/apps/server/src/workflow/scanSource.ts b/apps/server/src/workflow/scanSource.ts new file mode 100644 index 00000000000..923811f7492 --- /dev/null +++ b/apps/server/src/workflow/scanSource.ts @@ -0,0 +1,113 @@ +import * as Effect from "effect/Effect"; +import type { WorkflowSourceConfig } from "@t3tools/contracts"; +import type { + ExternalWorkItem, + WorkSourcePage, + WorkSourceProvider, + WorkSourceProviderError, +} from "./Services/WorkSourceProvider.ts"; + +// --------------------------------------------------------------------------- +// Locked tuning constants (do not change without the plan owner's sign-off). +// --------------------------------------------------------------------------- + +// Hard ceiling on listPage round-trips per (board, source) per sweep tick. +// Bounds a single tick's network + memory; a source with more pages than this +// is scanned partially (scanCompleted=false). +export const MAX_PAGES_PER_SOURCE_TICK = 10; +// Hard ceiling on accumulated items per (board, source) per sweep tick. +// +// COMPLETENESS CAVEAT: each tick re-scans from the first page (no persisted +// pagination cursor), so the SAME leading <=MAX_ITEMS items are fetched each +// tick. An active board still converges — as those items are admitted/closed +// they drop out of the upstream open query, exposing the next batch. But a +// STATIC backlog larger than this cap that never resolves is only ever scanned +// up to the cap; the tail is not reached. Full coverage of a large static +// backlog needs a persisted continuation cursor (resume past the cap) plus a +// cross-tick accumulated seen-set so missing/orphan detection stays correct — +// deliberately deferred as a larger change. recordSuccess only advances the +// cadence anchor so a partial scan no longer re-hammers the provider every tick. +export const MAX_ITEMS_PER_SOURCE_TICK = 500; +// The committer takes the board locks + a transaction PER chunk and releases +// between chunks, so this bounds how long the board is locked at once. +export const MAX_DELTAS_PER_RECONCILE_CHUNK = 50; + +// --------------------------------------------------------------------------- +// Pagination result +// --------------------------------------------------------------------------- + +export interface ScanResult { + readonly items: ReadonlyArray<ExternalWorkItem>; + // scanCompleted is TRUE iff we reached a page with no nextPageToken AND + // neither the page cap nor the item cap was hit. A cap hit while a + // nextPageToken is still present means the scan is PARTIAL → the + // completeness gate stays closed (no missing/orphan detection). NOTE: the + // cadence anchor (last_full_run_at) IS still advanced on a partial scan so + // the source respects its syncIntervalSec — see recordSuccess. + readonly scanCompleted: boolean; +} + +export const chunkArray = <A>( + items: ReadonlyArray<A>, + size: number, +): ReadonlyArray<ReadonlyArray<A>> => { + const out: Array<ReadonlyArray<A>> = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; +}; + +// Paginate listPage up to the page/item caps. completeness gate: +// scanCompleted=true ONLY when a page arrives with no nextPageToken before +// either cap was hit. If we stop because of a cap while a token remains, +// scanCompleted=false. +export const scanSource = ( + provider: WorkSourceProvider, + source: WorkflowSourceConfig, + since: string | undefined, +): Effect.Effect<ScanResult, WorkSourceProviderError> => + Effect.gen(function* () { + const items: Array<ExternalWorkItem> = []; + let pageToken: string | undefined = undefined; + let scanCompleted = false; + for (let page = 0; page < MAX_PAGES_PER_SOURCE_TICK; page++) { + const fetched: WorkSourcePage = yield* provider.listPage({ + connectionRef: source.connectionRef, + selector: source.selector, + ...(since === undefined ? {} : { since }), + ...(pageToken === undefined ? {} : { pageToken }), + pageSize: 100, + }); + for (const it of fetched.items) { + items.push(it); + } + if (fetched.nextPageToken === undefined) { + // Reached the end of the list without hitting a cap → complete. + scanCompleted = true; + break; + } + // More pages remain. Stop early if the item cap is reached (partial). + if (items.length >= MAX_ITEMS_PER_SOURCE_TICK) { + scanCompleted = false; + break; + } + pageToken = fetched.nextPageToken; + // If this was the last allowed page and a token still remains, the + // loop exits with scanCompleted still false (partial scan). + } + return { items, scanCompleted } satisfies ScanResult; + }); + +// Human-readable summary of any provider error for the last_error column. +export const describeWorkSourceProviderError = (error: WorkSourceProviderError): string => { + switch (error._tag) { + case "WorkSourceRateLimitError": + return `rate-limited (retryAfterMs=${error.retryAfterMs})`; + case "WorkSourceAuthError": + return `auth failed (connectionRef=${error.connectionRef})`; + case "WorkSourceTransientError": + case "WorkSourceConfigError": + return `${error._tag}: ${error.message}`; + } +}; diff --git a/apps/server/src/workflow/selfImprove/boardProposal.test.ts b/apps/server/src/workflow/selfImprove/boardProposal.test.ts new file mode 100644 index 00000000000..98cecb79a1b --- /dev/null +++ b/apps/server/src/workflow/selfImprove/boardProposal.test.ts @@ -0,0 +1,283 @@ +import type { + WorkflowBoardMetrics, + WorkflowDefinition, + WorkflowDryRunResult, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; + +import { buildProposalPrompt, parseBoardProposal } from "./boardProposalPrompt.ts"; +import { dryRunRegression, preservationGate } from "./boardProposalValidation.ts"; + +const baseDefinition: WorkflowDefinition = { + name: "Board A", + sources: [], + outbound: [], + lanes: [ + { key: "backlog" as never, name: "Backlog", entry: "manual" }, + { + key: "work" as never, + name: "Work", + entry: "auto", + pipeline: [ + { + key: "code" as never, + type: "agent", + agent: { instance: "claude_main" as never, model: "sonnet" as never }, + instruction: "do it" as never, + on: { success: "done" as never }, + }, + ], + }, + { key: "done" as never, name: "Done", entry: "auto", terminal: true }, + ], +} as WorkflowDefinition; + +const metrics: WorkflowBoardMetrics = { + windowDays: 30, + generatedAt: "2026-06-14T00:00:00.000Z", + throughput: { created: 5, shipped: 3 }, + cycleTime: { count: 3, p50Ms: 1000, p90Ms: 2000, avgMs: 1500 }, + wipByLane: [{ laneKey: "work", admitted: 2, queued: 1 }], + statusBreakdown: { running: 2 }, + attention: { + blocked: 1, + waitingOnUser: 0, + oldest: [ + { + ticketId: "t-1", + title: "SECRET PLAN: do not leak this title", + laneKey: "work", + ageMs: 99999, + }, + ], + }, + routeOutcomes: [ + { fromLane: "work", toLane: "done", source: "step_on", result: "success", count: 3 }, + ], + manualMoveCount: 4, + stepStats: [ + { + laneKey: "work", + stepKey: "code", + stepType: "agent", + succeeded: 3, + failed: 1, + retries: 2, + totalTokens: 1234, + avgDurationMs: 500, + }, + ], +}; + +describe("buildProposalPrompt", () => { + it("strips ticket titles from the attention list", () => { + const prompt = buildProposalPrompt({ definition: baseDefinition, metrics }); + assert.notInclude(prompt, "do not leak this title"); + // The numeric age is still present. + assert.include(prompt, "ageMs=99999"); + }); + + it("includes the definition and numeric metrics", () => { + const prompt = buildProposalPrompt({ definition: baseDefinition, metrics }); + assert.include(prompt, '"Board A"'); + assert.include(prompt, "Manual moves: 4"); + assert.include(prompt, "work.code"); + }); + + it("includes the no-change-sources focus instruction", () => { + const prompt = buildProposalPrompt({ definition: baseDefinition, metrics }); + assert.include(prompt, "do NOT change sources, outbound, or the board name"); + assert.include(prompt, '"proposedDefinition"'); + }); + + it("redacts a seeded credential token from the assembled prompt", () => { + const withToken = { + name: "Board A", + sources: [], + outbound: [], + lanes: [ + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "use token ghp_" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + }, + ], + }, + { key: "done", name: "Done", entry: "auto", terminal: true }, + ], + } as unknown as WorkflowDefinition; + const prompt = buildProposalPrompt({ definition: withToken, metrics }); + assert.notInclude(prompt, "ghp_" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345"); + assert.include(prompt, "[redacted]"); + }); +}); + +describe("parseBoardProposal", () => { + it("passes through a well-formed payload", () => { + const parsed = parseBoardProposal({ proposedDefinition: { name: "x" }, rationale: "because" }); + assert.deepEqual(parsed, { proposedDefinition: { name: "x" }, rationale: "because" }); + }); + + it("throws when rationale is not a string", () => { + assert.throws(() => parseBoardProposal({ proposedDefinition: {}, rationale: 1 as never })); + }); + + it("throws when proposedDefinition is missing", () => { + assert.throws(() => parseBoardProposal({ proposedDefinition: null, rationale: "x" })); + }); +}); + +describe("preservationGate", () => { + it("passes when sources/outbound/name unchanged", () => { + const result = preservationGate(baseDefinition, baseDefinition); + assert.isTrue(result.ok); + assert.equal(result.violations.length, 0); + assert.equal(result.laneDiffCount, 0); + }); + + it("fails when the board name changes", () => { + const proposed = { ...baseDefinition, name: "Board B" } as WorkflowDefinition; + const result = preservationGate(baseDefinition, proposed); + assert.isFalse(result.ok); + assert.isTrue(result.violations.some((v) => v.includes("board name"))); + }); + + it("fails when sources change", () => { + const proposed = { + ...baseDefinition, + sources: [{ provider: "github", url: "https://example.com" }], + } as unknown as WorkflowDefinition; + const result = preservationGate(baseDefinition, proposed); + assert.isFalse(result.ok); + assert.isTrue(result.violations.some((v) => v.includes("sources"))); + }); + + it("fails when outbound changes", () => { + const proposed = { + ...baseDefinition, + outbound: [{ when: { "==": [1, 1] }, connectionRef: "x" }], + } as unknown as WorkflowDefinition; + const result = preservationGate(baseDefinition, proposed); + assert.isFalse(result.ok); + assert.isTrue(result.violations.some((v) => v.includes("outbound"))); + }); + + it("fails when board settings (maxConcurrentTickets) change", () => { + const proposed = { + ...baseDefinition, + settings: { maxConcurrentTickets: 50 }, + } as unknown as WorkflowDefinition; + const result = preservationGate(baseDefinition, proposed); + assert.isFalse(result.ok); + assert.isTrue(result.violations.some((v) => v.includes("settings"))); + }); + + it("treats absent settings and an empty settings object as unchanged", () => { + const proposed = { + ...baseDefinition, + settings: {}, + } as unknown as WorkflowDefinition; + const result = preservationGate(baseDefinition, proposed); + assert.isTrue(result.ok); + }); + + it("counts lane diffs (added + changed)", () => { + const proposed = { + ...baseDefinition, + lanes: [ + ...baseDefinition.lanes, + { key: "extra" as never, name: "Extra", entry: "manual" }, + ].map((lane) => + (lane.key as string) === "backlog" ? { ...lane, name: "Backlog renamed" } : lane, + ), + } as WorkflowDefinition; + const result = preservationGate(baseDefinition, proposed); + assert.isTrue(result.ok); // name/sources/outbound unchanged + assert.equal(result.laneDiffCount, 2); // one changed, one added + }); + + it("fails when a proposal REMOVES an existing lane", () => { + const proposed = { + ...baseDefinition, + lanes: baseDefinition.lanes.filter((lane) => (lane.key as string) !== "work"), + } as WorkflowDefinition; + const result = preservationGate(baseDefinition, proposed); + assert.isFalse(result.ok); + assert.isTrue( + result.violations.some((v) => v.includes("removes/renames") && v.includes("work")), + ); + }); + + it("fails when a proposal RE-KEYS an existing lane (treated as removal)", () => { + const proposed = { + ...baseDefinition, + lanes: baseDefinition.lanes.map((lane) => + (lane.key as string) === "work" ? { ...lane, key: "work_v2" as never } : lane, + ), + } as WorkflowDefinition; + const result = preservationGate(baseDefinition, proposed); + assert.isFalse(result.ok); + assert.isTrue( + result.violations.some((v) => v.includes("removes/renames") && v.includes("work")), + ); + }); + + it("still passes when a proposal only ADDS a lane (superset is allowed)", () => { + const proposed = { + ...baseDefinition, + lanes: [...baseDefinition.lanes, { key: "extra" as never, name: "Extra", entry: "manual" }], + } as WorkflowDefinition; + const result = preservationGate(baseDefinition, proposed); + assert.isTrue(result.ok); + }); +}); + +describe("dryRunRegression", () => { + const result = ( + startLane: string, + scenario: WorkflowDryRunResult["scenario"], + end: WorkflowDryRunResult["end"], + ): WorkflowDryRunResult => ({ + startLane: startLane as never, + scenario, + hops: [], + end, + endLane: startLane as never, + notes: [], + }); + + it("flags a NEW no_route in proposed", () => { + const base = [result("work", "success", "terminal")]; + const proposed = [result("work", "success", "no_route")]; + const out = dryRunRegression(base, proposed); + assert.isFalse(out.ok); + assert.equal(out.regressions.length, 1); + }); + + it("flags a NEW cycle_cap in proposed", () => { + const base = [result("work", "failure", "manual")]; + const proposed = [result("work", "failure", "cycle_cap")]; + const out = dryRunRegression(base, proposed); + assert.isFalse(out.ok); + }); + + it("does NOT flag a dead end already present in base", () => { + const base = [result("work", "success", "no_route")]; + const proposed = [result("work", "success", "no_route")]; + const out = dryRunRegression(base, proposed); + assert.isTrue(out.ok); + }); + + it("does NOT flag a proposal that fixes a base dead end", () => { + const base = [result("work", "success", "no_route")]; + const proposed = [result("work", "success", "terminal")]; + const out = dryRunRegression(base, proposed); + assert.isTrue(out.ok); + }); +}); diff --git a/apps/server/src/workflow/selfImprove/boardProposalPrompt.ts b/apps/server/src/workflow/selfImprove/boardProposalPrompt.ts new file mode 100644 index 00000000000..7bbe0810295 --- /dev/null +++ b/apps/server/src/workflow/selfImprove/boardProposalPrompt.ts @@ -0,0 +1,136 @@ +/** + * Pure prompt builder + structured-output parser for board-improvement + * proposals. + * + * `buildProposalPrompt` assembles a single prompt from the current board + * definition plus a NUMERIC metrics summary, then runs the whole thing through + * `redactSensitiveText` as a defence-in-depth pass. Ticket TITLES from the + * attention list are deliberately NOT included — only ids/ages/lanes — so no + * free-text ticket content leaks into the meta-agent prompt. + * + * `parseBoardProposal` validates the structured output returned by the no-tool + * `generateBoardProposal` op (E3). E3 already returns a typed shape, so this is + * a thin validation seam kept for testability and to fail loudly on a provider + * that returns a malformed payload. + */ + +import type { WorkflowBoardMetrics, WorkflowDefinition } from "@t3tools/contracts"; + +import { redactSensitiveText } from "../redactSensitiveText.ts"; + +const FOCUS_INSTRUCTION = [ + "Identify dead/never-matched routes, retry-heavy or failing steps, lanes with high manual", + "correction, and stalled tickets. Propose a revised definition that addresses them; keep", + "changes targeted; do NOT change sources, outbound, or the board name. Output a single fenced", + '```json block with `{ "proposedDefinition": <string>, "rationale": <string> }`, where', + "proposedDefinition is the full WorkflowDefinition serialized as a JSON string (JSON.stringify of the definition object).", +].join(" "); + +/** + * Build a numeric, title-free metrics summary. Every value here is a number or + * a lane/step key — never a ticket title or other free text. + */ +const summarizeMetrics = (metrics: WorkflowBoardMetrics): string => { + const lines: Array<string> = []; + lines.push(`Window: ${metrics.windowDays} day(s)`); + lines.push( + `Throughput: created=${metrics.throughput.created}, shipped=${metrics.throughput.shipped}`, + ); + lines.push( + `Cycle time (ms): count=${metrics.cycleTime.count}, p50=${metrics.cycleTime.p50Ms}, p90=${metrics.cycleTime.p90Ms}, avg=${metrics.cycleTime.avgMs}`, + ); + lines.push(`Manual moves: ${metrics.manualMoveCount}`); + lines.push( + `Attention: blocked=${metrics.attention.blocked}, waitingOnUser=${metrics.attention.waitingOnUser}`, + ); + + // Oldest stalled tickets — NUMERIC only. Titles are intentionally stripped. + if (metrics.attention.oldest.length > 0) { + lines.push("Oldest stalled tickets (lane, ageMs — titles omitted):"); + for (const t of metrics.attention.oldest) { + lines.push(` - lane=${t.laneKey ?? "none"} ageMs=${t.ageMs}`); + } + } + + if (metrics.routeOutcomes.length > 0) { + lines.push("Route outcomes (from → to, source, result, count):"); + for (const r of metrics.routeOutcomes) { + lines.push( + ` - ${r.fromLane ?? "none"} -> ${r.toLane ?? "none"} [${r.source}/${r.result}] x${r.count}`, + ); + } + } + + if (metrics.stepStats.length > 0) { + lines.push("Step stats (lane.step, type, succeeded/failed/retries, tokens, avgMs):"); + for (const s of metrics.stepStats) { + lines.push( + ` - ${s.laneKey}.${s.stepKey} [${s.stepType}] ok=${s.succeeded} fail=${s.failed} retries=${s.retries} tokens=${s.totalTokens} avgMs=${s.avgDurationMs}`, + ); + } + } + + if (metrics.wipByLane.length > 0) { + lines.push("WIP by lane (admitted/queued):"); + for (const w of metrics.wipByLane) { + lines.push(` - ${w.laneKey} admitted=${w.admitted} queued=${w.queued}`); + } + } + + return lines.join("\n"); +}; + +export const buildProposalPrompt = ({ + definition, + metrics, +}: { + readonly definition: WorkflowDefinition; + readonly metrics: WorkflowBoardMetrics; +}): string => { + const definitionJson = JSON.stringify(definition, null, 2); + + const assembled = [ + "You are reviewing a t3 workflow board definition for possible improvements.", + "", + "## Current board metrics", + summarizeMetrics(metrics), + "", + "## Current board definition (WorkflowDefinition JSON)", + "```json", + definitionJson, + "```", + "", + "## Task", + FOCUS_INSTRUCTION, + ].join("\n"); + + // Defence-in-depth: strip any high-entropy / credential-shaped strings that + // may have leaked into the definition (e.g. a token pasted into an + // instruction). Numeric metrics are unaffected. + return redactSensitiveText(assembled); +}; + +export interface ParsedBoardProposal { + readonly proposedDefinition: unknown; + readonly rationale: string; +} + +/** + * Validate the structured output from E3. Throws a plain Error on a malformed + * payload; the caller maps that to an `invalid` proposal / RPC error. + */ +export const parseBoardProposal = (output: { + readonly proposedDefinition: unknown; + readonly rationale: string; +}): ParsedBoardProposal => { + if (output === null || typeof output !== "object") { + throw new Error("Board proposal output was not an object."); + } + if (typeof output.rationale !== "string") { + throw new Error("Board proposal output is missing a string `rationale`."); + } + if (output.proposedDefinition === undefined || output.proposedDefinition === null) { + throw new Error("Board proposal output is missing `proposedDefinition`."); + } + return { proposedDefinition: output.proposedDefinition, rationale: output.rationale }; +}; diff --git a/apps/server/src/workflow/selfImprove/boardProposalValidation.ts b/apps/server/src/workflow/selfImprove/boardProposalValidation.ts new file mode 100644 index 00000000000..9213450bb06 --- /dev/null +++ b/apps/server/src/workflow/selfImprove/boardProposalValidation.ts @@ -0,0 +1,158 @@ +/** + * Pure validation gates for board-improvement proposals. + * + * These run AFTER a structurally-valid proposed definition has been decoded. + * They are intentionally pure (no Effect, no I/O) so the gate logic is trivially + * testable; the RPC handler orchestrates them around the (effectful) lint and + * dry-run calls. + */ + +import type { WorkflowDefinition, WorkflowDryRunResult } from "@t3tools/contracts"; + +/** + * Canonical JSON for stable structural comparison: the value is round-tripped + * through JSON so key order from the two definitions does not matter (an object + * literal preserves insertion order, but we never rely on that here). + * + * `undefined` and an empty array are treated as the same "no entries" state so + * that adding/removing an empty `sources: []` is not flagged as a change. + */ +const canonical = (value: unknown): string => { + const normalize = (input: unknown): unknown => { + if (Array.isArray(input)) { + return input.map(normalize); + } + if (input !== null && typeof input === "object") { + const out: Record<string, unknown> = {}; + for (const key of Object.keys(input as Record<string, unknown>).sort()) { + out[key] = normalize((input as Record<string, unknown>)[key]); + } + return out; + } + return input; + }; + return JSON.stringify(normalize(value)); +}; + +const canonicalArray = (value: ReadonlyArray<unknown> | undefined): string => + canonical(value ?? []); + +export interface PreservationGateResult { + readonly ok: boolean; + readonly laneDiffCount: number; + readonly violations: ReadonlyArray<string>; +} + +/** + * Enforces that a proposal does NOT change the board's `name`, `sources`, or + * `outbound`. The meta-agent may only reshape lanes/steps/transitions; the + * board's identity and its external wiring (where work comes from and where + * results are pushed) are off-limits — those are human-controlled. + * + * `laneDiffCount` is a coarse count (lanes added + removed + changed by key) + * for the review UI; it does not gate anything. + */ +export const preservationGate = ( + baseDef: WorkflowDefinition, + proposedDef: WorkflowDefinition, +): PreservationGateResult => { + const violations: Array<string> = []; + + if (baseDef.name !== proposedDef.name) { + violations.push( + `Proposal changes the board name ("${baseDef.name}" → "${proposedDef.name}"); the name must be preserved.`, + ); + } + if (canonicalArray(baseDef.sources) !== canonicalArray(proposedDef.sources)) { + violations.push("Proposal changes the board `sources`; sources must be preserved."); + } + if (canonicalArray(baseDef.outbound) !== canonicalArray(proposedDef.outbound)) { + violations.push("Proposal changes the board `outbound` rules; outbound must be preserved."); + } + // Board-level `settings` (e.g. maxConcurrentTickets, which sizes the WIP + // admission semaphore) is external wiring the meta-agent may not touch — it + // only reshapes lanes/steps/transitions. `canonical` normalizes so an absent + // `settings` and an empty `{}` compare equal, matching the sources/outbound + // treatment. + if (canonical(baseDef.settings ?? {}) !== canonical(proposedDef.settings ?? {})) { + violations.push("Proposal changes the board `settings`; settings must be preserved."); + } + + const baseLanes = new Map(baseDef.lanes.map((lane) => [lane.key as string, lane])); + const proposedLanes = new Map(proposedDef.lanes.map((lane) => [lane.key as string, lane])); + + // The proposed lane-key set MUST be a SUPERSET of the base lane-key set: + // adding new lanes and tuning anything within a lane (including a lane's + // display `name`) is fine, but REMOVING or RE-KEYING an existing lane is the + // most destructive edit a board can take — tickets carry `current_lane_key` + // and routing references lane keys, so a dropped/re-keyed lane orphans parked + // tickets and silently changes routing. We forbid it as a hard preservation + // gate (a removed lane otherwise evades the dry-run regression check, which + // only walks lanes that still exist in the proposed def). v1 limitation: the + // agent cannot remove or rename lane keys — a human does that in the editor. + const removedLaneKeys = [...baseLanes.keys()].filter((key) => !proposedLanes.has(key)); + if (removedLaneKeys.length > 0) { + violations.push( + `Proposal removes/renames existing lane(s): ${removedLaneKeys.join(", ")} — not allowed; tickets reference lane keys.`, + ); + } + + let laneDiffCount = 0; + for (const [key, lane] of baseLanes) { + const other = proposedLanes.get(key); + if (other === undefined) { + laneDiffCount += 1; // removed + } else if (canonical(lane) !== canonical(other)) { + laneDiffCount += 1; // changed + } + } + for (const key of proposedLanes.keys()) { + if (!baseLanes.has(key)) { + laneDiffCount += 1; // added + } + } + + return { ok: violations.length === 0, laneDiffCount, violations }; +}; + +export interface DryRunRegressionResult { + readonly ok: boolean; + readonly regressions: ReadonlyArray<string>; +} + +/** + * Compares paired dry-run results (one entry per {startLane, scenario} combo) + * between the base and proposed definitions. A regression is a combo whose + * proposed `end` is a NEW dead end (`no_route` / `cycle_cap`) that the base did + * NOT already produce for that same combo. A combo that was already broken in + * the base is not a regression — the proposal can't be blamed for it. + * + * The two arrays are expected to be aligned by index (same combos in the same + * order); the function additionally keys by {startLane, scenario} defensively. + */ +const DEAD_ENDS: ReadonlySet<WorkflowDryRunResult["end"]> = new Set(["no_route", "cycle_cap"]); + +export const dryRunRegression = ( + baseResults: ReadonlyArray<WorkflowDryRunResult>, + proposedResults: ReadonlyArray<WorkflowDryRunResult>, +): DryRunRegressionResult => { + const comboKey = (r: WorkflowDryRunResult): string => `${r.startLane as string}::${r.scenario}`; + const baseByCombo = new Map(baseResults.map((r) => [comboKey(r), r])); + const regressions: Array<string> = []; + + for (const proposed of proposedResults) { + if (!DEAD_ENDS.has(proposed.end)) { + continue; + } + const base = baseByCombo.get(comboKey(proposed)); + // A combo already dead-ended in the base is pre-existing, not a regression. + if (base !== undefined && DEAD_ENDS.has(base.end)) { + continue; + } + regressions.push( + `Starting in lane "${proposed.startLane as string}" with a ${proposed.scenario} outcome now ends in "${proposed.end}" (was "${base?.end ?? "n/a"}").`, + ); + } + + return { ok: regressions.length === 0, regressions }; +}; diff --git a/apps/server/src/workflow/sourceAutoPull.test.ts b/apps/server/src/workflow/sourceAutoPull.test.ts new file mode 100644 index 00000000000..20ea6f28524 --- /dev/null +++ b/apps/server/src/workflow/sourceAutoPull.test.ts @@ -0,0 +1,100 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { ALWAYS_RULE } from "@t3tools/contracts/workSource"; +import { buildItemRuleContext, gateNewDeltas } from "./sourceAutoPull.ts"; + +const fields = (over = {}) => ({ + sourceId: "s", + provider: "github", + externalId: "1", + title: "Fix", + description: "body", + contentHash: "h", + metadata: { + provider: "github", + url: "u", + assignees: ["alice"], + labels: ["XS"], + lifecycle: "open", + }, + ...over, +}); + +describe("buildItemRuleContext", () => { + it("maps lifecycle→state, description→body, defaults arrays", () => { + assert.deepEqual(buildItemRuleContext(fields()), { + title: "Fix", + body: "body", + labels: ["XS"], + assignees: ["alice"], + state: "open", + provider: "github", + }); + }); + it("non-open lifecycle → state closed; missing arrays → []; missing description → ''", () => { + const ctx = buildItemRuleContext( + fields({ description: undefined, metadata: { provider: "github", lifecycle: "closed" } }), + ); + assert.equal(ctx.state, "closed"); + assert.deepEqual(ctx.labels, []); + assert.deepEqual(ctx.assignees, []); + assert.equal(ctx.body, ""); + }); +}); + +const newDelta = (id: string, labels: string[]) => ({ + _tag: "new" as const, + item: { + sourceId: "s", + provider: "github", + externalId: id, + title: id, + contentHash: "h", + metadata: { provider: "github", labels, lifecycle: "open" }, + }, +}); +const changedDelta = { + _tag: "changed" as const, + ticketId: "t", + item: { + sourceId: "s", + provider: "github", + externalId: "9", + title: "x", + contentHash: "h", + metadata: { provider: "github", labels: [], lifecycle: "open" }, + }, +}; +const evalXS = { + evaluate: (_r: unknown, ctx: any) => + Effect.succeed({ result: ctx.labels.includes("XS"), matchedPaths: [] }), +}; + +describe("gateNewDeltas", () => { + it.effect("rule null → drops ALL new, keeps non-new", () => + Effect.gen(function* () { + const out = yield* gateNewDeltas( + [newDelta("1", ["XS"]), changedDelta], + null, + evalXS as never, + ); + assert.deepEqual( + out.map((d) => d._tag), + ["changed"], + ); + }), + ); + it.effect("rule present → keeps matching new, drops non-matching, keeps all non-new", () => + Effect.gen(function* () { + const out = yield* gateNewDeltas( + [newDelta("1", ["XS"]), newDelta("2", ["L"]), changedDelta], + ALWAYS_RULE, + evalXS as never, + ); + assert.deepEqual( + out.map((d) => d.item.externalId), + ["1", "9"], + ); + }), + ); +}); diff --git a/apps/server/src/workflow/sourceAutoPull.ts b/apps/server/src/workflow/sourceAutoPull.ts new file mode 100644 index 00000000000..93d62ded0e8 --- /dev/null +++ b/apps/server/src/workflow/sourceAutoPull.ts @@ -0,0 +1,42 @@ +import * as Effect from "effect/Effect"; +import type { PredicateEvaluatorShape } from "./Services/PredicateEvaluator.ts"; +import type { SourceDelta, SourceItemFields } from "./Services/WorkflowSourceCommitter.ts"; + +export interface ItemRuleContext { + readonly title: string; + readonly body: string; + readonly labels: ReadonlyArray<string>; + readonly assignees: ReadonlyArray<string>; + readonly state: "open" | "closed"; + readonly provider: string; +} + +export const buildItemRuleContext = (item: SourceItemFields): ItemRuleContext => ({ + title: item.title, + body: item.description ?? "", + labels: item.metadata.labels ?? [], + assignees: item.metadata.assignees ?? [], + state: item.metadata.lifecycle === "open" ? "open" : "closed", + provider: item.provider, +}); + +export const gateNewDeltas = ( + deltas: ReadonlyArray<SourceDelta>, + rule: unknown | null, + evaluator: Pick<PredicateEvaluatorShape, "evaluate">, +): Effect.Effect<ReadonlyArray<SourceDelta>, never> => + Effect.gen(function* () { + const out: Array<SourceDelta> = []; + for (const delta of deltas) { + if (delta._tag !== "new") { + out.push(delta); + continue; + } + if (rule === null) continue; + const ev = yield* evaluator + .evaluate(rule, buildItemRuleContext(delta.item)) + .pipe(Effect.orElseSucceed(() => ({ result: false, matchedPaths: [] }))); // bad rule → no auto-create (lint prevents this) + if (ev.result) out.push(delta); + } + return out; + }); diff --git a/apps/server/src/workflow/sourceReconcileDiff.test.ts b/apps/server/src/workflow/sourceReconcileDiff.test.ts new file mode 100644 index 00000000000..8645908a92f --- /dev/null +++ b/apps/server/src/workflow/sourceReconcileDiff.test.ts @@ -0,0 +1,437 @@ +import { assert, describe, it } from "@effect/vitest"; + +import type { ExternalWorkItem } from "./Services/WorkSourceProvider.ts"; +import { + buildNewSourceDelta, + classifyDeltas, + hashContent, + serializeSourceMetadata, + type MappingRow, +} from "./sourceReconcileDiff.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeItem = (over: Partial<ExternalWorkItem> = {}): ExternalWorkItem => ({ + provider: "github", + externalId: "issue-1", + url: "https://github.com/owner/repo/issues/1", + lifecycle: "open", + version: { updatedAt: "2026-01-01T00:00:00Z" }, + fields: { + title: "Fix the bug", + description: "It is broken", + assignees: ["alice"], + labels: ["bug"], + }, + ...over, +}); + +// Default metadata matches the default makeItem() metadata so a mapping built +// for the default item reports NO metadata change (only content/lifecycle drive +// deltas) unless a test overrides sourceMetadataJson. +const DEFAULT_ITEM_METADATA_JSON = serializeSourceMetadata({ + provider: "github", + url: "https://github.com/owner/repo/issues/1", + assignees: ["alice"], + labels: ["bug"], + lifecycle: "open", +}); + +const makeMapping = (over: Partial<MappingRow> = {}): MappingRow => ({ + externalId: "issue-1", + ticketId: "ticket-abc", + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + providerVersion: "2026-01-01T00:00:00Z", + lifecycle: "open", + syncStatus: "active", + sourceMetadataJson: DEFAULT_ITEM_METADATA_JSON, + ...over, +}); + +const defaultInput = { + sourceId: "src-1", + provider: "github", +} as const; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("hashContent", () => { + it("produces the same hash for identical title+description", () => { + const h1 = hashContent({ title: "Fix the bug", description: "It is broken" }); + const h2 = hashContent({ title: "Fix the bug", description: "It is broken" }); + assert.equal(h1, h2); + }); + + it("produces a different hash when title changes", () => { + const h1 = hashContent({ title: "Fix the bug", description: "It is broken" }); + const h2 = hashContent({ title: "Fix another bug", description: "It is broken" }); + assert.notEqual(h1, h2); + }); + + it("produces a different hash when description changes", () => { + const h1 = hashContent({ title: "Fix the bug", description: "It is broken" }); + const h2 = hashContent({ title: "Fix the bug", description: "It is very broken" }); + assert.notEqual(h1, h2); + }); + + it("treats absent description consistently (undefined vs omitted)", () => { + const h1 = hashContent({ title: "Title" }); + const h2 = hashContent({ title: "Title", description: undefined }); + assert.equal(h1, h2); + }); +}); + +describe("classifyDeltas", () => { + it("new: an item with no mapping row produces a new delta", () => { + const item = makeItem(); + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [], + scanCompleted: true, + }); + + assert.lengthOf(result, 1); + const delta = result[0]!; + assert.equal(delta._tag, "new"); + if (delta._tag === "new") { + assert.equal(delta.item.externalId, "issue-1"); + assert.equal(delta.item.title, "Fix the bug"); + assert.equal(delta.item.description, "It is broken"); + assert.equal( + delta.item.contentHash, + hashContent({ title: "Fix the bug", description: "It is broken" }), + ); + } + }); + + it("changed: a mapped item with a different content hash produces a changed delta", () => { + const item = makeItem({ fields: { title: "Renamed issue", description: "New body" } }); + // Mapping has the OLD hash (computed from the old title/description). + const mapping = makeMapping({ + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 1); + const delta = result[0]!; + assert.equal(delta._tag, "changed"); + if (delta._tag === "changed") { + assert.equal(delta.ticketId, "ticket-abc"); + assert.equal(delta.item.title, "Renamed issue"); + assert.equal(delta.item.description, "New body"); + assert.equal( + delta.item.contentHash, + hashContent({ title: "Renamed issue", description: "New body" }), + ); + } + }); + + it('Fix 1: an upstream description cleared (non-empty → empty) produces a changed delta that CLEARS the description to ""', () => { + // Upstream item used to have a description; now it's cleared (absent → undefined + // on the ExternalWorkItem). The mapping carries the OLD hash (with the body). + const item = makeItem({ + fields: { title: "Fix the bug" }, // description omitted → cleared upstream + }); + const mapping = makeMapping({ + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 1); + const delta = result[0]!; + assert.equal(delta._tag, "changed"); + if (delta._tag === "changed") { + // The delta CARRIES an empty-string description (authoritative clear), + // never undefined — so the committer WRITES the clear. + assert.equal(delta.item.description, ""); + // hash is computed over the SAME normalized {title, description:""} so the + // carried value and the stored hash agree → next cycle is a no-op. + assert.equal(delta.item.contentHash, hashContent({ title: "Fix the bug", description: "" })); + } + + // Next cycle: the mapping now stores the cleared hash AND the item's metadata + // (no assignees/labels, since this item omitted them) → no further delta. + const noop = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [ + makeMapping({ + contentHash: hashContent({ title: "Fix the bug", description: "" }), + sourceMetadataJson: serializeSourceMetadata({ + provider: "github", + url: "https://github.com/owner/repo/issues/1", + assignees: [], + labels: [], + lifecycle: "open", + }), + }), + ], + scanCompleted: true, + }); + assert.lengthOf(noop, 0); + }); + + it("no-op: a mapped item whose hash matches produces NO delta", () => { + const item = makeItem(); + // Mapping hash matches the item exactly. + const mapping = makeMapping({ + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 0); + }); + + it("closed: lifecycle closed with open mapping produces a closed delta (takes precedence over changed)", () => { + // Title is also different → content changed AND lifecycle closed. + const item = makeItem({ + lifecycle: "closed", + fields: { title: "Renamed AND closed", description: "Different body" }, + }); + const mapping = makeMapping({ + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + lifecycle: "open", + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + // Only ONE delta: closed wins over changed. + assert.lengthOf(result, 1); + const delta = result[0]!; + assert.equal(delta._tag, "closed"); + if (delta._tag === "closed") { + assert.equal(delta.ticketId, "ticket-abc"); + } + }); + + it("already-closed mapping + closed item → no redundant closed delta", () => { + const item = makeItem({ lifecycle: "closed" }); + // Mapping already has lifecycle 'closed' → nothing to emit. + const mapping = makeMapping({ + lifecycle: "closed", + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 0); + }); + + it("missing-when-complete: active mapping absent from items when scanCompleted:true → missing delta", () => { + // No items in the fetch, but there IS an active mapping. + const mapping = makeMapping({ syncStatus: "active", lifecycle: "open" }); + + const result = classifyDeltas({ + ...defaultInput, + items: [], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 1); + const delta = result[0]!; + assert.equal(delta._tag, "missing"); + if (delta._tag === "missing") { + assert.equal(delta.ticketId, "ticket-abc"); + assert.equal(delta.confirmedDeleted, false); + } + }); + + it("missing-suppressed: active mapping absent from items when scanCompleted:false → NO missing delta", () => { + const mapping = makeMapping({ syncStatus: "active" }); + + const result = classifyDeltas({ + ...defaultInput, + items: [], + mappings: [mapping], + scanCompleted: false, + }); + + assert.lengthOf(result, 0); + }); + + it("orphaned mapping not re-emitted as missing (only active rows)", () => { + const mapping = makeMapping({ syncStatus: "orphaned" }); + + const result = classifyDeltas({ + ...defaultInput, + items: [], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 0); + }); + + it("multiple items: correct classification for each", () => { + const itemNew = makeItem({ externalId: "issue-100" }); + const itemChanged = makeItem({ + externalId: "issue-200", + fields: { title: "Changed title", description: "Changed body" }, + }); + const itemNoop = makeItem({ externalId: "issue-300" }); + const itemClosed = makeItem({ externalId: "issue-400", lifecycle: "closed" }); + + const mappingChanged = makeMapping({ + externalId: "issue-200", + ticketId: "ticket-200", + contentHash: hashContent({ title: "Old title", description: "Old body" }), + }); + const mappingNoop = makeMapping({ + externalId: "issue-300", + ticketId: "ticket-300", + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + }); + const mappingClosed = makeMapping({ + externalId: "issue-400", + ticketId: "ticket-400", + lifecycle: "open", + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + }); + const mappingMissing = makeMapping({ + externalId: "issue-999", + ticketId: "ticket-999", + syncStatus: "active", + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [itemNew, itemChanged, itemNoop, itemClosed], + mappings: [mappingChanged, mappingNoop, mappingClosed, mappingMissing], + scanCompleted: true, + }); + + // new (issue-100) + changed (issue-200) + closed (issue-400) + missing (issue-999) + // issue-300 is a no-op. + assert.lengthOf(result, 4); + assert.equal(result[0]!._tag, "new"); + assert.equal(result[1]!._tag, "changed"); + assert.equal(result[2]!._tag, "closed"); + assert.equal(result[3]!._tag, "missing"); + }); + + it("output ordering is deterministic: items in input order, then missing in mapping order", () => { + const item1 = makeItem({ externalId: "issue-1" }); + const item2 = makeItem({ externalId: "issue-2" }); + const mappingMissing1 = makeMapping({ externalId: "issue-99", ticketId: "ticket-99" }); + const mappingMissing2 = makeMapping({ externalId: "issue-98", ticketId: "ticket-98" }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item1, item2], + mappings: [mappingMissing1, mappingMissing2], + scanCompleted: true, + }); + + // Both items are new (unmapped), then two missing in mapping-array order. + assert.equal(result[0]!._tag, "new"); + if (result[0]!._tag === "new") assert.equal(result[0]!.item.externalId, "issue-1"); + assert.equal(result[1]!._tag, "new"); + if (result[1]!._tag === "new") assert.equal(result[1]!.item.externalId, "issue-2"); + assert.equal(result[2]!._tag, "missing"); + if (result[2]!._tag === "missing") assert.equal(result[2]!.item.externalId, "issue-99"); + assert.equal(result[3]!._tag, "missing"); + if (result[3]!._tag === "missing") assert.equal(result[3]!.item.externalId, "issue-98"); + }); + + it("deleted: lifecycle=deleted with open mapping and differing hash → closed delta (not changed)", () => { + // An item with lifecycle "deleted" should be treated like "closed" — + // emitting a "closed" delta even when the content hash differs. + const item = makeItem({ + lifecycle: "deleted", + fields: { title: "Deleted title", description: "Deleted body" }, + }); + const mapping = makeMapping({ + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + lifecycle: "open", + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 1); + assert.equal(result[0]!._tag, "closed"); + }); + + it("deleted: lifecycle=deleted with already-closed mapping → no delta (already terminal)", () => { + const item = makeItem({ lifecycle: "deleted" }); + const mapping = makeMapping({ lifecycle: "closed" }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 0); + }); +}); + +describe("buildNewSourceDelta", () => { + it("produces a 'new' delta whose contentHash + metadata match classifyDeltas exactly", () => { + const item = { + provider: "github" as const, + externalId: "issue-500", + url: "https://github.com/acme/app/issues/500", + lifecycle: "open" as const, + version: { updatedAt: "2026-06-16T00:00:00Z" }, + fields: { title: "Fix it", description: "body", assignees: ["alice"], labels: ["bug"] }, + }; + const delta = buildNewSourceDelta("src-1", item); + assert.equal(delta._tag, "new"); + if (delta._tag === "new") { + assert.equal(delta.item.sourceId, "src-1"); + assert.equal(delta.item.externalId, "issue-500"); + assert.equal(delta.item.title, "Fix it"); + assert.equal(delta.item.contentHash, hashContent(item.fields)); + assert.equal( + serializeSourceMetadata(delta.item.metadata), + serializeSourceMetadata({ + provider: "github", + url: item.url, + assignees: item.fields.assignees, + labels: item.fields.labels, + lifecycle: item.lifecycle, + }), + ); + } + }); +}); diff --git a/apps/server/src/workflow/sourceReconcileDiff.ts b/apps/server/src/workflow/sourceReconcileDiff.ts new file mode 100644 index 00000000000..8e952d32c79 --- /dev/null +++ b/apps/server/src/workflow/sourceReconcileDiff.ts @@ -0,0 +1,237 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { createHash } from "node:crypto"; + +import type { ExternalWorkItem } from "./Services/WorkSourceProvider.ts"; +import type { + SourceDelta, + SourceItemFields, + SourceItemMetadata, +} from "./Services/WorkflowSourceCommitter.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A row from `work_source_mapping` as required by the diff. + * Only the columns the reconciler reads are declared here; the committer's + * `readMapping` helper in Task 9 selects an equivalent superset. + */ +export interface MappingRow { + readonly externalId: string; + readonly ticketId: string; + readonly contentHash: string; + readonly providerVersion: string | null; + readonly lifecycle: string; // 'open' | 'closed' + readonly syncStatus: string; // 'active' | 'orphaned' + // Canonical serialized metadata as last persisted (work_source_mapping + // .source_metadata_json). Compared against the freshly-serialized item metadata + // so a metadata-only upstream change (labels/assignees/url) is still synced even + // when title/description (the content hash) are unchanged. + readonly sourceMetadataJson: string | null; +} + +export interface ClassifyDeltasInput { + readonly sourceId: string; + readonly provider: string; + readonly items: ReadonlyArray<ExternalWorkItem>; + /** All `work_source_mapping` rows for this (boardId, sourceId). */ + readonly mappings: ReadonlyArray<MappingRow>; + /** + * `true` when the provider returned all pages without hitting a page cap + * or error. Only a complete scan may produce `missing` deltas — a partial + * scan must never orphan items that simply weren't fetched yet. + */ + readonly scanCompleted: boolean; +} + +// --------------------------------------------------------------------------- +// Content hashing +// --------------------------------------------------------------------------- + +/** + * Deterministic content hash of the upstream fields that are synced to the + * ticket. Only `title` and `description` are authoritative (the committer's + * version gate compares this against `work_source_mapping.content_hash`). + * + * Uses SHA-256 over canonical JSON so the hash is stable across runs. + */ +export const hashContent = (fields: { + title: string; + description?: string | undefined; +}): string => { + // Source-owned descriptions are authoritative: a cleared/absent upstream + // description normalizes to "" (NOT null) so the hash AND the carried value + // agree. (An upstream item whose description was cleared must clear the + // ticket's description, and the stored hash must reflect the cleared value + // so the next no-change cycle is a no-op.) + const canonical = JSON.stringify({ title: fields.title, description: fields.description ?? "" }); + return createHash("sha256").update(canonical).digest("hex"); +}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +const buildMetadata = (item: ExternalWorkItem): SourceItemMetadata => ({ + provider: item.provider, + url: item.url, + assignees: item.fields.assignees, + labels: item.fields.labels, + lifecycle: item.lifecycle, +}); + +/** + * Canonical serialization of a source item's metadata. MUST stay byte-identical + * to what the committer persists into `work_source_mapping.source_metadata_json` + * so the reconcile diff can compare the two strings to detect metadata-only + * changes. The committer imports THIS function for its writes (single source of + * truth — do not fork the shape). + */ +export const serializeSourceMetadata = (metadata: SourceItemMetadata): string => + JSON.stringify({ + provider: metadata.provider, + url: metadata.url ?? null, + assignees: metadata.assignees ?? [], + labels: metadata.labels ?? [], + lifecycle: metadata.lifecycle ?? null, + }); + +export const buildSourceItemFields = ( + sourceId: string, + item: ExternalWorkItem, + contentHash: string, +): SourceItemFields => ({ + sourceId, + provider: item.provider, + externalId: item.externalId, + title: item.fields.title, + // Always carry the (possibly empty) description so the committer WRITES it — + // a cleared upstream description must clear the ticket, not leave it stale. + // "" is a valid clear; never carry undefined for a synced item. + description: item.fields.description ?? "", + contentHash, + providerVersion: item.version.updatedAt ?? item.version.etag ?? undefined, + metadata: buildMetadata(item), +}); + +/** Canonical `new` delta for a single item — used by curated import so its + * contentHash/metadata match the syncer's classify output exactly. */ +export const buildNewSourceDelta = (sourceId: string, item: ExternalWorkItem): SourceDelta => ({ + _tag: "new", + item: buildSourceItemFields(sourceId, item, hashContent(item.fields)), +}); + +/** + * Reconstruct a minimal `SourceItemFields` from a mapping row for use in + * `missing` deltas. The actual field values are not critical here — the + * committer only uses `externalId`/`sourceId`/`provider` to find the row + * in-tx; title/description/contentHash/metadata come from the stored mapping. + */ +const buildMissingFields = ( + sourceId: string, + provider: string, + row: MappingRow, +): SourceItemFields => ({ + sourceId, + provider, + externalId: row.externalId, + title: "", + description: undefined, + contentHash: row.contentHash, + providerVersion: row.providerVersion ?? undefined, + metadata: { provider }, +}); + +// --------------------------------------------------------------------------- +// Core classifier +// --------------------------------------------------------------------------- + +/** + * Pure reconcile diff — no IO. + * + * **Precedence rule (closed/deleted vs changed):** + * If an item is both changed (hash differs) AND closed/deleted (lifecycle === "closed" | "deleted"), + * we emit only `closed`. The committer already updates the mapping's + * `content_hash` when processing a `closed` delta, so a subsequent run will + * see no content delta. This keeps the committer logic simple and the user + * sees the correct terminal routing immediately. + * + * **Ordering:** + * Output follows the input `items` array order (new/changed/closed from + * items) then missing (from mappings, in their original order). This is + * deterministic and stable so tests can rely on ordering. + * + * **scan-completeness gate:** + * `missing` deltas are only emitted when `scanCompleted === true`. A partial + * or failed scan must never produce orphan deltas. + */ +export const classifyDeltas = (input: ClassifyDeltasInput): ReadonlyArray<SourceDelta> => { + const { sourceId, provider, items, mappings, scanCompleted } = input; + + // Build an index of mappings by externalId for O(1) lookup. + const mappingByExternalId = new Map<string, MappingRow>(); + for (const row of mappings) { + mappingByExternalId.set(row.externalId, row); + } + + // Track which externalIds were seen in the fetched items. + const seenExternalIds = new Set<string>(); + + const deltas: SourceDelta[] = []; + + for (const item of items) { + seenExternalIds.add(item.externalId); + const contentHash = hashContent(item.fields); + const fields = buildSourceItemFields(sourceId, item, contentHash); + const row = mappingByExternalId.get(item.externalId); + + if (row === undefined) { + // No mapping → this is a new item. + deltas.push({ _tag: "new", item: fields }); + } else { + // Mapped item. Closed/deleted takes precedence over changed. + const isTerminal = item.lifecycle === "closed" || item.lifecycle === "deleted"; + if (isTerminal && row.lifecycle !== "closed") { + deltas.push({ _tag: "closed", item: fields, ticketId: row.ticketId }); + } else if (!isTerminal && (row.lifecycle === "closed" || row.syncStatus === "orphaned")) { + // Item is OPEN upstream but its mapping is closed (previously source- + // closed) or orphaned (previously went missing). Reopen/reactivate: + // restore lifecycle/sync_status, route the ticket out of the closed lane, + // and refresh content. Emitting this regardless of the content hash is + // what unsticks an upstream reopen whose title/description did not change. + deltas.push({ _tag: "reopened", item: fields, ticketId: row.ticketId }); + } else if ( + !isTerminal && + (contentHash !== row.contentHash || + serializeSourceMetadata(fields.metadata) !== row.sourceMetadataJson) + ) { + // Emit changed for open+active items whose CONTENT (title/description) or + // METADATA (labels/assignees/url) differs from what is stored. Metadata is + // surfaced as the ticket's syncedSource, so a metadata-only change must + // still be persisted even though the content hash is unchanged. + deltas.push({ _tag: "changed", item: fields, ticketId: row.ticketId }); + } + // If hash === row.contentHash (or item is already closed in both places): + // no delta — this is a no-op. + } + } + + // Missing deltas: mappings whose externalId was NOT in the fetched items. + // Only emit when the scan was complete (all pages fetched without cap hit). + if (scanCompleted) { + for (const row of mappings) { + if (!seenExternalIds.has(row.externalId) && row.syncStatus === "active") { + const fields = buildMissingFields(sourceId, provider, row); + deltas.push({ + _tag: "missing", + item: fields, + ticketId: row.ticketId, + confirmedDeleted: false, + }); + } + } + } + + return deltas; +}; diff --git a/apps/server/src/workflow/ticketMessageBody.ts b/apps/server/src/workflow/ticketMessageBody.ts new file mode 100644 index 00000000000..9de90bc5e10 --- /dev/null +++ b/apps/server/src/workflow/ticketMessageBody.ts @@ -0,0 +1,13 @@ +export const MAX_TICKET_MESSAGE_BODY_LENGTH = 8_000; + +const TICKET_MESSAGE_TRUNCATION_SUFFIX = "..."; + +export function truncateTicketMessageBody(body: string): string { + if (body.length <= MAX_TICKET_MESSAGE_BODY_LENGTH) { + return body; + } + return `${body.slice( + 0, + MAX_TICKET_MESSAGE_BODY_LENGTH - TICKET_MESSAGE_TRUNCATION_SUFFIX.length, + )}${TICKET_MESSAGE_TRUNCATION_SUFFIX}`; +} diff --git a/apps/server/src/workflow/ticketRefs.test.ts b/apps/server/src/workflow/ticketRefs.test.ts new file mode 100644 index 00000000000..5df7c4ae6e8 --- /dev/null +++ b/apps/server/src/workflow/ticketRefs.test.ts @@ -0,0 +1,16 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { ticketBaseRef, ticketStepRef } from "./ticketRefs.ts"; + +describe("ticketRefs", () => { + it("builds a stable base ref", () => { + assert.equal(ticketBaseRef("t-1" as never), "refs/t3/tickets/dC0x/base"); + }); + + it("builds pre/post step refs", () => { + assert.equal( + ticketStepRef("t-1" as never, "sr-1" as never, "pre"), + "refs/t3/tickets/dC0x/step/c3ItMQ/pre", + ); + }); +}); diff --git a/apps/server/src/workflow/ticketRefs.ts b/apps/server/src/workflow/ticketRefs.ts new file mode 100644 index 00000000000..9a9902741e8 --- /dev/null +++ b/apps/server/src/workflow/ticketRefs.ts @@ -0,0 +1,20 @@ +import type { StepRunId, TicketId } from "@t3tools/contracts"; +import * as Encoding from "effect/Encoding"; + +export const TICKET_REFS_PREFIX = "refs/t3/tickets"; + +const encodeRefPart = (value: string) => Encoding.encodeBase64Url(value); + +export const ticketRefsPrefix = (ticketId: TicketId): string => + `${TICKET_REFS_PREFIX}/${encodeRefPart(ticketId as string)}`; + +export const ticketBaseRef = (ticketId: TicketId): string => `${ticketRefsPrefix(ticketId)}/base`; + +export const ticketStepRef = ( + ticketId: TicketId, + stepRunId: StepRunId, + kind: "pre" | "post", +): string => + `${TICKET_REFS_PREFIX}/${encodeRefPart(ticketId as string)}/step/${encodeRefPart( + stepRunId as string, + )}/${kind}`; diff --git a/apps/server/src/workflow/webhookRoute.ts b/apps/server/src/workflow/webhookRoute.ts new file mode 100644 index 00000000000..b1d06b3f0dc --- /dev/null +++ b/apps/server/src/workflow/webhookRoute.ts @@ -0,0 +1,236 @@ +import * as Effect from "effect/Effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { WorkflowEventStoreErrorCode } from "./Services/Errors.ts"; +import { sanitizeExternalEventPayload } from "./externalEvent.ts"; +import { WorkflowEngine } from "./Services/WorkflowEngine.ts"; +import { WorkflowReadModel } from "./Services/WorkflowReadModel.ts"; +import { WorkflowWebhook } from "./Services/WorkflowWebhook.ts"; + +const MAX_BODY_BYTES = 64 * 1024; +const MAX_NAME_LENGTH = 100; +const MAX_DELIVERY_ID_LENGTH = 128; +const MAX_CORRELATION_LENGTH = 200; + +const notFound = HttpServerResponse.text("Not Found", { status: 404 }); +// Transient infrastructure failure (e.g. SQLITE_BUSY/locked under concurrent +// engine commits). 503 keeps the delivery RETRYABLE — a 404 here would be read +// by senders (CI/PR automation) as "endpoint gone" and silently dropped. +const serviceUnavailable = (detail: string) => HttpServerResponse.text(detail, { status: 503 }); +const unprocessable = (detail: string) => + HttpServerResponse.json({ error: detail }, { status: 422 }).pipe( + Effect.orElseSucceed(() => HttpServerResponse.text(detail, { status: 422 })), + ); + +interface ParsedHookBody { + readonly name: string; + readonly ticketId: string; + readonly payload: unknown; + readonly deliveryId: string | undefined; +} + +const parseHookBody = (raw: string): ParsedHookBody | string => { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return "body must be JSON"; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return "body must be a JSON object"; + } + const body = parsed as Record<string, unknown>; + const name = typeof body["name"] === "string" ? body["name"].trim() : ""; + if (name === "" || name.length > MAX_NAME_LENGTH) { + return "name is required (1-100 chars)"; + } + const ticketId = typeof body["ticketId"] === "string" ? body["ticketId"].trim() : ""; + const branch = typeof body["branch"] === "string" ? body["branch"].trim() : ""; + if ((ticketId === "") === (branch === "")) { + return "exactly one of ticketId or branch is required"; + } + if (ticketId.length > MAX_CORRELATION_LENGTH || branch.length > MAX_CORRELATION_LENGTH) { + return "correlation value too long"; + } + let correlatedTicketId = ticketId; + if (branch !== "") { + const match = /^workflow\/(.+)$/.exec(branch); + if (match === null || match[1] === undefined) { + return 'branch must look like "workflow/<ticketId>"'; + } + correlatedTicketId = match[1]; + } + const rawDeliveryId = body["deliveryId"]; + if (rawDeliveryId !== undefined) { + if (typeof rawDeliveryId !== "string" || rawDeliveryId.length > MAX_DELIVERY_ID_LENGTH) { + return "deliveryId must be a string (max 128 chars)"; + } + } + return { + name, + ticketId: correlatedTicketId, + payload: sanitizeExternalEventPayload(body["payload"] ?? null), + deliveryId: typeof rawDeliveryId === "string" ? rawDeliveryId : undefined, + }; +}; + +/** + * Per-board webhook ingress: external systems (CI, PR automation, cron) POST + * events that move correlated tickets through their lane's onEvent matchers. + * Unknown board and bad token are indistinguishable (404, no oracle). + */ +export const workflowHooksRouteLayer = HttpRouter.add( + "POST", + "/hooks/workflow/:boardId", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (url._tag === "None") { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + const segments = url.value.pathname.split("/").filter((segment) => segment.length > 0); + const rawBoardId = segments[2] ?? ""; + let boardId: string; + // @effect-diagnostics-next-line tryCatchInEffectGen:off — synchronous decodeURIComponent parse guard; not an Effect failure + try { + boardId = decodeURIComponent(rawBoardId); + } catch { + // Malformed percent-encoding — keep the no-oracle 404 discipline. + return notFound; + } + if (boardId === "" || boardId.length > MAX_CORRELATION_LENGTH) { + return notFound; + } + // Reject oversized bodies before buffering when the client declares a + // length; the post-read byte check below covers lying clients. + const declaredLength = Number(request.headers["content-length"] ?? "0"); + if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) { + return yield* unprocessable("body must be 1 byte to 64 KiB of JSON"); + } + + const headerToken = request.headers["x-t3-webhook-token"]; + const token = typeof headerToken === "string" ? headerToken : ""; + if (token === "") { + return notFound; + } + // Resolved optionally so servers composed without the workflow runtime + // (tests, trimmed deployments) simply 404 instead of failing to build. + const webhookOption = yield* Effect.serviceOption(WorkflowWebhook); + const engineOption = yield* Effect.serviceOption(WorkflowEngine); + const readModelOption = yield* Effect.serviceOption(WorkflowReadModel); + if ( + webhookOption._tag === "None" || + engineOption._tag === "None" || + readModelOption._tag === "None" + ) { + return notFound; + } + const webhook = webhookOption.value; + // Distinguish a real DB error from a legitimate `false` (bad/absent token). + // A transient store failure during verification must NOT collapse into the + // no-oracle 404 (which senders treat as permanently gone); surface it as a + // retryable 503 instead, matching the recordDelivery/ingest branches below. + const verified = yield* webhook.verifyToken(boardId as never, token).pipe(Effect.result); + if (verified._tag === "Failure") { + return serviceUnavailable("token verification temporarily unavailable"); + } + if (!verified.success) { + return notFound; + } + + const raw = yield* request.text.pipe(Effect.orElseSucceed(() => "")); + if (raw.length === 0 || Buffer.byteLength(raw, "utf8") > MAX_BODY_BYTES) { + return yield* unprocessable("body must be 1 byte to 64 KiB of JSON"); + } + const parsed = parseHookBody(raw); + if (typeof parsed === "string") { + return yield* unprocessable(parsed); + } + + // Board must exist and own the ticket; the engine re-verifies, but a + // cheap read keeps error shapes clean. As with verifyToken, separate a real + // DB error (retryable 503) from a legitimate `null` (board absent → 404) so a + // transient store failure is not mis-reported as a permanent not-found. + const boardResult = yield* readModelOption.value.getBoard(boardId as never).pipe(Effect.result); + if (boardResult._tag === "Failure") { + return serviceUnavailable("board lookup temporarily unavailable"); + } + if (boardResult.success === null) { + return notFound; + } + + if (parsed.deliveryId !== undefined) { + // Best-effort at-least-once dedupe. recordDelivery returns `true` for an + // already-seen id (skip → 202 duplicate) and `false` for a fresh id + // (proceed to ingest). Fail closed: if the row cannot be recorded at all, + // a retried delivery could route twice — surface a retryable 503. + const recorded = yield* webhook + .recordDelivery(boardId as never, parsed.deliveryId) + .pipe(Effect.result); + if (recorded._tag === "Failure") { + return HttpServerResponse.text("delivery could not be recorded", { status: 503 }); + } + if (recorded.success) { + return yield* HttpServerResponse.json({ outcome: "duplicate" }, { status: 202 }).pipe( + Effect.orElseSucceed(() => HttpServerResponse.text("duplicate", { status: 202 })), + ); + } + } + + const result = yield* engineOption.value + .ingestExternalEvent({ + boardId: boardId as never, + name: parsed.name, + ticketId: parsed.ticketId as never, + payload: parsed.payload, + }) + .pipe(Effect.result); + if (result._tag === "Failure") { + // TERMINAL vs RETRYABLE classification. A permanent ingest failure (the + // ticket is not on this board — the event can NEVER succeed) must return a + // 4xx so the sender stops retrying; mapping it to 503 would loop forever. + // We branch on the machine-checkable error CODE, never the message string. + if (result.failure.code === WorkflowEventStoreErrorCode.ticketNotOnBoard) { + // Terminal: do NOT release the dedup row. Retrying is futile, so leaving + // the row recorded means a same-deliveryId retry is answered "duplicate" + // (202) rather than re-running ingest to the same permanent 422. + return yield* unprocessable("ticket not found on this board"); + } + + // The delivery row was recorded before ingest. Best-effort release on a + // TRANSIENT failure so the sender's retry re-ingests rather than being + // answered "duplicate". 503 keeps the failure retryable. + // + // ACCEPTED v1 LIMITATION (concurrency-safe at-least-once, NOT exactly-once): + // recordDelivery's INSERT ... ON CONFLICT DO NOTHING RETURNING already makes + // concurrent same-id deliveries safe — exactly one wins the claim and + // ingests; the loser is answered "duplicate". The only residual hole is a + // COMPOUND failure: ingest fails (transient DB error) AND this releaseDelivery + // also fails — then the dedupe row persists and the sender's retry is wrongly + // skipped (lost delivery). Two independent DB errors in sequence. + // + // True exactly-once would mean claiming the deliveryId in the SAME + // transaction as the ingest's event append. That is NOT a localized change: + // a single ingest's side effects already span multiple transactions (the + // route/move commit, then admitNext's backfill-admission commit, then + // post-lock runPipelineStarts), so atomicity would require collapsing the + // whole ingest into one transaction — fighting the admission-lock-outside / + // tx-inside design and the post-lock pipeline starts. High regression risk + // for a compound-failure window on a human-gated path whose senders already + // assume at-least-once retry semantics. Deliberately NOT pursued for v1. + if (parsed.deliveryId !== undefined) { + yield* webhook + .releaseDelivery(boardId as never, parsed.deliveryId) + .pipe(Effect.orElseSucceed(() => undefined)); + } + return HttpServerResponse.text("event could not be ingested", { status: 503 }); + } + return yield* HttpServerResponse.json( + { + outcome: result.success.outcome, + ...(result.success.toLane === undefined ? {} : { toLane: result.success.toLane }), + }, + { status: 202 }, + ).pipe(Effect.orElseSucceed(() => HttpServerResponse.text("accepted", { status: 202 }))); + }), +); diff --git a/apps/server/src/workflow/workflowFile.test.ts b/apps/server/src/workflow/workflowFile.test.ts new file mode 100644 index 00000000000..91b2c338369 --- /dev/null +++ b/apps/server/src/workflow/workflowFile.test.ts @@ -0,0 +1,1710 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + WorkflowDefinition, + type WorkflowDefinition as WorkflowDefinitionType, +} from "@t3tools/contracts"; +import { AsanaSelector, GithubSelector, JiraSelector } from "@t3tools/contracts/workSource"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { encodeWorkflowDefinitionJson, lintWorkflowDefinition } from "./workflowFile.ts"; +import { MAX_PREDICATE_DEPTH } from "./jsonLogicRule.ts"; + +const base = (lanes: unknown): WorkflowDefinitionType => + ({ name: "wf", lanes }) as unknown as WorkflowDefinitionType; + +const ctx = { + providerInstanceExists: (id: string) => id === "claude_main", + instructionFileExists: (path: string) => path === "prompts/ok.md", +}; + +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); + +describe("lintWorkflowDefinition", () => { + it.effect("exports an encoder that serializes decodable workflow JSON", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [{ key: "tests", type: "script", run: "pnpm test", timeout: "5 minutes" }], + }, + ]), + ); + const contents = encodeWorkflowDefinitionJson(definition); + const decoded = yield* decodeWorkflowDefinitionJson(contents); + assert.equal(decoded.name, "wf"); + assert.equal((decoded.lanes[0]?.pipeline?.[0] as any)?.type, "script"); + }), + ); + + it("passes a valid definition", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: { file: "prompts/ok.md" }, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + assert.deepEqual(errors, []); + }); + + it("flags duplicate lane keys", () => { + const errors = lintWorkflowDefinition( + base([ + { key: "a", name: "A", entry: "manual" }, + { key: "a", name: "A2", entry: "manual" }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "duplicate_lane_key")); + }); + + it("flags routing to a missing lane", () => { + const errors = lintWorkflowDefinition( + base([{ key: "a", name: "A", entry: "auto", on: { success: "ghost" } }]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "missing_lane_ref")); + }); + + it("flags step routing and transition targets that reference missing lanes", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "hi", + on: { failure: "missing-step-target" }, + }, + ], + transitions: [{ when: { "==": [{ var: "pipeline.result" }, "success"] }, to: "ghost" }], + }, + ]), + ctx, + ); + + assert.isTrue( + errors.some( + (e) => + e.code === "missing_lane_ref" && + e.stepKey === "review" && + e.message.includes("missing-step-target"), + ), + ); + assert.isTrue( + errors.some( + (e) => + e.code === "missing_lane_ref" && + e.message.includes("ghost") && + (e as any).transitionIndex === 0, + ), + ); + }); + + it("accepts well-formed predicate paths and explicit step precedence", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "tests", + type: "script", + run: "pnpm test", + on: { failure: "needs" }, + }, + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "hi", + captureOutput: true, + }, + ], + transitions: [ + { + when: { + and: [ + { "!=": [{ var: "steps.tests.exitCode" }, 0] }, + { "==": [{ var: "steps.review.output.verdict" }, "block"] }, + { in: [{ var: "pipeline.result" }, ["success", "failure"]] }, + { "!": { var: "status" } }, + ], + }, + to: "needs", + }, + ], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + + assert.deepEqual(errors, []); + }); + + it("flags disallowed predicate operators and invalid var forms", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [{ key: "tests", type: "script", run: "pnpm test" }], + transitions: [ + { when: { cat: ["a", "b"] }, to: "done" }, + { when: { var: ["steps.tests.exitCode", 0] }, to: "done" }, + { when: { var: 123 }, to: "done" }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + + assert.deepEqual( + errors.filter((e) => e.code === "invalid_json_logic").map((e) => (e as any).transitionIndex), + [0, 1, 2], + ); + }); + + it("flags a predicate nested deeper than MAX_PREDICATE_DEPTH without throwing", () => { + // Build a predicate with MAX_PREDICATE_DEPTH + 1 levels of "!" nesting. + // This must produce an invalid_json_logic lint error and must NOT throw. + let deepPredicate: unknown = { var: "pipeline.result" }; + for (let i = 0; i < MAX_PREDICATE_DEPTH + 1; i++) { + deepPredicate = { "!": deepPredicate }; + } + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [{ key: "tests", type: "script", run: "pnpm test" }], + transitions: [{ when: deepPredicate, to: "done" }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + assert.isTrue( + errors.some((e) => e.code === "invalid_json_logic"), + "too-deep predicate must produce an invalid_json_logic lint error", + ); + }); + + it("accepts a predicate nested at exactly MAX_PREDICATE_DEPTH", () => { + // A predicate at exactly the limit (not over) must pass lint cleanly. + let validPredicate: unknown = { var: "pipeline.result" }; + for (let i = 0; i < MAX_PREDICATE_DEPTH; i++) { + validPredicate = { "!": validPredicate }; + } + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [{ key: "tests", type: "script", run: "pnpm test" }], + transitions: [{ when: validPredicate, to: "done" }], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + assert.isFalse( + errors.some((e) => e.code === "invalid_json_logic"), + "predicate at exactly MAX_PREDICATE_DEPTH must not be flagged", + ); + }); + + it("flags unknown and ill-typed predicate paths", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "hi", + }, + { key: "approval", type: "approval", prompt: "Ship?" }, + ], + transitions: [ + { when: { var: "steps.missing.status" }, to: "done" }, + { when: { var: "steps.review.exitCode" }, to: "done" }, + { when: { var: "steps.review.output.verdict" }, to: "done" }, + { when: { var: "steps.approval.output.verdict" }, to: "done" }, + { when: { var: "pipeline.unknown" }, to: "done" }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + + assert.deepEqual( + errors + .filter((e) => e.code === "unknown_predicate_path") + .map((e) => (e as any).transitionIndex), + [0, 1, 2, 3, 4], + ); + }); + + it("flags path-unsafe step keys when predicates are present", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "bad.key", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "hi", + captureOutput: true, + }, + ], + transitions: [{ when: { var: "steps.bad.key.output.verdict" }, to: "done" }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + + assert.isTrue( + errors.some( + (e) => + e.code === "unsafe_step_key" && + e.stepKey === "bad.key" && + (e as any).transitionIndex === 0, + ), + ); + }); + + it("flags an unknown provider instance", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "nope", model: "x" }, + instruction: "hi", + }, + ], + }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "unknown_provider_instance")); + }); + + it("flags a missing instruction file", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "x" }, + instruction: { file: "prompts/missing.md" }, + }, + ], + }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "missing_instruction_file")); + }); + + it("flags unsafe instruction file paths before checking file existence", () => { + let existenceChecks = 0; + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "x" }, + instruction: { file: "../escape.md" }, + }, + ], + }, + ]), + { + providerInstanceExists: ctx.providerInstanceExists, + instructionFileExists: () => { + existenceChecks += 1; + return true; + }, + }, + ); + + assert.deepEqual( + errors.map((error) => ({ + code: error.code, + laneKey: error.laneKey, + stepKey: error.stepKey, + })), + [{ code: "unsafe_instruction_path", laneKey: "a", stepKey: "s" }], + ); + assert.equal(existenceChecks, 0); + }); + + it("flags an auto-lane cycle with no human/terminal break", () => { + const errors = lintWorkflowDefinition( + base([ + { key: "a", name: "A", entry: "auto", on: { success: "b" } }, + { key: "b", name: "B", entry: "auto", on: { success: "a" } }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "auto_lane_cycle")); + }); + + it("flags invalid WIP limits and accepts positive limits on non-terminal lanes", () => { + const invalidErrors = lintWorkflowDefinition( + base([ + { key: "zero", name: "Zero", entry: "manual", wipLimit: 0 }, + { key: "done", name: "Done", entry: "manual", terminal: true, wipLimit: 1 }, + ]), + ctx, + ); + + assert.deepEqual( + invalidErrors + .filter((error) => error.code === "invalid_wip_limit") + .map((error) => error.laneKey), + ["zero", "done"], + ); + + const validErrors = lintWorkflowDefinition( + base([ + { key: "backlog", name: "Backlog", entry: "manual", wipLimit: 2 }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + assert.deepEqual(validErrors, []); + }); + + it.effect("accepts retention only on terminal lanes with positive duration", () => + Effect.gen(function* () { + const valid = yield* decodeWorkflowDefinition( + base([ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "done", + name: "Done", + entry: "manual", + terminal: true, + retention: "7 days", + }, + ]), + ); + assert.deepEqual(lintWorkflowDefinition(valid, ctx), []); + + const nonTerminal = yield* decodeWorkflowDefinition( + base([ + { + key: "backlog", + name: "Backlog", + entry: "manual", + retention: "7 days", + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + assert.deepEqual( + lintWorkflowDefinition(nonTerminal, ctx).map((error) => error.code), + ["invalid_retention"], + ); + + const zeroRetention = yield* decodeWorkflowDefinition( + base([ + { + key: "done", + name: "Done", + entry: "manual", + terminal: true, + retention: "0 millis", + }, + ]), + ); + assert.deepEqual( + lintWorkflowDefinition(zeroRetention, ctx).map((error) => error.code), + ["invalid_retention"], + ); + }), + ); +}); + +describe("lintWorkflowDefinition retry + templates", () => { + const agentStep = (retry?: unknown, instruction: unknown = "Do the work.") => ({ + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction, + ...(retry === undefined ? {} : { retry }), + }); + + const lintLane = (pipeline: ReadonlyArray<unknown>) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([{ key: "a", name: "A", entry: "manual", pipeline }]), + ); + return lintWorkflowDefinition(definition, ctx); + }); + + it.effect("accepts retry within 2..5 on agent and script steps", () => + Effect.gen(function* () { + const errors = yield* lintLane([ + agentStep({ maxAttempts: 3, escalate: { model: "opus" } }), + { key: "t", type: "script", run: "pnpm test", retry: { maxAttempts: 2 } }, + ]); + assert.deepEqual(errors, []); + }), + ); + + it.effect("rejects maxAttempts outside 2..5", () => + Effect.gen(function* () { + const tooLow = yield* lintLane([agentStep({ maxAttempts: 1 })]); + assert.deepEqual( + tooLow.map((error) => error.code), + ["invalid_retry"], + ); + + const tooHigh = yield* lintLane([agentStep({ maxAttempts: 6 })]); + assert.deepEqual( + tooHigh.map((error) => error.code), + ["invalid_retry"], + ); + }), + ); + + it.effect("rejects escalation on script steps", () => + Effect.gen(function* () { + const errors = yield* lintLane([ + { + key: "t", + type: "script", + run: "pnpm test", + retry: { maxAttempts: 2, escalate: { model: "opus" } }, + }, + ]); + assert.deepEqual( + errors.map((error) => error.code), + ["invalid_retry"], + ); + }), + ); + + it.effect("rejects unknown escalation provider instances", () => + Effect.gen(function* () { + const errors = yield* lintLane([ + agentStep({ maxAttempts: 2, escalate: { instance: "nope" } }), + ]); + assert.deepEqual( + errors.map((error) => error.code), + ["unknown_provider_instance"], + ); + assert.match(errors[0]?.message ?? "", /retry escalation/); + }), + ); + + it.effect("accepts known ticket placeholders in inline instructions", () => + Effect.gen(function* () { + const errors = yield* lintLane([ + agentStep( + undefined, + "Review {{ticket.title}} ({{ticket.id}}): {{ticket.description}} vs {{ticket.baseRef}} and {{not.a.template}}", + ), + ]); + assert.deepEqual(errors, []); + }), + ); + + it.effect("flags unknown ticket placeholders in inline instructions", () => + Effect.gen(function* () { + const errors = yield* lintLane([agentStep(undefined, "Check {{ticket.priority}}")]); + assert.deepEqual( + errors.map((error) => error.code), + ["unknown_template_placeholder"], + ); + assert.match(errors[0]?.message ?? "", /ticket\.priority/); + }), + ); +}); + +describe("lintWorkflowDefinition file instruction templates", () => { + it.effect("flags unknown placeholders inside instruction files when content is available", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "manual", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: { file: "prompts/ok.md" }, + }, + ], + }, + ]), + ); + + const withBadContent = lintWorkflowDefinition(definition, { + ...ctx, + readInstructionFile: (path) => + path === "prompts/ok.md" ? "Review {{ticket.titel}}" : null, + }); + assert.deepEqual( + withBadContent.map((error) => error.code), + ["unknown_template_placeholder"], + ); + + const withGoodContent = lintWorkflowDefinition(definition, { + ...ctx, + readInstructionFile: (path) => + path === "prompts/ok.md" ? "Review {{ticket.title}} vs {{ticket.baseRef}}" : null, + }); + assert.deepEqual(withGoodContent, []); + + const withoutContent = lintWorkflowDefinition(definition, ctx); + assert.deepEqual(withoutContent, []); + }), + ); +}); + +describe("lintWorkflowDefinition auto self-loop bounds", () => { + const selfLoopLane = (when: unknown) => [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "review", + captureOutput: true, + }, + ], + transitions: [{ when, to: "impl" }], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]; + + it.effect("rejects unbounded auto self-transitions", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base(selfLoopLane({ "==": [{ var: "steps.review.output.verdict" }, "revise"] })), + ); + const errors = lintWorkflowDefinition(definition, ctx); + assert.deepEqual( + errors.map((error) => error.code), + ["auto_lane_cycle"], + ); + }), + ); + + it.effect("accepts auto self-transitions bounded by lane.runCount", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base( + selfLoopLane({ + and: [ + { "==": [{ var: "steps.review.output.verdict" }, "revise"] }, + { "<": [{ var: "lane.runCount" }, 3] }, + ], + }), + ), + ); + assert.deepEqual(lintWorkflowDefinition(definition, ctx), []); + }), + ); +}); + +describe("lintWorkflowDefinition pullRequest steps", () => { + const lintLane = (pipeline: ReadonlyArray<unknown>) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([ + { key: "a", name: "A", entry: "manual", pipeline }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + return lintWorkflowDefinition(definition, ctx); + }); + + it.effect("lints pullRequest steps", () => + Effect.gen(function* () { + // open step with land-only fields → invalid_step + const openWithLandFields = yield* lintLane([ + { key: "pr", type: "pullRequest", action: "open", strategy: "squash", deleteBranch: true }, + ]); + assert.isTrue(openWithLandFields.some((e) => e.code === "invalid_step")); + + // land step with open-only fields → invalid_step + const landWithOpenFields = yield* lintLane([ + { + key: "pr", + type: "pullRequest", + action: "land", + base: "main", + draft: false, + titleTemplate: "My PR", + bodyTemplate: "Body", + }, + ]); + assert.isTrue(landWithOpenFields.some((e) => e.code === "invalid_step")); + + // open step with unknown placeholder in titleTemplate → unknown_template_placeholder + const badTitle = yield* lintLane([ + { + key: "pr", + type: "pullRequest", + action: "open", + titleTemplate: "PR: {{ticket.bogus.path}}", + }, + ]); + assert.isTrue(badTitle.some((e) => e.code === "unknown_template_placeholder")); + + // open step with unknown placeholder in bodyTemplate → unknown_template_placeholder + const badBody = yield* lintLane([ + { + key: "pr", + type: "pullRequest", + action: "open", + bodyTemplate: "Fixes {{ticket.unknown}}", + }, + ]); + assert.isTrue(badBody.some((e) => e.code === "unknown_template_placeholder")); + + // clean open step → no errors + const cleanOpen = yield* lintLane([ + { + key: "pr", + type: "pullRequest", + action: "open", + base: "main", + titleTemplate: "PR: {{ticket.title}}", + bodyTemplate: "{{ticket.description}}", + }, + ]); + assert.deepEqual(cleanOpen, []); + + // clean land step → no errors + const cleanLand = yield* lintLane([ + { key: "pr", type: "pullRequest", action: "land", strategy: "squash", deleteBranch: true }, + ]); + assert.deepEqual(cleanLand, []); + }), + ); + + it.effect("allows steps.<key>.output for pullRequest open steps", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { key: "openPr", type: "pullRequest", action: "open" }, + { key: "landPr", type: "pullRequest", action: "land" }, + ], + transitions: [{ when: { var: "steps.openPr.output.prNumber" }, to: "done" }], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + const errors = lintWorkflowDefinition(definition, ctx); + // reading output from open step is fine + assert.isFalse( + errors.some((e) => e.code === "unknown_predicate_path" && e.message?.includes("openPr")), + ); + + // reading output from land step → error + const definitionWithLandOutput = yield* decodeWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [{ key: "landPr", type: "pullRequest", action: "land" }], + transitions: [{ when: { var: "steps.landPr.output.prNumber" }, to: "done" }], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + const landOutputErrors = lintWorkflowDefinition(definitionWithLandOutput, ctx); + assert.isTrue( + landOutputErrors.some( + (e) => e.code === "unknown_predicate_path" && e.message?.includes("can only read output"), + ), + ); + }), + ); + + it.effect("allows pr.* in onEvent.when", () => + Effect.gen(function* () { + // pr.ciState and pr.reviewDecision lint clean + const definitionClean = yield* decodeWorkflowDefinition( + base([ + { + key: "review", + name: "Review", + entry: "manual", + onEvent: [ + { name: "pr_update", when: { var: "pr.ciState" }, to: "done" }, + { name: "pr_review", when: { var: "pr.reviewDecision" }, to: "done" }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + assert.deepEqual(lintWorkflowDefinition(definitionClean, ctx), []); + + // pr.bogus → unknown_predicate_path + const definitionBogus = yield* decodeWorkflowDefinition( + base([ + { + key: "review", + name: "Review", + entry: "manual", + onEvent: [{ name: "pr_update", when: { var: "pr.bogus" }, to: "done" }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + const bogusErrors = lintWorkflowDefinition(definitionBogus, ctx); + assert.isTrue(bogusErrors.some((e) => e.code === "unknown_predicate_path")); + // error message mentions the allowed pr.* paths + const prError = bogusErrors.find((e) => e.code === "unknown_predicate_path"); + assert.match(prError?.message ?? "", /pr\.ciState/); + assert.match(prError?.message ?? "", /pr\.reviewDecision/); + }), + ); +}); + +describe("lintWorkflowDefinition lane actions", () => { + it.effect("rejects actions targeting missing lanes", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([ + { + key: "review", + name: "Review", + entry: "manual", + actions: [{ label: "Land it", to: "nope" }], + }, + ]), + ); + const errors = lintWorkflowDefinition(definition, ctx); + assert.deepEqual( + errors.map((error) => error.code), + ["missing_lane_ref"], + ); + assert.match(errors[0]?.message ?? "", /Land it/); + }), + ); + + it.effect("accepts actions targeting real lanes", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([ + { + key: "review", + name: "Review", + entry: "manual", + actions: [{ label: "Land it", to: "done", hint: "Merge the work." }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + assert.deepEqual(lintWorkflowDefinition(definition, ctx), []); + }), + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Source lint tests +// ───────────────────────────────────────────────────────────────────────────── + +const baseWithSources = (lanes: unknown, sources: unknown): WorkflowDefinitionType => + ({ name: "wf", lanes, sources }) as unknown as WorkflowDefinitionType; + +const selectorCtx = { + providerInstanceExists: () => true, + instructionFileExists: () => true, + selectorSchemaFor: (p: string) => + p === "github" ? GithubSelector : p === "asana" ? AsanaSelector : p === "jira" ? JiraSelector : null, +}; + +const twoLanes = [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, +]; + +describe("lintWorkflowDefinition sources", () => { + it("flags destinationLane referencing a missing lane", () => { + const def = baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "acme", repo: "api" }, + destinationLane: "nonexistent", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue( + errors.some((e) => e.code === "missing_lane_ref" && /destinationLane/.test(e.message)), + ); + }); + + it("flags closedLane referencing a missing lane", () => { + const def = baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "acme", repo: "api" }, + destinationLane: "backlog", + closedLane: "ghost", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue( + errors.some((e) => e.code === "missing_lane_ref" && /closedLane/.test(e.message)), + ); + }); + + it("flags closedLane that exists but is not terminal", () => { + const def = baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "acme", repo: "api" }, + destinationLane: "backlog", + closedLane: "backlog", // exists but not terminal + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "invalid_source" && /terminal/.test(e.message))); + }); + + it("flags a github selector missing required fields (owner/repo)", () => { + const def = baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { labels: ["bug"] }, // missing owner and repo + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "invalid_source")); + }); + + it("flags an unknown provider", () => { + const def = baseWithSources(twoLanes, [ + { + id: "trello1", + provider: "trello" as any, + connectionRef: "conn-1", + selector: {}, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue( + errors.some((e) => e.code === "invalid_source" && /unknown provider/.test(e.message)), + ); + }); + + it("accepts a valid Jira source without invalid_source error", () => { + const def = baseWithSources(twoLanes, [ + { + id: "jira1", + provider: "jira", + connectionRef: "conn-1", + selector: { projectKey: "ENG" }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isFalse(errors.some((e) => e.code === "invalid_source")); + }); + + it("flags duplicate source ids", () => { + const def = baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "acme", repo: "api" }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + { + id: "gh1", // duplicate + provider: "github", + connectionRef: "conn-2", + selector: { owner: "acme", repo: "web" }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "duplicate_source_id")); + }); + + it("flags an asana source with sectionGid set", () => { + const def = baseWithSources(twoLanes, [ + { + id: "asana1", + provider: "asana", + connectionRef: "conn-1", + selector: { projectGid: "1234567890", sectionGid: "999", includeCompleted: false }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "invalid_source" && /sectionGid/.test(e.message))); + }); + + it("flags an asana source with tagGid set", () => { + const def = baseWithSources(twoLanes, [ + { + id: "asana1", + provider: "asana", + connectionRef: "conn-1", + selector: { projectGid: "1234567890", tagGid: "777", includeCompleted: true }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "invalid_source" && /tagGid/.test(e.message))); + }); + + it("accepts a valid github source and a valid asana source", () => { + const def = baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "acme", repo: "api", labels: ["bug"], state: "open" }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + { + id: "asana1", + provider: "asana", + connectionRef: "conn-2", + selector: { projectGid: "1234567890", includeCompleted: false }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.deepEqual(errors, []); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Outbound rule lint tests +// ───────────────────────────────────────────────────────────────────────────── + +const baseWithOutbound = (lanes: unknown, outbound: unknown): WorkflowDefinitionType => + ({ name: "wf", lanes, outbound }) as unknown as WorkflowDefinitionType; + +const minimalLanes = [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, +]; + +const validOutboundRule = { + id: "rule-1", + on: "done", + to: "https://hooks.example.com/notify", + as: "generic", + enabled: true, +}; + +describe("lintWorkflowDefinition outbound rules", () => { + it("flags invalid_outbound for an unknown on trigger", () => { + const def = baseWithOutbound(minimalLanes, [ + { ...validOutboundRule, id: "r1", on: "unknown_trigger" }, + ]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isTrue( + errors.some((e) => e.code === "invalid_outbound" && /unknown.*trigger/.test(e.message)), + ); + }); + + it("flags invalid_outbound for an unknown as formatter", () => { + const def = baseWithOutbound(minimalLanes, [{ ...validOutboundRule, id: "r1", as: "teams" }]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isTrue(errors.some((e) => e.code === "invalid_outbound" && /formatter/.test(e.message))); + }); + + it("flags invalid_outbound for an empty to", () => { + const def = baseWithOutbound(minimalLanes, [{ ...validOutboundRule, id: "r1", to: " " }]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isTrue( + errors.some((e) => e.code === "invalid_outbound" && /to must not be empty/.test(e.message)), + ); + }); + + it("flags duplicate_outbound_id for two rules sharing an id", () => { + const def = baseWithOutbound(minimalLanes, [ + { ...validOutboundRule, id: "dup-id" }, + { ...validOutboundRule, id: "dup-id", to: "https://other.example.com" }, + ]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isTrue(errors.some((e) => e.code === "duplicate_outbound_id")); + }); + + it("flags invalid_outbound for a when referencing an unknown path", () => { + const def = baseWithOutbound(minimalLanes, [ + { + ...validOutboundRule, + id: "r1", + when: { "==": [{ var: "stepKey" }, "x"] }, + }, + ]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isTrue( + errors.some( + (e) => e.code === "invalid_outbound" && /unknown predicate path.*stepKey/.test(e.message), + ), + ); + }); + + it("flags invalid_outbound for a when using a disallowed operator", () => { + const def = baseWithOutbound(minimalLanes, [ + { + ...validOutboundRule, + id: "r1", + when: { cat: ["needs-attention", "!"] }, + }, + ]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isTrue( + errors.some( + (e) => e.code === "invalid_outbound" && /unsupported JSONLogic operator/.test(e.message), + ), + ); + }); + + it("accepts a valid rule with when using an allowed outbound path", () => { + const def = baseWithOutbound(minimalLanes, [ + { + ...validOutboundRule, + id: "r1", + when: { "==": [{ var: "toLane" }, "needs-attention"] }, + }, + ]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isFalse( + errors.some((e) => e.code === "invalid_outbound" || e.code === "duplicate_outbound_id"), + ); + }); + + it("accepts a valid rule with no when", () => { + const def = baseWithOutbound(minimalLanes, [validOutboundRule]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isFalse( + errors.some((e) => e.code === "invalid_outbound" || e.code === "duplicate_outbound_id"), + ); + }); + + it("accepts a valid rule with when referencing occurredAt", () => { + const def = baseWithOutbound(minimalLanes, [ + { + ...validOutboundRule, + id: "r1", + when: { "<": [{ var: "occurredAt" }, "2026-01-01T00:00:00.000Z"] }, + }, + ]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isFalse( + errors.some((e) => e.code === "invalid_outbound" || e.code === "duplicate_outbound_id"), + ); + }); + + it("emits one error per failing check (no early continue)", () => { + const def = baseWithOutbound(minimalLanes, [ + { ...validOutboundRule, id: "r1", on: "bogus_trigger", to: " " }, + ]); + const errors = lintWorkflowDefinition(def, ctx); + assert.equal(errors.filter((e) => e.code === "invalid_outbound").length, 2); + }); + + it("accepts all valid trigger and formatter combinations", () => { + const rules = [ + { ...validOutboundRule, id: "r1", on: "needs_attention", as: "generic" }, + { ...validOutboundRule, id: "r2", on: "blocked", as: "slack" }, + { ...validOutboundRule, id: "r3", on: "done", as: "generic" }, + { ...validOutboundRule, id: "r4", on: "lane_entered", as: "slack" }, + ]; + const def = baseWithOutbound(minimalLanes, rules); + const errors = lintWorkflowDefinition(def, ctx); + assert.isFalse( + errors.some((e) => e.code === "invalid_outbound" || e.code === "duplicate_outbound_id"), + ); + }); + + it("flags invalid_outbound for a rule id containing a space", () => { + const def = baseWithOutbound(minimalLanes, [{ ...validOutboundRule, id: "bad id" }]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isTrue(errors.some((e) => e.code === "invalid_outbound" && /bad id/.test(e.message))); + }); + + it("flags invalid_outbound for a rule id containing a newline", () => { + const def = baseWithOutbound(minimalLanes, [{ ...validOutboundRule, id: "bad\nid" }]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isTrue(errors.some((e) => e.code === "invalid_outbound")); + }); + + it("flags invalid_outbound for a rule id longer than 64 chars", () => { + const longId = "a".repeat(65); + const def = baseWithOutbound(minimalLanes, [{ ...validOutboundRule, id: longId }]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isTrue(errors.some((e) => e.code === "invalid_outbound" && e.message.includes(longId))); + }); + + it("does not flag invalid_outbound for a valid slug rule id", () => { + const def = baseWithOutbound(minimalLanes, [ + { ...validOutboundRule, id: "notify-blocked_1:v2" }, + ]); + const errors = lintWorkflowDefinition(def, ctx); + assert.isFalse( + errors.some((e) => e.code === "invalid_outbound" && /notify-blocked_1:v2/.test(e.message)), + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Source autoPull rule lint tests (B3) +// ───────────────────────────────────────────────────────────────────────────── + +const defWithSource = (sourceOverrides: Record<string, unknown>): WorkflowDefinitionType => + baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "acme", repo: "api" }, + destinationLane: "backlog", + closedLane: "done", + ...sourceOverrides, + }, + ]); + +describe("lintWorkflowDefinition source autoPull rules", () => { + it("source autoPull: unknown var path → unknown_predicate_path", () => { + const errors = lintWorkflowDefinition( + defWithSource({ autoPull: { rule: { in: ["XS", { var: "label" }] } } }), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "unknown_predicate_path")); + }); + it("source autoPull: disallowed operator → invalid_json_logic", () => { + const errors = lintWorkflowDefinition( + defWithSource({ autoPull: { rule: { some: [{ var: "labels" }, true] } } }), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "invalid_json_logic")); + }); + it("source autoPull: valid labels/state rule passes", () => { + const errors = lintWorkflowDefinition( + defWithSource({ + autoPull: { + rule: { and: [{ in: ["XS", { var: "labels" }] }, { "==": [{ var: "state" }, "open"] }] }, + }, + }), + ctx, + ); + assert.isFalse( + errors.some((e) => e.code === "invalid_json_logic" || e.code === "unknown_predicate_path"), + ); + }); + it("source autoPull: two disallowed operators → two invalid_json_logic errors", () => { + // Rule uses both `some` and `all`, each disallowed — must produce two errors, not one. + const errors = lintWorkflowDefinition( + defWithSource({ + autoPull: { + rule: { and: [{ some: [{ var: "labels" }, true] }, { all: [{ var: "labels" }, true] }] }, + }, + }), + ctx, + ); + const jsonLogicErrors = errors.filter((e) => e.code === "invalid_json_logic"); + assert.equal(jsonLogicErrors.length, 2); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// A3: Legacy round-trip — decode + lint (step 1) +// ───────────────────────────────────────────────────────────────────────────── + +describe("A3: legacy enabled → autoPull decode + lint round-trip", () => { + it.effect( + "legacy sources (enabled:true + enabled:false, no autoPull) decode and produce no autoPull-related lint errors", + () => + Effect.gen(function* () { + const rawDef = { + name: "legacy-board", + lanes: [ + { key: "inbox", name: "Inbox", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + sources: [ + { + id: "src-enabled", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "acme", repo: "api", state: "all" }, + destinationLane: "inbox", + closedLane: "done", + enabled: true, + // no autoPull + }, + { + id: "src-disabled", + provider: "github", + connectionRef: "conn-2", + selector: { owner: "acme", repo: "web", state: "all" }, + destinationLane: "inbox", + closedLane: "done", + enabled: false, + // no autoPull + }, + ], + }; + const definition = yield* decodeWorkflowDefinition(rawDef); + // Both sources decoded: enabled field preserved, autoPull absent. + assert.equal(definition.sources?.[0]?.enabled, true); + assert.equal(definition.sources?.[0]?.autoPull, undefined); + assert.equal(definition.sources?.[1]?.enabled, false); + assert.equal(definition.sources?.[1]?.autoPull, undefined); + + // Lint must produce zero autoPull-related errors (legacy enabled is not flagged). + const errors = lintWorkflowDefinition(definition, selectorCtx); + assert.isFalse( + errors.some( + (e) => e.code === "invalid_json_logic" || e.code === "unknown_predicate_path", + ), + "unexpected autoPull-related lint errors on legacy enabled sources", + ); + }), + ); +}); + +describe("lintWorkflowDefinition continueSession", () => { + // Both provider instances exist; only resumability differs. claude_main is a + // resumable provider; opencode_main is not (OpenCode lacks supportsSessionResume). + const resumeCtx = { + providerInstanceExists: (id: string) => id === "claude_main" || id === "opencode_main", + instructionFileExists: (path: string) => path === "prompts/ok.md", + providerInstanceSupportsResume: (id: string) => id === "claude_main", + }; + + const lint = (lanes: unknown, lintCtx = resumeCtx) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition(base(lanes)); + return lintWorkflowDefinition(definition, lintCtx); + }); + + it("rejects continueSession on a non-agent step", () => { + // `continueSession` is only on AgentStep, so decode strips it from a script + // step. The lint still defends against a hand-rolled/undecoded definition + // carrying the flag on a non-agent step — so lint the raw (un-decoded) shape. + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [{ key: "s", type: "script", run: "echo hi", continueSession: true }], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + resumeCtx, + ); + assert.deepEqual( + errors.map((e) => ({ code: e.code, laneKey: e.laneKey, stepKey: e.stepKey })), + [{ code: "invalid_continue_session", laneKey: "a", stepKey: "s" }], + ); + }); + + it.effect("rejects continueSession on a panel step", () => + Effect.gen(function* () { + const errors = yield* lint([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "Review {{ticket.title}}.", + captureOutput: true, + panel: 2, + continueSession: true, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]); + assert.deepEqual( + errors.map((e) => ({ code: e.code, laneKey: e.laneKey, stepKey: e.stepKey })), + [{ code: "invalid_continue_session", laneKey: "a", stepKey: "review" }], + ); + }), + ); + + it.effect("rejects continueSession on a non-resumable provider (OpenCode)", () => + Effect.gen(function* () { + const errors = yield* lint([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: "opencode_main", model: "sonnet" }, + instruction: "Implement {{ticket.title}}.", + continueSession: true, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]); + assert.deepEqual( + errors.map((e) => ({ code: e.code, laneKey: e.laneKey, stepKey: e.stepKey })), + [{ code: "invalid_continue_session", laneKey: "a", stepKey: "implement" }], + ); + }), + ); + + it.effect("rejects continueSession when a retry escalates to a non-resumable provider", () => + Effect.gen(function* () { + const errors = yield* lint([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + // Base provider resumes; the escalation target does NOT — and the + // escalated attempt still applies continueSession, so lint must reject. + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "Implement {{ticket.title}}.", + continueSession: true, + retry: { maxAttempts: 2, escalate: { instance: "opencode_main" } }, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]); + assert.deepEqual( + errors.map((e) => ({ code: e.code, laneKey: e.laneKey, stepKey: e.stepKey })), + [{ code: "invalid_continue_session", laneKey: "a", stepKey: "implement" }], + ); + }), + ); + + it.effect("accepts continueSession on a resumable agent step", () => + Effect.gen(function* () { + const errors = yield* lint([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "Implement {{ticket.title}}.", + continueSession: true, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]); + assert.deepEqual(errors, []); + }), + ); + + it.effect("rejects continueSession on a non-resumable provider with file instruction", () => + Effect.gen(function* () { + const errors = yield* lint([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: "opencode_main", model: "sonnet" }, + instruction: { file: "prompts/ok.md" }, + continueSession: true, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]); + assert.deepEqual( + errors.map((e) => ({ code: e.code, laneKey: e.laneKey, stepKey: e.stepKey })), + [{ code: "invalid_continue_session", laneKey: "a", stepKey: "implement" }], + ); + }), + ); +}); + +describe("lintWorkflowDefinition handoff references", () => { + const lint = (lanes: unknown, lintCtx: Record<string, unknown> = ctx) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition(base(lanes)); + return lintWorkflowDefinition(definition, lintCtx as never); + }); + + it.effect("flags {{step.<key>.output}} referencing a key not in the lane pipeline", () => + Effect.gen(function* () { + const errors = yield* lint([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "Use {{step.ghost.output}} please.", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]); + assert.deepEqual( + errors.map((e) => ({ code: e.code, laneKey: e.laneKey, stepKey: e.stepKey })), + [{ code: "invalid_handoff_reference", laneKey: "a", stepKey: "implement" }], + ); + }), + ); + + it.effect("allows a forward reference to a step key that exists later in the lane", () => + Effect.gen(function* () { + const errors = yield* lint([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "Consider {{step.review.output}} from the reviewer.", + }, + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "Review it.", + captureOutput: true, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]); + assert.deepEqual(errors, []); + }), + ); + + it.effect("flags {{prev.output}} on the first step of a lane", () => + Effect.gen(function* () { + const errors = yield* lint([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "Build on {{prev.output}}.", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]); + assert.deepEqual( + errors.map((e) => ({ code: e.code, laneKey: e.laneKey, stepKey: e.stepKey })), + [{ code: "invalid_handoff_reference", laneKey: "a", stepKey: "implement" }], + ); + }), + ); + + it.effect("allows {{prev.output}} on a non-first step", () => + Effect.gen(function* () { + const errors = yield* lint([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "Implement it.", + captureOutput: true, + }, + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "Review {{prev.output}}.", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]); + assert.deepEqual(errors, []); + }), + ); + + it.effect("lints handoff references inside a file instruction", () => + Effect.gen(function* () { + const errors = yield* lint( + [ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: { file: "prompts/ok.md" }, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + { + ...ctx, + readInstructionFile: (path: string) => + path === "prompts/ok.md" ? "Use {{step.ghost.output}}." : null, + }, + ); + assert.deepEqual( + errors.map((e) => ({ code: e.code, laneKey: e.laneKey, stepKey: e.stepKey })), + [{ code: "invalid_handoff_reference", laneKey: "a", stepKey: "implement" }], + ); + }), + ); +}); diff --git a/apps/server/src/workflow/workflowFile.ts b/apps/server/src/workflow/workflowFile.ts new file mode 100644 index 00000000000..d7a124210d5 --- /dev/null +++ b/apps/server/src/workflow/workflowFile.ts @@ -0,0 +1,784 @@ +import { WorkflowDefinition, type WorkflowLane, type WorkflowStep } from "@t3tools/contracts"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { + isSafeWorkflowInstructionPath, + unsafeWorkflowInstructionPathMessage, +} from "./instructionPath.ts"; +import { findHandoffReferences, unknownTicketPlaceholders } from "./instructionTemplate.ts"; +import { inspectJsonLogicRule } from "./jsonLogicRule.ts"; + +export type LintCode = + | "duplicate_lane_key" + | "duplicate_step_key" + | "missing_lane_ref" + | "unknown_provider_instance" + | "missing_instruction_file" + | "unsafe_instruction_path" + | "auto_lane_cycle" + | "unreachable_terminal" + | "invalid_wip_limit" + | "invalid_json_logic" + | "unknown_predicate_path" + | "unsafe_step_key" + | "invalid_retention" + | "invalid_retry" + | "invalid_panel" + | "unknown_template_placeholder" + | "invalid_step" + | "invalid_source" + | "duplicate_source_id" + | "invalid_outbound" + | "duplicate_outbound_id" + | "invalid_continue_session" + | "invalid_handoff_reference"; + +export interface LintError { + readonly code: LintCode; + readonly message: string; + readonly laneKey?: string; + readonly stepKey?: string; + readonly transitionIndex?: number; +} + +export interface LintContext { + readonly providerInstanceExists: (instanceId: string) => boolean; + // Whether a provider instance supports resuming its own session across + // turns/steps (Codex/Claude/Grok/Cursor do; OpenCode does not). Used to + // capability-gate `continueSession`. Absent => treated as resumable + // (permissive — the BoardRegistry lint has no provider info). + readonly providerInstanceSupportsResume?: (instanceId: string) => boolean; + readonly instructionFileExists: (repoRelativePath: string) => boolean; + // Returns the contents of an existing instruction file so template + // placeholders inside it can be linted; null/absent skips that check. + readonly readInstructionFile?: (repoRelativePath: string) => string | null; + // Returns the pure selector schema for a given provider name, or null if the + // provider is unknown. Used for synchronous (no-network) selector validation. + // The schema must have no DecodingServices requirement (pure, sync decode). + readonly selectorSchemaFor?: (provider: string) => Schema.Decoder<any> | null; +} + +const routingTargets = (lane: WorkflowLane): ReadonlyArray<string> => { + const on = lane.on; + if (!on) { + return []; + } + return [on.success, on.failure, on.blocked].flatMap((target) => + target === undefined ? [] : [target as string], + ); +}; + +const stepRoutingTargets = (step: WorkflowStep): ReadonlyArray<string> => { + const on = step.on; + if (!on) { + return []; + } + return [on.success, on.failure, on.blocked].flatMap((target) => + target === undefined ? [] : [target as string], + ); +}; + +export const encodeWorkflowDefinitionJson = Schema.encodeSync( + fromJsonStringPretty(WorkflowDefinition), +); + +export const MIN_STEP_RETRY_ATTEMPTS = 2; +export const MAX_STEP_RETRY_ATTEMPTS = 5; + +const PATH_SAFE_STEP_KEY = /^[A-Za-z0-9_-]+$/; + +const isReferencedStepPath = (path: string, stepKey: string) => + path === `steps.${stepKey}` || path.startsWith(`steps.${stepKey}.`); + +const predicatePathError = ( + path: string, + stepsByKey: ReadonlyMap<string, WorkflowStep>, +): string | null => { + if (path === "status" || path === "pipeline.result" || path === "lane.runCount") { + return null; + } + if (path.startsWith("pipeline.") || path.startsWith("lane.")) { + return `Unknown predicate path "${path}"`; + } + + const parts = path.split("."); + if (parts[0] !== "steps" || parts[1] === undefined || parts[1] === "") { + return `Unknown predicate path "${path}"`; + } + + const step = stepsByKey.get(parts[1]); + if (!step) { + return `Unknown predicate path "${path}"`; + } + + const field = parts[2]; + if (field === undefined) { + return null; + } + if (field === "status") { + return parts.length === 3 ? null : `Unknown predicate path "${path}"`; + } + if (field === "exitCode") { + return step.type === "script" && parts.length === 3 + ? null + : `Predicate path "${path}" can only read exitCode from script steps`; + } + if (field === "output") { + const allowed = + (step.type === "agent" && step.captureOutput === true) || + (step.type === "pullRequest" && step.action === "open"); + return allowed + ? null + : `Predicate path "${path}" can only read output from captureOutput agent steps or pullRequest open steps`; + } + + return `Unknown predicate path "${path}"`; +}; + +export const lintWorkflowDefinition = ( + def: WorkflowDefinition, + ctx: LintContext, +): ReadonlyArray<LintError> => { + const errors: LintError[] = []; + const laneKeys = new Set<string>(); + const allKeys = new Set(def.lanes.map((lane) => lane.key as string)); + + for (const lane of def.lanes) { + const laneKey = lane.key as string; + if (laneKeys.has(laneKey)) { + errors.push({ + code: "duplicate_lane_key", + laneKey, + message: `Duplicate lane key "${laneKey}"`, + }); + } + laneKeys.add(laneKey); + + if (lane.wipLimit !== undefined) { + if (lane.wipLimit < 1) { + errors.push({ + code: "invalid_wip_limit", + laneKey, + message: `Lane "${laneKey}" wipLimit must be at least 1`, + }); + } + if (lane.terminal === true) { + errors.push({ + code: "invalid_wip_limit", + laneKey, + message: `Terminal lane "${laneKey}" cannot define a wipLimit`, + }); + } + } + + if (lane.retention !== undefined) { + if (lane.terminal !== true) { + errors.push({ + code: "invalid_retention", + laneKey, + message: `Lane "${laneKey}" retention is only valid on terminal lanes`, + }); + } + if (Duration.toMillis(lane.retention) <= 0) { + errors.push({ + code: "invalid_retention", + laneKey, + message: `Terminal lane "${laneKey}" retention must be a positive duration`, + }); + } + } + + const stepKeys = new Set<string>(); + const stepsByKey = new Map<string, WorkflowStep>(); + // Full set of step keys in this lane's pipeline, computed up front so a + // handoff reference can point forward to a step defined later in the lane. + const laneStepKeys = new Set((lane.pipeline ?? []).map((step) => step.key as string)); + let stepIndex = 0; + for (const step of lane.pipeline ?? []) { + const isFirstStep = stepIndex === 0; + stepIndex += 1; + const stepKey = step.key as string; + if (stepKeys.has(stepKey)) { + errors.push({ + code: "duplicate_step_key", + laneKey, + stepKey, + message: `Duplicate step key "${stepKey}" in lane "${laneKey}"`, + }); + } + stepKeys.add(stepKey); + stepsByKey.set(stepKey, step); + + for (const target of stepRoutingTargets(step)) { + if (!allKeys.has(target)) { + errors.push({ + code: "missing_lane_ref", + laneKey, + stepKey, + message: `Step "${stepKey}" in lane "${laneKey}" routes to missing lane "${target}"`, + }); + } + } + + // continueSession resumes an agent's own provider session across + // steps/loops. It is valid only on a single (non-panel) agent step whose + // provider supports session resume. `continueSession` lives on AgentStep, + // so decode strips it from other step types; the non-agent guard still + // defends a hand-rolled/undecoded definition. Reading via a property + // probe keeps it type-safe across the step union. + if ((step as { continueSession?: unknown }).continueSession === true) { + if (step.type !== "agent") { + errors.push({ + code: "invalid_continue_session", + laneKey, + stepKey, + message: `Step "${stepKey}" continueSession is only valid on agent steps`, + }); + } else if (step.panel !== undefined && step.panel >= 2) { + errors.push({ + code: "invalid_continue_session", + laneKey, + stepKey, + message: `Step "${stepKey}" continueSession cannot be combined with a reviewer panel`, + }); + } else if (ctx.providerInstanceSupportsResume !== undefined) { + // A retry can escalate to a DIFFERENT provider instance; that attempt + // still applies continueSession, so every instance the step may run on + // — base + escalation — must support resume, not just the base. + const supportsResume = ctx.providerInstanceSupportsResume; + const escalateInstance = step.retry?.escalate?.instance; + const candidateInstances = [ + step.agent.instance as string, + ...(escalateInstance === undefined ? [] : [escalateInstance as string]), + ]; + const unsupported = candidateInstances.find((instance) => !supportsResume(instance)); + if (unsupported !== undefined) { + errors.push({ + code: "invalid_continue_session", + laneKey, + stepKey, + message: `Step "${stepKey}" provider instance "${unsupported}" does not support session resume`, + }); + } + } + } + + if (step.type === "agent" && step.panel !== undefined) { + if (step.panel < 2 || step.panel > 5) { + errors.push({ + code: "invalid_panel", + laneKey, + stepKey, + message: `Step "${stepKey}" panel must be between 2 and 5 reviewers`, + }); + } + if (step.captureOutput !== true) { + errors.push({ + code: "invalid_panel", + laneKey, + stepKey, + message: `Step "${stepKey}" panel requires captureOutput so verdicts can be compared`, + }); + } + } + + if ((step.type === "agent" || step.type === "script") && step.retry !== undefined) { + if ( + step.retry.maxAttempts < MIN_STEP_RETRY_ATTEMPTS || + step.retry.maxAttempts > MAX_STEP_RETRY_ATTEMPTS + ) { + errors.push({ + code: "invalid_retry", + laneKey, + stepKey, + message: `Step "${stepKey}" retry maxAttempts must be between ${MIN_STEP_RETRY_ATTEMPTS} and ${MAX_STEP_RETRY_ATTEMPTS}`, + }); + } + if (step.type === "script" && step.retry.escalate !== undefined) { + errors.push({ + code: "invalid_retry", + laneKey, + stepKey, + message: `Script step "${stepKey}" cannot define a retry escalation`, + }); + } + if ( + step.type === "agent" && + step.retry.escalate?.instance !== undefined && + !ctx.providerInstanceExists(step.retry.escalate.instance) + ) { + errors.push({ + code: "unknown_provider_instance", + laneKey, + stepKey, + message: `Unknown provider instance "${step.retry.escalate.instance}" in retry escalation`, + }); + } + } + + if (step.type === "pullRequest") { + if ( + step.action === "open" && + (step.strategy !== undefined || step.deleteBranch !== undefined) + ) { + errors.push({ + code: "invalid_step", + laneKey, + stepKey, + message: `Step "${stepKey}": strategy/deleteBranch only apply to action "land"`, + }); + } + if ( + step.action === "land" && + (step.base !== undefined || + step.draft !== undefined || + step.titleTemplate !== undefined || + step.bodyTemplate !== undefined) + ) { + errors.push({ + code: "invalid_step", + laneKey, + stepKey, + message: `Step "${stepKey}": base/draft/templates only apply to action "open"`, + }); + } + for (const template of [step.titleTemplate, step.bodyTemplate]) { + if (template !== undefined) { + for (const placeholder of unknownTicketPlaceholders(template)) { + errors.push({ + code: "unknown_template_placeholder", + laneKey, + stepKey, + message: `Step "${stepKey}" references unknown placeholder "{{ticket.${placeholder}}}"`, + }); + } + } + } + } + + if (step.type !== "agent") { + continue; + } + + const instructionText = + typeof step.instruction === "string" + ? step.instruction + : (ctx.readInstructionFile?.(step.instruction.file) ?? null); + if (instructionText !== null) { + for (const placeholder of unknownTicketPlaceholders(instructionText)) { + errors.push({ + code: "unknown_template_placeholder", + laneKey, + stepKey, + message: `Step "${stepKey}" instruction references unknown placeholder "{{ticket.${placeholder}}}"`, + }); + } + + // Inter-agent handoff references must resolve within this lane's + // pipeline. `{{prev.output}}` needs a preceding step, so it is invalid + // on the first step. `{{step.<key>.output}}` must name a step in the + // lane; forward references (a key defined later) are allowed. + for (const ref of findHandoffReferences(instructionText)) { + if (ref.kind === "prev") { + if (isFirstStep) { + errors.push({ + code: "invalid_handoff_reference", + laneKey, + stepKey, + message: `Step "${stepKey}" references "{{prev.output}}" but has no preceding step in lane "${laneKey}"`, + }); + } + } else if (ref.stepKey !== undefined && !laneStepKeys.has(ref.stepKey)) { + errors.push({ + code: "invalid_handoff_reference", + laneKey, + stepKey, + message: `Step "${stepKey}" references "{{step.${ref.stepKey}.output}}" but no step "${ref.stepKey}" exists in lane "${laneKey}"`, + }); + } + } + } + + if (!ctx.providerInstanceExists(step.agent.instance)) { + errors.push({ + code: "unknown_provider_instance", + laneKey, + stepKey, + message: `Unknown provider instance "${step.agent.instance}"`, + }); + } + + if (typeof step.instruction === "object") { + if (!isSafeWorkflowInstructionPath(step.instruction.file)) { + errors.push({ + code: "unsafe_instruction_path", + laneKey, + stepKey, + message: unsafeWorkflowInstructionPathMessage(step.instruction.file), + }); + } else if (!ctx.instructionFileExists(step.instruction.file)) { + errors.push({ + code: "missing_instruction_file", + laneKey, + stepKey, + message: `Instruction file not found: "${step.instruction.file}"`, + }); + } + } + } + + for (const target of routingTargets(lane)) { + if (!allKeys.has(target)) { + errors.push({ + code: "missing_lane_ref", + laneKey, + message: `Lane "${laneKey}" routes to missing lane "${target}"`, + }); + } + } + + for (const action of lane.actions ?? []) { + if (!allKeys.has(action.to as string)) { + errors.push({ + code: "missing_lane_ref", + laneKey, + message: `Lane "${laneKey}" action "${action.label}" targets missing lane "${action.to}"`, + }); + } + } + + for (const [eventIndex, eventMatcher] of (lane.onEvent ?? []).entries()) { + if (!allKeys.has(eventMatcher.to as string)) { + errors.push({ + code: "missing_lane_ref", + laneKey, + message: `Lane "${laneKey}" onEvent ${eventIndex} ("${eventMatcher.name}") targets missing lane "${eventMatcher.to}"`, + }); + } + if (eventMatcher.when !== undefined) { + const inspection = inspectJsonLogicRule(eventMatcher.when); + for (const issue of inspection.issues) { + errors.push({ + code: "invalid_json_logic", + laneKey, + message: `Lane "${laneKey}" onEvent ${eventIndex}: ${issue.message}`, + }); + } + // Event predicates see only the inbound event and PR state — not pipeline state. + for (const path of inspection.variablePaths) { + if ( + path !== "event.name" && + path !== "event.payload" && + !path.startsWith("event.payload.") && + path !== "pr.ciState" && + path !== "pr.reviewDecision" + ) { + errors.push({ + code: "unknown_predicate_path", + laneKey, + message: `Lane "${laneKey}" onEvent ${eventIndex}: unknown predicate path "${path}" (event predicates may read event.name, event.payload.*, pr.ciState, pr.reviewDecision)`, + }); + } + } + } + } + + for (const [transitionIndex, transition] of (lane.transitions ?? []).entries()) { + if (!allKeys.has(transition.to as string)) { + errors.push({ + code: "missing_lane_ref", + laneKey, + transitionIndex, + message: `Lane "${laneKey}" transition ${transitionIndex} routes to missing lane "${transition.to}"`, + }); + } + + const inspection = inspectJsonLogicRule(transition.when); + for (const issue of inspection.issues) { + errors.push({ + code: "invalid_json_logic", + laneKey, + transitionIndex, + message: `Lane "${laneKey}" transition ${transitionIndex}: ${issue.message}`, + }); + } + + // An auto lane that transitions back into itself re-runs its pipeline + // every time the predicate matches; without lane.runCount in the + // predicate that loop has no bound and burns agent runs forever. + if ( + lane.entry === "auto" && + (transition.to as string) === laneKey && + !inspection.variablePaths.includes("lane.runCount") + ) { + errors.push({ + code: "auto_lane_cycle", + laneKey, + transitionIndex, + message: `Auto lane "${laneKey}" transitions to itself without bounding the loop on lane.runCount`, + }); + } + + for (const path of inspection.variablePaths) { + for (const step of lane.pipeline ?? []) { + const stepKey = step.key as string; + if (!PATH_SAFE_STEP_KEY.test(stepKey) && isReferencedStepPath(path, stepKey)) { + errors.push({ + code: "unsafe_step_key", + laneKey, + stepKey, + transitionIndex, + message: `Step key "${stepKey}" must match [A-Za-z0-9_-]+ to be used in predicate paths`, + }); + } + } + + const message = predicatePathError(path, stepsByKey); + if (message !== null) { + errors.push({ + code: "unknown_predicate_path", + laneKey, + transitionIndex, + message, + }); + } + } + } + } + + const byKey = new Map<string, WorkflowLane>( + def.lanes.map((lane) => [lane.key as string, lane] as const), + ); + for (const lane of def.lanes) { + if (lane.entry !== "auto") { + continue; + } + + const seen = new Set<string>(); + let cursor: WorkflowLane | undefined = lane; + while (cursor && cursor.entry === "auto" && !cursor.terminal) { + const cursorKey = cursor.key as string; + if (seen.has(cursorKey)) { + errors.push({ + code: "auto_lane_cycle", + laneKey: lane.key as string, + message: `Auto-lane cycle detected starting at "${lane.key}"`, + }); + break; + } + seen.add(cursorKey); + const next = cursor.on?.success as string | undefined; + cursor = next ? byKey.get(next) : undefined; + } + } + + // ── Source lint (synchronous — pure schema decode, no network) ────────── + const seenSourceIds = new Set<string>(); + for (const source of def.sources ?? []) { + const sourceId = source.id as string; + + // Duplicate source id check + if (seenSourceIds.has(sourceId)) { + errors.push({ + code: "duplicate_source_id", + message: `Duplicate source id "${sourceId}"`, + }); + } + seenSourceIds.add(sourceId); + + // destinationLane must exist + if (!allKeys.has(source.destinationLane as string)) { + errors.push({ + code: "missing_lane_ref", + message: `Source "${sourceId}" destinationLane "${source.destinationLane}" does not exist`, + }); + } + + // closedLane must exist and be terminal + if (!allKeys.has(source.closedLane as string)) { + errors.push({ + code: "missing_lane_ref", + message: `Source "${sourceId}" closedLane "${source.closedLane}" does not exist`, + }); + } else { + const closedLaneDef = byKey.get(source.closedLane as string); + if (closedLaneDef?.terminal !== true) { + errors.push({ + code: "invalid_source", + message: `Source "${sourceId}" closedLane "${source.closedLane}" must be a terminal lane`, + }); + } + } + + // connectionRef must not be blank + const connectionRef = source.connectionRef as string; + if (!connectionRef || connectionRef.trim().length === 0) { + errors.push({ + code: "invalid_source", + message: `Source "${sourceId}" connectionRef must not be empty`, + }); + } + + // Selector schema validation (pure, synchronous) + if (ctx.selectorSchemaFor !== undefined) { + const schema = ctx.selectorSchemaFor(source.provider as string); + if (schema === null) { + errors.push({ + code: "invalid_source", + message: `Source "${sourceId}" has unknown provider "${source.provider}"`, + }); + } else { + const decodeExit = Schema.decodeUnknownExit(schema)(source.selector); + if (Exit.isFailure(decodeExit)) { + const squashed = Cause.squash(decodeExit.cause); + const message = Schema.isSchemaError(squashed) + ? String(squashed.message) + : Cause.pretty(decodeExit.cause); + errors.push({ + code: "invalid_source", + message: `Source "${sourceId}" selector is invalid: ${message}`, + }); + } else { + // Extra check: Asana section/tag filtering is not supported yet + if ( + (source.provider as string) === "asana" && + decodeExit.value !== undefined && + decodeExit.value !== null && + typeof decodeExit.value === "object" + ) { + const selector = decodeExit.value as Record<string, unknown>; + if (selector["sectionGid"] !== undefined || selector["tagGid"] !== undefined) { + errors.push({ + code: "invalid_source", + message: `Source "${sourceId}" Asana section/tag filtering is not supported yet; remove sectionGid/tagGid`, + }); + } + } + } + } + } + + // autoPull rule lint (jsonLogic + item-context allow-list) + const AUTO_PULL_ALLOWED_VARS = new Set([ + "title", + "body", + "labels", + "assignees", + "state", + "provider", + ]); + if (source.autoPull !== undefined) { + const inspection = inspectJsonLogicRule(source.autoPull.rule); + for (const issue of inspection.issues) { + errors.push({ + code: "invalid_json_logic", + message: `Source "${sourceId}" autoPull rule: ${issue.message}`, + }); + } + for (const path of inspection.variablePaths) { + if (!AUTO_PULL_ALLOWED_VARS.has(path)) { + errors.push({ + code: "unknown_predicate_path", + message: `Source "${sourceId}" autoPull: unknown predicate path "${path}" (allowed: title, body, labels, assignees, state, provider)`, + }); + } + } + } + } + + // ── Outbound rule lint ──────────────────────────────────────────────────── + // The outbound `when` predicate evaluates against OutboundEventContext, whose + // field set is DIFFERENT from the transition/onEvent path-sets, so it is + // validated against its own allow-list. Keep this in sync with + // OutboundEventContext in contracts/outbound.ts. + const OUTBOUND_ALLOWED_PATHS = new Set([ + "trigger", + "ticketId", + "boardId", + "title", + "status", + "fromLane", + "toLane", + "isTerminal", + "reason", + "occurredAt", + ]); + const OUTBOUND_TRIGGERS = new Set(["needs_attention", "blocked", "done", "lane_entered"]); + const OUTBOUND_FORMATTERS = new Set(["generic", "slack"]); + + const OUTBOUND_RULE_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; + const OUTBOUND_RULE_ID_MAX_LENGTH = 64; + + const seenOutboundIds = new Set<string>(); + for (const rule of def.outbound ?? []) { + const ruleId = rule.id as string; + + // Duplicate id check + if (seenOutboundIds.has(ruleId)) { + errors.push({ + code: "duplicate_outbound_id", + message: `Duplicate outbound rule id "${ruleId}"`, + }); + } + seenOutboundIds.add(ruleId); + + // Validate rule id is header-safe (no whitespace/control chars) and within max length + if (!OUTBOUND_RULE_ID_PATTERN.test(ruleId) || ruleId.length > OUTBOUND_RULE_ID_MAX_LENGTH) { + errors.push({ + code: "invalid_outbound", + message: `Outbound rule id "${ruleId}" must match ^[A-Za-z0-9._:-]+$ and be ≤64 chars`, + }); + } + + // Validate `on` trigger + if (!OUTBOUND_TRIGGERS.has(rule.on as string)) { + errors.push({ + code: "invalid_outbound", + message: `Outbound rule "${ruleId}" has unknown trigger "${rule.on}" (expected: needs_attention, blocked, done, lane_entered)`, + }); + } + + // Validate `as` formatter + if (!OUTBOUND_FORMATTERS.has(rule.as as string)) { + errors.push({ + code: "invalid_outbound", + message: `Outbound rule "${ruleId}" has unknown formatter "${rule.as}" (expected: generic, slack)`, + }); + } + + // Validate `to` non-empty. Defensive: the contract's TrimmedNonEmptyString + // already rejects blanks on decode (same as the sources block). + const to = rule.to as string; + if (!to || to.trim().length === 0) { + errors.push({ + code: "invalid_outbound", + message: `Outbound rule "${ruleId}" to must not be empty`, + }); + } + + // Validate optional `when` predicate + if (rule.when !== undefined) { + const inspection = inspectJsonLogicRule(rule.when); + for (const issue of inspection.issues) { + errors.push({ + code: "invalid_outbound", + message: `Outbound rule "${ruleId}" when: ${issue.message}`, + }); + } + for (const path of inspection.variablePaths) { + if (!OUTBOUND_ALLOWED_PATHS.has(path)) { + errors.push({ + code: "invalid_outbound", + message: `Outbound rule "${ruleId}" when: unknown predicate path "${path}" (allowed: trigger, ticketId, boardId, title, status, fromLane, toLane, isTerminal, reason, occurredAt)`, + }); + } + } + } + } + + return errors; +}; diff --git a/apps/server/src/workflow/workflowVersionHash.ts b/apps/server/src/workflow/workflowVersionHash.ts new file mode 100644 index 00000000000..3eb2c02da58 --- /dev/null +++ b/apps/server/src/workflow/workflowVersionHash.ts @@ -0,0 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { createHash } from "node:crypto"; + +export const sha256Hex = (value: string) => createHash("sha256").update(value).digest("hex"); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index 5a4ec54686e..16d4a867812 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -184,5 +184,252 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i expect(escapedStat).toBeNull(); }), ); + + it.effect( + "rejects board file writes when the board path is a symlink outside the workspace", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const outsidePath = path.join(outsideDir, "outside-board.json"); + const boardPath = path.join(cwd, ".t3/boards/foo.json"); + + yield* fileSystem.makeDirectory(path.dirname(boardPath), { recursive: true }); + yield* fileSystem.writeFileString(outsidePath, '{"name":"outside-before"}\n'); + yield* fileSystem.symlink(outsidePath, boardPath); + + const error = yield* workspaceFileSystem + .writeFile({ + cwd, + relativePath: ".t3/boards/foo.json", + contents: '{"name":"outside-after"}\n', + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("WorkspaceFileSystemError"); + const outside = yield* fileSystem.readFileString(outsidePath); + expect(outside).toBe('{"name":"outside-before"}\n'); + }), + ); + + it.effect("writes normal board files under the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: ".t3/boards/foo.json", + contents: '{"name":"inside"}\n', + }); + + const saved = yield* fileSystem.readFileString(path.join(cwd, ".t3/boards/foo.json")); + expect(saved).toBe('{"name":"inside"}\n'); + }), + ); + + it.effect("createFileExclusive creates once and rejects an existing file", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const created = yield* workspaceFileSystem.createFileExclusive({ + projectRoot: cwd, + relativePath: ".t3/boards/workflow-board.json", + contents: "{}\n", + }); + expect(created).toEqual({ relativePath: ".t3/boards/workflow-board.json" }); + + const error = yield* workspaceFileSystem + .createFileExclusive({ + projectRoot: cwd, + relativePath: ".t3/boards/workflow-board.json", + contents: '{"overwritten":true}\n', + }) + .pipe(Effect.flip); + expect(error._tag).toBe("WorkspaceFileSystemError"); + if (error._tag === "WorkspaceFileSystemError") { + expect(error.operation).toBe("workspaceFileSystem.createFileExclusive"); + } + + const saved = yield* fileSystem.readFileString( + path.join(cwd, ".t3/boards/workflow-board.json"), + ); + expect(saved).toBe("{}\n"); + }), + ); + }); + + describe("listFilesRecursive", () => { + it.effect("lists nested files as paths relative to the directory", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, ".t3/ticket/t1/DESCRIPTION.md", "# desc\n"); + yield* writeTextFile(cwd, ".t3/ticket/t1/handoff/review.md", "review\n"); + yield* writeTextFile(cwd, ".t3/ticket/t1/design/SPEC.md", "spec\n"); + yield* writeTextFile(cwd, ".t3/ticket/t1/design/PLAN.md", "plan\n"); + + const names = yield* workspaceFileSystem.listFilesRecursive!({ + cwd, + relativePath: ".t3/ticket/t1", + }); + + expect([...names].sort()).toEqual([ + "DESCRIPTION.md", + "design/PLAN.md", + "design/SPEC.md", + "handoff/review.md", + ]); + }), + ); + + it.effect("returns an empty list for a missing directory", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const names = yield* workspaceFileSystem.listFilesRecursive!({ + cwd, + relativePath: ".t3/ticket/missing", + }); + expect([...names]).toEqual([]); + }), + ); + }); + + describe("deleteFile", () => { + it.effect("deletes files relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const boardPath = path.join(cwd, ".t3/boards/delete-me.json"); + + yield* writeTextFile(cwd, ".t3/boards/delete-me.json", "{}\n"); + yield* workspaceFileSystem.deleteFile({ + cwd, + relativePath: ".t3/boards/delete-me.json", + }); + + const stat = yield* fileSystem.stat(boardPath).pipe(Effect.orElseSucceed(() => null)); + expect(stat).toBeNull(); + }), + ); + + it.effect("treats missing files as successful deletes", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + + yield* workspaceFileSystem.deleteFile({ + cwd, + relativePath: ".t3/boards/already-gone.json", + }); + }), + ); + + it.effect("rejects deletes outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + + const error = yield* workspaceFileSystem + .deleteFile({ + cwd, + relativePath: "../escape.md", + }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + }), + ); + + it.effect( + "rejects board file deletes when the board path is a symlink outside the workspace", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const outsidePath = path.join(outsideDir, "outside-board.json"); + const boardPath = path.join(cwd, ".t3/boards/foo.json"); + + yield* fileSystem.makeDirectory(path.dirname(boardPath), { recursive: true }); + yield* fileSystem.writeFileString(outsidePath, '{"name":"outside"}\n'); + yield* fileSystem.symlink(outsidePath, boardPath); + + const error = yield* workspaceFileSystem + .deleteFile({ + cwd, + relativePath: ".t3/boards/foo.json", + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("WorkspaceFileSystemError"); + const outside = yield* fileSystem.readFileString(outsidePath); + expect(outside).toBe('{"name":"outside"}\n'); + const symlinkTarget = yield* fileSystem.readFileString(boardPath); + expect(symlinkTarget).toBe('{"name":"outside"}\n'); + }), + ); + + it.effect("deletes dangling symlinks whose entries are inside the workspace", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const boardPath = path.join(cwd, ".t3/boards/dangling.json"); + const missingTarget = path.join(cwd, ".t3/boards/missing-target.json"); + + yield* fileSystem.makeDirectory(path.dirname(boardPath), { recursive: true }); + yield* fileSystem.symlink(missingTarget, boardPath); + + yield* workspaceFileSystem.deleteFile({ + cwd, + relativePath: ".t3/boards/dangling.json", + }); + + const linkTarget = yield* fileSystem + .readLink(boardPath) + .pipe(Effect.orElseSucceed(() => null)); + expect(linkTarget).toBeNull(); + }), + ); + + it.effect("deletes in-workspace symlink loops by unlinking the entry", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const boardPath = path.join(cwd, ".t3/boards/loop.json"); + + yield* fileSystem.makeDirectory(path.dirname(boardPath), { recursive: true }); + yield* fileSystem.symlink(boardPath, boardPath); + + yield* workspaceFileSystem.deleteFile({ + cwd, + relativePath: ".t3/boards/loop.json", + }); + + const symlinkTarget = yield* fileSystem + .readLink(boardPath) + .pipe(Effect.orElseSucceed(() => null)); + expect(symlinkTarget).toBeNull(); + }), + ); }); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts index 61056042bf3..6945ad6d6d6 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import { WorkspaceFileSystem, @@ -14,6 +15,10 @@ import { import * as WorkspaceEntries from "../WorkspaceEntries.ts"; import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; +/** Hard cap on entries returned by listFilesRecursive, so a pathological tree + * cannot force an unbounded walk over this read path. */ +const MAX_RECURSIVE_LIST_ENTRIES = 500; + const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; export const makeWorkspaceFileSystem = Effect.gen(function* () { @@ -82,9 +87,316 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { }, ); + const containsRealPath = (realRoot: string, realTarget: string) => { + const relative = path.relative(realRoot, realTarget); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + }; + + const containmentError = ( + input: { readonly cwd: string; readonly relativePath: string }, + operation: string, + detail: string, + ) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation, + detail, + }); + + const mapFileSystemError = + (input: { readonly cwd: string; readonly relativePath: string }, operation: string) => + (cause: unknown) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation, + detail: cause instanceof Error ? cause.message : String(cause), + notFound: isNotFoundError(cause), + cause, + }); + + const isNotFoundError = (cause: unknown): boolean => { + if (typeof cause !== "object" || cause === null || !("reason" in cause)) { + return false; + } + const reason = (cause as { readonly reason?: unknown }).reason; + return ( + typeof reason === "object" && + reason !== null && + "_tag" in reason && + (reason as { readonly _tag?: unknown })._tag === "NotFound" + ); + }; + + const realWorkspaceRoot = ( + input: { readonly cwd: string; readonly relativePath: string }, + operation: string, + ) => fileSystem.realPath(input.cwd).pipe(Effect.mapError(mapFileSystemError(input, operation))); + + const existingRealTargetWithinWorkspace = ( + input: { readonly cwd: string; readonly relativePath: string }, + absolutePath: string, + operation: string, + ) => + Effect.gen(function* () { + const realRoot = yield* realWorkspaceRoot(input, operation); + const realTarget = yield* fileSystem + .realPath(absolutePath) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + if (!containsRealPath(realRoot, realTarget)) { + return yield* containmentError( + input, + operation, + "Workspace file target resolves outside the workspace root.", + ); + } + return realTarget; + }); + + const writableRealTargetWithinWorkspace = ( + input: { readonly cwd: string; readonly relativePath: string }, + absolutePath: string, + operation: string, + ) => + Effect.gen(function* () { + const realRoot = yield* realWorkspaceRoot(input, operation); + const targetDirectory = path.dirname(absolutePath); + const realParent = yield* fileSystem + .realPath(targetDirectory) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + if (!containsRealPath(realRoot, realParent)) { + return yield* containmentError( + input, + operation, + "Workspace file parent resolves outside the workspace root.", + ); + } + + const realTarget = yield* fileSystem + .realPath(absolutePath) + .pipe(Effect.orElseSucceed(() => path.resolve(realParent, path.basename(absolutePath)))); + if (!containsRealPath(realRoot, realTarget)) { + return yield* containmentError( + input, + operation, + "Workspace file target resolves outside the workspace root.", + ); + } + return realTarget; + }); + + const deletableTargetWithinWorkspace = ( + input: { readonly cwd: string; readonly relativePath: string }, + absolutePath: string, + operation: string, + ) => + Effect.gen(function* () { + const realRoot = yield* realWorkspaceRoot(input, operation); + const symlinkTarget = yield* fileSystem + .readLink(absolutePath) + .pipe(Effect.orElseSucceed(() => null)); + + if (symlinkTarget !== null) { + const targetDirectory = path.dirname(absolutePath); + const realParent = yield* fileSystem + .realPath(targetDirectory) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + if (!containsRealPath(realRoot, realParent)) { + return yield* containmentError( + input, + operation, + "Workspace file parent resolves outside the workspace root.", + ); + } + + const absoluteLinkTarget = path.isAbsolute(symlinkTarget) + ? symlinkTarget + : path.resolve(targetDirectory, symlinkTarget); + const logicalRoot = path.resolve(input.cwd); + const logicalTarget = path.resolve(absoluteLinkTarget); + if ( + !containsRealPath(logicalRoot, logicalTarget) && + !containsRealPath(realRoot, logicalTarget) + ) { + return yield* containmentError( + input, + operation, + "Workspace file target resolves outside the workspace root.", + ); + } + + const realTarget = yield* fileSystem + .realPath(absoluteLinkTarget) + .pipe(Effect.orElseSucceed(() => null)); + if (realTarget !== null && !containsRealPath(realRoot, realTarget)) { + return yield* containmentError( + input, + operation, + "Workspace file target resolves outside the workspace root.", + ); + } + return true; + } + + const targetExists = yield* fileSystem.stat(absolutePath).pipe( + Effect.as(true), + Effect.catch((cause) => + isNotFoundError(cause) + ? Effect.succeed(false) + : Effect.fail(mapFileSystemError(input, operation)(cause)), + ), + ); + if (!targetExists) { + return false; + } + + const realTarget = yield* fileSystem + .realPath(absolutePath) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + if (!containsRealPath(realRoot, realTarget)) { + return yield* containmentError( + input, + operation, + "Workspace file target resolves outside the workspace root.", + ); + } + return true; + }); + + const readFileString: WorkspaceFileSystemShape["readFileString"] = Effect.fn( + "WorkspaceFileSystem.readFileString", + )(function* (input) { + const operation = "workspaceFileSystem.readFileString"; + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + const realTarget = yield* existingRealTargetWithinWorkspace( + input, + target.absolutePath, + operation, + ); + + return yield* fileSystem + .readFileString(realTarget) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + }); + + const readFileStringCapped: NonNullable<WorkspaceFileSystemShape["readFileStringCapped"]> = + Effect.fn("WorkspaceFileSystem.readFileStringCapped")(function* (input) { + const operation = "workspaceFileSystem.readFileStringCapped"; + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + const realTarget = yield* existingRealTargetWithinWorkspace( + input, + target.absolutePath, + operation, + ); + // Stream at most maxBytes off disk and decode — never materialise the whole + // file in memory just to truncate it. + return yield* fileSystem + .stream(realTarget, { bytesToRead: input.maxBytes }) + .pipe( + Stream.decodeText(), + Stream.mkString, + Effect.mapError(mapFileSystemError(input, operation)), + ); + }); + + const listFiles: WorkspaceFileSystemShape["listFiles"] = Effect.fn( + "WorkspaceFileSystem.listFiles", + )(function* (input) { + const operation = "workspaceFileSystem.listFiles"; + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + const exists = yield* fileSystem + .exists(target.absolutePath) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + if (!exists) { + return []; + } + const realTarget = yield* existingRealTargetWithinWorkspace( + input, + target.absolutePath, + operation, + ); + const entries = yield* fileSystem + .readDirectory(realTarget) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + const files: string[] = []; + for (const entry of entries) { + const info = yield* fileSystem + .stat(path.join(realTarget, entry)) + .pipe(Effect.orElseSucceed(() => null)); + if (info?.type === "File") { + files.push(entry); + } + } + return files.sort((left, right) => left.localeCompare(right)); + }); + + const listFilesRecursive: NonNullable<WorkspaceFileSystemShape["listFilesRecursive"]> = Effect.fn( + "WorkspaceFileSystem.listFilesRecursive", + )(function* (input) { + const operation = "workspaceFileSystem.listFilesRecursive"; + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + const exists = yield* fileSystem + .exists(target.absolutePath) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + if (!exists) { + return []; + } + const realRoot = yield* realWorkspaceRoot(input, operation); + const realTarget = yield* existingRealTargetWithinWorkspace( + input, + target.absolutePath, + operation, + ); + const limit = input.maxEntries ?? MAX_RECURSIVE_LIST_ENTRIES; + const results: string[] = []; + const walk = (relDir: string, absDir: string): Effect.Effect<void, WorkspaceFileSystemError> => + Effect.gen(function* () { + const entries = yield* fileSystem + .readDirectory(absDir) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + for (const entry of [...entries].sort((left, right) => left.localeCompare(right))) { + if (results.length >= limit) { + return; + } + const absEntry = path.join(absDir, entry); + // Resolve symlinks and skip anything that escapes the workspace, so a + // symlinked scratch entry can't leak file names from outside the worktree. + const realEntry = yield* fileSystem + .realPath(absEntry) + .pipe(Effect.orElseSucceed(() => null)); + if (realEntry === null || !containsRealPath(realRoot, realEntry)) { + continue; + } + const info = yield* fileSystem.stat(absEntry).pipe(Effect.orElseSucceed(() => null)); + const relPath = relDir === "" ? entry : `${relDir}/${entry}`; + if (info?.type === "File") { + results.push(relPath); + } else if (info?.type === "Directory") { + yield* walk(relPath, absEntry); + } + } + }); + yield* walk("", realTarget); + return results.sort((left, right) => left.localeCompare(right)); + }); + const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn( "WorkspaceFileSystem.writeFile", )(function* (input) { + const operation = "workspaceFileSystem.writeFile"; const target = yield* workspacePaths.resolveRelativePathWithinRoot({ workspaceRoot: input.cwd, relativePath: input.relativePath, @@ -102,22 +414,82 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { }), ), ); - yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( + yield* writableRealTargetWithinWorkspace(input, target.absolutePath, operation); + yield* fileSystem + .writeFileString(target.absolutePath, input.contents) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + yield* workspaceEntries.refresh(input.cwd); + return { relativePath: target.relativePath }; + }); + + const createFileExclusive: WorkspaceFileSystemShape["createFileExclusive"] = Effect.fn( + "WorkspaceFileSystem.createFileExclusive", + )(function* (input) { + const operation = "workspaceFileSystem.createFileExclusive"; + const fileInput = { cwd: input.projectRoot, relativePath: input.relativePath }; + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.projectRoot, + relativePath: input.relativePath, + }); + + yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( Effect.mapError( (cause) => new WorkspaceFileSystemError({ - cwd: input.cwd, + cwd: input.projectRoot, relativePath: input.relativePath, - operation: "workspaceFileSystem.writeFile", + operation: "workspaceFileSystem.makeDirectory", detail: cause.message, cause, }), ), ); - yield* workspaceEntries.refresh(input.cwd); + yield* writableRealTargetWithinWorkspace(fileInput, target.absolutePath, operation); + yield* fileSystem.writeFileString(target.absolutePath, input.contents, { flag: "wx" }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemError({ + cwd: input.projectRoot, + relativePath: input.relativePath, + operation: "workspaceFileSystem.createFileExclusive", + detail: cause.message, + cause, + }), + ), + ); + yield* workspaceEntries.refresh(input.projectRoot); return { relativePath: target.relativePath }; }); - return { readFile, writeFile } satisfies WorkspaceFileSystemShape; + + const deleteFile: WorkspaceFileSystemShape["deleteFile"] = Effect.fn( + "WorkspaceFileSystem.deleteFile", + )(function* (input) { + const operation = "workspaceFileSystem.deleteFile"; + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + const exists = yield* deletableTargetWithinWorkspace(input, target.absolutePath, operation); + if (!exists) { + return; + } + + yield* fileSystem + .remove(target.absolutePath, { force: true }) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + yield* workspaceEntries.refresh(input.cwd); + }); + + return { + readFile, + readFileString, + readFileStringCapped, + listFiles, + listFilesRecursive, + writeFile, + createFileExclusive, + deleteFile, + } satisfies WorkspaceFileSystemShape; }); export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem); diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts index 5126ec417bf..1e60cbbf847 100644 --- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts @@ -25,6 +25,10 @@ export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceF relativePath: Schema.optional(Schema.String), operation: Schema.String, detail: Schema.String, + // True only when the underlying failure is a genuine file/dir absence + // (ENOENT). Callers must distinguish this from transient IO errors + // (EACCES/EIO/EBUSY/...) before treating a read as "file does not exist". + notFound: Schema.optional(Schema.Boolean), cause: Schema.optional(Schema.Defect()), }, ) { @@ -47,6 +51,57 @@ export interface WorkspaceFileSystemShape { WorkspaceFileSystemError | WorkspacePathOutsideRootError >; + /** + * Read a file relative to the workspace root. + * + * Rejects paths that escape the workspace root. + */ + readonly readFileString: (input: { + readonly cwd: string; + readonly relativePath: string; + }) => Effect.Effect<string, WorkspaceFileSystemError | WorkspacePathOutsideRootError>; + + /** + * Read AT MOST `maxBytes` bytes of a file (UTF-8 decoded), without loading the + * whole file into memory. Use this when only a bounded preview is needed (e.g. + * truncated ticket-artifact display) so an arbitrarily large file cannot force + * a full-memory read over an RPC. Optional: callers MUST fall back to + * `readFileString` when this is absent (some lightweight mocks omit it). + */ + readonly readFileStringCapped?: (input: { + readonly cwd: string; + readonly relativePath: string; + readonly maxBytes: number; + }) => Effect.Effect<string, WorkspaceFileSystemError | WorkspacePathOutsideRootError>; + + /** + * List the regular files directly inside a directory relative to the + * workspace root (sorted by name). A missing directory lists as empty. + */ + readonly listFiles: (input: { + readonly cwd: string; + readonly relativePath: string; + }) => Effect.Effect< + ReadonlyArray<string>, + WorkspaceFileSystemError | WorkspacePathOutsideRootError + >; + + /** + * Recursively list the regular files under a directory relative to the + * workspace root, returning paths relative to that directory (e.g. + * `design/SPEC.md`), sorted. Symlinked entries that resolve outside the + * workspace are skipped. Optional — callers should fall back to the flat + * {@link listFiles} when a lightweight implementation omits it. + */ + readonly listFilesRecursive?: (input: { + readonly cwd: string; + readonly relativePath: string; + readonly maxEntries?: number; + }) => Effect.Effect< + ReadonlyArray<string>, + WorkspaceFileSystemError | WorkspacePathOutsideRootError + >; + /** * Write a file relative to the workspace root. * @@ -59,6 +114,31 @@ export interface WorkspaceFileSystemShape { ProjectWriteFileResult, WorkspaceFileSystemError | WorkspacePathOutsideRootError >; + /** + * Create a file relative to the workspace root, failing if it already exists. + * + * Creates parent directories as needed and rejects paths that escape the + * workspace root. + */ + readonly createFileExclusive: (input: { + readonly projectRoot: string; + readonly relativePath: string; + readonly contents: string; + }) => Effect.Effect< + ProjectWriteFileResult, + WorkspaceFileSystemError | WorkspacePathOutsideRootError + >; + + /** + * Delete a file relative to the workspace root. + * + * Rejects paths that escape the workspace root. Missing files are treated as + * already deleted so callers can retry safely. + */ + readonly deleteFile: (input: { + readonly cwd: string; + readonly relativePath: string; + }) => Effect.Effect<void, WorkspaceFileSystemError | WorkspacePathOutsideRootError>; } /** diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 34c993de84f..c3a362fd4c8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -4,6 +4,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Context from "effect/Context"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -13,6 +14,8 @@ import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthWorkflowOperateScope, + AuthWorkflowReadScope, AuthReviewWriteScope, AuthRelayWriteScope, AuthTerminalOperateScope, @@ -30,6 +33,7 @@ import { OrchestrationDispatchCommandError, type OrchestrationEvent, type OrchestrationShellStreamEvent, + type OrchestrationThreadShell, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, @@ -45,10 +49,14 @@ import { AssetAccessError, EnvironmentAuthorizationError, ThreadId, + type TicketId, type TerminalAttachStreamEvent, type TerminalError, type TerminalEvent, + type TerminalHistoryAttachStreamEvent, type TerminalMetadataStreamEvent, + WORKFLOW_WS_METHODS, + WorkflowRpcError, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -69,6 +77,7 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import { ProviderService } from "./provider/Services/ProviderService.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; @@ -107,6 +116,31 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import { BoardDiscovery } from "./workflow/Services/BoardDiscovery.ts"; +import { BoardRegistry } from "./workflow/Services/BoardRegistry.ts"; +import { ProjectScriptTrust } from "./workflow/Services/ProjectScriptTrust.ts"; +import { ProjectWorkspaceResolver } from "./workflow/Services/ProjectWorkspaceResolver.ts"; +import { TicketDiffQuery } from "./workflow/Services/TicketDiffQuery.ts"; +import { WorkflowBoardEvents } from "./workflow/Services/WorkflowBoardEvents.ts"; +import { WorkflowBoardSaveLocks } from "./workflow/Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardVersionStore } from "./workflow/Services/WorkflowBoardVersionStore.ts"; +import { WorkflowEngine } from "./workflow/Services/WorkflowEngine.ts"; +import { WorkflowAgentSessionStore } from "./workflow/Services/WorkflowAgentSessionStore.ts"; +import { WorkflowEventStore } from "./workflow/Services/WorkflowEventStore.ts"; +import { WorkflowIntakeService } from "./workflow/Services/WorkflowIntake.ts"; +import { WorkflowThreadJanitor } from "./workflow/Services/WorkflowThreadJanitor.ts"; +import { PredicateEvaluator } from "./workflow/Services/PredicateEvaluator.ts"; +import { WorkflowWebhook } from "./workflow/Services/WorkflowWebhook.ts"; +import { WorkflowWorktreeJanitor } from "./workflow/Services/WorkflowWorktreeJanitor.ts"; +import { WorkflowFileLoader } from "./workflow/Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "./workflow/Services/WorkflowReadModel.ts"; +import { TextGeneration } from "./textGeneration/TextGeneration.ts"; +import { WorkSourceConnectionStore } from "./workflow/Services/WorkSourceConnectionStore.ts"; +import { WorkSourceProviderRegistry } from "./workflow/Services/WorkSourceProvider.ts"; +import { WorkflowSourceCommitter } from "./workflow/Services/WorkflowSourceCommitter.ts"; +import { WorkflowOutboundConnectionStore } from "./workflow/Services/WorkflowOutboundConnectionStore.ts"; +import { workflowRpcHandlers } from "./workflow/Layers/WorkflowRpcHandlers.ts"; +import { ticketBaseRef } from "./workflow/ticketRefs.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); @@ -137,7 +171,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< const PROVIDER_STATUS_DEBOUNCE_MS = 200; -const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ +export const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope], @@ -145,6 +179,52 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], + [WORKFLOW_WS_METHODS.listBoards, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.listNeedsAttentionTickets, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.createBoard, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.deleteBoard, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.renameBoard, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.subscribeBoard, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getBoard, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getBoardDefinition, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.saveBoardDefinition, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.listBoardVersions, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getBoardVersion, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getTicketDetail, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getTicketDiff, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.createTicket, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.editTicket, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.moveTicket, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.runLane, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.resolveApproval, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.answerTicketStep, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.postTicketMessage, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.editTicketMessage, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.setProjectScriptTrust, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.cancelStep, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.intakeTickets, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.listTicketArtifacts, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getWebhookConfig, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.getBoardDigest, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getBoardMetrics, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.dryRunBoard, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.proposeBoardImprovement, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.listBoardProposals, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getBoardProposal, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.resolveBoardProposal, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.revertBoardProposal, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.listWorkSourceConnections, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.createWorkSourceConnection, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.deleteWorkSourceConnection, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.listImportableWorkItems, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.importWorkItems, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.listOutboundConnections, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.createOutboundConnection, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.deleteOutboundConnection, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.importBoard, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.createWorkflowBoard, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.generateWorkflowDraft, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.listBoardTemplates, AuthWorkflowReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], @@ -184,6 +264,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ [WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope], [WS_METHODS.terminalOpen, AuthTerminalOperateScope], [WS_METHODS.terminalAttach, AuthTerminalOperateScope], + [WS_METHODS.terminalAttachHistory, AuthTerminalOperateScope], [WS_METHODS.terminalWrite, AuthTerminalOperateScope], [WS_METHODS.terminalResize, AuthTerminalOperateScope], [WS_METHODS.terminalClear, AuthTerminalOperateScope], @@ -293,6 +374,62 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const relayClient = yield* RelayClient.RelayClient; + const workflowEngine = yield* WorkflowEngine; + const workflowEventStore = yield* WorkflowEventStore; + const workflowWorktreeJanitor = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<WorkflowWorktreeJanitor>, + WorkflowWorktreeJanitor, + ); + const workflowIntake = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<WorkflowIntakeService>, + WorkflowIntakeService, + ); + const workflowThreadJanitor = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<WorkflowThreadJanitor>, + WorkflowThreadJanitor, + ); + const workflowWebhook = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<WorkflowWebhook>, + WorkflowWebhook, + ); + const workflowAgentSessions = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<WorkflowAgentSessionStore>, + WorkflowAgentSessionStore, + ); + const workflowProviderService = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<ProviderService>, + ProviderService, + ); + const workflowPredicates = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<PredicateEvaluator>, + PredicateEvaluator, + ); + const workflowTextGeneration = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<TextGeneration>, + TextGeneration, + ); + const workflowReadModel = yield* WorkflowReadModel; + const workflowBoardRegistry = yield* BoardRegistry; + const workflowTicketDiff = yield* TicketDiffQuery; + const workflowBoardEvents = yield* WorkflowBoardEvents; + const workflowBoardSaveLocks = yield* WorkflowBoardSaveLocks; + const workflowBoardVersions = yield* WorkflowBoardVersionStore; + const workflowFileLoader = yield* WorkflowFileLoader; + const workflowBoardDiscovery = yield* BoardDiscovery; + const workflowProjectWorkspaceResolver = yield* ProjectWorkspaceResolver; + const projectScriptTrust = yield* ProjectScriptTrust; + // WorkSourceConnectionStoreLive is provided by WorkflowServerRuntimeLive + // (via WorkSourceLive), so resolve it as a required service — the + // connection RPCs need a real store, not a standby no-op. + const workflowConnectionStore = yield* WorkSourceConnectionStore; + const workflowSourceProviders = yield* WorkSourceProviderRegistry; + const workflowSourceCommitter = yield* WorkflowSourceCommitter; + // WorkflowOutboundConnectionStore is optional — only available when the + // outbound feature is wired up by WorkflowServerRuntimeLive. + const workflowOutboundConnectionStore = Context.getOption( + (yield* Effect.context<never>()) as Context.Context<WorkflowOutboundConnectionStore>, + WorkflowOutboundConnectionStore, + ); const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -505,7 +642,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => }), ); case "thread.unarchived": - return projectionSnapshotQuery.getThreadShellById(event.payload.threadId).pipe( + return projectionSnapshotQuery.isThreadHidden(event.payload.threadId).pipe( + Effect.flatMap((hidden) => + hidden + ? Effect.succeed(Option.none<OrchestrationThreadShell>()) + : projectionSnapshotQuery + .getThreadShellById(event.payload.threadId) + .pipe(Effect.orElseSucceed(() => Option.none<OrchestrationThreadShell>())), + ), Effect.map((thread) => Option.map(thread, (nextThread) => ({ kind: "thread-upserted" as const, @@ -519,18 +663,24 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => if (event.aggregateKind !== "thread") { return Effect.succeed(Option.none()); } - return projectionSnapshotQuery - .getThreadShellById(ThreadId.make(event.aggregateId)) - .pipe( - Effect.map((thread) => - Option.map(thread, (nextThread) => ({ - kind: "thread-upserted" as const, - sequence: event.sequence, - thread: nextThread, - })), - ), - Effect.orElseSucceed(() => Option.none()), - ); + // Hidden (workflow-internal) threads never reach the sidebar. + return projectionSnapshotQuery.isThreadHidden(ThreadId.make(event.aggregateId)).pipe( + Effect.flatMap((hidden) => + hidden + ? Effect.succeed(Option.none<OrchestrationThreadShell>()) + : projectionSnapshotQuery + .getThreadShellById(ThreadId.make(event.aggregateId)) + .pipe(Effect.orElseSucceed(() => Option.none<OrchestrationThreadShell>())), + ), + Effect.map((thread) => + Option.map(thread, (nextThread) => ({ + kind: "thread-upserted" as const, + sequence: event.sequence, + thread: nextThread, + })), + ), + Effect.orElseSucceed(() => Option.none()), + ); } }; @@ -785,7 +935,108 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => .refreshStatus(cwd) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + const ticketWorktrees = { + resolveForTicket: (ticketId: TicketId) => + Effect.gen(function* () { + const refName = `workflow/${ticketId as string}`; + const refs = yield* gitWorkflow + .listRefs({ + cwd: config.cwd, + query: refName, + limit: 100, + }) + .pipe( + Effect.mapError( + (cause) => + new WorkflowRpcError({ + message: "Failed to resolve workflow ticket worktree refs", + cause, + }), + ), + ); + const ref = refs.refs.find( + (candidate) => + candidate.name === refName && + candidate.isRemote !== true && + candidate.worktreePath !== null, + ); + if (!ref?.worktreePath) { + return yield* new WorkflowRpcError({ + message: `Workflow ticket ${ticketId} does not have an attached worktree`, + }); + } + return { + cwd: ref.worktreePath, + baseRef: ticketBaseRef(ticketId), + }; + }), + }; + + const workflowHandlers = workflowRpcHandlers({ + engine: workflowEngine, + eventStore: workflowEventStore, + readModel: workflowReadModel, + boardRegistry: workflowBoardRegistry, + boardDiscovery: workflowBoardDiscovery, + projectWorkspaceResolver: workflowProjectWorkspaceResolver, + workspaceFileSystem, + ticketDiff: workflowTicketDiff, + ticketWorktrees, + boardEvents: workflowBoardEvents, + saveLocks: workflowBoardSaveLocks, + versionStore: workflowBoardVersions, + ...(Option.isSome(workflowWorktreeJanitor) + ? { worktreeJanitor: workflowWorktreeJanitor.value } + : {}), + ...(Option.isSome(workflowIntake) ? { intake: workflowIntake.value } : {}), + ...(Option.isSome(workflowThreadJanitor) + ? { threadJanitor: workflowThreadJanitor.value } + : {}), + ...(Option.isSome(workflowWebhook) ? { webhook: workflowWebhook.value } : {}), + ...(Option.isSome(workflowAgentSessions) + ? { agentSessions: workflowAgentSessions.value } + : {}), + ...(Option.isSome(workflowProviderService) + ? { provider: workflowProviderService.value } + : {}), + ...(Option.isSome(workflowPredicates) ? { predicates: workflowPredicates.value } : {}), + ...(Option.isSome(workflowTextGeneration) + ? { textGeneration: workflowTextGeneration.value } + : {}), + fileLoader: workflowFileLoader, + projectScriptTrust, + connectionStore: workflowConnectionStore, + workSourceProviders: workflowSourceProviders, + sourceCommitter: workflowSourceCommitter, + ...(Option.isSome(workflowOutboundConnectionStore) + ? { outboundConnectionStore: workflowOutboundConnectionStore.value } + : {}), + observeRpcEffect, + observeRpcStreamEffect, + // Gate mutating workflow RPCs behind startup + workflow-recovery + // readiness (mirrors how orchestration commands go through + // startup.enqueueCommand): defer the effect until recovery is done, and + // fail it as a retryable WorkflowRpcError if startup/recovery failed. + // awaitWorkflowReady specifically rejects when recovery failed, so a + // half-recovered projection is never mutated. + gate: <A, E, R>( + effect: Effect.Effect<A, E, R>, + ): Effect.Effect<A, E | WorkflowRpcError, R> => + Effect.all([startup.awaitCommandReady, startup.awaitWorkflowReady]).pipe( + Effect.mapError( + (cause) => + new WorkflowRpcError({ + message: + "Workflow runtime is not ready (server is starting up or recovery failed)", + cause, + }), + ), + Effect.andThen(effect), + ), + }); + return WsRpcGroup.of({ + ...workflowHandlers, [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( ORCHESTRATION_WS_METHODS.dispatchCommand, @@ -1407,6 +1658,17 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "terminal" }, ), + [WS_METHODS.terminalAttachHistory]: (input) => + observeRpcStream( + WS_METHODS.terminalAttachHistory, + Stream.callback<TerminalHistoryAttachStreamEvent, TerminalError>((queue) => + Effect.acquireRelease( + terminalManager.attachHistoryStream(input, (event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ), + { "rpc.aggregate": "terminal" }, + ), [WS_METHODS.terminalWrite]: (input) => observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { "rpc.aggregate": "terminal", diff --git a/apps/server/src/wsRpcScope.test.ts b/apps/server/src/wsRpcScope.test.ts new file mode 100644 index 00000000000..20ad7edc081 --- /dev/null +++ b/apps/server/src/wsRpcScope.test.ts @@ -0,0 +1,25 @@ +import { WsRpcGroup } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import { RPC_REQUIRED_SCOPE } from "./ws.ts"; + +/** + * Regression guard: every RPC method registered in WsRpcGroup must have a + * declared authorization scope in RPC_REQUIRED_SCOPE. If this test fails it + * means a new method was added to the RPC group without wiring its scope, + * which causes ws.ts to throw "no declared authorization scope" on every real + * call for that method. + */ +it("every WsRpcGroup method has a declared authorization scope in RPC_REQUIRED_SCOPE", () => { + const missing: string[] = []; + for (const [methodTag] of WsRpcGroup.requests) { + if (!RPC_REQUIRED_SCOPE.has(methodTag)) { + missing.push(methodTag); + } + } + assert.deepStrictEqual( + missing, + [], + `The following WsRpcGroup methods are missing from RPC_REQUIRED_SCOPE:\n ${missing.join("\n ")}`, + ); +}); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0bb881a8fac..1e1b6ce8358 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -4,10 +4,13 @@ import "../index.css"; import { EventId, ORCHESTRATION_WS_METHODS, + BoardId, EnvironmentId, type EnvironmentApi, type MessageId, type OrchestrationReadModel, + type BoardListEntry, + type BoardSnapshot, type ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -244,6 +247,7 @@ function createBaseServerConfig(): ServerConfig { function createMockEnvironmentApi(input: { browse: EnvironmentApi["filesystem"]["browse"]; dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"]; + workflow?: Partial<EnvironmentApi["workflow"]>; }): EnvironmentApi { return { terminal: {} as EnvironmentApi["terminal"], @@ -267,6 +271,9 @@ function createMockEnvironmentApi(input: { vcs: {} as EnvironmentApi["vcs"], git: {} as EnvironmentApi["git"], review: {} as EnvironmentApi["review"], + workflow: { + ...input.workflow, + } as EnvironmentApi["workflow"], orchestration: { dispatchCommand: input.dispatchCommand, getTurnDiff: (() => { @@ -1815,6 +1822,8 @@ describe("ChatView timeline estimator parity (full app)", () => { useStore.setState({ activeEnvironmentId: null, environmentStateById: {}, + boardStateById: {}, + boardsByScopedProjectKey: {}, }); useUiStateStore.setState({ projectExpandedById: {}, @@ -5002,6 +5011,606 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("confirms workflow board deletion from the sidebar and refreshes the board list", async () => { + const boardId = BoardId.make(`${PROJECT_ID}__delivery`); + let boards: BoardListEntry[] = [ + { + boardId, + name: "Delivery", + filePath: ".t3/boards/delivery.json", + error: null, + }, + ]; + const listBoardsMock = vi.fn<EnvironmentApi["workflow"]["listBoards"]>(async () => boards); + const deleteBoardMock = vi.fn<EnvironmentApi["workflow"]["deleteBoard"]>(async (input) => { + expect(input).toEqual({ boardId }); + boards = []; + }); + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: listBoardsMock, + deleteBoard: deleteBoardMock, + }, + }), + ); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-board-delete-test" as MessageId, + targetText: "board delete target", + }), + }); + + try { + const boardRow = page.getByTestId(`board-row-${boardId}`); + await expect.element(boardRow).toBeInTheDocument(); + await boardRow.hover(); + + const deleteButton = document.querySelector<HTMLButtonElement>( + `[data-testid="board-delete-${boardId}"]`, + ); + expect(deleteButton, "Expected a delete button on the workflow board row.").not.toBeNull(); + deleteButton?.click(); + + await expect.element(page.getByText('Delete board "Delivery"?')).toBeInTheDocument(); + await expect + .element( + page.getByText( + "This permanently deletes the board file, its tickets, and version history.", + ), + ) + .toBeInTheDocument(); + + await page.getByRole("button", { name: "Delete board", exact: true }).click(); + + await vi.waitFor( + () => { + expect(deleteBoardMock).toHaveBeenCalledWith({ boardId }); + }, + { timeout: 8_000, interval: 16 }, + ); + await vi.waitFor( + () => { + expect(document.querySelector(`[data-testid="board-row-${boardId}"]`)).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + expect( + listBoardsMock.mock.calls.filter(([input]) => input.projectId === PROJECT_ID).length, + ).toBeGreaterThanOrEqual(2); + } finally { + __resetEnvironmentApiOverridesForTests(); + await mounted.cleanup(); + } + }); + + it("renames workflow boards inline from the sidebar and refreshes the board list", async () => { + const boardId = BoardId.make(`${PROJECT_ID}__delivery`); + let boards: BoardListEntry[] = [ + { + boardId, + name: "Delivery", + filePath: ".t3/boards/delivery.json", + error: null, + }, + ]; + const listBoardsMock = vi.fn<EnvironmentApi["workflow"]["listBoards"]>(async () => boards); + const renameBoardMock = vi.fn<EnvironmentApi["workflow"]["renameBoard"]>(async (input) => { + expect(input).toEqual({ boardId, name: "Renamed Delivery" }); + boards = [{ ...boards[0]!, name: input.name }]; + }); + const getBoardMock = vi.fn<EnvironmentApi["workflow"]["getBoard"]>(async () => ({ + projectId: PROJECT_ID, + board: { + boardId, + name: boards[0]!.name, + lanes: [], + }, + tickets: [], + })); + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: listBoardsMock, + renameBoard: renameBoardMock, + getBoard: getBoardMock, + }, + }), + ); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-board-rename-test" as MessageId, + targetText: "board rename target", + }), + }); + + try { + const boardRow = page.getByTestId(`board-row-${boardId}`); + await expect.element(boardRow).toBeInTheDocument(); + await boardRow.hover(); + + const renameButton = document.querySelector<HTMLButtonElement>( + `[data-testid="board-rename-${boardId}"]`, + ); + expect(renameButton, "Expected a rename button on the workflow board row.").not.toBeNull(); + renameButton?.click(); + + const input = page.getByTestId(`board-rename-input-${boardId}`); + await input.fill("Renamed Delivery"); + document + .querySelector<HTMLInputElement>(`[data-testid="board-rename-input-${boardId}"]`) + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + await vi.waitFor( + () => { + expect(renameBoardMock).toHaveBeenCalledWith({ boardId, name: "Renamed Delivery" }); + }, + { timeout: 8_000, interval: 16 }, + ); + await expect.element(page.getByText("Renamed Delivery")).toBeInTheDocument(); + expect( + listBoardsMock.mock.calls.filter(([input]) => input.projectId === PROJECT_ID).length, + ).toBeGreaterThanOrEqual(2); + } finally { + __resetEnvironmentApiOverridesForTests(); + await mounted.cleanup(); + } + }); + + it("keeps workflow board inline rename open when the rename fails", async () => { + const boardId = BoardId.make(`${PROJECT_ID}__delivery`); + const boards: BoardListEntry[] = [ + { + boardId, + name: "Delivery", + filePath: ".t3/boards/delivery.json", + error: null, + }, + ]; + const listBoardsMock = vi.fn<EnvironmentApi["workflow"]["listBoards"]>(async () => boards); + let rejectRename: (error: Error) => void = () => { + throw new Error("rename promise was not started"); + }; + let resolveRenameStarted: () => void = () => {}; + const renameStarted = new Promise<void>((resolve) => { + resolveRenameStarted = resolve; + }); + const renameBoardMock = vi.fn<EnvironmentApi["workflow"]["renameBoard"]>( + (input) => + new Promise<void>((_resolve, reject) => { + expect(input).toEqual({ boardId, name: "Renamed Delivery" }); + rejectRename = reject; + resolveRenameStarted(); + }), + ); + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: listBoardsMock, + renameBoard: renameBoardMock, + }, + }), + ); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-board-rename-failure-test" as MessageId, + targetText: "board rename failure target", + }), + }); + + try { + const boardRow = page.getByTestId(`board-row-${boardId}`); + await expect.element(boardRow).toBeInTheDocument(); + await boardRow.hover(); + + const renameButton = document.querySelector<HTMLButtonElement>( + `[data-testid="board-rename-${boardId}"]`, + ); + expect(renameButton, "Expected a rename button on the workflow board row.").not.toBeNull(); + renameButton?.click(); + + const input = page.getByTestId(`board-rename-input-${boardId}`); + await input.fill("Renamed Delivery"); + document + .querySelector<HTMLInputElement>(`[data-testid="board-rename-input-${boardId}"]`) + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + await renameStarted; + await vi.waitFor( + () => { + const renameInput = document.querySelector<HTMLInputElement>( + `[data-testid="board-rename-input-${boardId}"]`, + ); + expect(renameInput?.disabled).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + rejectRename(new Error("rename unavailable")); + + await vi.waitFor( + () => { + expect(renameBoardMock).toHaveBeenCalledWith({ boardId, name: "Renamed Delivery" }); + const renameInput = document.querySelector<HTMLInputElement>( + `[data-testid="board-rename-input-${boardId}"]`, + ); + expect(renameInput).not.toBeNull(); + expect(renameInput?.disabled).toBe(false); + }, + { timeout: 8_000, interval: 16 }, + ); + await expect.element(page.getByText("Renamed Delivery")).not.toBeInTheDocument(); + } finally { + __resetEnvironmentApiOverridesForTests(); + await mounted.cleanup(); + } + }); + + it("updates the active workflow board header after sidebar rename", async () => { + const boardId = BoardId.make(`${PROJECT_ID}__delivery`); + let boardSnapshot = { + projectId: PROJECT_ID, + board: { + boardId, + name: "Delivery", + lanes: [], + }, + tickets: [], + } satisfies BoardSnapshot; + let boards: BoardListEntry[] = [ + { + boardId, + name: "Delivery", + filePath: ".t3/boards/delivery.json", + error: null, + }, + ]; + const listBoardsMock = vi.fn<EnvironmentApi["workflow"]["listBoards"]>(async () => boards); + const renameBoardMock = vi.fn<EnvironmentApi["workflow"]["renameBoard"]>(async (input) => { + expect(input).toEqual({ boardId, name: "Renamed Delivery" }); + boards = [{ ...boards[0]!, name: input.name }]; + boardSnapshot = { + ...boardSnapshot, + board: { + ...boardSnapshot.board, + name: input.name, + }, + }; + }); + const getBoardMock = vi.fn<EnvironmentApi["workflow"]["getBoard"]>(async () => boardSnapshot); + const getBoardDefinitionMock = vi.fn<EnvironmentApi["workflow"]["getBoardDefinition"]>( + async () => ({ + definition: { name: boardSnapshot.board.name, lanes: [] }, + versionHash: "v0", + }), + ); + const subscribeBoardMock = vi.fn<EnvironmentApi["workflow"]["subscribeBoard"]>( + (_input, callback) => { + callback({ kind: "snapshot", snapshot: boardSnapshot }); + return () => undefined; + }, + ); + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: listBoardsMock, + renameBoard: renameBoardMock, + getBoard: getBoardMock, + getBoardDefinition: getBoardDefinitionMock, + subscribeBoard: subscribeBoardMock, + }, + }), + ); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-board-rename-active-test" as MessageId, + targetText: "active board rename target", + }), + initialPath: `/${LOCAL_ENVIRONMENT_ID}/board?boardId=${boardId}`, + }); + + try { + await expect.element(page.getByRole("heading", { name: "Delivery" })).toBeInTheDocument(); + const boardRow = page.getByTestId(`board-row-${boardId}`); + await expect.element(boardRow).toBeInTheDocument(); + await boardRow.hover(); + + const renameButton = document.querySelector<HTMLButtonElement>( + `[data-testid="board-rename-${boardId}"]`, + ); + expect(renameButton, "Expected a rename button on the workflow board row.").not.toBeNull(); + renameButton?.click(); + + const input = page.getByTestId(`board-rename-input-${boardId}`); + await input.fill("Renamed Delivery"); + document + .querySelector<HTMLInputElement>(`[data-testid="board-rename-input-${boardId}"]`) + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + await vi.waitFor( + () => { + expect(renameBoardMock).toHaveBeenCalledWith({ boardId, name: "Renamed Delivery" }); + expect( + useStore.getState().boardStateById[`${LOCAL_ENVIRONMENT_ID}:${boardId}`]?.boardName, + ).toBe("Renamed Delivery"); + }, + { timeout: 8_000, interval: 16 }, + ); + await expect + .element(page.getByRole("heading", { name: "Renamed Delivery" })) + .toBeInTheDocument(); + expect(mounted.router.state.location.pathname).toBe(`/${LOCAL_ENVIRONMENT_ID}/board`); + } finally { + __resetEnvironmentApiOverridesForTests(); + await mounted.cleanup(); + } + }); + + it("deletes workflow boards through the environment that owns the board row", async () => { + const sharedRepositoryIdentity = { + canonicalKey: "github.com/example/shared-project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-project.git", + }, + }; + const localBoardId = BoardId.make(`${PROJECT_ID}__local`); + const remoteBoardId = BoardId.make(`${PROJECT_ID}__remote`); + let localBoards: BoardListEntry[] = [ + { + boardId: localBoardId, + name: "Local board", + filePath: ".t3/boards/local.json", + error: null, + }, + ]; + let remoteBoards: BoardListEntry[] = [ + { + boardId: remoteBoardId, + name: "Remote board", + filePath: ".t3/boards/remote.json", + error: null, + }, + ]; + const localListBoardsMock = vi.fn<EnvironmentApi["workflow"]["listBoards"]>( + async () => localBoards, + ); + const remoteListBoardsMock = vi.fn<EnvironmentApi["workflow"]["listBoards"]>( + async () => remoteBoards, + ); + const localDeleteBoardMock = vi.fn<EnvironmentApi["workflow"]["deleteBoard"]>(async () => { + localBoards = []; + }); + const remoteDeleteBoardMock = vi.fn<EnvironmentApi["workflow"]["deleteBoard"]>( + async (input) => { + expect(input).toEqual({ boardId: remoteBoardId }); + remoteBoards = []; + }, + ); + + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: localListBoardsMock, + deleteBoard: localDeleteBoardMock, + }, + }), + ); + __setEnvironmentApiOverrideForTests( + REMOTE_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: remoteListBoardsMock, + deleteBoard: remoteDeleteBoardMock, + }, + }), + ); + + const localSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-board-delete-scoped-env-test" as MessageId, + targetText: "board delete scoped env target", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...localSnapshot, + projects: localSnapshot.projects.map((project) => ({ + ...project, + repositoryIdentity: sharedRepositoryIdentity, + })), + }, + }); + + try { + useSavedEnvironmentRegistryStore.getState().upsert({ + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Remote", + httpBaseUrl: "https://remote.example.test", + wsBaseUrl: "wss://remote.example.test/ws", + createdAt: NOW_ISO, + lastConnectedAt: NOW_ISO, + }); + useSavedEnvironmentRuntimeStore.getState().patch(REMOTE_ENVIRONMENT_ID, { + connectionState: "connected", + authState: "authenticated", + descriptor: { + ...fixture.serverConfig.environment, + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Remote", + }, + serverConfig: { + ...fixture.serverConfig, + environment: { + ...fixture.serverConfig.environment, + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Remote", + }, + }, + connectedAt: NOW_ISO, + }); + useStore.getState().syncServerShellSnapshot( + toShellSnapshot({ + ...localSnapshot, + projects: localSnapshot.projects.map((project) => ({ + ...project, + repositoryIdentity: sharedRepositoryIdentity, + })), + threads: [], + }), + REMOTE_ENVIRONMENT_ID, + ); + + const remoteBoardRow = page.getByTestId(`board-row-${remoteBoardId}`); + await expect.element(remoteBoardRow).toBeInTheDocument(); + await remoteBoardRow.hover(); + + const deleteButton = document.querySelector<HTMLButtonElement>( + `[data-testid="board-delete-${remoteBoardId}"]`, + ); + expect( + deleteButton, + "Expected a delete button on the remote workflow board row.", + ).not.toBeNull(); + deleteButton?.click(); + + await page.getByRole("button", { name: "Delete board", exact: true }).click(); + + await vi.waitFor( + () => { + expect(remoteDeleteBoardMock).toHaveBeenCalledWith({ boardId: remoteBoardId }); + }, + { timeout: 8_000, interval: 16 }, + ); + expect(localDeleteBoardMock).not.toHaveBeenCalled(); + } finally { + __resetEnvironmentApiOverridesForTests(); + await mounted.cleanup(); + } + }); + + it("navigates away after deleting the currently open workflow board", async () => { + const boardId = BoardId.make(`${PROJECT_ID}__delivery`); + const boardSnapshot = { + projectId: PROJECT_ID, + board: { + boardId, + name: "Delivery", + lanes: [], + }, + tickets: [], + } satisfies BoardSnapshot; + let boards: BoardListEntry[] = [ + { + boardId, + name: "Delivery", + filePath: ".t3/boards/delivery.json", + error: null, + }, + ]; + const listBoardsMock = vi.fn<EnvironmentApi["workflow"]["listBoards"]>(async () => boards); + const deleteBoardMock = vi.fn<EnvironmentApi["workflow"]["deleteBoard"]>(async () => { + boards = []; + }); + const getBoardMock = vi.fn<EnvironmentApi["workflow"]["getBoard"]>(async () => boardSnapshot); + const getBoardDefinitionMock = vi.fn<EnvironmentApi["workflow"]["getBoardDefinition"]>( + async () => ({ + definition: { name: boardSnapshot.board.name, lanes: [] }, + versionHash: "v0", + }), + ); + const subscribeBoardMock = vi.fn<EnvironmentApi["workflow"]["subscribeBoard"]>( + (_input, callback) => { + callback({ kind: "snapshot", snapshot: boardSnapshot }); + return () => undefined; + }, + ); + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: listBoardsMock, + deleteBoard: deleteBoardMock, + getBoard: getBoardMock, + getBoardDefinition: getBoardDefinitionMock, + subscribeBoard: subscribeBoardMock, + }, + }), + ); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-active-board-delete-test" as MessageId, + targetText: "active board delete target", + }), + initialPath: `/${LOCAL_ENVIRONMENT_ID}/board?boardId=${boardId}`, + }); + + try { + const boardRow = page.getByTestId(`board-row-${boardId}`); + await expect.element(boardRow).toBeInTheDocument(); + + const deleteButton = await waitForElement( + () => document.querySelector<HTMLButtonElement>(`[data-testid="board-delete-${boardId}"]`), + "Expected a delete button on the workflow board row.", + ); + deleteButton.click(); + + await page.getByRole("button", { name: "Delete board", exact: true }).click(); + + await vi.waitFor( + () => { + expect(deleteBoardMock).toHaveBeenCalledWith({ boardId }); + }, + { timeout: 8_000, interval: 16 }, + ); + await waitForURL( + mounted.router, + (pathname) => pathname === "/", + "Deleting the active workflow board should navigate to the no-board route.", + ); + } finally { + __resetEnvironmentApiOverridesForTests(); + await mounted.cleanup(); + } + }); + it("shows the sidebar terminal indicator from terminal metadata activity", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52f25945510..22ba71702c6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3342,7 +3342,6 @@ function ChatViewContent(props: ChatViewProps) { } } planSidebarDismissedForTurnRef.current = null; - // eslint-disable-next-line react-hooks/exhaustive-deps -- activeThreadRef is reset transitively }, [activeThread?.id]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. @@ -3554,6 +3553,20 @@ function ChatViewContent(props: ChatViewProps) { const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext, }); + + if ( + !command && + !shortcutContext.terminalFocus && + !shortcutContext.modelPickerOpen && + shouldTypeToFocusComposer(event) + ) { + if (composerRef.current?.insertTextAtEnd(event.key)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + if (!command) return; if (command === "terminal.toggle") { diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index ebc4aa0a698..ddf4b3ed56e 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -7,6 +7,7 @@ export function RightPanelSheet(props: { children: ReactNode; open: boolean; onClose: () => void; + className?: string; }) { return ( <Sheet @@ -21,7 +22,7 @@ export function RightPanelSheet(props: { side="right" showCloseButton={false} keepMounted - className={RIGHT_PANEL_SHEET_CLASS_NAME} + className={props.className ?? RIGHT_PANEL_SHEET_CLASS_NAME} > {props.children} </SheetPopup> diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index fc6cbd1c0ed..2066ef7abf3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -3,8 +3,10 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { createThreadJumpHintVisibilityController, + getSidebarBoardRowKey, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, + nextDefaultBoardName, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, @@ -12,6 +14,7 @@ import { hasUnseenCompletion, isContextMenuPointerDown, isTrailingDoubleClick, + isSidebarBoardRouteActive, orderItemsByPreferredIds, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, @@ -38,6 +41,46 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("sidebar board identity", () => { + it("includes environment in board row keys", () => { + expect( + getSidebarBoardRowKey({ + environmentId: "environment-local", + projectId: "project-1", + boardId: "project-1__delivery", + }), + ).not.toBe( + getSidebarBoardRowKey({ + environmentId: "environment-remote", + projectId: "project-1", + boardId: "project-1__delivery", + }), + ); + }); + + it("matches active board routes by environment and board id", () => { + const activeRouteBoard = { + environmentId: "environment-local", + boardId: "project-1__delivery", + }; + + expect( + isSidebarBoardRouteActive(activeRouteBoard, { + environmentId: "environment-local", + projectId: "project-1", + boardId: "project-1__delivery", + }), + ).toBe(true); + expect( + isSidebarBoardRouteActive(activeRouteBoard, { + environmentId: "environment-remote", + projectId: "project-1", + boardId: "project-1__delivery", + }), + ).toBe(false); + }); +}); + function makeLatestTurn(overrides?: { completedAt?: string | null; startedAt?: string | null; @@ -293,6 +336,14 @@ describe("resolveSidebarNewThreadSeedContext", () => { }); }); +describe("nextDefaultBoardName", () => { + it("chooses the first unused Workflow board name", () => { + expect(nextDefaultBoardName([])).toBe("Workflow board"); + expect(nextDefaultBoardName(["Workflow board"])).toBe("Workflow board 2"); + expect(nextDefaultBoardName(["Workflow board", "Workflow board 2"])).toBe("Workflow board 3"); + }); +}); + 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 41f4e39bb73..aa9dc7429c2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -23,6 +23,30 @@ type SidebarProject = { updatedAt?: string | undefined; }; +export interface SidebarBoardRouteIdentity { + readonly environmentId: string; + readonly boardId: string; +} + +export interface SidebarBoardIdentity extends SidebarBoardRouteIdentity { + readonly projectId: string; +} + +export function getSidebarBoardRowKey(board: SidebarBoardIdentity): string { + return `${board.environmentId}:${board.projectId}:${board.boardId}`; +} + +export function isSidebarBoardRouteActive( + activeRouteBoard: SidebarBoardRouteIdentity | null, + board: SidebarBoardIdentity, +): boolean { + return ( + activeRouteBoard !== null && + activeRouteBoard.environmentId === board.environmentId && + activeRouteBoard.boardId === board.boardId + ); +} + export type ThreadTraversalDirection = "previous" | "next"; export interface ThreadStatusPill { @@ -222,6 +246,20 @@ export function resolveSidebarNewThreadSeedContext(input: { }; } +export function nextDefaultBoardName(existingNames: readonly string[]): string { + const existing = new Set(existingNames); + const baseName = "Workflow board"; + if (!existing.has(baseName)) { + return baseName; + } + for (let index = 2; ; index += 1) { + const candidate = `${baseName} ${index}`; + if (!existing.has(candidate)) { + return candidate; + } + } +} + export function orderItemsByPreferredIds<TItem, TId>(input: { items: readonly TItem[]; preferredIds: readonly TId[]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9e6ff1c34cb..14c2e1a9842 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,10 +5,13 @@ import { CloudIcon, FolderPlusIcon, Globe2Icon, + PencilIcon, SearchIcon, SettingsIcon, + SquareKanbanIcon, SquarePenIcon, TerminalIcon, + Trash2Icon, TriangleAlertIcon, } from "lucide-react"; import { @@ -41,6 +44,8 @@ import { type ContextMenuItem, type DesktopUpdateState, ProjectId, + type BoardListEntry, + type EnvironmentId, type ScopedThreadRef, type SidebarProjectGroupingMode, type ThreadEnvMode, @@ -68,6 +73,7 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform, newCommandId } from "../lib/utils"; import { selectProjectByRef, + selectBoardsForProject, selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, @@ -114,6 +120,15 @@ import { shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; import { Button } from "./ui/button"; import { Dialog, @@ -161,10 +176,12 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { + getSidebarBoardRowKey, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, + isSidebarBoardRouteActive, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, @@ -174,7 +191,8 @@ import { shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useThreadJumpHintVisibility, - ThreadStatusPill, + type SidebarBoardRouteIdentity, + type ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; @@ -182,6 +200,8 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; +import { deleteBoard, listBoards, renameBoard } from "../workflow/boardRpc"; +import { CreateWorkflowDialog } from "./board/CreateWorkflowDialog"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { @@ -800,13 +820,274 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); }); +interface SidebarBoardRowProps { + entry: BoardListEntry; + environmentId: EnvironmentId; + projectId: ProjectId; + isActive: boolean; + deleteBoardForProjectMember: (board: SidebarProjectBoardRow) => Promise<void>; + renameBoardForProjectMember: (board: SidebarProjectBoardRow, name: string) => Promise<boolean>; +} + +const SidebarBoardRow = memo(function SidebarBoardRow(props: SidebarBoardRowProps) { + const { + entry, + environmentId, + projectId, + isActive, + deleteBoardForProjectMember, + renameBoardForProjectMember, + } = props; + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [renameName, setRenameName] = useState(entry.name); + const [isRenameSaving, setIsRenameSaving] = useState(false); + const renameCommittedRef = useRef(false); + const renameInputRef = useRef<HTMLInputElement | null>(null); + const linkRender = useMemo( + () => ( + <Link + to="/$environmentId/board" + params={{ environmentId }} + search={{ boardId: entry.boardId }} + /> + ), + [entry.boardId, environmentId], + ); + const renameRowRender = useMemo(() => <div role="button" tabIndex={0} />, []); + const rowRender = isRenaming ? renameRowRender : linkRender; + useEffect(() => { + if (!isRenaming) { + setRenameName(entry.name); + } + }, [entry.name, isRenaming]); + const cancelRename = useCallback(() => { + renameCommittedRef.current = true; + renameInputRef.current = null; + setIsRenaming(false); + setRenameName(entry.name); + }, [entry.name]); + const startRename = useCallback( + (event: React.MouseEvent<HTMLButtonElement>) => { + event.preventDefault(); + event.stopPropagation(); + renameCommittedRef.current = false; + setRenameName(entry.name); + setIsRenaming(true); + }, + [entry.name], + ); + const handleRenameInputRef = useCallback((element: HTMLInputElement | null) => { + if (element && renameInputRef.current !== element) { + renameInputRef.current = element; + element.focus(); + element.select(); + } + }, []); + const commitRename = useCallback(async () => { + const trimmed = renameName.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Board name cannot be empty", + }); + cancelRename(); + return; + } + if (trimmed === entry.name) { + cancelRename(); + return; + } + setIsRenameSaving(true); + try { + const renamed = await renameBoardForProjectMember( + { entry, environmentId, projectId }, + trimmed, + ); + if (renamed) { + setIsRenaming(false); + renameInputRef.current = null; + } else { + renameCommittedRef.current = false; + } + } finally { + setIsRenameSaving(false); + } + }, [cancelRename, entry, environmentId, projectId, renameBoardForProjectMember, renameName]); + const handleRenameInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { + setRenameName(event.target.value); + }, []); + const handleRenameInputKeyDown = useCallback( + (event: React.KeyboardEvent<HTMLInputElement>) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renameCommittedRef.current = true; + void commitRename(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelRename(); + } + }, + [cancelRename, commitRename], + ); + const handleRenameInputBlur = useCallback(() => { + if (!renameCommittedRef.current) { + cancelRename(); + } + }, [cancelRename]); + const stopRenameInputPropagation = useCallback( + (event: React.SyntheticEvent<HTMLInputElement>) => { + event.stopPropagation(); + }, + [], + ); + const openDeleteConfirmation = useCallback((event: React.MouseEvent<HTMLButtonElement>) => { + event.preventDefault(); + event.stopPropagation(); + setDeleteConfirmOpen(true); + }, []); + const confirmDelete = useCallback(async () => { + setIsDeleting(true); + try { + await deleteBoardForProjectMember({ entry, environmentId, projectId }); + setDeleteConfirmOpen(false); + } finally { + setIsDeleting(false); + } + }, [deleteBoardForProjectMember, entry, environmentId, projectId]); + + return ( + <SidebarMenuSubItem className="w-full" data-thread-selection-safe> + <SidebarMenuSubButton + render={rowRender} + size="sm" + isActive={isActive} + data-testid={`board-row-${entry.boardId}`} + className="h-6 w-full translate-x-0 justify-start px-2 pr-12 text-left" + > + <span className="flex min-w-0 flex-1 items-center gap-1.5"> + <SquareKanbanIcon className="size-3 shrink-0 text-muted-foreground/70" /> + {isRenaming ? ( + <input + ref={handleRenameInputRef} + data-testid={`board-rename-input-${entry.boardId}`} + aria-label={`Rename board ${entry.name}`} + className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-base outline-none sm:text-xs" + value={renameName} + disabled={isRenameSaving} + onChange={handleRenameInputChange} + onKeyDown={handleRenameInputKeyDown} + onBlur={handleRenameInputBlur} + onClick={stopRenameInputPropagation} + onPointerDown={stopRenameInputPropagation} + /> + ) : ( + <Tooltip> + <TooltipTrigger + render={<span className="min-w-0 flex-1 truncate text-xs">{entry.name}</span>} + /> + <TooltipPopup side="top" className="max-w-80 whitespace-normal leading-tight"> + {entry.name} + </TooltipPopup> + </Tooltip> + )} + </span> + {entry.error ? ( + <Tooltip> + <TooltipTrigger + render={ + <span + aria-label="Board has a loading error" + className="ml-auto mr-5 inline-flex size-4 shrink-0 items-center justify-center text-amber-500" + > + <TriangleAlertIcon className="size-3" /> + </span> + } + /> + <TooltipPopup side="top" className="max-w-80 whitespace-normal leading-tight"> + {entry.error} + </TooltipPopup> + </Tooltip> + ) : null} + </SidebarMenuSubButton> + {!isRenaming ? ( + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + data-thread-selection-safe + data-testid={`board-rename-${entry.boardId}`} + aria-label={`Rename board ${entry.name}`} + className="pointer-events-none absolute top-1/2 right-6 inline-flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 opacity-0 transition-colors transition-opacity duration-150 hover:bg-secondary hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100" + onClick={startRename} + > + <PencilIcon className="size-3.5" /> + </button> + } + /> + <TooltipPopup side="top">Rename board</TooltipPopup> + </Tooltip> + ) : null} + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + data-thread-selection-safe + data-testid={`board-delete-${entry.boardId}`} + aria-label={`Delete board ${entry.name}`} + className="pointer-events-none absolute top-1/2 right-1 inline-flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 opacity-0 transition-colors transition-opacity duration-150 hover:bg-destructive/10 hover:text-destructive focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100" + onClick={openDeleteConfirmation} + disabled={isRenaming} + > + <Trash2Icon className="size-3.5" /> + </button> + } + /> + <TooltipPopup side="top">Delete board</TooltipPopup> + </Tooltip> + <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> + <AlertDialogPopup> + <AlertDialogHeader> + <AlertDialogTitle>Delete board "{entry.name}"?</AlertDialogTitle> + <AlertDialogDescription> + This permanently deletes the board file, its tickets, and version history. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> + <Button + variant="destructive" + disabled={isDeleting} + onClick={() => void confirmDelete()} + > + Delete board + </Button> + </AlertDialogFooter> + </AlertDialogPopup> + </AlertDialog> + </SidebarMenuSubItem> + ); +}); + +interface SidebarProjectBoardRow { + readonly entry: BoardListEntry; + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +} + interface SidebarProjectThreadListProps { projectKey: string; projectExpanded: boolean; + renderedBoards: readonly SidebarProjectBoardRow[]; hasOverflowingThreads: boolean; hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; renderedThreads: readonly SidebarThreadSummary[]; + activeRouteBoardRef: SidebarBoardRouteIdentity | null; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; @@ -823,6 +1104,8 @@ interface SidebarProjectThreadListProps { confirmingArchiveThreadKey: string | null; setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>; confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>; + deleteBoardForProjectMember: (board: SidebarProjectBoardRow) => Promise<void>; + renameBoardForProjectMember: (board: SidebarProjectBoardRow, name: string) => Promise<boolean>; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; handleThreadClick: ( event: React.MouseEvent, @@ -854,10 +1137,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( const { projectKey, projectExpanded, + renderedBoards, hasOverflowingThreads, hiddenThreadStatus, orderedProjectThreadKeys, renderedThreads, + activeRouteBoardRef, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -874,6 +1159,8 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( confirmingArchiveThreadKey, setConfirmingArchiveThreadKey, confirmArchiveButtonRefs, + deleteBoardForProjectMember, + renameBoardForProjectMember, attachThreadListAutoAnimateRef, handleThreadClick, navigateToThread, @@ -905,6 +1192,26 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( </div> </SidebarMenuSubItem> ) : null} + {shouldShowThreadPanel && + renderedBoards.map((board) => ( + <SidebarBoardRow + key={getSidebarBoardRowKey({ + environmentId: board.environmentId, + projectId: board.projectId, + boardId: board.entry.boardId, + })} + entry={board.entry} + environmentId={board.environmentId} + projectId={board.projectId} + isActive={isSidebarBoardRouteActive(activeRouteBoardRef, { + environmentId: board.environmentId, + projectId: board.projectId, + boardId: board.entry.boardId, + })} + deleteBoardForProjectMember={deleteBoardForProjectMember} + renameBoardForProjectMember={renameBoardForProjectMember} + /> + ))} {shouldShowThreadPanel && renderedThreads.map((thread) => { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); @@ -980,6 +1287,7 @@ interface SidebarProjectItemProps { project: SidebarProjectSnapshot; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; + activeRouteBoardRef: SidebarBoardRouteIdentity | null; newThreadShortcutLabel: string | null; handleNewThread: ReturnType<typeof useNewThreadHandler>["handleNewThread"]; archiveThread: ReturnType<typeof useThreadActions>["archiveThread"]; @@ -1000,6 +1308,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec project, isThreadListExpanded, activeRouteThreadKey, + activeRouteBoardRef, newThreadShortcutLabel, handleNewThread, archiveThread, @@ -1112,6 +1421,28 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ), ), ); + const projectBoardLists = useStore( + useShallow( + useMemo( + () => (state: import("../store").AppState) => + project.memberProjects.map((member) => + selectBoardsForProject(state, scopeProjectRef(member.environmentId, member.id)), + ), + [project.memberProjects], + ), + ), + ); + const projectBoards = useMemo<SidebarProjectBoardRow[]>( + () => + project.memberProjects.flatMap((member, index) => + (projectBoardLists[index] ?? []).map((entry) => ({ + entry, + environmentId: member.environmentId, + projectId: member.id, + })), + ), + [project.memberProjects, projectBoardLists], + ); const sidebarThreadByKey = useMemo( () => new Map( @@ -1131,6 +1462,53 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const projectExpanded = useUiStateStore( (state) => state.projectExpandedById[project.projectKey] ?? true, ); + const fetchBoardsForProjectMember = useCallback(async (member: SidebarProjectGroupMember) => { + const api = readEnvironmentApi(member.environmentId); + if (!api) { + return []; + } + const entries = await listBoards(api, member.id); + useStore.getState().setProjectBoards(scopeProjectRef(member.environmentId, member.id), entries); + return entries; + }, []); + + useEffect(() => { + if (!projectExpanded) { + return; + } + + let cancelled = false; + for (const member of project.memberProjects) { + const api = readEnvironmentApi(member.environmentId); + if (!api) { + continue; + } + void listBoards(api, member.id) + .then((entries) => { + if (!cancelled) { + useStore + .getState() + .setProjectBoards(scopeProjectRef(member.environmentId, member.id), entries); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to load boards for ${member.name}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + } + + return () => { + cancelled = true; + }; + }, [project.memberProjects, projectExpanded]); const threadLastVisitedAts = useUiStateStore( useShallow((state) => projectThreads.map( @@ -1153,6 +1531,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [projectGroupingSelection, setProjectGroupingSelection] = useState< SidebarProjectGroupingMode | "inherit" >("inherit"); + // Create-workflow wizard state: which project member to create into (null = closed) + const [createBoardTarget, setCreateBoardTarget] = useState<SidebarProjectGroupMember | null>( + null, + ); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef<HTMLInputElement | null>(null); const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>()); @@ -1278,12 +1660,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), ), renderedThreads, - showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0, + showEmptyThreadState: + projectExpanded && visibleProjectThreads.length === 0 && projectBoards.length === 0, shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null, }; }, [ isThreadListExpanded, pinnedCollapsedThread, + projectBoards.length, projectExpanded, projectThreads, sidebarThreadPreviewCount, @@ -1796,6 +2180,110 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [defaultThreadEnvMode, handleNewThread, isMobile, router, setOpenMobile], ); + const deleteBoardForProjectMember = useCallback( + async (board: SidebarProjectBoardRow) => { + const member = project.memberProjects.find( + (candidate) => + candidate.id === board.projectId && candidate.environmentId === board.environmentId, + ); + if (!member) { + toastManager.add({ + type: "error", + title: "Project API unavailable", + }); + return; + } + + const api = readEnvironmentApi(board.environmentId); + if (!api) { + toastManager.add({ + type: "error", + title: "Project API unavailable", + }); + return; + } + + try { + await deleteBoard(api, board.entry.boardId); + await fetchBoardsForProjectMember(member); + if ( + isSidebarBoardRouteActive(activeRouteBoardRef, { + environmentId: board.environmentId, + projectId: board.projectId, + boardId: board.entry.boardId, + }) + ) { + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ to: "/", replace: true }); + } + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to delete board for ${member.name}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [ + activeRouteBoardRef, + fetchBoardsForProjectMember, + isMobile, + project.memberProjects, + router, + setOpenMobile, + ], + ); + + const renameBoardForProjectMember = useCallback( + async (board: SidebarProjectBoardRow, name: string) => { + const member = project.memberProjects.find( + (candidate) => + candidate.id === board.projectId && candidate.environmentId === board.environmentId, + ); + if (!member) { + toastManager.add({ + type: "error", + title: "Project API unavailable", + }); + return false; + } + + const api = readEnvironmentApi(board.environmentId); + if (!api) { + toastManager.add({ + type: "error", + title: "Project API unavailable", + }); + return false; + } + + try { + await renameBoard(api, board.entry.boardId, name); + const snapshot = await api.workflow.getBoard({ boardId: board.entry.boardId }); + useStore.getState().applyBoardStreamItem(board.environmentId, snapshot.board.boardId, { + kind: "snapshot", + snapshot, + }); + await fetchBoardsForProjectMember(member); + return true; + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to rename board for ${member.name}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return false; + } + }, + [fetchBoardsForProjectMember, project.memberProjects], + ); + const handleCreateThreadClick = useCallback( (event: React.MouseEvent<HTMLButtonElement>) => { event.preventDefault(); @@ -1836,6 +2324,49 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects], ); + const handleAddBoardClick = useCallback( + (event: React.MouseEvent<HTMLButtonElement>) => { + event.preventDefault(); + event.stopPropagation(); + + void (async () => { + const api = readLocalApi(); + if (!api) { + return; + } + + // Step 1 (multi-member only): pick which project environment to target. + let targetMember: SidebarProjectGroupMember | undefined; + if (project.memberProjects.length === 1) { + targetMember = project.memberProjects[0]!; + } else { + const clickedMemberKey = await api.contextMenu.show( + project.memberProjects.map((member) => ({ + id: member.physicalProjectKey, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + })), + { x: event.clientX, y: event.clientY }, + ); + if (!clickedMemberKey) { + return; + } + targetMember = project.memberProjects.find( + (member) => member.physicalProjectKey === clickedMemberKey, + ); + if (!targetMember) { + return; + } + } + + // Step 2: open the create-workflow wizard for this project member. + // The wizard owns empty/template/import/agent-assisted creation and is + // available even with zero agents (agent-only paths disable inside it). + setCreateBoardTarget(targetMember); + })(); + }, + [project.groupedProjectCount, project.memberProjects], + ); + const attemptArchiveThread = useCallback( async (threadRef: ScopedThreadRef) => { try { @@ -2075,6 +2606,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ], ); + // The add-board action opens the create-workflow wizard, which offers + // agent-free paths (empty board, import from file). So the affordance is + // available whenever there's a project member to create into — agent-only + // paths are disabled inside the wizard, not the whole button. + const canCreateBoard = project.memberProjects.length > 0; + return ( <> <div className="group/project-header relative"> @@ -2155,10 +2692,31 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec </TooltipPopup> </Tooltip> )} - <Tooltip> - <TooltipTrigger - render={ - <div className="pointer-events-none absolute top-[calc(50%+1px)] right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100"> + <div className="pointer-events-none absolute top-[calc(50%+1px)] right-0.5 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100"> + <Tooltip> + <TooltipTrigger + render={ + <span className="inline-flex"> + <button + type="button" + aria-label={`Add board in ${project.displayName}`} + data-testid="add-board-button" + disabled={!canCreateBoard} + className={`${SIDEBAR_ICON_ACTION_BUTTON_CLASS} hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground/60`} + onClick={handleAddBoardClick} + > + <SquareKanbanIcon className="size-3.5" /> + </button> + </span> + } + /> + <TooltipPopup side="top"> + {canCreateBoard ? "Add board" : "No project available"} + </TooltipPopup> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={ <button type="button" aria-label={`Create new thread in ${project.displayName}`} @@ -2168,22 +2726,24 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec > <SquarePenIcon className="size-3.5" /> </button> - </div> - } - /> - <TooltipPopup side="top"> - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - </TooltipPopup> - </Tooltip> + } + /> + <TooltipPopup side="top"> + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + </TooltipPopup> + </Tooltip> + </div> </div> <SidebarProjectThreadList projectKey={project.projectKey} projectExpanded={projectExpanded} + renderedBoards={projectBoards} hasOverflowingThreads={hasOverflowingThreads} hiddenThreadStatus={hiddenThreadStatus} orderedProjectThreadKeys={orderedProjectThreadKeys} renderedThreads={renderedThreads} + activeRouteBoardRef={activeRouteBoardRef} showEmptyThreadState={showEmptyThreadState} shouldShowThreadPanel={shouldShowThreadPanel} isThreadListExpanded={isThreadListExpanded} @@ -2200,6 +2760,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec confirmingArchiveThreadKey={confirmingArchiveThreadKey} setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey} confirmArchiveButtonRefs={confirmArchiveButtonRefs} + deleteBoardForProjectMember={deleteBoardForProjectMember} + renameBoardForProjectMember={renameBoardForProjectMember} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} handleThreadClick={handleThreadClick} navigateToThread={navigateToThread} @@ -2331,6 +2893,46 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec </DialogFooter> </DialogPopup> </Dialog> + + {createBoardTarget !== null + ? (() => { + const createApi = readEnvironmentApi(createBoardTarget.environmentId); + if (!createApi) { + return null; + } + const existingBoards = selectBoardsForProject( + useStore.getState(), + scopeProjectRef(createBoardTarget.environmentId, createBoardTarget.id), + ); + return ( + <CreateWorkflowDialog + open={true} + onOpenChange={(next) => { + if (!next) { + setCreateBoardTarget(null); + } + }} + api={createApi} + projectId={createBoardTarget.id} + environmentId={createBoardTarget.environmentId} + projectName={createBoardTarget.name} + existingBoardNames={existingBoards.map((entry) => entry.name)} + onCreated={(boardId) => { + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/$environmentId/board", + params: { environmentId: createBoardTarget.environmentId }, + search: { boardId }, + }); + // Best-effort list refresh — navigation must not be blocked or skipped if this fails. + void fetchBoardsForProjectMember(createBoardTarget).catch(() => {}); + }} + /> + ); + })() + : null} </> ); }); @@ -2725,6 +3327,26 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const activeRouteEnvironmentId = useParams({ + strict: false, + select: (params) => (typeof params.environmentId === "string" ? params.environmentId : null), + }); + const activeRouteBoardId = useLocation({ + select: (loc) => { + const search = loc.search as { readonly boardId?: unknown }; + return typeof search.boardId === "string" ? search.boardId : null; + }, + }); + const activeRouteBoardRef = useMemo<SidebarBoardRouteIdentity | null>( + () => + activeRouteEnvironmentId && activeRouteBoardId + ? { + environmentId: activeRouteEnvironmentId, + boardId: activeRouteBoardId, + } + : null, + [activeRouteBoardId, activeRouteEnvironmentId], + ); return ( <SidebarContent className="gap-0"> @@ -2832,6 +3454,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null } + activeRouteBoardRef={activeRouteBoardRef} newThreadShortcutLabel={newThreadShortcutLabel} handleNewThread={handleNewThread} archiveThread={archiveThread} @@ -2864,6 +3487,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null } + activeRouteBoardRef={activeRouteBoardRef} newThreadShortcutLabel={newThreadShortcutLabel} handleNewThread={handleNewThread} archiveThread={archiveThread} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 4972e07bbc2..1fb7ac67c18 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -788,7 +788,6 @@ export function TerminalViewport({ }; // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); useEffect(() => { diff --git a/apps/web/src/components/board/AddFromIssuesDialog.tsx b/apps/web/src/components/board/AddFromIssuesDialog.tsx new file mode 100644 index 00000000000..a3a471fbaf7 --- /dev/null +++ b/apps/web/src/components/board/AddFromIssuesDialog.tsx @@ -0,0 +1,407 @@ +import type { EnvironmentApi } from "@t3tools/contracts"; +import type { + ImportableWorkItemView, + ListImportableWorkItemsResult, +} from "@t3tools/contracts/workSource"; +import { BoardId } from "@t3tools/contracts"; +import { useEffect, useRef, useState } from "react"; + +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { Checkbox } from "~/components/ui/checkbox"; +import { + Dialog, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { toastManager } from "~/components/ui/toast"; +import { + applyPickerFilters, + defaultChecked, + groupSelectedBySource, + selectionKey, + type FilterState, +} from "~/workflow/importPicker"; + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function ItemRow({ + row, + checked, + onToggle, +}: { + readonly row: ImportableWorkItemView; + readonly checked: boolean; + readonly onToggle: () => void; +}) { + const isMapped = row.mappedTicketId !== null; + const isClosed = row.lifecycle === "closed"; + const isDeleted = row.lifecycle === "deleted"; + const disabled = isMapped || isDeleted; + + return ( + <li className="flex items-start gap-3 rounded-md border border-border/60 bg-card/30 px-3 py-2"> + <Checkbox + checked={checked} + disabled={disabled} + onCheckedChange={disabled ? undefined : () => onToggle()} + aria-label={`Select ${row.title}`} + className="mt-0.5 shrink-0" + /> + <div className="min-w-0 flex-1"> + <div className="flex flex-wrap items-center gap-1.5"> + <span className="text-[11px] font-medium text-muted-foreground">{row.displayRef}</span> + <span className="truncate text-xs font-medium text-foreground">{row.title}</span> + {isMapped && row.mappedLane !== null ? ( + <Badge variant="info" size="sm"> + On board · {row.mappedLane} + </Badge> + ) : null} + {isClosed ? ( + <Badge variant="secondary" size="sm"> + closed + </Badge> + ) : null} + {isDeleted ? ( + <Badge variant="warning" size="sm"> + deleted + </Badge> + ) : null} + </div> + {row.container || row.assignees.length > 0 ? ( + <p className="mt-0.5 text-[11px] text-muted-foreground"> + {row.container} + {row.container && row.assignees.length > 0 ? " · " : ""} + {row.assignees.join(", ")} + </p> + ) : null} + </div> + </li> + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export function AddFromIssuesDialog(props: { + readonly boardId: string; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onImported: () => void; + readonly api: EnvironmentApi | null | undefined; +}) { + const { boardId, open, onOpenChange, onImported, api } = props; + + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState<string | null>(null); + const [result, setResult] = useState<ListImportableWorkItemsResult | null>(null); + const [checked, setChecked] = useState<Set<string>>(new Set()); + const [filter, setFilter] = useState<FilterState>({ + search: "", + assignedToMe: false, + hideTasked: false, + }); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState<string | null>(null); + + // Guards an in-flight submit against the dialog closing / unmounting mid-flight, + // mirroring the load effect's `cancelled` flag. `reset()` flips `aborted` so a + // late-resolving import never writes state into a torn-down/reopened dialog. + const submitGuardRef = useRef<{ aborted: boolean }>({ aborted: false }); + + // Load items when the dialog opens (or boardId changes while open) + useEffect(() => { + if (!open || !api) { + return; + } + + let cancelled = false; + setLoading(true); + setLoadError(null); + setResult(null); + setChecked(new Set()); + setFilter({ search: "", assignedToMe: false, hideTasked: false }); + setSubmitError(null); + + void api.workflow + .listImportableWorkItems({ boardId: BoardId.make(boardId) }) + .then((res) => { + if (cancelled) return; + const initialChecked = new Set<string>(); + for (const item of res.items) { + if (defaultChecked(item)) { + initialChecked.add(selectionKey(item)); + } + } + setResult(res); + setChecked(initialChecked); + }) + .catch((cause) => { + if (cancelled) return; + setLoadError(cause instanceof Error ? cause.message : "Failed to load work items."); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [open, boardId, api]); + + const reset = () => { + submitGuardRef.current.aborted = true; + setLoading(false); + setLoadError(null); + setResult(null); + setChecked(new Set()); + setFilter({ search: "", assignedToMe: false, hideTasked: false }); + setSubmitting(false); + setSubmitError(null); + }; + + const handleAdd = async () => { + if (!api || checked.size === 0 || submitting) return; + + const guard = { aborted: false }; + submitGuardRef.current = guard; + setSubmitting(true); + setSubmitError(null); + + const groups = groupSelectedBySource(checked); + let importedTotal = 0; + let skippedTotal = 0; + const failures: string[] = []; + + // Each source imports independently: a later source throwing must not discard + // the tickets an earlier source already created. We accumulate across all + // sources, then decide the outcome from the totals + collected failures. + for (const [sourceId, externalIds] of Object.entries(groups)) { + try { + const res = await api.workflow.importWorkItems({ + boardId: BoardId.make(boardId), + sourceId, + externalIds, + }); + importedTotal += res.imported.length; + skippedTotal += res.skipped.length; + } catch (cause) { + const label = sourceById.get(sourceId)?.container ?? sourceId; + const detail = cause instanceof Error ? cause.message : "Import failed."; + failures.push(`${label}: ${detail}`); + } + } + + if (guard.aborted) return; + + // Partial success still refreshes the board so the tickets that landed show up. + if (importedTotal > 0) { + onImported(); + toastManager.add({ + type: "success", + title: `Added ${importedTotal} item${importedTotal === 1 ? "" : "s"}${ + skippedTotal > 0 ? ` (${skippedTotal} already on board or out of scope)` : "" + }`, + }); + } + + if (failures.length > 0) { + // Keep the dialog open so the user sees which source(s) failed. + setSubmitError(failures.join(" ")); + setSubmitting(false); + return; + } + + setSubmitting(false); + handleOpenChange(false); + }; + + const toggleItem = (key: string) => { + setChecked((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + const visibleItems = + result !== null ? applyPickerFilters(result.items, filter, result.viewer) : []; + + const sources = result?.sources ?? []; + const truncated = result?.truncated ?? {}; + const sourceErrors = result?.sourceErrors ?? {}; + + // Lookup so per-source notices (and submit failures) can name the source by its + // container (e.g. "owner/repo") rather than an opaque source id. + const sourceById = new Map(sources.map((s) => [s.sourceId, s])); + + const checkedCount = checked.size; + const addDisabled = checkedCount === 0 || submitting; + + // Single close path: forwards to the parent and resets local state (which also + // aborts any in-flight submit). The Cancel button and the Dialog's own close + // affordances (Esc / backdrop) both route through here so reset runs exactly once. + const handleOpenChange = (nextOpen: boolean) => { + onOpenChange(nextOpen); + if (!nextOpen) { + reset(); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogPopup className="max-h-[calc(100dvh-2rem)] max-w-2xl overflow-hidden"> + <div className="flex min-h-0 flex-col"> + <DialogHeader> + <DialogTitle>Add from issues</DialogTitle> + </DialogHeader> + + <div + className="min-h-0 flex-1 space-y-3 overflow-y-auto px-6 pt-1 pb-3" + data-slot="dialog-panel" + > + {/* Search / filter bar */} + {!loading && !loadError && result !== null && sources.length > 0 ? ( + <div className="space-y-2"> + <Input + value={filter.search} + onChange={(e) => { + const value = e.currentTarget.value; + setFilter((f) => ({ ...f, search: value })); + }} + placeholder="Search or paste a URL…" + aria-label="Search or paste a URL" + /> + <div className="flex flex-wrap items-center gap-3 text-xs"> + <label className="flex cursor-pointer items-center gap-1.5 text-muted-foreground"> + <input + type="checkbox" + checked={filter.assignedToMe} + onChange={(e) => { + const checked = e.currentTarget.checked; + setFilter((f) => ({ ...f, assignedToMe: checked })); + }} + className="size-3.5 rounded border-input" + /> + Assigned to me + </label> + <label className="flex cursor-pointer items-center gap-1.5 text-muted-foreground"> + <input + type="checkbox" + checked={filter.hideTasked} + onChange={(e) => { + const checked = e.currentTarget.checked; + setFilter((f) => ({ ...f, hideTasked: checked })); + }} + className="size-3.5 rounded border-input" + /> + Hide already on board + </label> + </div> + </div> + ) : null} + + {/* Loading state */} + {loading ? <p className="text-xs text-muted-foreground">Loading…</p> : null} + + {/* Load error state */} + {!loading && loadError !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {loadError} + </p> + ) : null} + + {/* No sources configured */} + {!loading && loadError === null && result !== null && sources.length === 0 ? ( + <p className="text-xs text-muted-foreground"> + This board has no configured work sources. + </p> + ) : null} + + {/* Items list */} + {!loading && loadError === null && result !== null && sources.length > 0 ? ( + <> + {/* Per-source errors */} + {Object.entries(sourceErrors).map(([sourceId, msg]) => + msg ? ( + <p key={sourceId} className="text-xs text-destructive-foreground" role="alert"> + {sourceById.get(sourceId)?.container ?? sourceId}: {msg} + </p> + ) : null, + )} + + {/* Per-source truncated notices */} + {Object.entries(truncated).map(([sourceId, isTruncated]) => + isTruncated ? ( + <p key={sourceId} className="text-xs text-muted-foreground"> + {sourceById.get(sourceId)?.container ?? sourceId}: showing first results only + — refine your filters to see more. + </p> + ) : null, + )} + + {visibleItems.length === 0 ? ( + <p className="text-xs text-muted-foreground">No importable items found.</p> + ) : ( + <ul className="space-y-1.5"> + {visibleItems.map((row) => { + const key = selectionKey(row); + return ( + <ItemRow + key={key} + row={row} + checked={checked.has(key)} + onToggle={() => toggleItem(key)} + /> + ); + })} + </ul> + )} + </> + ) : null} + + {/* Submit error */} + {submitError !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {submitError} + </p> + ) : null} + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + handleOpenChange(false); + }} + > + Cancel + </Button> + <Button + type="button" + size="sm" + disabled={addDisabled} + onClick={() => { + void handleAdd(); + }} + > + {submitting + ? "Adding…" + : checkedCount > 0 + ? `Add ${checkedCount} item${checkedCount === 1 ? "" : "s"}` + : "Add"} + </Button> + </DialogFooter> + </div> + </DialogPopup> + </Dialog> + ); +} diff --git a/apps/web/src/components/board/AgentSessionDialog.tsx b/apps/web/src/components/board/AgentSessionDialog.tsx new file mode 100644 index 00000000000..2092a2ed7e5 --- /dev/null +++ b/apps/web/src/components/board/AgentSessionDialog.tsx @@ -0,0 +1,198 @@ +import type { + EnvironmentApi, + OrchestrationMessage, + OrchestrationThreadActivity, + OrchestrationThreadStreamItem, + ThreadId, +} from "@t3tools/contracts"; +import { MessagesSquareIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { cn } from "~/lib/utils"; + +interface SessionState { + readonly messages: ReadonlyArray<OrchestrationMessage>; + readonly activities: ReadonlyArray<OrchestrationThreadActivity>; +} + +/** + * Upsert by id, preserving first-seen order. A streaming message is re-emitted + * under the same id as it grows, so replace in place; genuinely new messages + * (or activities) append. Mirrors StepActivityFeed's dedup-by-id behaviour. + */ +function upsertById<T extends { readonly id: unknown }>( + current: ReadonlyArray<T>, + incoming: ReadonlyArray<T>, +): ReadonlyArray<T> { + const next = [...current]; + for (const item of incoming) { + const index = next.findIndex((existing) => existing.id === item.id); + if (index === -1) { + next.push(item); + } else { + next[index] = item; + } + } + return next; +} + +/** + * Read-only view of the hidden orchestration thread behind an agent step — + * the full conversation (instruction, assistant replies) plus the activity + * log. Total transparency into what the agent actually did. + */ +export function AgentSessionDialog({ + api, + threadId, + stepKey, +}: { + readonly api: EnvironmentApi | null | undefined; + readonly threadId: ThreadId; + readonly stepKey: string; +}) { + const [open, setOpen] = useState(false); + const [session, setSession] = useState<SessionState | null>(null); + + useEffect(() => { + if (!open || !api) { + return; + } + setSession(null); + return api.orchestration.subscribeThread( + { threadId }, + (item: OrchestrationThreadStreamItem) => { + if (item.kind === "snapshot") { + setSession({ + messages: item.snapshot.thread.messages, + activities: item.snapshot.thread.activities, + }); + return; + } + // After the initial snapshot only incremental events arrive (the server + // never re-snapshots). Fold message/activity events into the transcript + // so a still-running step's session stays live instead of frozen. + if (item.event.type === "thread.message-sent") { + const { messageId, role, text, attachments, turnId, streaming, createdAt, updatedAt } = + item.event.payload; + const message: OrchestrationMessage = { + id: messageId, + role, + text, + ...(attachments === undefined ? {} : { attachments }), + turnId, + streaming, + createdAt, + updatedAt, + }; + setSession((current) => + current === null + ? current + : { ...current, messages: upsertById(current.messages, [message]) }, + ); + return; + } + if (item.event.type === "thread.activity-appended") { + const { activity } = item.event.payload; + setSession((current) => + current === null + ? current + : { ...current, activities: upsertById(current.activities, [activity]) }, + ); + } + }, + ); + }, [api, open, threadId]); + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <Button + type="button" + size="xs" + variant="outline" + disabled={!api} + title="View the agent's full session for this step" + onClick={(event) => { + event.stopPropagation(); + setOpen(true); + }} + > + <MessagesSquareIcon className="size-3.5" /> + View agent session + </Button> + <DialogPopup className="max-h-[calc(100dvh-2rem)] max-w-3xl overflow-hidden"> + <div className="flex min-h-0 flex-col"> + <DialogHeader> + <DialogTitle>Agent session · {stepKey}</DialogTitle> + <DialogDescription> + Read-only transcript of the agent run behind this step. + </DialogDescription> + </DialogHeader> + <div + className="min-h-0 flex-1 space-y-3 overflow-y-auto px-6 pt-1 pb-4" + data-testid="agent-session-transcript" + > + {session === null ? ( + <p className="text-sm text-muted-foreground">Loading session…</p> + ) : ( + <> + {session.messages.length === 0 ? ( + <p className="text-sm text-muted-foreground">No messages recorded.</p> + ) : ( + <ol className="space-y-2"> + {session.messages.map((message) => ( + <li + key={message.id as string} + className={cn( + "rounded-md border border-border/60 p-2.5", + message.role === "user" ? "bg-accent/20" : "bg-background/70", + )} + > + <div className="mb-1 flex items-center justify-between gap-2 text-[11px] text-muted-foreground"> + <span className="font-medium uppercase tracking-wide"> + {message.role === "user" ? "Instruction" : "Agent"} + </span> + <time dateTime={message.createdAt}> + {new Date(message.createdAt).toLocaleTimeString()} + </time> + </div> + <p className="whitespace-pre-wrap text-xs leading-5 text-foreground"> + {message.text} + </p> + </li> + ))} + </ol> + )} + {session.activities.length > 0 ? ( + <details> + <summary className="cursor-pointer text-xs text-muted-foreground select-none"> + Activity log ({session.activities.length}) + </summary> + <ol className="mt-2 space-y-1"> + {session.activities.map((activity) => ( + <li + key={activity.id as string} + className="flex items-baseline gap-2 text-[11px] text-muted-foreground" + > + <span className="shrink-0 font-medium">{activity.kind}</span> + <span className="truncate">{activity.summary}</span> + </li> + ))} + </ol> + </details> + ) : null} + </> + )} + </div> + </div> + </DialogPopup> + </Dialog> + ); +} diff --git a/apps/web/src/components/board/BoardDigestDialog.tsx b/apps/web/src/components/board/BoardDigestDialog.tsx new file mode 100644 index 00000000000..a2fd2418b6f --- /dev/null +++ b/apps/web/src/components/board/BoardDigestDialog.tsx @@ -0,0 +1,185 @@ +import type { WorkflowBoardDigest } from "@t3tools/contracts"; +import { NewspaperIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { formatDuration } from "~/session-logic"; +import { formatTokenCount } from "~/workflow/usageFormat"; + +/** + * The board's stand-up summary: what moved, what shipped, what it cost, and + * which tickets have been waiting on a human the longest. + */ +export function BoardDigestDialog({ + disabled, + needsAttentionCount, + onFetchDigest, + open: controlledOpen, + onOpenChange, +}: { + readonly disabled: boolean; + readonly needsAttentionCount: number; + readonly onFetchDigest: () => Promise<WorkflowBoardDigest>; + readonly open?: boolean; + readonly onOpenChange?: (open: boolean) => void; +}) { + const isControlled = onOpenChange !== undefined; + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const open = isControlled ? (controlledOpen ?? false) : uncontrolledOpen; + const setOpen = (next: boolean) => { + if (isControlled) { + onOpenChange(next); + } else { + setUncontrolledOpen(next); + } + }; + const [digest, setDigest] = useState<WorkflowBoardDigest | null>(null); + const [error, setError] = useState<string | null>(null); + // A close (or re-open) invalidates in-flight fetches so a slow response + // can never repopulate the dialog with stale content. + const requestRef = useRef(0); + + const load = async () => { + const requestId = ++requestRef.current; + setError(null); + setDigest(null); + try { + const next = await onFetchDigest(); + if (requestRef.current === requestId) { + setDigest(next); + } + } catch (cause) { + if (requestRef.current === requestId) { + setError(cause instanceof Error ? cause.message : "Failed to load the digest."); + } + } + }; + + // In controlled mode the parent owns the trigger, so the load the + // self-contained trigger's onClick performed must fire when the dialog + // transitions to open. + useEffect(() => { + if (isControlled && open && digest === null && error === null) { + void load(); + } + }, [isControlled, open]); + + return ( + <Dialog + open={open} + onOpenChange={(nextOpen) => { + setOpen(nextOpen); + if (!nextOpen) { + requestRef.current += 1; + setDigest(null); + } + }} + > + {isControlled ? null : ( + <Button + type="button" + size="xs" + variant="outline" + disabled={disabled} + title="What happened on this board in the last 24 hours" + onClick={() => { + // onOpenChange only fires for internal open changes (Esc, + // backdrop) — a controlled setOpen must kick off its own load. + setOpen(true); + void load(); + }} + > + <NewspaperIcon className="size-3.5" /> + Digest + {needsAttentionCount > 0 ? ( + <Badge size="sm" variant="warning" data-testid="board-needs-attention-count"> + {needsAttentionCount} + </Badge> + ) : null} + </Button> + )} + <DialogPopup className="max-h-[calc(100dvh-2rem)] max-w-lg overflow-hidden"> + <div className="flex min-h-0 flex-col"> + <DialogHeader> + <DialogTitle>Board digest</DialogTitle> + <DialogDescription> + The last {digest?.windowHours ?? 24} hours on this board. + </DialogDescription> + </DialogHeader> + <div + className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 pt-1 pb-4" + data-testid="board-digest" + > + {error !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {error} + </p> + ) : digest === null ? ( + <p className="text-sm text-muted-foreground">Loading…</p> + ) : ( + <> + <dl className="grid grid-cols-2 gap-3"> + <div className="rounded-md border border-border/70 bg-card/35 p-3"> + <dt className="text-xs text-muted-foreground">Shipped</dt> + <dd className="text-lg font-semibold text-foreground">{digest.shippedCount}</dd> + </div> + <div className="rounded-md border border-border/70 bg-card/35 p-3"> + <dt className="text-xs text-muted-foreground">Created</dt> + <dd className="text-lg font-semibold text-foreground">{digest.createdCount}</dd> + </div> + <div className="rounded-md border border-border/70 bg-card/35 p-3"> + <dt className="text-xs text-muted-foreground">Tokens spent</dt> + <dd className="text-lg font-semibold text-foreground"> + {digest.totalTokens > 0 ? formatTokenCount(digest.totalTokens) : "0"} + </dd> + </div> + <div className="rounded-md border border-border/70 bg-card/35 p-3"> + <dt className="text-xs text-muted-foreground">Agent time</dt> + <dd className="text-lg font-semibold text-foreground"> + {digest.totalDurationMs > 0 ? formatDuration(digest.totalDurationMs) : "0"} + </dd> + </div> + </dl> + <section> + <h3 className="mb-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wide"> + Waiting on you + </h3> + {digest.needsAttention.length === 0 ? ( + <p className="text-sm text-muted-foreground"> + Nothing — the board is running itself. + </p> + ) : ( + <ol className="space-y-1.5"> + {digest.needsAttention.map((ticket) => ( + <li + key={ticket.ticketId as string} + className="flex items-center justify-between gap-2 rounded-md border border-warning/40 bg-warning/5 px-2.5 py-1.5" + > + <span className="min-w-0 truncate text-sm text-foreground"> + {ticket.title} + </span> + <span className="shrink-0 text-[11px] text-muted-foreground"> + {ticket.status === "blocked" ? "blocked" : "waiting"} ·{" "} + {formatDuration(ticket.sinceMs)} + </span> + </li> + ))} + </ol> + )} + </section> + </> + )} + </div> + </div> + </DialogPopup> + </Dialog> + ); +} diff --git a/apps/web/src/components/board/BoardHeaderControls.browser.tsx b/apps/web/src/components/board/BoardHeaderControls.browser.tsx new file mode 100644 index 00000000000..4c10c7dd4b8 --- /dev/null +++ b/apps/web/src/components/board/BoardHeaderControls.browser.tsx @@ -0,0 +1,59 @@ +import "../../index.css"; + +import { page } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { BoardHeaderControls } from "./BoardHeaderControls"; + +const lanes = [ + { key: "backlog", name: "Backlog", entry: "manual", pipelineStepCount: 0 }, + { key: "implement", name: "Implement", entry: "auto", pipelineStepCount: 2 }, +] as const; + +describe("BoardHeaderControls", () => { + it("opens a create-ticket dialog and submits title plus description", async () => { + const onCreateTicket = vi.fn(); + render( + <BoardHeaderControls boardId="delivery" lanes={lanes} onCreateTicket={onCreateTicket} />, + ); + + await expect.element(page.getByLabelText("New ticket title")).not.toBeInTheDocument(); + + await page.getByRole("button", { name: "New ticket" }).click(); + await expect.element(page.getByRole("heading", { name: "New ticket" })).toBeInTheDocument(); + + await page.getByLabelText("Ticket title").fill("Ship workflow modal"); + await page + .getByLabelText("Ticket description") + .fill("Acceptance criteria and implementation notes."); + await page.getByRole("button", { name: "Create ticket" }).click(); + + await vi.waitFor(() => { + expect(onCreateTicket).toHaveBeenCalledWith({ + title: "Ship workflow modal", + description: "Acceptance criteria and implementation notes.", + initialLane: "backlog", + }); + }); + }); + + it("toggles the workflow editor from the board header", async () => { + const onToggleWorkflowEditor = vi.fn(); + render( + <BoardHeaderControls + boardId="delivery" + lanes={lanes} + workflowEditorOpen={false} + onCreateTicket={() => {}} + onToggleWorkflowEditor={onToggleWorkflowEditor} + />, + ); + + await page.getByRole("button", { name: "Edit workflow" }).click(); + + await vi.waitFor(() => { + expect(onToggleWorkflowEditor).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/apps/web/src/components/board/BoardHeaderControls.test.tsx b/apps/web/src/components/board/BoardHeaderControls.test.tsx new file mode 100644 index 00000000000..378b3e420a9 --- /dev/null +++ b/apps/web/src/components/board/BoardHeaderControls.test.tsx @@ -0,0 +1,70 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { BoardHeaderControls, getDefaultInitialLane } from "./BoardHeaderControls"; + +const lanes = [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "implement", name: "Implement", entry: "auto" }, +] as const; + +describe("BoardHeaderControls", () => { + it("defaults new tickets to the first board lane", () => { + expect(getDefaultInitialLane(lanes)).toBe("backlog"); + expect(getDefaultInitialLane([])).toBeNull(); + }); + + it("renders only closed board action triggers in the board header", () => { + const markup = renderToStaticMarkup( + <BoardHeaderControls boardId="delivery" lanes={lanes} onCreateTicket={() => {}} />, + ); + + expect(markup).not.toContain("Register board"); + expect(markup).toContain("New ticket"); + expect(markup).not.toContain("Edit workflow"); + expect(markup).not.toContain("New ticket title"); + expect(markup).not.toContain("Backlog"); + expect(markup).not.toContain("Implement"); + }); + + it("renders the intake trigger only when proposing is wired", () => { + const without = renderToStaticMarkup( + <BoardHeaderControls boardId="delivery" lanes={lanes} onCreateTicket={() => {}} />, + ); + expect(without).not.toContain("Intake"); + + const withIntake = renderToStaticMarkup( + <BoardHeaderControls + boardId="delivery" + lanes={lanes} + onCreateTicket={() => {}} + onProposeTickets={async () => []} + />, + ); + expect(withIntake).toContain("Intake"); + }); + + it("renders the workflow editor toggle when provided", () => { + const markup = renderToStaticMarkup( + <BoardHeaderControls + boardId="delivery" + lanes={lanes} + workflowEditorOpen={false} + onCreateTicket={() => {}} + onToggleWorkflowEditor={() => {}} + />, + ); + + expect(markup).toMatch(/<button[^>]*type="button"[^>]*>.*Edit workflow<\/button>/s); + expect(markup).not.toContain("New ticket title"); + expect(markup).not.toContain("Backlog"); + }); + + it("renders the New ticket action as a dialog trigger button", () => { + const markup = renderToStaticMarkup( + <BoardHeaderControls boardId="delivery" lanes={lanes} onCreateTicket={() => {}} />, + ); + + expect(markup).toMatch(/<button[^>]*type="button"[^>]*>.*New ticket<\/button>/s); + }); +}); diff --git a/apps/web/src/components/board/BoardHeaderControls.tsx b/apps/web/src/components/board/BoardHeaderControls.tsx new file mode 100644 index 00000000000..6977482dcf0 --- /dev/null +++ b/apps/web/src/components/board/BoardHeaderControls.tsx @@ -0,0 +1,583 @@ +import type { + AgentSelection, + EnvironmentApi, + WorkflowBoardDigest, + WorkflowBoardMetrics, + WorkflowWebhookConfig, +} from "@t3tools/contracts"; +import { + BarChart2Icon, + DownloadIcon, + MoreHorizontalIcon, + NewspaperIcon, + PencilIcon, + PlusIcon, + SparklesIcon, + WandSparklesIcon, + WebhookIcon, +} from "lucide-react"; +import type { ComponentType } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; + +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { Textarea } from "~/components/ui/textarea"; +import type { IntakeTicketInput } from "~/workflow/intakeState"; + +import { AddFromIssuesDialog } from "./AddFromIssuesDialog"; +import { BoardDigestDialog } from "./BoardDigestDialog"; +import { BoardMetricsDialog } from "./BoardMetricsDialog"; +import { IntakeDialog } from "./IntakeDialog"; +import { SelfImproveDialog } from "./SelfImproveDialog"; +import { WebhookConfigDialog } from "./WebhookConfigDialog"; + +export interface BoardHeaderLane { + readonly key: string; + readonly name: string; +} + +export interface NewTicketInput { + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: string; + readonly dependsOn?: ReadonlyArray<string> | undefined; + readonly tokenBudget?: number | undefined; +} + +export interface BoardHeaderTicketOption { + readonly ticketId: string; + readonly title: string; +} + +export const getDefaultInitialLane = (lanes: ReadonlyArray<BoardHeaderLane>): string | null => + lanes[0]?.key ?? null; + +export function BoardHeaderControls({ + boardId, + lanes, + tickets = [], + workflowEditorOpen = false, + intakeDisabledReason, + needsAttentionCount = 0, + api, + onCreateTicket, + onCreateTicketAsync, + onProposeTickets, + onToggleWorkflowEditor, + onFetchDigest, + onFetchMetrics, + onFetchWebhookConfig, + boardHasSources = false, + onRefresh, +}: { + readonly boardId: string | null; + readonly lanes: ReadonlyArray<BoardHeaderLane>; + readonly tickets?: ReadonlyArray<BoardHeaderTicketOption>; + readonly workflowEditorOpen?: boolean | undefined; + readonly intakeDisabledReason?: string | undefined; + readonly api?: EnvironmentApi | null | undefined; + readonly onCreateTicket: (input: NewTicketInput) => void; + readonly onCreateTicketAsync?: ((input: NewTicketInput) => Promise<string | void>) | undefined; + readonly onProposeTickets?: + | ((braindump: string, agent: AgentSelection) => Promise<ReadonlyArray<IntakeTicketInput>>) + | undefined; + readonly onToggleWorkflowEditor?: (() => void) | undefined; + readonly needsAttentionCount?: number | undefined; + readonly onFetchDigest?: (() => Promise<WorkflowBoardDigest>) | undefined; + readonly onFetchMetrics?: ((windowDays: 1 | 7 | 30) => Promise<WorkflowBoardMetrics>) | undefined; + readonly onFetchWebhookConfig?: ((rotate: boolean) => Promise<WorkflowWebhookConfig>) | undefined; + readonly boardHasSources?: boolean | undefined; + readonly onRefresh?: (() => void) | undefined; +}) { + const [open, setOpen] = useState(false); + const [activeDialog, setActiveDialog] = useState< + null | "webhook" | "digest" | "insights" | "suggest" | "intake" | "add-from-issues" + >(null); + + // Measured overflow: render the six secondary buttons inline when they fit, + // otherwise collapse them into a single "More" menu. SSR / first paint is + // always expanded (no probe) so the SSR snapshot tests see inline buttons. + const [mounted, setMounted] = useState(false); + const [collapsed, setCollapsed] = useState(false); + const containerRef = useRef<HTMLDivElement | null>(null); + const probeRef = useRef<HTMLDivElement | null>(null); + + useEffect(() => { + setMounted(true); + }, []); + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [initialLane, setInitialLane] = useState(() => getDefaultInitialLane(lanes) ?? ""); + const [dependsOn, setDependsOn] = useState<ReadonlyArray<string>>([]); + const [tokenBudget, setTokenBudget] = useState(""); + + useEffect(() => { + if (lanes.some((lane) => lane.key === initialLane)) { + return; + } + setInitialLane(getDefaultInitialLane(lanes) ?? ""); + }, [initialLane, lanes]); + + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + const canCreateTicket = Boolean(boardId && initialLane && trimmedTitle); + + const resetForm = () => { + setTitle(""); + setDescription(""); + setInitialLane(getDefaultInitialLane(lanes) ?? ""); + setDependsOn([]); + setTokenBudget(""); + }; + + const handleCreateIntakeTickets = async ( + tickets: ReadonlyArray<{ + readonly title: string; + readonly description?: string | undefined; + readonly dependsOnIndices: ReadonlyArray<number>; + }>, + ) => { + const lane = getDefaultInitialLane(lanes); + if (lane === null) { + return; + } + // Sequential so dependency edges can reference the ids of the tickets + // created earlier in this same batch. + const createdIds: Array<string | undefined> = []; + for (const ticket of tickets) { + const dependsOn = ticket.dependsOnIndices + .map((index) => createdIds[index]) + .filter((ticketId): ticketId is string => ticketId !== undefined); + const input = { + title: ticket.title, + ...(ticket.description === undefined ? {} : { description: ticket.description }), + initialLane: lane, + ...(dependsOn.length > 0 ? { dependsOn } : {}), + }; + if (onCreateTicketAsync) { + createdIds.push((await onCreateTicketAsync(input)) ?? undefined); + } else { + onCreateTicket(input); + createdIds.push(undefined); + } + } + }; + + // Secondary actions, in render order. Only include an action when its + // handler/prop is present, matching the previous conditional rendering. + interface SecondaryAction { + readonly key: string; + readonly label: string; + readonly icon: ComponentType<{ className?: string }>; + readonly disabled: boolean; + readonly onSelect: () => void; + readonly pressed?: boolean; + readonly title?: string; + readonly badge?: number; + } + + const secondaryActions: ReadonlyArray<SecondaryAction> = [ + ...(onFetchWebhookConfig + ? [ + { + key: "webhook", + label: "Webhook", + icon: WebhookIcon, + disabled: !boardId, + onSelect: () => setActiveDialog("webhook"), + title: "Let CI, PR automation, or cron move tickets on this board", + } satisfies SecondaryAction, + ] + : []), + ...(onFetchDigest + ? [ + { + key: "digest", + label: "Digest", + icon: NewspaperIcon, + disabled: !boardId, + onSelect: () => setActiveDialog("digest"), + title: "What happened on this board in the last 24 hours", + ...(needsAttentionCount > 0 ? { badge: needsAttentionCount } : {}), + } satisfies SecondaryAction, + ] + : []), + ...(onFetchMetrics + ? [ + { + key: "insights", + label: "Insights", + icon: BarChart2Icon, + disabled: !boardId, + onSelect: () => setActiveDialog("insights"), + title: "Board metrics and throughput charts", + } satisfies SecondaryAction, + ] + : []), + ...(onToggleWorkflowEditor + ? [ + { + key: "edit-workflow", + label: "Edit workflow", + icon: PencilIcon, + disabled: !boardId, + onSelect: onToggleWorkflowEditor, + pressed: workflowEditorOpen, + } satisfies SecondaryAction, + ] + : []), + ...(api !== undefined + ? [ + { + key: "suggest", + label: "Suggest improvements", + icon: WandSparklesIcon, + disabled: !boardId, + onSelect: () => setActiveDialog("suggest"), + title: boardId ? "Suggest AI improvements to this board" : "No board selected", + } satisfies SecondaryAction, + ] + : []), + ...(onProposeTickets + ? [ + { + key: "intake", + label: "Intake", + icon: SparklesIcon, + disabled: !boardId || lanes.length === 0 || intakeDisabledReason !== undefined, + onSelect: () => setActiveDialog("intake"), + title: + intakeDisabledReason !== undefined + ? intakeDisabledReason + : "Turn a braindump into tickets", + } satisfies SecondaryAction, + ] + : []), + ...(boardHasSources && boardId + ? [ + { + key: "add-from-issues", + label: "Add from issues", + icon: DownloadIcon, + disabled: false, + onSelect: () => setActiveDialog("add-from-issues"), + title: "Import work items from connected sources", + } satisfies SecondaryAction, + ] + : []), + ]; + + // Re-measure on mount and whenever the container resizes (sidebar toggle, + // board-name length change, window resize). The probe holds the full inline + // layout off-screen so its scrollWidth is the natural required width. + useLayoutEffect(() => { + if (!mounted) { + return; + } + const container = containerRef.current; + if (!container) { + return; + } + const measure = () => { + const probe = probeRef.current; + const node = containerRef.current; + if (!probe || !node) { + return; + } + // +4px buffer avoids flicker right at the boundary. + setCollapsed(probe.scrollWidth + 4 > node.clientWidth); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(container); + return () => { + observer.disconnect(); + }; + }, [mounted, secondaryActions.length]); + + const renderInlineAction = (action: SecondaryAction) => ( + <Button + key={action.key} + type="button" + size="xs" + variant={action.key === "edit-workflow" && action.pressed ? "secondary" : "outline"} + disabled={action.disabled} + {...(action.title !== undefined ? { title: action.title } : {})} + {...(action.key === "edit-workflow" ? { "aria-pressed": action.pressed } : {})} + onClick={action.onSelect} + > + <action.icon className="size-3.5" /> + {action.label} + {action.badge !== undefined ? ( + <Badge size="sm" variant="warning" data-testid="board-needs-attention-count"> + {action.badge} + </Badge> + ) : null} + </Button> + ); + + return ( + <div ref={containerRef} className="flex min-w-0 flex-1 items-center justify-end gap-2"> + {/* Off-screen probe: the full inline secondary layout, measured for fit. */} + {mounted && secondaryActions.length > 0 ? ( + <div + ref={probeRef} + aria-hidden + className="pointer-events-none absolute -left-[9999px] top-0 flex items-center gap-2" + > + {secondaryActions.map(renderInlineAction)} + </div> + ) : null} + + {secondaryActions.length > 0 ? ( + collapsed ? ( + <Menu> + <MenuTrigger + render={ + <Button type="button" size="xs" variant="outline" aria-label="More board actions" /> + } + > + <MoreHorizontalIcon className="size-3.5" /> + More + </MenuTrigger> + <MenuPopup align="end"> + {secondaryActions.map((action) => ( + <MenuItem + key={action.key} + disabled={action.disabled} + onClick={action.onSelect} + {...(action.title !== undefined ? { title: action.title } : {})} + > + <action.icon className="size-4" /> + {action.label} + {action.badge !== undefined ? ( + <Badge size="sm" variant="warning" data-testid="board-needs-attention-count"> + {action.badge} + </Badge> + ) : null} + </MenuItem> + ))} + </MenuPopup> + </Menu> + ) : ( + secondaryActions.map(renderInlineAction) + ) + ) : null} + + {/* Controlled dialog bodies — rendered once regardless of collapse so a + menu close never unmounts an open dialog. */} + {onFetchWebhookConfig ? ( + <WebhookConfigDialog + disabled={!boardId} + onFetchConfig={onFetchWebhookConfig} + open={activeDialog === "webhook"} + onOpenChange={(o) => setActiveDialog(o ? "webhook" : null)} + /> + ) : null} + {onFetchDigest ? ( + <BoardDigestDialog + disabled={!boardId} + needsAttentionCount={needsAttentionCount} + onFetchDigest={onFetchDigest} + open={activeDialog === "digest"} + onOpenChange={(o) => setActiveDialog(o ? "digest" : null)} + /> + ) : null} + {onFetchMetrics ? ( + <BoardMetricsDialog + disabled={!boardId} + onFetchMetrics={onFetchMetrics} + open={activeDialog === "insights"} + onOpenChange={(o) => setActiveDialog(o ? "insights" : null)} + /> + ) : null} + {api !== undefined ? ( + <SelfImproveDialog + boardId={boardId} + disabled={!boardId} + api={api} + open={activeDialog === "suggest"} + onOpenChange={(o) => setActiveDialog(o ? "suggest" : null)} + /> + ) : null} + {onProposeTickets ? ( + <IntakeDialog + disabled={!boardId || lanes.length === 0 || intakeDisabledReason !== undefined} + disabledReason={intakeDisabledReason} + onPropose={onProposeTickets} + onCreateTickets={handleCreateIntakeTickets} + open={activeDialog === "intake"} + onOpenChange={(o) => setActiveDialog(o ? "intake" : null)} + /> + ) : null} + {boardHasSources && boardId ? ( + <AddFromIssuesDialog + boardId={boardId} + api={api} + open={activeDialog === "add-from-issues"} + onOpenChange={(o) => setActiveDialog(o ? "add-from-issues" : null)} + onImported={() => onRefresh?.()} + /> + ) : null} + <Dialog + open={open} + onOpenChange={(nextOpen) => { + setOpen(nextOpen); + if (!nextOpen) { + resetForm(); + } + }} + > + <Button + type="button" + size="xs" + disabled={!boardId || lanes.length === 0} + onClick={() => setOpen(true)} + > + <PlusIcon className="size-3.5" /> + New ticket + </Button> + <DialogPopup className="max-h-[calc(100dvh-2rem)] max-w-xl overflow-hidden"> + <form + className="flex min-h-0 flex-col" + onSubmit={(event) => { + event.preventDefault(); + if (!canCreateTicket) { + return; + } + + const parsedBudget = Number.parseInt(tokenBudget, 10); + onCreateTicket({ + title: trimmedTitle, + ...(trimmedDescription ? { description: trimmedDescription } : {}), + initialLane, + ...(dependsOn.length > 0 ? { dependsOn } : {}), + ...(Number.isFinite(parsedBudget) && parsedBudget > 0 + ? { tokenBudget: parsedBudget } + : {}), + }); + resetForm(); + setOpen(false); + }} + > + <DialogHeader> + <DialogTitle>New ticket</DialogTitle> + <DialogDescription> + Capture the work request, context, and acceptance criteria before adding it to the + board. + </DialogDescription> + </DialogHeader> + <div + className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 pt-1 pb-3" + data-slot="dialog-panel" + > + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Title</span> + <Input + value={title} + placeholder="Ticket title" + onChange={(event) => setTitle(event.currentTarget.value)} + aria-label="Ticket title" + autoFocus + /> + </label> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Description</span> + <Textarea + value={description} + placeholder="Describe the work, useful context, and acceptance criteria." + onChange={(event) => setDescription(event.currentTarget.value)} + aria-label="Ticket description" + rows={8} + /> + </label> + {tickets.length > 0 ? ( + <fieldset className="grid gap-1.5"> + <legend className="text-xs font-medium text-foreground"> + Depends on (held until these land) + </legend> + <div className="max-h-32 space-y-1 overflow-y-auto rounded-md border border-border/70 p-2"> + {tickets.map((option) => ( + <label key={option.ticketId} className="flex items-center gap-2 text-xs"> + <input + type="checkbox" + checked={dependsOn.includes(option.ticketId)} + onChange={(event) => { + // currentTarget is nulled before the updater runs. + const checked = event.currentTarget.checked; + setDependsOn((current) => + checked + ? [...current, option.ticketId] + : current.filter((ticketId) => ticketId !== option.ticketId), + ); + }} + aria-label={`Depends on ${option.title}`} + /> + <span className="truncate">{option.title}</span> + </label> + ))} + </div> + </fieldset> + ) : null} + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Token budget (optional)</span> + <Input + value={tokenBudget} + type="number" + min={0} + step={1000} + placeholder="e.g. 500000 — agent steps block once usage reaches it" + onChange={(event) => setTokenBudget(event.currentTarget.value)} + aria-label="Token budget" + /> + </label> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Initial lane</span> + <select + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground disabled:opacity-64" + value={initialLane} + disabled={lanes.length === 0} + onChange={(event) => setInitialLane(event.currentTarget.value)} + aria-label="Initial lane" + > + {lanes.map((lane) => ( + <option key={lane.key} value={lane.key}> + {lane.name} + </option> + ))} + </select> + </label> + </div> + <DialogFooter> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + resetForm(); + setOpen(false); + }} + > + Cancel + </Button> + <Button type="submit" size="sm" disabled={!canCreateTicket}> + Create ticket + </Button> + </DialogFooter> + </form> + </DialogPopup> + </Dialog> + </div> + ); +} diff --git a/apps/web/src/components/board/BoardMetricsDialog.tsx b/apps/web/src/components/board/BoardMetricsDialog.tsx new file mode 100644 index 00000000000..d4870f97e0f --- /dev/null +++ b/apps/web/src/components/board/BoardMetricsDialog.tsx @@ -0,0 +1,398 @@ +import type { WorkflowBoardMetrics } from "@t3tools/contracts"; +import { BarChart2Icon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { formatDuration } from "~/session-logic"; + +// ─── Primitives ───────────────────────────────────────────────────────────── + +function StatCard({ label, value }: { readonly label: string; readonly value: string }) { + return ( + <div className="rounded-md border border-border/70 bg-card/35 p-3"> + <dt className="text-xs text-muted-foreground">{label}</dt> + <dd className="text-lg font-semibold text-foreground">{value}</dd> + </div> + ); +} + +/** A labelled horizontal bar. `fraction` is 0–1; clamped to [0,1]. */ +function BarRow({ + label, + value, + fraction, +}: { + readonly label: string; + readonly value: number; + readonly fraction: number; +}) { + const pct = Math.max(0, Math.min(1, fraction)) * 100; + return ( + <div className="space-y-1"> + <div className="flex items-center justify-between gap-2 text-xs"> + <span className="min-w-0 truncate text-muted-foreground">{label}</span> + <span className="shrink-0 font-medium text-foreground">{value}</span> + </div> + <div className="h-1.5 w-full rounded-full bg-border/40"> + <div + className="h-1.5 rounded-full bg-primary/60 transition-all" + style={{ width: `${pct}%` }} + /> + </div> + </div> + ); +} + +function SectionHeading({ children }: { readonly children: string }) { + return ( + <h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> + {children} + </h3> + ); +} + +// ─── Window selector ──────────────────────────────────────────────────────── + +type WindowDays = 1 | 7 | 30; +const WINDOWS: ReadonlyArray<{ label: string; value: WindowDays }> = [ + { label: "24h", value: 1 }, + { label: "7d", value: 7 }, + { label: "30d", value: 30 }, +]; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function fmtMs(ms: number, count: number): string { + if (count === 0) return "—"; + return formatDuration(ms); +} + +function maxOf(values: ReadonlyArray<number>): number { + if (values.length === 0) return 0; + return Math.max(...values); +} + +// ─── Dialog ───────────────────────────────────────────────────────────────── + +export function BoardMetricsDialog({ + disabled, + onFetchMetrics, + open: controlledOpen, + onOpenChange, +}: { + readonly disabled: boolean; + readonly onFetchMetrics: (windowDays: WindowDays) => Promise<WorkflowBoardMetrics>; + readonly open?: boolean; + readonly onOpenChange?: (open: boolean) => void; +}) { + const isControlled = onOpenChange !== undefined; + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const open = isControlled ? (controlledOpen ?? false) : uncontrolledOpen; + const setOpen = (next: boolean) => { + if (isControlled) { + onOpenChange(next); + } else { + setUncontrolledOpen(next); + } + }; + const [windowDays, setWindowDays] = useState<WindowDays>(7); + const [metrics, setMetrics] = useState<WorkflowBoardMetrics | null>(null); + const [error, setError] = useState<string | null>(null); + const requestRef = useRef(0); + + const load = async (days: WindowDays) => { + const requestId = ++requestRef.current; + setError(null); + setMetrics(null); + try { + const next = await onFetchMetrics(days); + if (requestRef.current === requestId) { + setMetrics(next); + } + } catch (cause) { + if (requestRef.current === requestId) { + setError(cause instanceof Error ? cause.message : "Failed to load metrics."); + } + } + }; + + const handleWindowChange = (days: WindowDays) => { + setWindowDays(days); + void load(days); + }; + + const windowLabel: string = + WINDOWS.find((w) => w.value === windowDays)?.label ?? `${String(windowDays)}d`; + + const hasAnyData = + metrics !== null && + (metrics.throughput.created > 0 || + metrics.throughput.shipped > 0 || + metrics.cycleTime.count > 0 || + metrics.wipByLane.some((l) => l.admitted > 0 || l.queued > 0) || + Object.keys(metrics.statusBreakdown).length > 0 || + metrics.attention.blocked > 0 || + metrics.attention.waitingOnUser > 0 || + metrics.attention.oldest.length > 0 || + metrics.routeOutcomes.length > 0 || + metrics.manualMoveCount > 0 || + metrics.stepStats.length > 0); + + // In controlled mode the parent owns the trigger, so the load the + // self-contained trigger's onClick performed (with the current windowDays) + // must fire when the dialog transitions to open. + useEffect(() => { + if (isControlled && open && metrics === null && error === null) { + void load(windowDays); + } + }, [isControlled, open]); + + return ( + <Dialog + open={open} + onOpenChange={(nextOpen) => { + setOpen(nextOpen); + if (!nextOpen) { + requestRef.current += 1; + setMetrics(null); + } + }} + > + {isControlled ? null : ( + <Button + type="button" + size="xs" + variant="outline" + disabled={disabled} + title="Board metrics and throughput charts" + onClick={() => { + setOpen(true); + void load(windowDays); + }} + > + <BarChart2Icon className="size-3.5" /> + Insights + </Button> + )} + <DialogPopup className="max-h-[calc(100dvh-2rem)] max-w-lg overflow-hidden"> + <div className="flex min-h-0 flex-col"> + <DialogHeader> + <DialogTitle>Board insights</DialogTitle> + <DialogDescription> + Current state and windowed activity data for this board. + </DialogDescription> + </DialogHeader> + + {/* Window selector — applies to windowed sections only */} + <div className="flex shrink-0 items-center gap-2 px-6 pb-2"> + <span className="text-xs text-muted-foreground">Window:</span> + {WINDOWS.map(({ label, value }) => ( + <Button + key={value} + type="button" + size="xs" + variant={windowDays === value ? "secondary" : "ghost"} + onClick={() => handleWindowChange(value)} + > + {label} + </Button> + ))} + </div> + + <div + className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 pt-1 pb-4" + data-testid="board-metrics" + > + {error !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {error} + </p> + ) : metrics === null ? ( + <p className="text-sm text-muted-foreground">Loading…</p> + ) : !hasAnyData ? ( + <p className="text-sm text-muted-foreground">No data for this board.</p> + ) : ( + <> + {/* ── Throughput ── */} + <section> + <SectionHeading>{`Throughput (last ${windowLabel})`}</SectionHeading> + <dl className="grid grid-cols-2 gap-3"> + <StatCard label="Created" value={String(metrics.throughput.created)} /> + <StatCard label="Shipped" value={String(metrics.throughput.shipped)} /> + <StatCard label="Manual moves" value={String(metrics.manualMoveCount)} /> + </dl> + </section> + + {/* ── Cycle time ── */} + <section> + <SectionHeading>{`Cycle time (last ${windowLabel})`}</SectionHeading> + <dl className="grid grid-cols-3 gap-3"> + <StatCard + label="p50" + value={fmtMs(metrics.cycleTime.p50Ms, metrics.cycleTime.count)} + /> + <StatCard + label="p90" + value={fmtMs(metrics.cycleTime.p90Ms, metrics.cycleTime.count)} + /> + <StatCard + label="avg" + value={fmtMs(metrics.cycleTime.avgMs, metrics.cycleTime.count)} + /> + </dl> + </section> + + {/* ── Attention ── */} + <section> + <SectionHeading>Attention needed (current)</SectionHeading> + <dl className="grid grid-cols-2 gap-3"> + <StatCard label="Blocked" value={String(metrics.attention.blocked)} /> + <StatCard + label="Waiting on you" + value={String(metrics.attention.waitingOnUser)} + /> + </dl> + {metrics.attention.oldest.length > 0 ? ( + <ol className="mt-3 space-y-1.5"> + {metrics.attention.oldest.map((ticket) => ( + <li + key={ticket.ticketId} + className="flex items-center justify-between gap-2 rounded-md border border-warning/40 bg-warning/5 px-2.5 py-1.5" + > + <span className="min-w-0 truncate text-sm text-foreground"> + {ticket.title} + </span> + <span className="shrink-0 text-[11px] text-muted-foreground"> + {ticket.laneKey ?? "—"} · {formatDuration(ticket.ageMs)} + </span> + </li> + ))} + </ol> + ) : null} + </section> + + {/* ── WIP by lane ── */} + {metrics.wipByLane.length > 0 ? ( + <section> + <SectionHeading>Current WIP by lane</SectionHeading> + <div className="space-y-3"> + {(() => { + const wipMax = maxOf( + metrics.wipByLane.flatMap((l) => [l.admitted, l.queued]), + ); + return metrics.wipByLane.map((lane) => ( + <div key={lane.laneKey} className="space-y-1.5"> + <BarRow + label={`${lane.laneKey} — admitted`} + value={lane.admitted} + fraction={wipMax > 0 ? lane.admitted / wipMax : 0} + /> + <BarRow + label={`${lane.laneKey} — queued`} + value={lane.queued} + fraction={wipMax > 0 ? lane.queued / wipMax : 0} + /> + </div> + )); + })()} + </div> + </section> + ) : null} + + {/* ── Status breakdown ── */} + {Object.keys(metrics.statusBreakdown).length > 0 ? ( + <section> + <SectionHeading>Status breakdown (current)</SectionHeading> + <div className="space-y-2"> + {(() => { + const entries = Object.entries(metrics.statusBreakdown); + const maxCount = maxOf(entries.map(([, v]) => v)); + return entries.map(([status, count]) => ( + <BarRow + key={status} + label={status} + value={count} + fraction={maxCount > 0 ? count / maxCount : 0} + /> + )); + })()} + </div> + </section> + ) : null} + + {/* ── Route outcomes ── */} + {metrics.routeOutcomes.length > 0 ? ( + <section> + <SectionHeading>{`Route outcomes (last ${windowLabel})`}</SectionHeading> + <div className="space-y-2"> + {(() => { + const maxCount = maxOf(metrics.routeOutcomes.map((r) => r.count)); + return metrics.routeOutcomes.map((r, i) => { + const from = r.fromLane ?? "—"; + const to = r.toLane ?? "—"; + const resultSuffix = r.result === "n/a" ? "" : ` (${r.result})`; + return ( + <BarRow + key={i} + label={`${r.source}: ${from} → ${to}${resultSuffix}`} + value={r.count} + fraction={maxCount > 0 ? r.count / maxCount : 0} + /> + ); + }); + })()} + </div> + </section> + ) : null} + + {/* ── Step stats ── */} + {metrics.stepStats.length > 0 ? ( + <section> + <SectionHeading>{`Step stats (last ${windowLabel})`}</SectionHeading> + <div className="space-y-3"> + {(() => { + const maxSucceeded = maxOf(metrics.stepStats.map((s) => s.succeeded)); + return metrics.stepStats.map((step, i) => ( + <div + key={i} + className="rounded-md border border-border/60 bg-card/20 p-2.5" + > + <div className="mb-1.5 flex items-center justify-between gap-2 text-xs"> + <span className="font-medium text-foreground"> + {step.laneKey}/{step.stepKey} + </span> + <span className="shrink-0 text-muted-foreground"> + {step.stepType} + </span> + </div> + <BarRow + label={`ok ${step.succeeded} / fail ${step.failed} / retry ${step.retries}`} + value={step.succeeded} + fraction={maxSucceeded > 0 ? step.succeeded / maxSucceeded : 0} + /> + {step.avgDurationMs > 0 ? ( + <p className="mt-1 text-[11px] text-muted-foreground"> + avg {formatDuration(step.avgDurationMs)} + </p> + ) : null} + </div> + )); + })()} + </div> + </section> + ) : null} + </> + )} + </div> + </div> + </DialogPopup> + </Dialog> + ); +} diff --git a/apps/web/src/components/board/BoardView.test.tsx b/apps/web/src/components/board/BoardView.test.tsx new file mode 100644 index 00000000000..1264af583aa --- /dev/null +++ b/apps/web/src/components/board/BoardView.test.tsx @@ -0,0 +1,74 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { BoardView, resolveBoardDropLaneKey, type BoardViewState } from "./BoardView"; + +const boardState = { + lanes: [ + { + key: "backlog", + name: "Backlog", + entry: "manual", + pipelineStepCount: 0, + wipLimit: 1, + admittedTicketIds: ["ticket-1"], + queuedTicketIds: ["ticket-3"], + }, + { + key: "done", + name: "Done", + entry: "manual", + pipelineStepCount: 0, + terminal: true, + admittedTicketIds: ["ticket-2"], + queuedTicketIds: [], + }, + ], + ticketIds: ["ticket-1", "ticket-2", "ticket-3"], + ticketById: { + "ticket-1": { + ticketId: "ticket-1", + title: "Add board lanes", + currentLaneKey: "backlog", + status: "waiting_on_user", + }, + "ticket-2": { + ticketId: "ticket-2", + title: "Ship milestone", + currentLaneKey: "done", + status: "done", + }, + "ticket-3": { + ticketId: "ticket-3", + title: "Wait for review capacity", + currentLaneKey: "backlog", + queuedAt: "2026-06-07T00:00:00.000Z", + status: "queued", + }, + }, +} satisfies BoardViewState; + +describe("BoardView", () => { + it("renders lanes, ticket cards, and status badges", () => { + const markup = renderToStaticMarkup( + <BoardView state={boardState} onMove={() => {}} onOpen={() => {}} />, + ); + + expect(markup).toContain("Backlog"); + expect(markup).toContain("Done"); + expect(markup).toContain("Add board lanes"); + expect(markup).toContain("Ship milestone"); + expect(markup).toContain("waiting on you"); + expect(markup).toContain("done"); + expect(markup).toContain("1/1"); + expect(markup).toContain("Queued"); + expect(markup).toContain("Wait for review capacity"); + expect(markup).toContain("queued"); + }); + + it("resolves a card drop target back to the destination lane", () => { + expect(resolveBoardDropLaneKey(boardState, "ticket-1", "lane:done")).toBe("done"); + expect(resolveBoardDropLaneKey(boardState, "ticket-1", "ticket-2")).toBe("done"); + expect(resolveBoardDropLaneKey(boardState, "ticket-1", "ticket-1")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/board/BoardView.tsx b/apps/web/src/components/board/BoardView.tsx new file mode 100644 index 00000000000..3851968a3bf --- /dev/null +++ b/apps/web/src/components/board/BoardView.tsx @@ -0,0 +1,105 @@ +import { + closestCorners, + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; + +import { LaneColumn, type LaneColumnView } from "./LaneColumn"; + +export interface BoardViewTicket { + readonly ticketId: string; + readonly title: string; + readonly description?: string | undefined; + readonly currentLaneKey: string; + readonly status: string; + readonly queuedAt?: string | undefined; + readonly totalTokens?: number | undefined; + readonly totalDurationMs?: number | undefined; + readonly pr?: + | { + readonly number: number; + readonly url: string; + readonly state: "open" | "merged" | "closed"; + readonly ciState?: "pending" | "success" | "failure" | undefined; + } + | undefined; +} + +export interface BoardViewState { + readonly lanes: ReadonlyArray<LaneColumnView>; + readonly ticketIds: ReadonlyArray<string>; + readonly ticketById: Record<string, BoardViewTicket>; +} + +export function resolveBoardDropLaneKey( + state: BoardViewState, + ticketId: string, + overId: string | null, +): string | null { + if (!overId) { + return null; + } + + const targetLaneKey = overId.startsWith("lane:") + ? overId.slice("lane:".length) + : state.ticketById[overId]?.currentLaneKey; + const currentLaneKey = state.ticketById[ticketId]?.currentLaneKey; + + if (!targetLaneKey || targetLaneKey === currentLaneKey) { + return null; + } + + return targetLaneKey; +} + +const ticketsForIds = ( + state: BoardViewState, + ticketIds: ReadonlyArray<string>, +): ReadonlyArray<BoardViewTicket> => + ticketIds + .map((ticketId) => state.ticketById[ticketId]) + .filter((ticket): ticket is BoardViewTicket => ticket !== undefined); + +export function BoardView({ + state, + onMove, + onOpen, +}: { + readonly state: BoardViewState; + readonly onMove: (ticketId: string, toLane: string) => void; + readonly onOpen: (id: string) => void; +}) { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + ); + const handleDragEnd = (event: DragEndEvent) => { + const ticketId = String(event.active.id); + const overId = event.over ? String(event.over.id) : null; + const toLane = resolveBoardDropLaneKey(state, ticketId, overId); + + if (toLane) { + onMove(ticketId, toLane); + } + }; + + return ( + <DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleDragEnd}> + <div className="flex h-full min-h-0 gap-4 overflow-x-auto overflow-y-hidden px-4 py-3"> + {state.lanes.map((lane) => ( + <LaneColumn + key={lane.key} + lane={lane} + onOpen={onOpen} + admittedTickets={ticketsForIds(state, lane.admittedTicketIds)} + queuedTickets={ticketsForIds(state, lane.queuedTicketIds)} + /> + ))} + </div> + </DndContext> + ); +} diff --git a/apps/web/src/components/board/CreateWorkflowDialog.tsx b/apps/web/src/components/board/CreateWorkflowDialog.tsx new file mode 100644 index 00000000000..736da1631b6 --- /dev/null +++ b/apps/web/src/components/board/CreateWorkflowDialog.tsx @@ -0,0 +1,1248 @@ +import type { + AgentSelection, + BoardId, + BoardTemplateSummary, + EnvironmentApi, + ProjectId, + ProviderInstanceId, + ProviderOptionSelection, + WorkflowDefinitionEncoded, + WorkflowLintError, +} from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; + +import { ProviderModelPicker } from "~/components/chat/ProviderModelPicker"; +import { TraitsPicker } from "~/components/chat/TraitsPicker"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { useSettings } from "~/hooks/useSettings"; +import { getAppModelOptionsForInstance, type AppModelOption } from "~/modelSelection"; +import { deriveProviderInstanceEntries, sortProviderInstanceEntries } from "~/providerInstances"; +import { useServerProviders } from "~/rpc/serverState"; +import { nextDefaultBoardName } from "~/components/Sidebar.logic"; +import { + decodeAutoPullRule, + effectiveAutoPullRule, + summarizeAutoPull, +} from "@t3tools/contracts/workSource"; +import { + createWorkflowBoard, + generateWorkflowDraft, + listBoardTemplates, +} from "~/workflow/boardRpc"; +import { lintErrorKey } from "~/workflow/editorModel"; +import { resolveRecentAgent } from "~/workflow/resolveRecentAgent"; + +import { ImportBoardDialog } from "./ImportBoardDialog"; +import { SourceWizard } from "./editor/SourceWizard"; +import type { WorkflowLaneEncoded } from "./editor/WorkflowEditor"; + +export interface CreateWorkflowDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + /** The target project member's project id (passed to BOTH generate + create). */ + readonly projectId: ProjectId; + /** The target project member's environment id (used for the import dialog). */ + readonly environmentId: string; + /** Display name of the target project member, for context in the header. */ + readonly projectName: string; + readonly api: EnvironmentApi; + /** Board names that already exist for this project — seeds the default name. */ + readonly existingBoardNames: ReadonlyArray<string>; + /** Called with the new boardId once a board is created (caller navigates). */ + readonly onCreated: (boardId: string) => void; +} + +type WizardStep = "name" | "choose" | "agent" | "source"; + +/** Mirrors the server contract `description: isMaxLength(4000)` so the textarea + * cannot hold a value the server will reject. */ +const DESCRIPTION_MAX_LENGTH = 4000; + +// Encoded element types derived from the wire-shape `WorkflowDefinitionEncoded` +// (`generateWorkflowDraft` returns the encoded definition). We render these, so +// we type against the encoded variants — branded keys are plain strings here. +type EncodedLane = NonNullable<WorkflowDefinitionEncoded["lanes"]>[number]; +type EncodedStep = NonNullable<EncodedLane["pipeline"]>[number]; +type EncodedStepRouting = NonNullable<EncodedStep["on"]>; +type EncodedAgentStep = Extract<EncodedStep, { type: "agent" }>; +type EncodedOnEvent = NonNullable<EncodedLane["onEvent"]>[number]; + +/** Lint errors shown inline, mirroring ImportBoardDialog's styling. */ +function LintErrorList({ errors }: { readonly errors: ReadonlyArray<WorkflowLintError> }) { + if (errors.length === 0) { + return null; + } + return ( + <div className="space-y-1"> + <p className="text-xs font-medium text-destructive-foreground"> + The board definition has errors: + </p> + <ul className="rounded-md border border-warning/45 bg-warning/8 p-2 text-sm text-warning-foreground"> + {errors.map((err) => ( + <li key={lintErrorKey(err)}> + <span className="font-mono text-xs opacity-70">{err.code}</span> + {err.laneKey !== undefined ? ( + <span className="opacity-70"> · lane {String(err.laneKey)}</span> + ) : null} + {err.stepKey !== undefined ? ( + <span className="opacity-70"> / step {String(err.stepKey)}</span> + ) : null} + {" — "} + {err.message} + </li> + ))} + </ul> + </div> + ); +} + +export function CreateWorkflowDialog({ + open, + onOpenChange, + projectId, + environmentId, + projectName, + api, + existingBoardNames, + onCreated, +}: CreateWorkflowDialogProps) { + const [step, setStep] = useState<WizardStep>("name"); + const [name, setName] = useState(""); + const [agent, setAgent] = useState<AgentSelection | null>(null); + const [creating, setCreating] = useState(false); + const [error, setError] = useState<string | null>(null); + const [lintErrors, setLintErrors] = useState<ReadonlyArray<WorkflowLintError>>([]); + + // Template step + const [templates, setTemplates] = useState<ReadonlyArray<BoardTemplateSummary> | null>(null); + const [templatesLoading, setTemplatesLoading] = useState(false); + const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null); + const [importOpen, setImportOpen] = useState(false); + + // Agent-assisted step + const [description, setDescription] = useState(""); + const [generating, setGenerating] = useState(false); + const [draft, setDraft] = useState<{ + readonly definition: WorkflowDefinitionEncoded; + readonly rationale: string; + } | null>(null); + + // Source step — populated after successful board creation. + const [createdBoardId, setCreatedBoardId] = useState<BoardId | null>(null); + const [sourceStepDefinition, setSourceStepDefinition] = + useState<WorkflowDefinitionEncoded | null>(null); + const [sourceStepVersionHash, setSourceStepVersionHash] = useState<string | null>(null); + const [sourceWizardOpen, setSourceWizardOpen] = useState(false); + const [sourceSaving, setSourceSaving] = useState(false); + const [sourceError, setSourceError] = useState<string | null>(null); + const [sourceLintErrors, setSourceLintErrors] = useState<ReadonlyArray<WorkflowLintError>>([]); + + const providers = useServerProviders(); + const settings = useSettings(); + + // Whether any agent provider is available. When null, agent-driven paths are + // disabled (Empty + Import stay enabled). + const hasAgent = useMemo(() => resolveRecentAgent(providers) !== null, [providers]); + + const instanceEntries = useMemo( + () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)), + [providers], + ); + const modelOptionsByInstance = useMemo(() => { + const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>(); + for (const entry of instanceEntries) { + out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); + } + return out; + }, [instanceEntries, settings]); + const activeInstanceId = (agent?.instance ?? "") as ProviderInstanceId; + const selectedEntry = instanceEntries.find((entry) => entry.instanceId === activeInstanceId); + + const defaultName = useMemo(() => nextDefaultBoardName(existingBoardNames), [existingBoardNames]); + + // Seed the name + recent agent each time the dialog opens; reset on close. + useEffect(() => { + if (open) { + setName((current) => (current.trim().length === 0 ? defaultName : current)); + setAgent((current) => current ?? resolveRecentAgent()); + } else { + setStep("name"); + setName(""); + setAgent(null); + setCreating(false); + setError(null); + setLintErrors([]); + setTemplates(null); + setTemplatesLoading(false); + setPendingTemplateId(null); + setImportOpen(false); + setDescription(""); + setGenerating(false); + setDraft(null); + setCreatedBoardId(null); + setSourceStepDefinition(null); + setSourceStepVersionHash(null); + setSourceWizardOpen(false); + setSourceSaving(false); + setSourceError(null); + setSourceLintErrors([]); + } + }, [open, defaultName]); + + const clearFeedback = () => { + setError(null); + setLintErrors([]); + }; + + const trimmedName = name.trim(); + + // Apply a create result: on success, transition to the source step; on failure, + // surface message/lint errors. + const applyCreateResult = (result: Awaited<ReturnType<typeof createWorkflowBoard>>) => { + if (result.ok) { + const boardId = result.boardId; + setCreatedBoardId(boardId); + // Fetch the board definition so the SourceWizard can build a well-typed lanes list. + void api.workflow + .getBoardDefinition({ boardId }) + .then(({ definition, versionHash }) => { + setSourceStepDefinition(definition); + setSourceStepVersionHash(versionHash); + }) + .catch((cause: unknown) => { + // If the fetch fails we still advance to the source step; the user can skip. + setSourceError( + cause instanceof Error ? cause.message : "Could not load board definition.", + ); + }); + setStep("source"); + return; + } + setLintErrors(result.lintErrors); + if (result.message !== undefined) { + setError(result.message); + } + }; + + /** Finish board creation — navigate to the board and close the dialog. */ + const finishCreation = (boardId: BoardId) => { + onCreated(boardId); + onOpenChange(false); + }; + + /** Save the new source onto the already-created board, then navigate. */ + const handleSourceSave = async ( + source: NonNullable<WorkflowDefinitionEncoded["sources"]>[number], + ) => { + if (!createdBoardId) return; + setSourceSaving(true); + setSourceError(null); + setSourceLintErrors([]); + + // Snapshot current definition + version (may be retried on conflict). + let definition = sourceStepDefinition; + let versionHash = sourceStepVersionHash; + + // If the definition hasn't loaded yet, try a fresh fetch. + if (definition === null || versionHash === null) { + try { + const fetched = await api.workflow.getBoardDefinition({ boardId: createdBoardId }); + definition = fetched.definition; + versionHash = fetched.versionHash; + setSourceStepDefinition(definition); + setSourceStepVersionHash(versionHash); + } catch (cause) { + setSourceError(cause instanceof Error ? cause.message : "Could not load board definition."); + setSourceSaving(false); + return; + } + } + + const updatedDefinition: WorkflowDefinitionEncoded = { + ...definition, + sources: [...(definition.sources ?? []), source], + }; + + try { + const result = await api.workflow.saveBoardDefinition({ + boardId: createdBoardId, + definition: updatedDefinition, + expectedVersionHash: versionHash, + }); + if (result.ok) { + finishCreation(createdBoardId); + return; + } + if ("conflict" in result && result.conflict) { + // Optimistic-concurrency conflict: re-fetch and retry once. + try { + const fetched = await api.workflow.getBoardDefinition({ boardId: createdBoardId }); + setSourceStepDefinition(fetched.definition); + setSourceStepVersionHash(fetched.versionHash); + setSourceError( + "The board was updated concurrently. The source wizard has been reset with the latest definition — please click 'Set up a source' again.", + ); + } catch { + setSourceError("Version conflict and could not re-fetch the board definition."); + } + return; + } + // Lint errors. + if ("lintErrors" in result) { + setSourceLintErrors(result.lintErrors); + setSourceError("The source configuration has validation errors (see below)."); + return; + } + setSourceError("Saving the source failed for an unknown reason."); + } catch (cause) { + setSourceError(cause instanceof Error ? cause.message : "Saving the source failed."); + } finally { + setSourceSaving(false); + } + }; + + const createEmpty = async () => { + if (creating) { + return; + } + setCreating(true); + clearFeedback(); + try { + const result = await createWorkflowBoard(api, { + projectId, + name: trimmedName, + choice: { kind: "empty" }, + }); + applyCreateResult(result); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Creating the board failed."); + } finally { + setCreating(false); + } + }; + + const createFromTemplate = async (template: BoardTemplateSummary) => { + if (creating) { + return; + } + setCreating(true); + clearFeedback(); + try { + const result = await createWorkflowBoard(api, { + projectId, + name: trimmedName, + choice: { + kind: "template", + templateId: template.id, + ...(template.requiresAgent && agent !== null ? { agent } : {}), + }, + }); + applyCreateResult(result); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Creating the board failed."); + } finally { + setCreating(false); + setPendingTemplateId(null); + } + }; + + const loadTemplates = async () => { + setTemplatesLoading(true); + clearFeedback(); + try { + const result = await listBoardTemplates(api); + setTemplates(result.templates); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Loading templates failed."); + } finally { + setTemplatesLoading(false); + } + }; + + const generate = async () => { + const trimmedDescription = description.trim(); + if (generating || agent === null || trimmedDescription.length === 0) { + return; + } + setGenerating(true); + clearFeedback(); + setDraft(null); + try { + const result = await generateWorkflowDraft(api, { + projectId, + name: trimmedName, + description: trimmedDescription, + agent, + }); + if (result.ok) { + setDraft({ definition: result.definition, rationale: result.rationale }); + } else { + setError(result.message); + if (result.lintErrors !== undefined) { + setLintErrors(result.lintErrors); + } + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Generating the workflow failed."); + } finally { + setGenerating(false); + } + }; + + const createFromDraft = async () => { + if (creating || draft === null) { + return; + } + setCreating(true); + clearFeedback(); + try { + const result = await createWorkflowBoard(api, { + projectId, + name: trimmedName, + choice: { kind: "definition", definition: draft.definition }, + }); + applyCreateResult(result); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Creating the board failed."); + } finally { + setCreating(false); + } + }; + + const agentPicker = (disabled: boolean) => ( + <div className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Agent</span> + <div className="flex flex-wrap items-center gap-2" data-testid="create-workflow-agent"> + <ProviderModelPicker + activeInstanceId={activeInstanceId} + model={agent?.model ?? ""} + lockedProvider={null} + instanceEntries={instanceEntries} + modelOptionsByInstance={modelOptionsByInstance} + triggerVariant="outline" + disabled={disabled} + onInstanceModelChange={(instanceId, model) => { + setAgent((current) => ({ + ...(current?.options === undefined ? {} : { options: current.options }), + instance: instanceId, + model, + })); + }} + /> + {selectedEntry && agent ? ( + <TraitsPicker + provider={selectedEntry.driverKind} + instanceId={selectedEntry.instanceId} + models={selectedEntry.models} + model={agent.model} + modelOptions={agent.options as ReadonlyArray<ProviderOptionSelection> | undefined} + prompt="" + onPromptChange={() => {}} + allowPromptInjectedEffort={false} + triggerVariant="outline" + disabled={disabled} + onModelOptionsChange={(nextOptions) => { + setAgent((current) => + current === null + ? current + : { + instance: current.instance, + model: current.model, + ...(nextOptions === undefined || nextOptions.length === 0 + ? {} + : { options: nextOptions }), + }, + ); + }} + /> + ) : null} + {agent === null ? ( + <span className="text-xs text-muted-foreground">No agent provider available.</span> + ) : null} + </div> + </div> + ); + + return ( + <> + <Dialog + open={open} + onOpenChange={(nextOpen) => { + // If the dialog is being closed while a board has already been + // created (we're on the source step), route through finishCreation + // so onCreated always fires — even when the user dismisses via + // X / Escape / backdrop instead of Skip / Done. + if (!nextOpen && createdBoardId !== null) { + finishCreation(createdBoardId); + return; + } + onOpenChange(nextOpen); + }} + > + <DialogPopup className="max-h-[calc(100dvh-2rem)] max-w-2xl overflow-hidden"> + <div className="flex min-h-0 flex-col"> + <DialogHeader> + <DialogTitle>Create workflow board</DialogTitle> + <DialogDescription> + {step === "name" + ? `Name the board for ${projectName}.` + : step === "choose" + ? "Start empty, pick a template, or let an agent draft it for you." + : step === "source" + ? "Your board is ready. Optionally connect a work source to start pulling in issues." + : "Describe how you work — an agent drafts a board you can review before creating."} + </DialogDescription> + </DialogHeader> + + <div + className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 pt-1 pb-3" + data-slot="dialog-panel" + > + {step === "name" ? ( + <div className="grid gap-1.5"> + <label className="text-xs font-medium text-foreground" htmlFor="board-name"> + Board name + </label> + <Input + id="board-name" + value={name} + autoFocus + placeholder="Board name" + onChange={(event) => setName(event.currentTarget.value)} + aria-label="Board name" + /> + </div> + ) : null} + + {step === "choose" ? ( + <div className="space-y-2" data-testid="create-workflow-choices"> + <button + type="button" + className="w-full rounded-md border border-border/70 bg-card/35 p-3 text-left hover:bg-accent/40 disabled:cursor-not-allowed disabled:opacity-60" + disabled={creating} + onClick={() => void createEmpty()} + > + <p className="text-sm font-medium text-foreground">Empty board</p> + <p className="text-xs text-muted-foreground"> + Start with a blank board and add lanes yourself. + </p> + </button> + + <button + type="button" + className="w-full rounded-md border border-border/70 bg-card/35 p-3 text-left hover:bg-accent/40 disabled:cursor-not-allowed disabled:opacity-60" + disabled={creating} + onClick={() => { + clearFeedback(); + if (templates === null && !templatesLoading) { + void loadTemplates(); + } + }} + > + <p className="text-sm font-medium text-foreground">From a template</p> + <p className="text-xs text-muted-foreground"> + Pick a starting point or import a workflow file. + </p> + </button> + + {templatesLoading ? ( + <p className="px-1 text-xs text-muted-foreground">Loading templates…</p> + ) : null} + + {templates !== null ? ( + <ul className="space-y-2 pl-3" data-testid="create-workflow-templates"> + {templates.map((template) => { + const templateDisabled = creating || (template.requiresAgent && !hasAgent); + return ( + <li key={template.id}> + <button + type="button" + className="w-full rounded-md border border-border/60 bg-background p-2.5 text-left hover:bg-accent/40 disabled:cursor-not-allowed disabled:opacity-50" + disabled={templateDisabled} + title={ + template.requiresAgent && !hasAgent + ? "Connect an agent to use this" + : undefined + } + onClick={() => { + setPendingTemplateId(template.id); + void createFromTemplate(template); + }} + > + <p className="text-sm font-medium text-foreground"> + {template.name} + {creating && pendingTemplateId === template.id + ? " — creating…" + : ""} + </p> + <p className="text-xs text-muted-foreground"> + {template.description} + </p> + {template.requiresAgent && !hasAgent ? ( + <p className="mt-1 text-[11px] text-muted-foreground/80"> + Connect an agent to use this + </p> + ) : null} + </button> + </li> + ); + })} + <li> + <button + type="button" + className="w-full rounded-md border border-dashed border-border/60 bg-background p-2.5 text-left hover:bg-accent/40 disabled:cursor-not-allowed disabled:opacity-50" + disabled={creating} + onClick={() => setImportOpen(true)} + > + <p className="text-sm font-medium text-foreground">Import from file…</p> + <p className="text-xs text-muted-foreground"> + Load a board definition from JSON. + </p> + </button> + </li> + </ul> + ) : null} + + <button + type="button" + className="w-full rounded-md border border-border/70 bg-card/35 p-3 text-left hover:bg-accent/40 disabled:cursor-not-allowed disabled:opacity-60" + disabled={creating || !hasAgent} + title={hasAgent ? undefined : "Connect an agent to use this"} + onClick={() => { + clearFeedback(); + setStep("agent"); + }} + > + <p className="text-sm font-medium text-foreground">Agent-assisted</p> + <p className="text-xs text-muted-foreground"> + Describe your workflow and an agent drafts a board. + </p> + {hasAgent ? null : ( + <p className="mt-1 text-[11px] text-muted-foreground/80"> + Connect an agent to use this + </p> + )} + </button> + </div> + ) : null} + + {step === "agent" ? ( + draft === null ? ( + <> + {agentPicker(generating)} + <div className="grid gap-1.5"> + <label + className="text-xs font-medium text-foreground" + htmlFor="workflow-description" + > + Describe how you work with your agents + </label> + <Textarea + id="workflow-description" + value={description} + rows={8} + maxLength={DESCRIPTION_MAX_LENGTH} + placeholder="e.g. I want plan → implement → review → merge, with an approval gate before merging…" + onChange={(event) => setDescription(event.currentTarget.value)} + aria-label="Workflow description" + disabled={generating} + /> + <p className="text-[11px] text-muted-foreground"> + {DESCRIPTION_MAX_LENGTH - description.length} characters remaining + </p> + </div> + </> + ) : ( + <div className="space-y-3" data-testid="create-workflow-review"> + <h3 className="text-sm font-semibold text-foreground"> + Review: {trimmedName.length > 0 ? trimmedName : name} + </h3> + <div className="space-y-1"> + <p className="text-xs font-medium text-foreground">Rationale</p> + <p className="whitespace-pre-wrap rounded-md border border-border/70 bg-card/35 p-3 text-sm text-foreground"> + {draft.rationale} + </p> + </div> + <div className="space-y-1"> + <p className="text-xs font-medium text-foreground">What this board will do</p> + <p className="text-[11px] text-muted-foreground"> + Review every lane, agent instruction, and route below before creating — this + is exactly what the agents will be told to do. + </p> + <DraftSummary definition={draft.definition} /> + </div> + </div> + ) + ) : null} + + {step === "source" ? ( + <div className="space-y-3" data-testid="create-workflow-source-step"> + <div className="rounded-md border border-border/70 bg-card/35 p-4 space-y-3"> + <div> + <p className="text-sm font-medium text-foreground">Connect a work source</p> + <p className="mt-1 text-xs text-muted-foreground"> + Work sources pull issues from GitHub or Asana into your board automatically. + You can always add one later from the editor. + </p> + </div> + {sourceStepDefinition !== null ? ( + <Button + type="button" + size="sm" + disabled={sourceSaving} + onClick={() => setSourceWizardOpen(true)} + > + Set up a source + </Button> + ) : sourceError !== null ? null : ( + <p className="text-xs text-muted-foreground">Loading board definition…</p> + )} + </div> + {sourceError !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {sourceError} + </p> + ) : null} + <LintErrorList errors={sourceLintErrors} /> + </div> + ) : null} + + {error !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {error} + </p> + ) : null} + <LintErrorList errors={lintErrors} /> + </div> + + <DialogFooter> + {step === "name" ? ( + <> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => onOpenChange(false)} + > + Cancel + </Button> + <Button + type="button" + size="sm" + disabled={trimmedName.length === 0} + onClick={() => { + clearFeedback(); + setStep("choose"); + }} + > + Next + </Button> + </> + ) : null} + + {step === "choose" ? ( + <Button + type="button" + variant="outline" + size="sm" + disabled={creating} + onClick={() => { + clearFeedback(); + setStep("name"); + }} + > + Back + </Button> + ) : null} + + {step === "agent" ? ( + draft === null ? ( + <> + <Button + type="button" + variant="outline" + size="sm" + disabled={generating} + onClick={() => { + clearFeedback(); + setStep("choose"); + }} + > + Back + </Button> + <Button + type="button" + size="sm" + disabled={generating || agent === null || description.trim().length === 0} + onClick={() => void generate()} + > + {generating ? "Generating…" : "Generate"} + </Button> + </> + ) : ( + <> + <Button + type="button" + variant="outline" + size="sm" + disabled={creating} + onClick={() => { + clearFeedback(); + setDraft(null); + }} + > + Discard + </Button> + <Button + type="button" + variant="outline" + size="sm" + disabled={creating || generating} + onClick={() => void generate()} + > + {generating ? "Generating…" : "Regenerate"} + </Button> + <Button + type="button" + size="sm" + disabled={creating} + onClick={() => void createFromDraft()} + > + {creating ? "Creating…" : "Create"} + </Button> + </> + ) + ) : null} + + {step === "source" ? ( + <> + <Button + type="button" + variant="outline" + size="sm" + disabled={sourceSaving} + onClick={() => { + if (createdBoardId !== null) { + finishCreation(createdBoardId); + } + }} + > + Skip + </Button> + <Button + type="button" + size="sm" + disabled={sourceSaving} + onClick={() => { + if (createdBoardId !== null) { + finishCreation(createdBoardId); + } + }} + > + Done + </Button> + </> + ) : null} + </DialogFooter> + </div> + </DialogPopup> + </Dialog> + + {step === "source" && sourceStepDefinition !== null && createdBoardId !== null ? ( + <SourceWizard + open={sourceWizardOpen} + onOpenChange={setSourceWizardOpen} + mode="create" + lanes={sourceStepDefinition.lanes as ReadonlyArray<WorkflowLaneEncoded>} + listWorkSourceConnections={api.workflow.listWorkSourceConnections} + createWorkSourceConnection={api.workflow.createWorkSourceConnection} + disabled={sourceSaving} + onSave={(source) => { + void handleSourceSave(source); + }} + /> + ) : null} + + <ImportBoardDialog + open={importOpen} + onOpenChange={setImportOpen} + api={api} + projectId={projectId} + onSuccess={(boardId) => { + setImportOpen(false); + onCreated(boardId); + onOpenChange(false); + }} + /> + </> + ); +} + +/** A small key/value row — label muted, value as escaped text. */ +function MetaRow({ label, children }: { readonly label: string; readonly children: ReactNode }) { + return ( + <p className="text-xs text-foreground"> + <span className="font-mono opacity-70">{label}</span> + {" · "} + {children} + </p> + ); +} + +/** Render a model-authored instruction. May be inline text or a file reference. + * Long instructions are shown in full inside a scrollable <pre> block. All + * content is escaped JSX text — never HTML. */ +function StepInstructionView({ + instruction, +}: { + readonly instruction: EncodedAgentStep["instruction"]; +}) { + if (typeof instruction === "object" && instruction !== null && "file" in instruction) { + return ( + <MetaRow label="instruction (file)"> + <span className="font-mono">{String(instruction.file)}</span> + </MetaRow> + ); + } + const text = typeof instruction === "string" ? instruction : String(instruction); + return ( + <div className="space-y-0.5"> + <p className="font-mono text-[11px] text-muted-foreground">instruction</p> + <pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded border border-border/60 bg-background p-2 text-xs text-foreground"> + {text} + </pre> + </div> + ); +} + +/** Render a single pipeline step's full executable semantics, by type. */ +function StepView({ step }: { readonly step: EncodedStep }) { + return ( + <li className="rounded border border-border/50 bg-background/60 p-2"> + <p className="text-xs font-medium text-foreground"> + <span className="font-mono opacity-70">{step.type}</span> + {" · "} + {String(step.key)} + </p> + {step.type === "agent" ? ( + <div className="mt-1 space-y-1"> + <StepInstructionView instruction={step.instruction} /> + {step.captureOutput !== undefined ? ( + <MetaRow label="captureOutput">{String(step.captureOutput)}</MetaRow> + ) : null} + {step.panel !== undefined ? <MetaRow label="panel">{String(step.panel)}</MetaRow> : null} + {step.retry !== undefined ? ( + <MetaRow label="retry"> + <span className="font-mono">{JSON.stringify(step.retry)}</span> + </MetaRow> + ) : null} + <StepRoutingView routing={step.on} /> + </div> + ) : step.type === "approval" ? ( + <div className="mt-1 space-y-1"> + {step.prompt !== undefined && step.prompt.length > 0 ? ( + <div className="space-y-0.5"> + <p className="font-mono text-[11px] text-muted-foreground">prompt</p> + <pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded border border-border/60 bg-background p-2 text-xs text-foreground"> + {step.prompt} + </pre> + </div> + ) : ( + <p className="text-[11px] text-muted-foreground">No prompt.</p> + )} + <StepRoutingView routing={step.on} /> + </div> + ) : ( + // script / merge / pullRequest — show the remaining declarative fields + // as escaped JSON so nothing is hidden. + <div className="mt-1 space-y-1"> + <MetaRow label="config"> + <span className="font-mono break-words">{JSON.stringify(stepRest(step))}</span> + </MetaRow> + <StepRoutingView routing={step.on} /> + </div> + )} + </li> + ); +} + +/** Strip the common key/type/on fields so the JSON config view shows only the + * type-specific declarative fields. */ +function stepRest(step: EncodedStep): Record<string, unknown> { + const { + key: _key, + type: _type, + on: _on, + ...rest + } = step as Record<string, unknown> & { + key: unknown; + type: unknown; + on?: unknown; + }; + return rest; +} + +/** Render success/failure/blocked step routing targets, if any. */ +function StepRoutingView({ routing }: { readonly routing: EncodedStepRouting | undefined }) { + if (routing === undefined) { + return null; + } + const entries = (["success", "failure", "blocked"] as const).filter( + (k) => routing[k] !== undefined, + ); + if (entries.length === 0) { + return null; + } + return ( + <p className="text-xs text-foreground"> + <span className="font-mono opacity-70">on</span> + {" · "} + {entries.map((k, i) => ( + <span key={k}> + {i > 0 ? ", " : ""} + {k} → {String(routing[k])} + </span> + ))} + </p> + ); +} + +/** + * Render the FULL executable semantics of a generated board so the human-review + * gate is meaningful: for every lane we show its entry mode, terminal flag, + * every pipeline step (including the complete agent instruction / approval + * prompt), its transitions, human actions, and success/failure/blocked routing. + * + * Every model-authored string (instructions, prompts, names, transition JSON) + * is rendered as escaped React text children — NEVER dangerouslySetInnerHTML. + */ +function DraftSummary({ definition }: { readonly definition: WorkflowDefinitionEncoded }) { + const lanes = definition.lanes ?? []; + const settings = definition.settings; + const sources = definition.sources ?? []; + const outbound = definition.outbound ?? []; + if (lanes.length === 0) { + return <p className="text-xs text-muted-foreground">No lanes in the generated board.</p>; + } + return ( + <> + {/* ── Board-level settings ─────────────────────────────────────── */} + {settings !== undefined ? ( + <div className="mb-2 rounded-md border border-border/70 bg-card/35 p-3 space-y-1"> + <p className="text-[11px] font-semibold text-foreground">Board settings</p> + {(Object.keys(settings) as Array<keyof typeof settings>).map((k) => ( + <MetaRow key={String(k)} label={String(k)}> + {String(settings[k])} + </MetaRow> + ))} + </div> + ) : null} + + {/* ── External sources ─────────────────────────────────────────── */} + {sources.length > 0 ? ( + <div className="mb-2 rounded-md border border-warning/45 bg-warning/8 p-3 space-y-1"> + <p className="text-[11px] font-semibold text-foreground"> + External sources ({sources.length}) + </p> + <p className="text-[11px] text-muted-foreground"> + These external systems will push tickets into this board. + </p> + <ul className="space-y-1"> + {sources.map((src, srcIndex) => ( + <li + key={`${String(src.id)}-${srcIndex}`} + className="rounded border border-border/50 bg-background/60 p-2 space-y-0.5" + > + <MetaRow label="id">{String(src.id)}</MetaRow> + <MetaRow label="provider">{String(src.provider)}</MetaRow> + <MetaRow label="connection">{String(src.connectionRef)}</MetaRow> + <MetaRow label="destinationLane">{String(src.destinationLane)}</MetaRow> + <MetaRow label="closedLane">{String(src.closedLane)}</MetaRow> + {(() => { + const rule = effectiveAutoPullRule(src); + if (rule === null) { + return <MetaRow label="auto-pull">Manual only</MetaRow>; + } + const criteria = decodeAutoPullRule(rule); + const summary = criteria === null ? "advanced rule" : summarizeAutoPull(criteria); + return <MetaRow label="auto-pull">{summary}</MetaRow>; + })()} + {src.syncIntervalSec !== undefined ? ( + <MetaRow label="syncIntervalSec">{String(src.syncIntervalSec)}</MetaRow> + ) : null} + <MetaRow label="selector"> + <span className="font-mono break-words">{JSON.stringify(src.selector)}</span> + </MetaRow> + </li> + ))} + </ul> + </div> + ) : null} + + {/* ── Outbound webhooks (security-relevant) ────────────────────── */} + {outbound.length > 0 ? ( + <div className="mb-2 rounded-md border border-destructive/40 bg-destructive/8 p-3 space-y-1"> + <p className="text-[11px] font-semibold text-foreground"> + Outbound webhooks ({outbound.length}) + </p> + <p className="text-[11px] text-muted-foreground"> + These rules send data to external URLs when board events occur — verify destinations + before creating. + </p> + <ul className="space-y-1"> + {outbound.map((rule, ruleIndex) => ( + <li + key={`${String(rule.id)}-${ruleIndex}`} + className="rounded border border-border/50 bg-background/60 p-2 space-y-0.5" + > + <MetaRow label="id">{String(rule.id)}</MetaRow> + <MetaRow label="trigger">{String(rule.on)}</MetaRow> + <MetaRow label="destination"> + <span className="font-mono break-words">{String(rule.to)}</span> + </MetaRow> + <MetaRow label="format">{String(rule.as)}</MetaRow> + <MetaRow label="enabled">{String(rule.enabled)}</MetaRow> + {rule.when !== undefined ? ( + <MetaRow label="when"> + <span className="font-mono break-words">{JSON.stringify(rule.when)}</span> + </MetaRow> + ) : null} + </li> + ))} + </ul> + </div> + ) : null} + + <ol className="space-y-2"> + {lanes.map((lane, laneIndex) => { + const pipeline = lane.pipeline ?? []; + const transitions = lane.transitions ?? []; + const actions = lane.actions ?? []; + const laneOnEvent = lane.onEvent ?? []; + const laneOn = lane.on; + const laneOnEntries = + laneOn === undefined + ? [] + : (["success", "failure", "blocked"] as const).filter((k) => laneOn[k] !== undefined); + return ( + <li + key={`${String(lane.key)}-${laneIndex}`} + className="rounded-md border border-border/70 bg-card/35 p-3" + > + <p className="text-sm font-medium text-foreground">{lane.name}</p> + <p className="text-[11px] text-muted-foreground"> + {String(lane.key)} · entry: {String(lane.entry)} + {lane.terminal === true ? " · terminal" : ""} + </p> + + {pipeline.length > 0 ? ( + <ul className="mt-1.5 space-y-1.5"> + {pipeline.map((stepEntry, stepIndex) => ( + <StepView key={`${String(stepEntry.key)}-${stepIndex}`} step={stepEntry} /> + ))} + </ul> + ) : ( + <p className="mt-1 text-xs text-muted-foreground">No pipeline steps.</p> + )} + + {transitions.length > 0 ? ( + <div className="mt-2 space-y-0.5"> + <p className="text-[11px] font-medium text-foreground">Transitions</p> + <ul className="space-y-0.5"> + {transitions.map((transition, transitionIndex) => ( + <li + key={`${String(transition.to)}-${transitionIndex}`} + className="text-xs text-foreground" + > + → {String(transition.to)} + {transition.when !== undefined ? ( + <span className="font-mono opacity-70"> + {" when "} + {JSON.stringify(transition.when)} + </span> + ) : null} + </li> + ))} + </ul> + </div> + ) : null} + + {actions.length > 0 ? ( + <div className="mt-2 space-y-0.5"> + <p className="text-[11px] font-medium text-foreground">Actions</p> + <ul className="space-y-0.5"> + {actions.map((action, actionIndex) => ( + <li + key={`${String(action.label)}-${actionIndex}`} + className="text-xs text-foreground" + > + {action.label} → {String(action.to)} + {action.hint !== undefined && action.hint.length > 0 + ? ` (${action.hint})` + : ""} + </li> + ))} + </ul> + </div> + ) : null} + + {laneOnEntries.length > 0 ? ( + <p className="mt-2 text-xs text-foreground"> + <span className="font-mono opacity-70">routing</span> + {" · "} + {laneOnEntries.map((k, i) => ( + <span key={k}> + {i > 0 ? ", " : ""} + {k} → {String(laneOn?.[k])} + </span> + ))} + </p> + ) : null} + + {laneOnEvent.length > 0 ? ( + <div className="mt-2 space-y-0.5"> + <p className="text-[11px] font-medium text-foreground">On event</p> + <ul className="space-y-0.5"> + {laneOnEvent.map((ev: EncodedOnEvent, evIndex: number) => ( + <li key={`${String(ev.name)}-${evIndex}`} className="text-xs text-foreground"> + <span className="font-mono">{String(ev.name)}</span> + {" → "} + {String(ev.to)} + {ev.when !== undefined ? ( + <span className="font-mono opacity-70"> + {" when "} + {JSON.stringify(ev.when)} + </span> + ) : null} + </li> + ))} + </ul> + </div> + ) : null} + </li> + ); + })} + </ol> + + <details className="mt-2"> + <summary className="cursor-pointer text-[11px] text-muted-foreground hover:text-foreground"> + Raw JSON + </summary> + <pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded border border-border/60 bg-background p-2 text-[11px] text-foreground"> + {JSON.stringify(definition, null, 2)} + </pre> + </details> + </> + ); +} diff --git a/apps/web/src/components/board/ImportBoardDialog.tsx b/apps/web/src/components/board/ImportBoardDialog.tsx new file mode 100644 index 00000000000..90568f5379c --- /dev/null +++ b/apps/web/src/components/board/ImportBoardDialog.tsx @@ -0,0 +1,269 @@ +import type { + EnvironmentApi, + WorkflowDefinitionEncoded, + WorkflowLintError, +} from "@t3tools/contracts"; +import type { ProjectId } from "@t3tools/contracts"; +import { UploadIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { lintErrorKey } from "~/workflow/editorModel"; +import { importBoard } from "~/workflow/boardRpc"; + +export interface ImportBoardDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly api: EnvironmentApi; + readonly projectId: ProjectId; + /** Called with the new boardId once the import succeeds and warnings (if any) are dismissed. */ + readonly onSuccess: (boardId: string) => void; +} + +/** + * A dialog that lets the user paste (or upload) a board definition JSON and + * import it into the given project via boardRpc.importBoard. + * + * - Client-side JSON.parse guard: an invalid JSON string never reaches the RPC. + * - On {ok:false}: lint errors are shown inline (same style as the editor). + * - On {ok:true, warnings}: warnings surface with a "Go to board" button. + * - On {ok:true, no warnings}: closes immediately and calls onSuccess. + */ +export function ImportBoardDialog({ + open, + onOpenChange, + api, + projectId, + onSuccess, +}: ImportBoardDialogProps) { + const [json, setJson] = useState(""); + const [parseError, setParseError] = useState<string | null>(null); + const [lintErrors, setLintErrors] = useState<ReadonlyArray<WorkflowLintError>>([]); + const [rpcError, setRpcError] = useState<string | null>(null); + const [warnings, setWarnings] = useState<ReadonlyArray<string>>([]); + const [successBoardId, setSuccessBoardId] = useState<string | null>(null); + const [loading, setLoading] = useState(false); + const fileInputRef = useRef<HTMLInputElement>(null); + + const clearFeedback = () => { + setParseError(null); + setLintErrors([]); + setRpcError(null); + setWarnings([]); + setSuccessBoardId(null); + }; + + const handleOpenChange = (next: boolean) => { + if (!next) { + // Reset all state when closing + setJson(""); + clearFeedback(); + setLoading(false); + } + onOpenChange(next); + }; + + const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result; + if (typeof text === "string") { + setJson(text); + clearFeedback(); + } + }; + reader.readAsText(file); + // Reset so the same file can be re-selected after a correction + event.target.value = ""; + }; + + const handleImport = async () => { + clearFeedback(); + + // Client-side guard: parse first — no RPC call if invalid JSON. + // The server performs the real schema validation; the cast here lets the + // call compile while keeping the client-side guard clean. + let definition: WorkflowDefinitionEncoded; + try { + definition = JSON.parse(json) as WorkflowDefinitionEncoded; + } catch { + setParseError("Invalid JSON — fix the syntax and try again."); + return; + } + + setLoading(true); + try { + const result = await importBoard(api, { projectId, definition }); + + if (!result.ok) { + setLintErrors(result.lintErrors); + return; + } + + if (result.warnings.length > 0) { + // Stay open: show warnings + "Go to board" button + setSuccessBoardId(result.boardId); + setWarnings(result.warnings); + return; + } + + // Success, no warnings — close immediately. + handleOpenChange(false); + onSuccess(result.boardId); + } catch (cause) { + setRpcError(cause instanceof Error ? cause.message : "An unexpected error occurred."); + } finally { + setLoading(false); + } + }; + + const handleGoToBoard = () => { + if (!successBoardId) { + return; + } + const boardId = successBoardId; + handleOpenChange(false); + onSuccess(boardId); + }; + + const showWarningState = warnings.length > 0 && successBoardId !== null; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogPopup className="max-w-lg"> + <DialogHeader> + <DialogTitle>Import board from JSON</DialogTitle> + <DialogDescription> + Paste a board definition below or upload a <code>.json</code> file. + </DialogDescription> + </DialogHeader> + + <DialogPanel className="space-y-4"> + {/* File upload affordance */} + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => fileInputRef.current?.click()} + disabled={loading} + > + <UploadIcon className="size-3.5" /> + Upload file + </Button> + <input + ref={fileInputRef} + type="file" + accept=".json,application/json" + className="hidden" + onChange={handleFileChange} + /> + <span className="text-xs text-muted-foreground">or paste JSON below</span> + </div> + + {/* JSON textarea */} + <textarea + className="h-48 w-full resize-none rounded-md border border-input bg-background px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-64" + placeholder='{ "name": "My board", "lanes": [ … ] }' + value={json} + onChange={(e) => { + setJson(e.target.value); + clearFeedback(); + }} + disabled={loading} + spellCheck={false} + /> + + {/* JSON parse error */} + {parseError !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {parseError} + </p> + ) : null} + + {/* Lint errors from the server — dialog stays open for retry */} + {lintErrors.length > 0 ? ( + <div className="space-y-1"> + <p className="text-xs font-medium text-destructive-foreground"> + The board definition has errors: + </p> + <ul className="rounded-md border border-warning/45 bg-warning/8 p-2 text-sm text-warning-foreground"> + {lintErrors.map((err) => ( + <li key={lintErrorKey(err)}> + <span className="font-mono text-xs opacity-70">{err.code}</span> + {err.laneKey !== undefined ? ( + <span className="opacity-70"> · lane {String(err.laneKey)}</span> + ) : null} + {err.stepKey !== undefined ? ( + <span className="opacity-70"> / step {String(err.stepKey)}</span> + ) : null} + {" — "} + {err.message} + </li> + ))} + </ul> + </div> + ) : null} + + {/* Unexpected RPC error */} + {rpcError !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {rpcError} + </p> + ) : null} + + {/* Warnings after a successful import — offer navigation */} + {showWarningState ? ( + <div className="rounded-md border border-warning/45 bg-warning/8 p-3 space-y-2"> + <p className="text-sm font-medium text-warning-foreground"> + Board created. These need attention for this environment: + </p> + <ul className="list-disc list-inside space-y-0.5 text-sm text-warning-foreground"> + {warnings.map((w, i) => ( + // eslint-disable-next-line react/no-array-index-key + <li key={i}>{w}</li> + ))} + </ul> + </div> + ) : null} + + {/* Standing disclaimer */} + <p className="text-xs text-muted-foreground"> + Connections (work sources / outbound) and agent instances aren't imported — + reconfigure them after importing. + </p> + </DialogPanel> + + <DialogFooter> + <Button variant="outline" onClick={() => handleOpenChange(false)} disabled={loading}> + Cancel + </Button> + + {showWarningState ? ( + <Button onClick={handleGoToBoard}>Go to board</Button> + ) : ( + <Button + onClick={() => void handleImport()} + disabled={loading || json.trim().length === 0} + > + {loading ? "Importing…" : "Import"} + </Button> + )} + </DialogFooter> + </DialogPopup> + </Dialog> + ); +} diff --git a/apps/web/src/components/board/IntakeDialog.tsx b/apps/web/src/components/board/IntakeDialog.tsx new file mode 100644 index 00000000000..5552473ad0c --- /dev/null +++ b/apps/web/src/components/board/IntakeDialog.tsx @@ -0,0 +1,362 @@ +import type { + AgentSelection, + ProviderInstanceId, + ProviderOptionSelection, +} from "@t3tools/contracts"; +import { SparklesIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { ProviderModelPicker } from "~/components/chat/ProviderModelPicker"; +import { TraitsPicker } from "~/components/chat/TraitsPicker"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { useSettings } from "~/hooks/useSettings"; +import { getAppModelOptionsForInstance, type AppModelOption } from "~/modelSelection"; +import { deriveProviderInstanceEntries, sortProviderInstanceEntries } from "~/providerInstances"; +import { useServerProviders } from "~/rpc/serverState"; +import { + approvedIntakeTickets, + toIntakeDrafts, + updateIntakeDraft, + type ApprovedIntakeTicket, + type IntakeProposalDraft, + type IntakeTicketInput, +} from "~/workflow/intakeState"; +import { resolveRecentAgent } from "~/workflow/resolveRecentAgent"; + +export function IntakeDialog({ + disabled, + disabledReason, + onCreateTickets, + onPropose, + open: controlledOpen, + onOpenChange, +}: { + readonly disabled: boolean; + readonly disabledReason?: string | undefined; + readonly onCreateTickets: (tickets: ReadonlyArray<ApprovedIntakeTicket>) => Promise<void>; + readonly onPropose: ( + braindump: string, + agent: AgentSelection, + ) => Promise<ReadonlyArray<IntakeTicketInput>>; + readonly open?: boolean; + readonly onOpenChange?: (open: boolean) => void; +}) { + const isControlled = onOpenChange !== undefined; + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const open = isControlled ? (controlledOpen ?? false) : uncontrolledOpen; + const setOpen = (next: boolean) => { + if (isControlled) { + onOpenChange(next); + } else { + setUncontrolledOpen(next); + } + }; + const [braindump, setBraindump] = useState(""); + const [proposing, setProposing] = useState(false); + const [creating, setCreating] = useState(false); + const [error, setError] = useState<string | null>(null); + const [drafts, setDrafts] = useState<ReadonlyArray<IntakeProposalDraft> | null>(null); + const [agent, setAgent] = useState<AgentSelection | null>(null); + + const providers = useServerProviders(); + const settings = useSettings(); + const instanceEntries = useMemo( + () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)), + [providers], + ); + const modelOptionsByInstance = useMemo(() => { + const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>(); + for (const entry of instanceEntries) { + out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); + } + return out; + }, [instanceEntries, settings]); + // The stored instance is a routing key; cast (not `.make`) so a non-slug + // instance never throws while rendering. + const activeInstanceId = (agent?.instance ?? "") as ProviderInstanceId; + const selectedEntry = instanceEntries.find((entry) => entry.instanceId === activeInstanceId); + + const reset = () => { + setBraindump(""); + setProposing(false); + setCreating(false); + setError(null); + setDrafts(null); + }; + + const createApproved = async (tickets: ReadonlyArray<ApprovedIntakeTicket>) => { + setCreating(true); + setError(null); + try { + await onCreateTickets(tickets); + reset(); + setOpen(false); + } catch (cause) { + // Keep the edited proposals on screen so nothing is lost on failure. + setError(cause instanceof Error ? cause.message : "Creating tickets failed."); + setCreating(false); + } + }; + + const propose = async () => { + const trimmed = braindump.trim(); + if (!trimmed || proposing || agent === null) { + return; + } + setProposing(true); + setError(null); + try { + const proposals = await onPropose(trimmed, agent); + setDrafts(toIntakeDrafts(proposals)); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Ticket intake failed."); + } finally { + setProposing(false); + } + }; + + const approved = drafts === null ? [] : approvedIntakeTickets(drafts); + + // In controlled mode the parent owns the trigger, so the default-agent + // selection the self-contained trigger's onClick performed must fire when + // the dialog transitions to open. + useEffect(() => { + if (isControlled && open) { + setAgent((current) => current ?? resolveRecentAgent()); + } + }, [isControlled, open]); + + return ( + <Dialog + open={open} + onOpenChange={(nextOpen) => { + setOpen(nextOpen); + if (!nextOpen) { + reset(); + } + }} + > + {isControlled ? null : ( + <Button + type="button" + size="xs" + variant="outline" + disabled={disabled} + title={disabled ? disabledReason : "Turn a braindump into tickets"} + onClick={() => { + setOpen(true); + // Default to the user's most recent agent; they can change it below. + setAgent((current) => current ?? resolveRecentAgent()); + }} + > + <SparklesIcon className="size-3.5" /> + Intake + </Button> + )} + <DialogPopup className="max-h-[calc(100dvh-2rem)] max-w-2xl overflow-hidden"> + <div className="flex min-h-0 flex-col"> + <DialogHeader> + <DialogTitle>Ticket intake</DialogTitle> + <DialogDescription> + Paste everything on your mind. An agent reads the project and proposes tickets — you + review and approve before anything is created. + </DialogDescription> + </DialogHeader> + <div + className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 pt-1 pb-3" + data-slot="dialog-panel" + > + {drafts === null ? ( + <> + <div className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Agent</span> + <div className="flex flex-wrap items-center gap-2" data-testid="intake-agent"> + <ProviderModelPicker + activeInstanceId={activeInstanceId} + model={agent?.model ?? ""} + lockedProvider={null} + instanceEntries={instanceEntries} + modelOptionsByInstance={modelOptionsByInstance} + triggerVariant="outline" + disabled={proposing} + onInstanceModelChange={(instanceId, model) => { + // Options survive a model switch — the effort picker + // only surfaces valid ones and providers ignore + // unknown option ids (same policy as agent steps). + setAgent((current) => ({ + ...(current?.options === undefined ? {} : { options: current.options }), + instance: instanceId, + model, + })); + }} + /> + {selectedEntry && agent ? ( + <TraitsPicker + provider={selectedEntry.driverKind} + instanceId={selectedEntry.instanceId} + models={selectedEntry.models} + model={agent.model} + modelOptions={ + agent.options as ReadonlyArray<ProviderOptionSelection> | undefined + } + prompt="" + onPromptChange={() => {}} + allowPromptInjectedEffort={false} + triggerVariant="outline" + disabled={proposing} + onModelOptionsChange={(nextOptions) => { + setAgent((current) => + current === null + ? current + : { + instance: current.instance, + model: current.model, + ...(nextOptions === undefined || nextOptions.length === 0 + ? {} + : { options: nextOptions }), + }, + ); + }} + /> + ) : null} + {agent === null ? ( + <span className="text-xs text-muted-foreground"> + No agent provider available. + </span> + ) : null} + </div> + </div> + <Textarea + value={braindump} + placeholder="Braindump: bugs you noticed, features you want, cleanups, anything…" + onChange={(event) => setBraindump(event.currentTarget.value)} + aria-label="Braindump" + rows={12} + autoFocus + disabled={proposing} + /> + </> + ) : ( + <ol className="space-y-3" data-testid="intake-proposals"> + {drafts.map((draft, index) => ( + <li + key={index} + className="space-y-2 rounded-md border border-border/70 bg-card/35 p-3" + > + {draft.dependsOn.length > 0 ? ( + <p className="text-[11px] text-muted-foreground"> + After {draft.dependsOn.map((dependency) => `#${dependency + 1}`).join(", ")} + </p> + ) : null} + <div className="flex items-center gap-2"> + <input + type="checkbox" + checked={draft.include} + onChange={(event) => { + const include = event.currentTarget.checked; + setDrafts((current) => + current === null + ? current + : updateIntakeDraft(current, index, { include }), + ); + }} + aria-label={`Include proposal ${index + 1}`} + /> + <Input + value={draft.title} + disabled={!draft.include} + onChange={(event) => { + const title = event.currentTarget.value; + setDrafts((current) => + current === null + ? current + : updateIntakeDraft(current, index, { title }), + ); + }} + aria-label={`Proposal ${index + 1} title`} + /> + </div> + <Textarea + value={draft.description} + disabled={!draft.include} + rows={3} + onChange={(event) => { + const description = event.currentTarget.value; + setDrafts((current) => + current === null + ? current + : updateIntakeDraft(current, index, { description }), + ); + }} + aria-label={`Proposal ${index + 1} description`} + /> + </li> + ))} + </ol> + )} + {error !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {error} + </p> + ) : null} + </div> + <DialogFooter> + {drafts === null ? ( + <> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + reset(); + setOpen(false); + }} + > + Cancel + </Button> + <Button + type="button" + size="sm" + disabled={!braindump.trim() || proposing || agent === null} + onClick={() => { + void propose(); + }} + > + {proposing ? "Proposing…" : "Propose tickets"} + </Button> + </> + ) : ( + <> + <Button type="button" variant="outline" size="sm" onClick={() => setDrafts(null)}> + Back + </Button> + <Button + type="button" + size="sm" + disabled={approved.length === 0 || creating} + onClick={() => { + void createApproved(approved); + }} + > + {creating + ? "Creating…" + : `Create ${approved.length} ticket${approved.length === 1 ? "" : "s"}`} + </Button> + </> + )} + </DialogFooter> + </div> + </DialogPopup> + </Dialog> + ); +} diff --git a/apps/web/src/components/board/LaneColumn.tsx b/apps/web/src/components/board/LaneColumn.tsx new file mode 100644 index 00000000000..5744f075b2d --- /dev/null +++ b/apps/web/src/components/board/LaneColumn.tsx @@ -0,0 +1,82 @@ +import { useDroppable } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; + +import { cn } from "~/lib/utils"; + +import { TicketCard, type TicketCardView } from "./TicketCard"; + +export interface LaneColumnView { + readonly key: string; + readonly name: string; + readonly entry: string; + readonly pipelineStepCount: number; + readonly wipLimit?: number | undefined; + readonly terminal?: boolean | undefined; + readonly admittedTicketIds: ReadonlyArray<string>; + readonly queuedTicketIds: ReadonlyArray<string>; +} + +export function LaneColumn({ + lane, + admittedTickets, + queuedTickets, + onOpen, +}: { + readonly lane: LaneColumnView; + readonly admittedTickets: ReadonlyArray<TicketCardView>; + readonly queuedTickets: ReadonlyArray<TicketCardView>; + readonly onOpen: (id: string) => void; +}) { + const { setNodeRef, isOver } = useDroppable({ id: `lane:${lane.key}` }); + const tickets = [...admittedTickets, ...queuedTickets]; + const headerCount = + lane.wipLimit === undefined + ? String(tickets.length) + : `${admittedTickets.length}/${lane.wipLimit}`; + + return ( + <section ref={setNodeRef} className="flex w-72 shrink-0 flex-col" aria-label={lane.name}> + <header className="flex min-h-6 items-baseline gap-1.5 px-1.5 pb-1.5"> + <h2 className="truncate text-[13px] font-semibold text-foreground">{lane.name}</h2> + <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{headerCount}</span> + {lane.wipLimit !== undefined && queuedTickets.length > 0 ? ( + <span className="shrink-0 text-xs tabular-nums text-muted-foreground/80"> + +{queuedTickets.length} queued + </span> + ) : null} + {lane.entry === "auto" ? ( + <span className="ml-auto shrink-0 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70"> + auto + </span> + ) : null} + </header> + <SortableContext + items={tickets.map((ticket) => ticket.ticketId)} + strategy={verticalListSortingStrategy} + > + <div + className={cn( + "flex min-h-16 flex-1 flex-col gap-2 rounded-lg p-1.5 transition-colors", + isOver ? "bg-primary/4 ring-1 ring-primary/35" : "bg-muted/75", + )} + > + {admittedTickets.map((ticket) => ( + <TicketCard key={ticket.ticketId} ticket={ticket} onOpen={onOpen} /> + ))} + {queuedTickets.length > 0 ? ( + <div className="mt-1 border-t border-border/70 pt-2"> + <div className="px-1.5 pb-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70"> + Queued + </div> + <div className="flex flex-col gap-2"> + {queuedTickets.map((ticket) => ( + <TicketCard key={ticket.ticketId} ticket={ticket} onOpen={onOpen} /> + ))} + </div> + </div> + ) : null} + </div> + </SortableContext> + </section> + ); +} diff --git a/apps/web/src/components/board/MarkdownComposerField.tsx b/apps/web/src/components/board/MarkdownComposerField.tsx new file mode 100644 index 00000000000..ab2b266daa3 --- /dev/null +++ b/apps/web/src/components/board/MarkdownComposerField.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; + +import ChatMarkdown from "~/components/ChatMarkdown"; +import { Textarea } from "~/components/ui/textarea"; +import { cn } from "~/lib/utils"; + +/** + * A comment composer field with a Write/Preview toggle. The Write tab is a + * plain `Textarea`; the Preview tab renders the current draft through + * `ChatMarkdown` (chat-style line breaks) so authors can verify Markdown before + * submitting. Shared by the reply composer and the inline edit form. + * + * `label` renders a visible caption wrapping the textarea (matching the + * surrounding form fields). When omitted, pass `ariaLabel` so the textarea + * still has an accessible name. + */ +export function MarkdownComposerField({ + value, + onChange, + disabled = false, + label, + ariaLabel, + cwd, + placeholder, + className, +}: { + readonly value: string; + readonly onChange: (value: string) => void; + readonly disabled?: boolean | undefined; + readonly label?: string | undefined; + readonly ariaLabel?: string | undefined; + readonly cwd?: string | undefined; + readonly placeholder?: string | undefined; + readonly className?: string | undefined; +}) { + const [mode, setMode] = useState<"write" | "preview">("write"); + + const toggle = ( + <span className="inline-flex items-center gap-0.5 rounded-md border border-input bg-background p-0.5"> + <button + type="button" + className={cn( + "rounded-sm px-2 py-0.5 text-[11px] font-medium transition-colors", + mode === "write" + ? "bg-accent text-foreground" + : "text-muted-foreground hover:text-foreground", + )} + aria-pressed={mode === "write"} + onClick={() => setMode("write")} + > + Write + </button> + <button + type="button" + className={cn( + "rounded-sm px-2 py-0.5 text-[11px] font-medium transition-colors", + mode === "preview" + ? "bg-accent text-foreground" + : "text-muted-foreground hover:text-foreground", + )} + aria-pressed={mode === "preview"} + onClick={() => setMode("preview")} + > + Preview + </button> + </span> + ); + + const control = + mode === "preview" ? ( + <div + className="min-h-16.5 rounded-lg border border-input bg-background px-3 py-2" + data-testid="markdown-composer-preview" + > + {value.trim() ? ( + <ChatMarkdown text={value} cwd={cwd} lineBreaks className="text-sm leading-5" /> + ) : ( + <p className="text-xs text-muted-foreground">Nothing to preview yet.</p> + )} + </div> + ) : ( + <Textarea + size="sm" + value={value} + disabled={disabled} + aria-label={label ? undefined : ariaLabel} + placeholder={placeholder} + onChange={(event) => onChange(event.currentTarget.value)} + /> + ); + + return ( + <div className={cn("space-y-1", className)}> + <div className="flex items-center justify-between gap-2"> + {label ? ( + <span className="text-xs font-medium text-muted-foreground">{label}</span> + ) : ( + <span /> + )} + {toggle} + </div> + {label ? ( + // Wrap the control in the captioned label so the textarea inherits the + // visible caption as its accessible name (`getByLabelText`-friendly). + <label className="block"> + <span className="sr-only">{label}</span> + {control} + </label> + ) : ( + control + )} + </div> + ); +} diff --git a/apps/web/src/components/board/SelfImproveDialog.tsx b/apps/web/src/components/board/SelfImproveDialog.tsx new file mode 100644 index 00000000000..f6fa40c4a51 --- /dev/null +++ b/apps/web/src/components/board/SelfImproveDialog.tsx @@ -0,0 +1,916 @@ +import type { + AgentSelection, + EnvironmentApi, + ProviderInstanceId, + ProviderOptionSelection, + WorkflowBoardProposalView, + WorkflowDefinitionEncoded, + WorkflowDryRunScenario, + WorkflowLintError, +} from "@t3tools/contracts"; +import { BoardId, LaneKey } from "@t3tools/contracts"; +import { CheckCircleIcon, CircleSlash2Icon, WandSparklesIcon, XCircleIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { ProviderModelPicker } from "~/components/chat/ProviderModelPicker"; +import { TraitsPicker } from "~/components/chat/TraitsPicker"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { useSettings } from "~/hooks/useSettings"; +import { getAppModelOptionsForInstance, type AppModelOption } from "~/modelSelection"; +import { deriveProviderInstanceEntries, sortProviderInstanceEntries } from "~/providerInstances"; +import { useServerProviders } from "~/rpc/serverState"; +import { + getBoardProposal, + listBoardProposals, + proposeBoardImprovement, + resolveBoardProposal, + revertBoardProposal, +} from "~/workflow/boardRpc"; +import { resolveRecentAgent } from "~/workflow/resolveRecentAgent"; + +import { DiffView } from "./editor/history/DiffView"; +import { DryRunPanel } from "./editor/DryRunPanel"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +function statusLabel(status: WorkflowBoardProposalView["status"]): string { + switch (status) { + case "pending": + return "Pending review"; + case "approved": + return "Approved"; + case "rejected": + return "Rejected"; + case "reverted": + return "Reverted"; + case "superseded": + return "Superseded"; + case "invalid": + return "Invalid"; + } +} + +function statusColor(status: WorkflowBoardProposalView["status"]): string { + switch (status) { + case "pending": + return "text-foreground"; + case "approved": + return "text-success-foreground"; + case "rejected": + case "invalid": + return "text-destructive"; + case "reverted": + case "superseded": + return "text-muted-foreground"; + } +} + +// ─── Validation summary ─────────────────────────────────────────────────────── + +function ValidationSummary({ + validation, +}: { + readonly validation: WorkflowBoardProposalView["validation"]; +}) { + return ( + <div className="space-y-2"> + <div className="flex flex-wrap gap-3 text-xs"> + <CheckRow ok={validation.preservationOk} label="Lane preservation" /> + <CheckRow ok={validation.lintOk} label="Lint" /> + <CheckRow ok={validation.dryRunOk} label="Dry-run" /> + {validation.laneDiffCount > 0 ? ( + <span className="text-muted-foreground"> + {validation.laneDiffCount} lane{validation.laneDiffCount === 1 ? "" : "s"} changed + </span> + ) : null} + </div> + + {validation.messages.length > 0 ? ( + <ul className="space-y-0.5 rounded-md border border-warning/45 bg-warning/8 p-2 text-xs text-warning-foreground"> + {validation.messages.map((msg, i) => ( + <li key={i}>{msg}</li> + ))} + </ul> + ) : null} + + {validation.lintErrors.length > 0 ? ( + <LintErrorList lintErrors={validation.lintErrors} /> + ) : null} + + {validation.dryRunRegressions.length > 0 ? ( + <ul className="space-y-0.5 rounded-md border border-destructive/30 bg-destructive/8 p-2 text-xs text-destructive"> + <li className="font-medium">Dry-run regressions:</li> + {validation.dryRunRegressions.map((msg, i) => ( + <li key={i}>{msg}</li> + ))} + </ul> + ) : null} + </div> + ); +} + +function CheckRow({ ok, label }: { readonly ok: boolean; readonly label: string }) { + return ( + <span + className={`flex items-center gap-1 ${ok ? "text-success-foreground" : "text-destructive"}`} + > + {ok ? <CheckCircleIcon className="size-3.5" /> : <XCircleIcon className="size-3.5" />} + {label} + </span> + ); +} + +function LintErrorList({ lintErrors }: { readonly lintErrors: ReadonlyArray<WorkflowLintError> }) { + return ( + <ul className="rounded-md border border-warning/45 bg-warning/8 p-2 text-xs text-warning-foreground"> + {lintErrors.map((e, i) => ( + <li key={i}>{e.message}</li> + ))} + </ul> + ); +} + +// ─── Proposal row in the list ───────────────────────────────────────────────── + +function ProposalRow({ + proposal, + selected, + onSelect, +}: { + readonly proposal: WorkflowBoardProposalView; + readonly selected: boolean; + readonly onSelect: () => void; +}) { + return ( + <button + type="button" + onClick={onSelect} + className={`w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/30 ${ + selected ? "border-border bg-muted/20" : "border-border/60 bg-card/30" + }`} + > + <div className="flex items-start justify-between gap-2"> + <span className={`text-xs font-medium ${statusColor(proposal.status)}`}> + {statusLabel(proposal.status)} + {proposal.outdated ? ( + <span className="ml-1.5 rounded bg-warning/15 px-1 py-0.5 text-[10px] font-normal text-warning-foreground"> + outdated + </span> + ) : null} + </span> + <span className="text-[11px] text-muted-foreground">{formatDate(proposal.createdAt)}</span> + </div> + {proposal.rationale ? ( + <p className="mt-1 line-clamp-2 text-[11px] text-muted-foreground">{proposal.rationale}</p> + ) : null} + </button> + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +type Step = "generate" | "review" | "list"; + +export function SelfImproveDialog({ + boardId, + disabled, + api, + open: controlledOpen, + onOpenChange, +}: { + readonly boardId: string | null; + readonly disabled: boolean; + readonly api: EnvironmentApi | null | undefined; + readonly open?: boolean; + readonly onOpenChange?: (open: boolean) => void; +}) { + const isControlled = onOpenChange !== undefined; + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const open = isControlled ? (controlledOpen ?? false) : uncontrolledOpen; + const setOpen = (next: boolean) => { + if (isControlled) { + onOpenChange(next); + } else { + setUncontrolledOpen(next); + } + }; + const [step, setStep] = useState<Step>("generate"); + const [agent, setAgent] = useState<AgentSelection | null>(null); + + // Generate step state + const [generating, setGenerating] = useState(false); + const [generateError, setGenerateError] = useState<string | null>(null); + + // Review step state + const [proposalId, setProposalId] = useState<string | null>(null); + const [proposal, setProposal] = useState<WorkflowBoardProposalView | null>(null); + const [proposedDefinition, setProposedDefinition] = useState<WorkflowDefinitionEncoded | null>( + null, + ); + const [baseDefinition, setBaseDefinition] = useState<WorkflowDefinitionEncoded | null>(null); + const [loadingProposal, setLoadingProposal] = useState(false); + const [reviewError, setReviewError] = useState<string | null>(null); + const [resolving, setResolving] = useState(false); + const [dryRunOpen, setDryRunOpen] = useState(false); + + // List step state + const [proposals, setProposals] = useState<ReadonlyArray<WorkflowBoardProposalView> | null>(null); + const [loadingList, setLoadingList] = useState(false); + const [listError, setListError] = useState<string | null>(null); + const [selectedListProposalId, setSelectedListProposalId] = useState<string | null>(null); + const [revertingId, setRevertingId] = useState<string | null>(null); + const [revertError, setRevertError] = useState<string | null>(null); + + // Provider/model picker state (mirrored from IntakeDialog) + const providers = useServerProviders(); + const settings = useSettings(); + const instanceEntries = useMemo( + () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)), + [providers], + ); + const modelOptionsByInstance = useMemo(() => { + const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>(); + for (const entry of instanceEntries) { + out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); + } + return out; + }, [instanceEntries, settings]); + + const activeInstanceId = (agent?.instance ?? "") as ProviderInstanceId; + const selectedEntry = instanceEntries.find((e) => e.instanceId === activeInstanceId); + + // ─── Helpers ───────────────────────────────────────────────────────────── + + const resetAll = () => { + setStep("generate"); + setGenerating(false); + setGenerateError(null); + setProposalId(null); + setProposal(null); + setProposedDefinition(null); + setBaseDefinition(null); + setLoadingProposal(false); + setReviewError(null); + setResolving(false); + setDryRunOpen(false); + setProposals(null); + setLoadingList(false); + setListError(null); + setSelectedListProposalId(null); + setRevertingId(null); + setRevertError(null); + }; + + const loadProposal = async (pid: string) => { + if (!api) return; + setLoadingProposal(true); + setReviewError(null); + try { + const result = await getBoardProposal(api, pid); + setProposal(result.proposal); + setProposedDefinition(result.proposedDefinition); + setBaseDefinition(result.baseDefinition); + } catch (cause) { + setReviewError(cause instanceof Error ? cause.message : "Failed to load proposal."); + } finally { + setLoadingProposal(false); + } + }; + + const loadProposalList = async () => { + if (!api || !boardId) return; + setLoadingList(true); + setListError(null); + try { + const result = await listBoardProposals(api, BoardId.make(boardId)); + // pending first, then by createdAt descending + const sorted = [...result.proposals].sort((a, b) => { + if (a.status === "pending" && b.status !== "pending") return -1; + if (b.status === "pending" && a.status !== "pending") return 1; + return b.createdAt.localeCompare(a.createdAt); + }); + setProposals(sorted); + } catch (cause) { + setListError(cause instanceof Error ? cause.message : "Failed to load proposals."); + } finally { + setLoadingList(false); + } + }; + + // ─── Generate ───────────────────────────────────────────────────────────── + + const handleGenerate = async () => { + if (!api || !boardId || !agent || generating) return; + setGenerating(true); + setGenerateError(null); + try { + const result = await proposeBoardImprovement(api, { + boardId: BoardId.make(boardId), + agent, + }); + const newProposal = result.proposal; + setProposalId(newProposal.proposalId); + setProposal(newProposal); + + if (newProposal.status === "invalid") { + // Stay on generate step but show the invalidity inline + setGenerateError(null); // clear any prior RPC error + } else { + // Go to review and fetch full detail (with defs) + setStep("review"); + await loadProposal(newProposal.proposalId); + } + } catch (cause) { + setGenerateError(cause instanceof Error ? cause.message : "Failed to generate proposal."); + } finally { + setGenerating(false); + } + }; + + // ─── Approve / Reject ───────────────────────────────────────────────────── + + const handleResolve = async (action: "approve" | "reject") => { + if (!api || !proposalId || resolving) return; + setResolving(true); + setReviewError(null); + try { + const result = await resolveBoardProposal(api, { proposalId, action }); + if ( + result.ok && + result.proposal.status === (action === "approve" ? "approved" : "rejected") + ) { + setProposal(result.proposal); + // Close only on a genuine approved transition; stay to show status on reject + if (action === "approve") { + resetAll(); + setOpen(false); + } + } else if (result.ok) { + // ok:true but status didn't land on the expected value — treat as non-success: + // refresh the proposal so the dialog reflects the real current state + setProposal(result.proposal); + setReviewError( + "Unexpected proposal state after action — please review the current status.", + ); + } else { + // {ok:false} — do not close; show the reason and re-fetch so the dialog + // reflects the server-side status (e.g. now superseded/invalid) and + // disables Approve rather than letting a second click re-fire + const reasonMsg = + result.reason === "conflict" + ? "The board changed while this proposal was open — re-generate to get a fresh one." + : result.message; + setReviewError(reasonMsg); + // Re-fetch proposal to surface the updated status (superseded, invalid, etc.) + if (proposalId) { + await loadProposal(proposalId); + } + } + } catch (cause) { + setReviewError(cause instanceof Error ? cause.message : "Action failed."); + } finally { + setResolving(false); + } + }; + + // ─── Revert ─────────────────────────────────────────────────────────────── + + const handleRevert = async (pid: string) => { + if (!api || revertingId !== null) return; + setRevertingId(pid); + setRevertError(null); + try { + const result = await revertBoardProposal(api, pid); + if (result.ok && result.proposal.status === "reverted") { + // Refresh list to show new status + await loadProposalList(); + // Refresh selected proposal view if we have it open + if (selectedListProposalId === pid) { + await loadProposal(pid); + } + } else if (result.ok) { + // ok:true but status didn't land on "reverted" — refresh to surface real state + setRevertError( + "Unexpected proposal state after revert — please review the current status.", + ); + await loadProposalList(); + if (selectedListProposalId === pid) { + await loadProposal(pid); + } + } else { + // {ok:false} — keep dialog open, show reason, and re-fetch so the selected + // proposal view reflects the server-side status (e.g. conflict, already reverted) + const reasonMsg = + result.reason === "conflict" + ? "The board changed — re-run the revert after reviewing the latest definition." + : result.message; + setRevertError(reasonMsg); + // Re-fetch list and selected proposal to surface the updated status + await loadProposalList(); + if (selectedListProposalId === pid) { + await loadProposal(pid); + } + } + } catch (cause) { + setRevertError(cause instanceof Error ? cause.message : "Revert failed."); + } finally { + setRevertingId(null); + } + }; + + // ─── Open ───────────────────────────────────────────────────────────────── + + const handleOpen = () => { + setOpen(true); + setAgent((current) => current ?? resolveRecentAgent()); + }; + + // In controlled mode the parent owns the trigger, so the default-agent + // selection handleOpen performed must fire when the dialog transitions open. + useEffect(() => { + if (isControlled && open) { + setAgent((current) => current ?? resolveRecentAgent()); + } + }, [isControlled, open]); + + // ─── Render ─────────────────────────────────────────────────────────────── + + const isApprovable = proposal !== null && proposal.status === "pending" && !proposal.outdated; + + const invalidProposal = proposal !== null && proposal.status === "invalid" ? proposal : null; + + return ( + <Dialog + open={open} + onOpenChange={(nextOpen) => { + setOpen(nextOpen); + if (!nextOpen) { + resetAll(); + } + }} + > + {isControlled ? null : ( + <Button + type="button" + size="xs" + variant="outline" + disabled={disabled || !boardId} + title={disabled ? "No board selected" : "Suggest AI improvements to this board"} + onClick={handleOpen} + > + <WandSparklesIcon className="size-3.5" /> + Suggest improvements + </Button> + )} + <DialogPopup className="max-h-[calc(100dvh-2rem)] max-w-2xl overflow-hidden"> + <div className="flex min-h-0 flex-col"> + {/* ── Generate step ──────────────────────────────────────────── */} + {step === "generate" ? ( + <> + <DialogHeader> + <DialogTitle>Suggest board improvements</DialogTitle> + <DialogDescription> + An AI agent reviews this board's workflow definition and proposes targeted + improvements. You'll review and approve before anything changes. + </DialogDescription> + </DialogHeader> + <div + className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 pt-1 pb-3" + data-slot="dialog-panel" + > + <div className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Agent</span> + <div className="flex flex-wrap items-center gap-2"> + <ProviderModelPicker + activeInstanceId={activeInstanceId} + model={agent?.model ?? ""} + lockedProvider={null} + instanceEntries={instanceEntries} + modelOptionsByInstance={modelOptionsByInstance} + triggerVariant="outline" + disabled={generating} + onInstanceModelChange={(instanceId, model) => { + setAgent((current) => ({ + ...(current?.options === undefined ? {} : { options: current.options }), + instance: instanceId, + model, + })); + }} + /> + {selectedEntry && agent ? ( + <TraitsPicker + provider={selectedEntry.driverKind} + instanceId={selectedEntry.instanceId} + models={selectedEntry.models} + model={agent.model} + modelOptions={ + agent.options as ReadonlyArray<ProviderOptionSelection> | undefined + } + prompt="" + onPromptChange={() => {}} + allowPromptInjectedEffort={false} + triggerVariant="outline" + disabled={generating} + onModelOptionsChange={(nextOptions) => { + setAgent((current) => + current === null + ? current + : { + instance: current.instance, + model: current.model, + ...(nextOptions === undefined || nextOptions.length === 0 + ? {} + : { options: nextOptions }), + }, + ); + }} + /> + ) : null} + {agent === null ? ( + <span className="text-xs text-muted-foreground"> + No agent provider available. + </span> + ) : null} + </div> + </div> + + {/* Invalid proposal returned (shown inline on generate step) */} + {invalidProposal !== null ? ( + <div className="space-y-2 rounded-md border border-destructive/30 bg-destructive/8 p-3"> + <p className="text-xs font-medium text-destructive"> + The generated proposal was invalid and cannot be approved. + </p> + {invalidProposal.rationale ? ( + <p className="text-xs text-muted-foreground">{invalidProposal.rationale}</p> + ) : null} + <ValidationSummary validation={invalidProposal.validation} /> + </div> + ) : null} + + {generateError !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {generateError} + </p> + ) : null} + </div> + <DialogFooter> + <Button + type="button" + size="sm" + variant="outline" + onClick={() => { + setStep("list"); + void loadProposalList(); + }} + > + View past proposals + </Button> + <Button + type="button" + size="sm" + variant="outline" + onClick={() => { + resetAll(); + setOpen(false); + }} + > + Cancel + </Button> + <Button + type="button" + size="sm" + disabled={agent === null || generating} + onClick={() => void handleGenerate()} + > + {generating + ? "Generating…" + : invalidProposal !== null + ? "Re-generate" + : "Generate"} + </Button> + </DialogFooter> + </> + ) : null} + + {/* ── Review step ────────────────────────────────────────────── */} + {step === "review" ? ( + <> + <DialogHeader> + <DialogTitle>Review proposal</DialogTitle> + <DialogDescription> + Inspect the suggested changes and approve or reject them. + </DialogDescription> + </DialogHeader> + + {/* Dry-run panel sits above scrollable body when open */} + {dryRunOpen && proposedDefinition && api ? ( + <DryRunPanel + definition={proposedDefinition} + onDryRun={(input) => + api.workflow.dryRunBoard({ + definition: proposedDefinition, + startLane: LaneKey.make(input.startLane), + scenario: input.scenario as WorkflowDryRunScenario, + }) + } + onClose={() => setDryRunOpen(false)} + /> + ) : null} + + <div + className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 pt-1 pb-3" + data-slot="dialog-panel" + > + {loadingProposal ? ( + <p className="text-xs text-muted-foreground">Loading proposal…</p> + ) : null} + + {proposal !== null && !loadingProposal ? ( + <> + {/* Status badge */} + {proposal.status !== "pending" ? ( + <p className={`text-xs font-medium ${statusColor(proposal.status)}`}> + {statusLabel(proposal.status)} + {proposal.outdated ? ( + <span className="ml-2 rounded bg-warning/15 px-1 py-0.5 text-[10px] font-normal text-warning-foreground"> + outdated + </span> + ) : null} + </p> + ) : null} + + {/* Rationale */} + {proposal.rationale ? ( + <div className="space-y-1"> + <span className="text-xs font-medium text-foreground">Rationale</span> + <p className="text-xs text-muted-foreground">{proposal.rationale}</p> + </div> + ) : null} + + {/* Validation */} + <div className="space-y-1"> + <span className="text-xs font-medium text-foreground">Validation</span> + <ValidationSummary validation={proposal.validation} /> + </div> + + {/* Diff */} + {baseDefinition && proposedDefinition ? ( + <div className="space-y-1"> + <span className="text-xs font-medium text-foreground">Changes</span> + <DiffView + currentDefinition={proposedDefinition} + versionDefinition={baseDefinition} + /> + </div> + ) : null} + + {/* Simulate toggle */} + {proposedDefinition && !dryRunOpen ? ( + <Button + type="button" + size="sm" + variant="outline" + onClick={() => setDryRunOpen(true)} + > + Simulate proposed workflow + </Button> + ) : null} + </> + ) : null} + + {reviewError !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {reviewError} + </p> + ) : null} + </div> + <DialogFooter> + <Button + type="button" + size="sm" + variant="outline" + onClick={() => { + setStep("generate"); + setProposal(null); + setProposedDefinition(null); + setBaseDefinition(null); + setReviewError(null); + setDryRunOpen(false); + }} + > + Back + </Button> + <Button + type="button" + size="sm" + variant="outline" + disabled={resolving || loadingProposal} + onClick={() => void handleResolve("reject")} + > + {resolving ? "Working…" : "Reject"} + </Button> + <Button + type="button" + size="sm" + disabled={!isApprovable || resolving || loadingProposal} + title={ + proposal?.outdated + ? "Proposal is outdated — re-generate to get a fresh one" + : proposal?.status !== "pending" + ? `Proposal is ${proposal?.status ?? "not pending"}` + : undefined + } + onClick={() => void handleResolve("approve")} + > + {resolving ? "Working…" : "Approve"} + </Button> + </DialogFooter> + </> + ) : null} + + {/* ── List step ──────────────────────────────────────────────── */} + {step === "list" ? ( + <> + <DialogHeader> + <DialogTitle>Past proposals</DialogTitle> + <DialogDescription> + Recent improvement proposals for this board. Select one to view or revert. + </DialogDescription> + </DialogHeader> + <div + className="min-h-0 flex-1 space-y-3 overflow-y-auto px-6 pt-1 pb-3" + data-slot="dialog-panel" + > + {loadingList ? ( + <p className="text-xs text-muted-foreground">Loading proposals…</p> + ) : null} + + {!loadingList && listError !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {listError} + </p> + ) : null} + + {!loadingList && proposals !== null && proposals.length === 0 ? ( + <p className="text-xs text-muted-foreground">No proposals yet.</p> + ) : null} + + {proposals !== null && proposals.length > 0 ? ( + <div className="space-y-2"> + {proposals.map((p) => ( + <div key={p.proposalId} className="space-y-1"> + <ProposalRow + proposal={p} + selected={selectedListProposalId === p.proposalId} + onSelect={() => { + if (selectedListProposalId === p.proposalId) { + setSelectedListProposalId(null); + } else { + setSelectedListProposalId(p.proposalId); + void (async () => { + setLoadingProposal(true); + setReviewError(null); + try { + const result = await getBoardProposal(api!, p.proposalId); + setProposal(result.proposal); + setProposedDefinition(result.proposedDefinition); + setBaseDefinition(result.baseDefinition); + } catch (cause) { + setReviewError( + cause instanceof Error + ? cause.message + : "Failed to load proposal.", + ); + } finally { + setLoadingProposal(false); + } + })(); + } + }} + /> + + {selectedListProposalId === p.proposalId ? ( + <div className="space-y-3 rounded-md border border-border/60 bg-card/20 px-3 py-2"> + {loadingProposal ? ( + <p className="text-xs text-muted-foreground">Loading…</p> + ) : null} + + {!loadingProposal && + proposal !== null && + proposal.proposalId === p.proposalId ? ( + <> + {proposal.rationale ? ( + <div className="space-y-0.5"> + <span className="text-xs font-medium text-foreground"> + Rationale + </span> + <p className="text-xs text-muted-foreground"> + {proposal.rationale} + </p> + </div> + ) : null} + <ValidationSummary validation={proposal.validation} /> + {baseDefinition && proposedDefinition ? ( + <DiffView + currentDefinition={proposedDefinition} + versionDefinition={baseDefinition} + /> + ) : null} + {p.status === "approved" ? ( + <Button + type="button" + size="sm" + variant="outline" + disabled={revertingId !== null} + onClick={() => void handleRevert(p.proposalId)} + > + <CircleSlash2Icon className="size-3.5" /> + {revertingId === p.proposalId + ? "Reverting…" + : "Revert this improvement"} + </Button> + ) : null} + </> + ) : null} + + {reviewError !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {reviewError} + </p> + ) : null} + </div> + ) : null} + </div> + ))} + </div> + ) : null} + + {revertError !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {revertError} + </p> + ) : null} + </div> + <DialogFooter> + <Button + type="button" + size="sm" + variant="outline" + onClick={() => { + setStep("generate"); + setProposals(null); + setSelectedListProposalId(null); + setProposal(null); + setProposedDefinition(null); + setBaseDefinition(null); + setReviewError(null); + setRevertError(null); + }} + > + Back + </Button> + <Button + type="button" + size="sm" + onClick={() => { + setStep("generate"); + setProposals(null); + setSelectedListProposalId(null); + setProposal(null); + setProposedDefinition(null); + setBaseDefinition(null); + setReviewError(null); + setRevertError(null); + }} + > + New proposal + </Button> + </DialogFooter> + </> + ) : null} + </div> + </DialogPopup> + </Dialog> + ); +} diff --git a/apps/web/src/components/board/StepActivityFeed.browser.tsx b/apps/web/src/components/board/StepActivityFeed.browser.tsx new file mode 100644 index 00000000000..6546927256b --- /dev/null +++ b/apps/web/src/components/board/StepActivityFeed.browser.tsx @@ -0,0 +1,95 @@ +import "../../index.css"; + +import type { + EnvironmentApi, + OrchestrationThreadActivity, + OrchestrationThreadStreamItem, + ThreadId, +} from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { StepActivityFeed } from "./StepActivityFeed"; + +const threadId = "thread-feed" as ThreadId; + +const activity = (id: string, summary: string): OrchestrationThreadActivity => + ({ + id: id as never, + tone: "info", + kind: "tool.completed", + summary, + payload: {}, + turnId: null, + createdAt: "2026-06-09T00:00:00.000Z", + }) as OrchestrationThreadActivity; + +function makeApi(initialActivities: ReadonlyArray<OrchestrationThreadActivity>) { + let deliver: ((item: OrchestrationThreadStreamItem) => void) | null = null; + const api = { + orchestration: { + subscribeThread: ( + _input: unknown, + callback: (item: OrchestrationThreadStreamItem) => void, + ) => { + deliver = callback; + callback({ + kind: "snapshot", + snapshot: { + snapshotSequence: 1, + thread: { activities: initialActivities }, + }, + } as OrchestrationThreadStreamItem); + return () => { + deliver = null; + }; + }, + }, + } as unknown as EnvironmentApi; + return { + api, + push: (item: OrchestrationThreadStreamItem) => deliver?.(item), + }; +} + +describe("StepActivityFeed", () => { + it("renders snapshot activities and appends live ones", async () => { + const harness = makeApi([activity("a1", "Read src/app.ts")]); + render(<StepActivityFeed api={harness.api} threadId={threadId} live={true} />); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain("Read src/app.ts"); + expect(document.body.textContent).toContain("Agent activity"); + }); + + harness.push({ + kind: "event", + event: { + type: "thread.activity-appended", + payload: { threadId, activity: activity("a2", "Edited src/app.ts") }, + }, + } as OrchestrationThreadStreamItem); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain("Edited src/app.ts"); + }); + }); + + it("renders nothing for an idle step with no activity", async () => { + const harness = makeApi([]); + render(<StepActivityFeed api={harness.api} threadId={threadId} live={false} />); + + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="step-activity-feed"]')).toBeNull(); + }); + }); + + it("shows a waiting hint while live with no activity yet", async () => { + const harness = makeApi([]); + render(<StepActivityFeed api={harness.api} threadId={threadId} live={true} />); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain("Waiting for the agent to start"); + }); + }); +}); diff --git a/apps/web/src/components/board/StepActivityFeed.tsx b/apps/web/src/components/board/StepActivityFeed.tsx new file mode 100644 index 00000000000..855c52f4613 --- /dev/null +++ b/apps/web/src/components/board/StepActivityFeed.tsx @@ -0,0 +1,107 @@ +import type { + EnvironmentApi, + OrchestrationThreadActivity, + OrchestrationThreadStreamItem, + ThreadId, +} from "@t3tools/contracts"; +import { useEffect, useState } from "react"; + +import { cn } from "~/lib/utils"; + +const MAX_VISIBLE_ACTIVITIES = 8; +const MAX_TRACKED_ACTIVITIES = 50; + +const toneDotClassName: Record<string, string> = { + info: "bg-info", + success: "bg-success", + warning: "bg-warning", + danger: "bg-destructive", +}; + +function appendActivities( + current: ReadonlyArray<OrchestrationThreadActivity>, + incoming: ReadonlyArray<OrchestrationThreadActivity>, +): ReadonlyArray<OrchestrationThreadActivity> { + const byId = new Map(current.map((activity) => [activity.id as string, activity])); + for (const activity of incoming) { + byId.set(activity.id as string, activity); + } + return [...byId.values()].slice(-MAX_TRACKED_ACTIVITIES); +} + +export function StepActivityFeed({ + api, + threadId, + live, +}: { + readonly api: EnvironmentApi | null | undefined; + readonly threadId: ThreadId; + readonly live: boolean; +}) { + const [activities, setActivities] = useState<ReadonlyArray<OrchestrationThreadActivity>>([]); + + useEffect(() => { + if (!api) { + return; + } + setActivities([]); + return api.orchestration.subscribeThread( + { threadId }, + (item: OrchestrationThreadStreamItem) => { + if (item.kind === "snapshot") { + setActivities((current) => appendActivities(current, item.snapshot.thread.activities)); + return; + } + if (item.event.type === "thread.activity-appended") { + const activity = item.event.payload.activity; + setActivities((current) => appendActivities(current, [activity])); + } + }, + ); + }, [api, threadId]); + + const visible = activities.slice(-MAX_VISIBLE_ACTIVITIES); + if (visible.length === 0 && !live) { + return null; + } + + return ( + <div + className="mt-2 space-y-1 rounded-md border border-border/60 bg-muted/15 p-2" + data-testid="step-activity-feed" + > + <div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground"> + {live ? ( + <span aria-hidden className="relative flex size-2"> + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success/60" /> + <span className="relative inline-flex size-2 rounded-full bg-success" /> + </span> + ) : null} + {live ? "Agent activity" : "Recent agent activity"} + </div> + {visible.length === 0 ? ( + <p className="text-xs text-muted-foreground">Waiting for the agent to start…</p> + ) : ( + <ol className="space-y-0.5"> + {visible.map((activity) => ( + <li key={String(activity.id)} className="flex items-start gap-1.5 text-xs leading-5"> + <span + aria-hidden + className={cn( + "mt-1.5 size-1.5 shrink-0 rounded-full", + toneDotClassName[activity.tone] ?? "bg-muted-foreground/60", + )} + /> + <span + className="min-w-0 flex-1 truncate text-muted-foreground" + title={activity.summary} + > + {activity.summary} + </span> + </li> + ))} + </ol> + )} + </div> + ); +} diff --git a/apps/web/src/components/board/TicketArtifacts.tsx b/apps/web/src/components/board/TicketArtifacts.tsx new file mode 100644 index 00000000000..761d02253f4 --- /dev/null +++ b/apps/web/src/components/board/TicketArtifacts.tsx @@ -0,0 +1,87 @@ +import { TicketId, type EnvironmentApi, type WorkflowTicketArtifact } from "@t3tools/contracts"; +import { useState } from "react"; + +/** + * The ticket's case file: scratch documents the pipeline wrote under + * .t3/ticket/<id>/ (PLAN.md, SPEC.md, REVIEW.md, ...), loaded lazily when + * the section is opened. + */ +export function TicketArtifacts({ + api, + ticketId, +}: { + readonly api: EnvironmentApi | null | undefined; + readonly ticketId: string; +}) { + const [artifacts, setArtifacts] = useState<ReadonlyArray<WorkflowTicketArtifact> | null>(null); + const [error, setError] = useState<string | null>(null); + const [loading, setLoading] = useState(false); + + const load = async () => { + if (!api || loading || artifacts !== null) { + return; + } + setLoading(true); + setError(null); + try { + const result = await api.workflow.listTicketArtifacts({ + ticketId: TicketId.make(ticketId), + }); + setArtifacts(result.artifacts); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Failed to load artifacts."); + } finally { + setLoading(false); + } + }; + + return ( + <section className="rounded-md border border-border/70 bg-card/35 p-3"> + <details + onToggle={(event) => { + if ((event.currentTarget as HTMLDetailsElement).open) { + void load(); + } + }} + > + <summary className="cursor-pointer text-sm font-medium text-foreground select-none"> + Artifacts + {artifacts !== null ? ( + <span className="ml-2 text-xs font-normal text-muted-foreground"> + {artifacts.length} + </span> + ) : null} + </summary> + <div className="mt-2 space-y-2" data-testid="ticket-artifacts"> + {loading ? <p className="text-xs text-muted-foreground">Loading…</p> : null} + {error !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {error} + </p> + ) : null} + {artifacts !== null && artifacts.length === 0 ? ( + <p className="text-xs text-muted-foreground"> + No artifacts yet — pipeline steps write plans and reviews here. + </p> + ) : null} + {(artifacts ?? []).map((artifact) => ( + <details + key={artifact.name} + className="rounded-md border border-border/60 bg-background/70" + > + <summary className="cursor-pointer px-2 py-1.5 text-xs font-medium text-foreground select-none"> + {artifact.name} + {artifact.truncated === true ? ( + <span className="ml-2 font-normal text-muted-foreground">(truncated)</span> + ) : null} + </summary> + <pre className="max-h-72 overflow-auto border-t border-border/60 p-2 text-[11px] leading-4 whitespace-pre-wrap text-muted-foreground"> + {artifact.content} + </pre> + </details> + ))} + </div> + </details> + </section> + ); +} diff --git a/apps/web/src/components/board/TicketCard.test.tsx b/apps/web/src/components/board/TicketCard.test.tsx new file mode 100644 index 00000000000..16727aecc25 --- /dev/null +++ b/apps/web/src/components/board/TicketCard.test.tsx @@ -0,0 +1,196 @@ +import { DndContext } from "@dnd-kit/core"; +import { SortableContext } from "@dnd-kit/sortable"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { TicketCard } from "./TicketCard"; + +const renderTicketCard = (status: string) => + renderToStaticMarkup( + <DndContext> + <SortableContext items={[`ticket-${status}`]}> + <TicketCard + ticket={{ + ticketId: `ticket-${status}`, + title: `Ticket ${status}`, + status, + }} + onOpen={() => {}} + /> + </SortableContext> + </DndContext>, + ); + +describe("TicketCard", () => { + it("renders a queued badge for queued tickets", () => { + const markup = renderToStaticMarkup( + <DndContext> + <SortableContext items={["ticket-queued"]}> + <TicketCard + ticket={{ + ticketId: "ticket-queued", + title: "Wait for capacity", + description: "Hold until the release lane has room.", + status: "queued", + }} + onOpen={() => {}} + /> + </SortableContext> + </DndContext>, + ); + + expect(markup).toContain("Wait for capacity"); + expect(markup).toContain("Hold until the release lane has room."); + expect(markup).toContain("queued"); + }); + + it("renders a waiting-on-dependencies badge when dependencies are unresolved", () => { + const markup = renderToStaticMarkup( + <DndContext> + <SortableContext items={["ticket-dep"]}> + <TicketCard + ticket={{ + ticketId: "ticket-dep", + title: "Blocked work", + status: "queued", + unresolvedDependencyCount: 2, + }} + onOpen={() => {}} + /> + </SortableContext> + </DndContext>, + ); + + expect(markup).toContain("waiting on"); + expect(markup).toContain("2"); + expect(markup).toContain("dependencies"); + }); + + it("states status once, in words, with the right tone", () => { + const cases = [ + { status: "running", tone: "success", label: "running" }, + { status: "blocked", tone: "warning", label: "blocked" }, + { status: "waiting_on_user", tone: "warning", label: "waiting on you" }, + { status: "failed", tone: "destructive", label: "failed" }, + { status: "queued", tone: "muted", label: "queued" }, + { status: "done", tone: "settled", label: "done" }, + ]; + + for (const { status, tone, label } of cases) { + const markup = renderTicketCard(status); + + expect(markup).toContain(`data-status="${status}"`); + expect(markup).toContain(`data-status-tone="${tone}"`); + expect(markup).toContain('data-testid="ticket-status"'); + expect(markup).toContain(label); + // Status is a single text element: no accent border, no status dot pile. + expect(markup).not.toContain("border-l-4"); + expect(markup).not.toContain("ticket-status-accent"); + } + }); + + it("renders no status chrome at all for idle tickets", () => { + const markup = renderTicketCard("idle"); + + expect(markup).toContain('data-status="idle"'); + expect(markup).not.toContain('data-testid="ticket-status"'); + expect(markup).not.toContain("border-l-4"); + }); + + it("shows a live indicator only while running", () => { + expect(renderTicketCard("running")).toContain("animate-ping"); + for (const status of ["idle", "queued", "blocked", "waiting_on_user", "failed", "done"]) { + expect(renderTicketCard(status)).not.toContain("animate-ping"); + } + }); + + it("renders the PR chip with the number when pr is present", () => { + const markup = renderToStaticMarkup( + <DndContext> + <SortableContext items={["ticket-pr"]}> + <TicketCard + ticket={{ + ticketId: "ticket-pr", + title: "Add OAuth", + status: "done", + pr: { number: 42, url: "https://github.com/org/repo/pull/42", state: "open" }, + }} + onOpen={() => {}} + /> + </SortableContext> + </DndContext>, + ); + + expect(markup).toContain('data-testid="ticket-pr-chip"'); + expect(markup).toContain("#42"); + }); + + it("shows a success-colored dot when ciState=success", () => { + const markup = renderToStaticMarkup( + <DndContext> + <SortableContext items={["ticket-ci"]}> + <TicketCard + ticket={{ + ticketId: "ticket-ci", + title: "CI green", + status: "running", + pr: { + number: 7, + url: "https://github.com/org/repo/pull/7", + state: "open", + ciState: "success", + }, + }} + onOpen={() => {}} + /> + </SortableContext> + </DndContext>, + ); + + expect(markup).toContain("bg-success"); + }); + + it("shows a destructive dot when ciState=failure", () => { + const markup = renderToStaticMarkup( + <DndContext> + <SortableContext items={["ticket-ci-fail"]}> + <TicketCard + ticket={{ + ticketId: "ticket-ci-fail", + title: "CI red", + status: "blocked", + pr: { + number: 8, + url: "https://github.com/org/repo/pull/8", + state: "open", + ciState: "failure", + }, + }} + onOpen={() => {}} + /> + </SortableContext> + </DndContext>, + ); + + expect(markup).toContain("bg-destructive"); + }); + + it("renders no PR chip when pr is absent", () => { + const markup = renderToStaticMarkup( + <DndContext> + <SortableContext items={["ticket-no-pr"]}> + <TicketCard + ticket={{ + ticketId: "ticket-no-pr", + title: "No PR yet", + status: "idle", + }} + onOpen={() => {}} + /> + </SortableContext> + </DndContext>, + ); + + expect(markup).not.toContain('data-testid="ticket-pr-chip"'); + }); +}); diff --git a/apps/web/src/components/board/TicketCard.tsx b/apps/web/src/components/board/TicketCard.tsx new file mode 100644 index 00000000000..ca851436e84 --- /dev/null +++ b/apps/web/src/components/board/TicketCard.tsx @@ -0,0 +1,202 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { cva } from "class-variance-authority"; +import type { CSSProperties } from "react"; + +import { cn } from "~/lib/utils"; +import { ticketAging } from "~/workflow/agingFormat"; +import { useNowTick } from "~/workflow/useNowTick"; +import { ticketUsageSummary } from "~/workflow/usageFormat"; + +export interface TicketCardView { + readonly ticketId: string; + readonly title: string; + readonly description?: string | undefined; + readonly status: string; + readonly totalTokens?: number | undefined; + readonly totalDurationMs?: number | undefined; + readonly unresolvedDependencyCount?: number | undefined; + readonly tokenBudget?: number | undefined; + readonly updatedAt?: string | undefined; + readonly pr?: + | { + readonly number: number; + readonly url: string; + readonly state: "open" | "merged" | "closed"; + readonly ciState?: "pending" | "success" | "failure" | undefined; + } + | undefined; +} + +interface TicketStatusMeta { + readonly label: string; + readonly tone: "destructive" | "muted" | "settled" | "success" | "warning"; + readonly textClassName: string; + /** Live execution gets a pulsing indicator; nothing else earns a dot. */ + readonly live?: boolean; +} + +const ticketCardVariants = cva( + "group w-full cursor-grab rounded-md border border-border/70 bg-card px-3 py-2.5 text-left text-sm text-card-foreground shadow-xs transition-[border-color,box-shadow,background-color] hover:border-border hover:shadow-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-default", + { + variants: { + dragging: { + false: "", + true: "opacity-50 shadow-md", + }, + }, + defaultVariants: { + dragging: false, + }, + }, +); + +// Status is said once, in words. Idle cards say nothing: an untouched card in +// a lane needs no extra signal beyond its position on the board. +const statusMetaByStatus: Record<string, TicketStatusMeta | undefined> = { + idle: undefined, + queued: { + label: "queued", + tone: "muted", + textClassName: "text-muted-foreground", + }, + running: { + label: "running", + tone: "success", + textClassName: "text-success-foreground", + live: true, + }, + waiting_on_user: { + label: "waiting on you", + tone: "warning", + textClassName: "text-warning-foreground", + }, + blocked: { + label: "blocked", + tone: "warning", + textClassName: "text-warning-foreground", + }, + failed: { + label: "failed", + tone: "destructive", + textClassName: "text-destructive-foreground", + }, + done: { + label: "done", + tone: "settled", + textClassName: "text-muted-foreground/80", + }, +}; + +export function TicketCard({ + ticket, + onOpen, +}: { + readonly ticket: TicketCardView; + readonly onOpen: (id: string) => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: ticket.ticketId, + }); + const meta = statusMetaByStatus[ticket.status] ?? null; + const usageSummary = ticketUsageSummary(ticket); + const unresolvedDependencies = ticket.unresolvedDependencyCount ?? 0; + const aging = ticketAging(ticket, useNowTick(60_000)); + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + }; + // A ticket stuck on a human escalates in place: the aging label replaces the + // plain status word rather than stacking a second indicator on the card. + const statusLabel = aging?.label ?? meta?.label ?? null; + const statusClassName = + aging === null + ? meta?.textClassName + : aging.level === "alert" + ? "text-destructive-foreground" + : "text-warning-foreground"; + const showFooter = statusLabel !== null || usageSummary !== null || ticket.pr !== undefined; + + return ( + <button + ref={setNodeRef} + type="button" + style={style} + className={ticketCardVariants({ dragging: isDragging })} + data-status={ticket.status} + onClick={() => onOpen(ticket.ticketId)} + {...attributes} + {...listeners} + > + <span className="block truncate font-medium leading-5">{ticket.title}</span> + {ticket.description ? ( + <span className="mt-1 block line-clamp-2 text-xs leading-4 text-muted-foreground"> + {ticket.description} + </span> + ) : null} + {unresolvedDependencies > 0 ? ( + <span + className="mt-1.5 block text-[11px] leading-4 text-warning-foreground" + data-testid="ticket-dependency-badge" + > + waiting on {unresolvedDependencies} dependenc + {unresolvedDependencies === 1 ? "y" : "ies"} + </span> + ) : null} + {showFooter ? ( + <span className="mt-2 flex items-baseline gap-1.5"> + {statusLabel !== null ? ( + <span + className={cn( + "flex min-w-0 items-baseline gap-1.5 truncate text-[11px] font-medium leading-4", + statusClassName, + )} + data-status-tone={ + aging === null ? meta?.tone : aging.level === "alert" ? "destructive" : "warning" + } + data-testid="ticket-status" + > + {meta?.live ? ( + <span aria-hidden="true" className="relative flex size-1.5 self-center"> + <span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 motion-safe:animate-ping" /> + <span className="relative inline-flex size-1.5 rounded-full bg-success" /> + </span> + ) : null} + {statusLabel} + </span> + ) : null} + {usageSummary ? ( + <span + className="ml-auto shrink-0 font-mono text-[10px] leading-4 tabular-nums text-muted-foreground/90" + data-testid="ticket-usage-summary" + > + {usageSummary} + </span> + ) : null} + {ticket.pr !== undefined ? ( + <span + className="ml-auto flex shrink-0 items-center gap-1 font-mono text-[10px] leading-4 tabular-nums text-muted-foreground/90" + data-testid="ticket-pr-chip" + > + <span + className={cn( + "inline-block size-1.5 rounded-full", + ticket.pr.state === "merged" + ? "bg-muted-foreground/50" + : ticket.pr.state === "closed" + ? "bg-muted-foreground/40" + : ticket.pr.ciState === "failure" + ? "bg-destructive" + : ticket.pr.ciState === "success" + ? "bg-success" + : "bg-muted-foreground/40", + )} + /> + #{ticket.pr.number} + </span> + ) : null} + </span> + ) : null} + </button> + ); +} diff --git a/apps/web/src/components/board/TicketDiff.tsx b/apps/web/src/components/board/TicketDiff.tsx new file mode 100644 index 00000000000..74719541b96 --- /dev/null +++ b/apps/web/src/components/board/TicketDiff.tsx @@ -0,0 +1,138 @@ +import { FileDiff } from "@pierre/diffs/react"; +import type { EnvironmentApi, TicketDiff as TicketDiffData, TicketId } from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { DiffStatLabel } from "~/components/chat/DiffStatLabel"; +import { + buildFileDiffRenderKey, + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, +} from "~/lib/diffRendering"; +import { useTheme } from "~/hooks/useTheme"; +import { getTicketDiff } from "~/workflow/boardRpc"; + +type TicketDiffLoadState = + | { readonly status: "loading" } + | { readonly status: "loaded"; readonly diff: TicketDiffData } + | { readonly status: "error"; readonly message: string }; + +export function TicketDiff({ + api, + ticketId, +}: { + readonly api: EnvironmentApi; + readonly ticketId: TicketId; +}) { + const { resolvedTheme } = useTheme(); + const [loadState, setLoadState] = useState<TicketDiffLoadState>({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setLoadState({ status: "loading" }); + + void getTicketDiff(api, ticketId).then( + (diff) => { + if (!cancelled) { + setLoadState({ status: "loaded", diff }); + } + }, + (error: unknown) => { + if (!cancelled) { + setLoadState({ status: "error", message: errorMessage(error) }); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [api, ticketId]); + + if (loadState.status === "loading") { + return ( + <section className="rounded-md border border-border/70 bg-card/35 p-3 text-sm text-muted-foreground"> + Loading diff... + </section> + ); + } + + if (loadState.status === "error") { + return ( + <section className="rounded-md border border-destructive/35 bg-destructive/6 p-3 text-sm text-destructive-foreground"> + {loadState.message} + </section> + ); + } + + return <TicketDiffContent diff={loadState.diff} resolvedTheme={resolvedTheme} />; +} + +export function TicketDiffContent({ + diff, + resolvedTheme, +}: { + readonly diff: TicketDiffData; + readonly resolvedTheme: "light" | "dark"; +}) { + const renderablePatch = useMemo( + () => getRenderablePatch(diff.patch, `workflow-ticket:${diff.ticketId}:${resolvedTheme}`), + [diff.patch, diff.ticketId, resolvedTheme], + ); + + return ( + <section className="flex min-h-0 flex-col gap-3 rounded-md border border-border/70 bg-card/35 p-3"> + <header className="space-y-1"> + <h3 className="text-sm font-medium text-foreground">Accumulated diff</h3> + <p className="truncate font-mono text-[11px] text-muted-foreground">Base {diff.baseRef}</p> + </header> + {diff.files.length > 0 ? ( + <ul className="space-y-1"> + {diff.files.map((file) => ( + <li + key={file.path} + className="flex items-center gap-2 rounded-md bg-background/70 px-2 py-1 text-xs" + > + <span className="min-w-0 flex-1 truncate font-mono text-foreground/85"> + {file.path} + </span> + <span className="shrink-0 font-mono tabular-nums"> + <DiffStatLabel additions={file.additions} deletions={file.deletions} /> + </span> + </li> + ))} + </ul> + ) : ( + <p className="text-xs text-muted-foreground">No changed files.</p> + )} + {diff.truncated ? <p className="text-xs text-warning-foreground">Patch truncated.</p> : null} + {!renderablePatch ? ( + <p className="text-xs text-muted-foreground">No patch available.</p> + ) : renderablePatch.kind === "raw" ? ( + <pre className="max-h-80 overflow-auto rounded-md border border-border/70 bg-background/80 p-2 font-mono text-[11px] leading-relaxed text-foreground/85"> + {renderablePatch.text} + </pre> + ) : ( + <div className="diff-render-surface max-h-[42rem] overflow-auto rounded-md border border-border/70 bg-background/70 p-2"> + {renderablePatch.files.map((fileDiff) => ( + <div key={buildFileDiffRenderKey(fileDiff)} className="mb-2 last:mb-0"> + <FileDiff + fileDiff={fileDiff} + options={{ + collapsed: false, + diffStyle: "unified", + theme: resolveDiffThemeName(resolvedTheme), + }} + /> + <span className="sr-only">{resolveFileDiffPath(fileDiff)}</span> + </div> + ))} + </div> + )} + </section> + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unable to load ticket diff."; +} diff --git a/apps/web/src/components/board/TicketDrawer.browser.tsx b/apps/web/src/components/board/TicketDrawer.browser.tsx new file mode 100644 index 00000000000..5ed1a61fdb8 --- /dev/null +++ b/apps/web/src/components/board/TicketDrawer.browser.tsx @@ -0,0 +1,651 @@ +import "../../index.css"; + +import { MessageId, ProjectId, TicketId, type EnvironmentApi } from "@t3tools/contracts"; +import type { ComponentType } from "react"; +import { page } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { + TicketDrawer, + type TicketDrawerAnswerInput, + type TicketDrawerEditInput, +} from "./TicketDrawer"; + +function createApi() { + return { + terminal: { + attachHistory: vi.fn( + ( + _input: { readonly threadId: string; readonly terminalId: string }, + listener: (event: unknown) => void, + ) => { + listener({ + type: "snapshot", + snapshot: { + threadId: "script-thread-1", + terminalId: "script-terminal-1", + history: "running tests\n", + status: "running", + }, + }); + return vi.fn(); + }, + ), + }, + workflow: { + answerTicketStep: vi.fn(async () => undefined), + editTicket: vi.fn(async () => undefined), + editTicketMessage: vi.fn(async () => undefined), + cancelStep: vi.fn(async () => undefined), + setProjectScriptTrust: vi.fn(async () => undefined), + getTicketDiff: vi.fn(async () => ({ + ticketId: TicketId.make("ticket-1"), + baseRef: "refs/workflow/tickets/ticket-1/base", + patch: "", + files: [], + truncated: false, + })), + }, + } as unknown as EnvironmentApi; +} + +function createDeferred<T>() { + let resolve!: (value: T | PromiseLike<T>) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise<T>((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +const detail = { + ticket: { + ticketId: "ticket-1", + boardId: "board-1", + title: "Review release blockers", + currentLaneKey: "review", + status: "blocked", + }, + steps: [ + { + stepRunId: "step-running", + stepKey: "tests", + stepType: "script", + status: "running", + waitingReason: null, + blockedReason: null, + scriptThreadId: "script-thread-1", + terminalId: "script-terminal-1", + scriptStatus: "running", + exitCode: null, + signal: null, + }, + { + stepRunId: "step-blocked", + stepKey: "trust", + stepType: "script", + status: "blocked", + waitingReason: null, + blockedReason: "Project not trusted to run scripts", + scriptThreadId: null, + terminalId: null, + scriptStatus: null, + exitCode: null, + signal: null, + }, + ], +} as const; + +describe("TicketDrawer script controls", () => { + it("cancels running scripts and trusts blocked projects before rerunning the lane", async () => { + const api = createApi(); + const onRunLane = vi.fn(); + const Drawer = TicketDrawer as ComponentType< + Parameters<typeof TicketDrawer>[0] & { readonly projectId: ProjectId } + >; + + await render( + <Drawer + api={api} + projectId={ProjectId.make("project-1")} + detail={detail} + lanes={[{ key: "review", name: "Review", entry: "manual", pipelineStepCount: 2 }]} + onApprove={async () => undefined} + onRunLane={onRunLane} + />, + ); + + await expect.element(page.getByText("running tests")).toBeInTheDocument(); + + await page.getByRole("button", { name: "Cancel" }).click(); + await vi.waitFor(() => { + expect(api.workflow.cancelStep).toHaveBeenCalledWith({ stepRunId: "step-running" }); + }); + + await page.getByRole("button", { name: "Trust this project & run" }).click(); + await vi.waitFor(() => { + expect(api.workflow.setProjectScriptTrust).toHaveBeenCalledWith({ + projectId: ProjectId.make("project-1"), + trusted: true, + }); + expect(onRunLane).toHaveBeenCalledOnce(); + }); + }); + + it("edits ticket metadata and submits a text plus image reply to an awaiting agent step", async () => { + const api = createApi(); + const onAnswerStep = vi.fn(async (_input: TicketDrawerAnswerInput) => undefined); + const onEditTicket = vi.fn(async (_input: TicketDrawerEditInput) => undefined); + + await render( + <TicketDrawer + api={api} + detail={{ + ticket: { + ticketId: "ticket-1", + boardId: "board-1", + title: "Review release blockers", + description: "Confirm old clients still parse the websocket payload.", + currentLaneKey: "review", + status: "waiting_on_user", + }, + steps: [ + { + stepRunId: "step-awaiting", + stepKey: "agent-review", + stepType: "agent", + status: "awaiting_user", + waitingReason: "Need compatibility guidance", + providerResponseKind: "user-input", + }, + ], + messages: [ + { + messageId: MessageId.make("message-agent"), + ticketId: "ticket-1", + stepRunId: "step-awaiting", + author: "agent", + body: "Should the guard accept the legacy shape?", + attachments: [], + createdAt: "2026-06-08T14:00:00.000Z", + }, + ], + }} + lanes={[{ key: "review", name: "Review", entry: "manual", pipelineStepCount: 1 }]} + onApprove={async () => undefined} + onAnswerStep={onAnswerStep} + onEditTicket={onEditTicket} + onRunLane={() => undefined} + />, + ); + + await expect.element(page.getByText("Should the guard accept the legacy shape?")).toBeVisible(); + await expect + .element(page.getByText("Confirm old clients still parse the websocket payload.")) + .toBeVisible(); + + await page.getByRole("button", { name: "Edit ticket" }).click(); + await page.getByLabelText("Ticket title").fill("Updated blockers"); + await page.getByLabelText("Ticket description").fill("Preserve legacy websocket parsing."); + await page.getByRole("button", { name: "Save ticket" }).click(); + + await vi.waitFor(() => { + expect(onEditTicket).toHaveBeenCalledWith({ + ticketId: "ticket-1", + title: "Updated blockers", + description: "Preserve legacy websocket parsing.", + }); + }); + + await page.getByLabelText("Ticket reply").fill("Yes, accept both shapes."); + const fileInput = document.querySelector<HTMLInputElement>('input[type="file"]'); + expect(fileInput).toBeTruthy(); + if (!fileInput) { + throw new Error("Expected an image attachment input."); + } + const file = new File(["png"], "payload.png", { type: "image/png" }); + Object.defineProperty(fileInput, "files", { configurable: true, value: [file] }); + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + await expect.element(page.getByText("payload.png")).toBeVisible(); + + await page.getByRole("button", { name: "Send reply" }).click(); + + await vi.waitFor(() => { + expect(onAnswerStep).toHaveBeenCalledOnce(); + const input = onAnswerStep.mock.calls[0]?.[0]; + expect(input).toBeDefined(); + if (!input) { + throw new Error("Expected answer input."); + } + const attachments = input.attachments ?? []; + expect(input).toMatchObject({ + stepRunId: "step-awaiting", + text: "Yes, accept both shapes.", + }); + expect(attachments).toHaveLength(1); + expect(attachments[0]).toMatchObject({ + kind: "image", + name: "payload.png", + mimeType: "image/png", + sizeBytes: 3, + }); + const attachment = attachments[0]; + expect(attachment?.kind).toBe("image"); + if (!attachment || attachment.kind !== "image") { + throw new Error("Expected an image attachment."); + } + expect(attachment.dataUrl).toMatch(/^data:image\/png;base64,/); + }); + }); + + it("disables the reply composer while sending and surfaces send failures", async () => { + const api = createApi(); + const sendResult = createDeferred<void>(); + const onAnswerStep = vi.fn((_input: TicketDrawerAnswerInput) => sendResult.promise); + + await render( + <TicketDrawer + api={api} + detail={{ + ticket: { + ticketId: "ticket-1", + boardId: "board-1", + title: "Review release blockers", + currentLaneKey: "review", + status: "waiting_on_user", + }, + steps: [ + { + stepRunId: "step-awaiting", + stepKey: "agent-review", + stepType: "agent", + status: "awaiting_user", + waitingReason: "Need compatibility guidance", + providerResponseKind: "user-input", + }, + ], + messages: [], + }} + lanes={[{ key: "review", name: "Review", entry: "manual", pipelineStepCount: 1 }]} + onApprove={async () => undefined} + onAnswerStep={onAnswerStep} + onRunLane={() => undefined} + />, + ); + + const replyInput = page.getByLabelText("Ticket reply"); + const sendButton = page.getByRole("button", { name: "Send reply" }); + + await replyInput.fill("Keep the compatibility guard."); + await sendButton.click(); + + await vi.waitFor(() => { + expect(onAnswerStep).toHaveBeenCalledOnce(); + }); + await expect.element(replyInput).toBeDisabled(); + await expect.element(sendButton).toBeDisabled(); + + sendResult.reject(new Error("RPC failed")); + + await expect.element(page.getByText("RPC failed")).toBeVisible(); + await expect.element(replyInput).toBeEnabled(); + await expect.element(sendButton).toBeEnabled(); + expect(onAnswerStep).toHaveBeenCalledOnce(); + }); + + it("disables approval actions while pending and surfaces approval failures", async () => { + const api = createApi(); + const approvalResult = createDeferred<void>(); + const onApprove = vi.fn((_stepRunId: string, _approved: boolean) => approvalResult.promise); + + await render( + <TicketDrawer + api={api} + detail={{ + ticket: { + ticketId: "ticket-1", + boardId: "board-1", + title: "Review release blockers", + currentLaneKey: "review", + status: "waiting_on_user", + }, + steps: [ + { + stepRunId: "step-approval", + stepKey: "agent-approval", + stepType: "agent", + status: "awaiting_user", + waitingReason: "Approve the provider request?", + providerResponseKind: "request", + }, + ], + messages: [], + }} + lanes={[{ key: "review", name: "Review", entry: "manual", pipelineStepCount: 1 }]} + onApprove={onApprove} + onRunLane={() => undefined} + />, + ); + + const approveButton = page.getByRole("button", { name: "Approve" }); + const rejectButton = page.getByRole("button", { name: "Reject" }); + + await approveButton.click(); + + await vi.waitFor(() => { + expect(onApprove).toHaveBeenCalledWith("step-approval", true); + }); + await expect.element(approveButton).toBeDisabled(); + await expect.element(rejectButton).toBeDisabled(); + + approvalResult.reject(new Error("Approval RPC failed")); + + await expect.element(page.getByText("Approval RPC failed")).toBeVisible(); + await expect.element(approveButton).toBeEnabled(); + await expect.element(rejectButton).toBeEnabled(); + expect(onApprove).toHaveBeenCalledOnce(); + }); +}); + +describe("TicketDrawer comments", () => { + it("posts a comment when no step is awaiting input", async () => { + const api = createApi(); + const onPostComment = vi.fn(async (_input: unknown) => undefined); + + await render( + <TicketDrawer + api={api} + detail={{ + ticket: { + ticketId: "ticket-quiet", + boardId: "board-1", + title: "Quiet ticket", + currentLaneKey: "backlog", + status: "idle", + }, + steps: [], + messages: [], + }} + lanes={[{ key: "backlog", name: "Backlog", entry: "manual", pipelineStepCount: 0 }]} + onApprove={async () => undefined} + onPostComment={onPostComment} + onRunLane={() => undefined} + />, + ); + + await expect.element(page.getByText(/No discussion yet/)).toBeVisible(); + await page.getByLabelText("Add a comment").fill("Remember to check the auth flow."); + await page.getByRole("button", { name: "Comment" }).click(); + + await vi.waitFor(() => { + expect(onPostComment).toHaveBeenCalledWith({ + ticketId: "ticket-quiet", + text: "Remember to check the auth flow.", + }); + }); + }); + + it("previews the comment draft as Markdown before posting", async () => { + const api = createApi(); + const onPostComment = vi.fn(async (_input: unknown) => undefined); + + await render( + <TicketDrawer + api={api} + detail={{ + ticket: { + ticketId: "ticket-preview", + boardId: "board-1", + title: "Preview ticket", + currentLaneKey: "backlog", + status: "idle", + }, + steps: [], + messages: [], + }} + lanes={[{ key: "backlog", name: "Backlog", entry: "manual", pipelineStepCount: 0 }]} + onApprove={async () => undefined} + onPostComment={onPostComment} + onRunLane={() => undefined} + />, + ); + + await page.getByLabelText("Add a comment").fill("**bold draft**"); + await page.getByRole("button", { name: "Preview" }).click(); + + const preview = page.getByText("bold draft"); + await expect.element(preview).toBeVisible(); + expect(preview.element().closest("strong")).not.toBeNull(); + }); + + it("edits the user's own comment with a Markdown preview and saves through onEditMessage", async () => { + const api = createApi(); + const onEditMessage = vi.fn(async (_messageId: string, _body: string) => undefined); + + await render( + <TicketDrawer + api={api} + detail={{ + ticket: { + ticketId: "ticket-edit", + boardId: "board-1", + title: "Edit ticket", + currentLaneKey: "backlog", + status: "idle", + }, + steps: [], + messages: [ + { + messageId: MessageId.make("message-own"), + ticketId: "ticket-edit", + author: "user", + body: "Original comment.", + attachments: [], + createdAt: "2026-06-08T14:00:00.000Z", + }, + { + messageId: MessageId.make("message-agent"), + ticketId: "ticket-edit", + author: "agent", + body: "Agent reply.", + attachments: [], + createdAt: "2026-06-08T14:01:00.000Z", + }, + ], + }} + lanes={[{ key: "backlog", name: "Backlog", entry: "manual", pipelineStepCount: 0 }]} + onApprove={async () => undefined} + onEditMessage={onEditMessage} + onPostComment={async () => undefined} + onRunLane={() => undefined} + />, + ); + + // Exactly one Edit-comment button — for the user's own comment, not the agent's. + const editButtons = document.querySelectorAll('[aria-label="Edit comment"]'); + expect(editButtons).toHaveLength(1); + + await page.getByRole("button", { name: "Edit comment" }).click(); + + const editField = page.getByLabelText("Edit comment"); + await expect.element(editField).toBeVisible(); + await editField.fill("Edited **comment**."); + await page.getByRole("button", { name: "Save" }).click(); + + await vi.waitFor(() => { + expect(onEditMessage).toHaveBeenCalledWith("message-own", "Edited **comment**."); + }); + }); + + it("cancels editing without calling onEditMessage", async () => { + const api = createApi(); + const onEditMessage = vi.fn(async (_messageId: string, _body: string) => undefined); + + await render( + <TicketDrawer + api={api} + detail={{ + ticket: { + ticketId: "ticket-cancel", + boardId: "board-1", + title: "Cancel ticket", + currentLaneKey: "backlog", + status: "idle", + }, + steps: [], + messages: [ + { + messageId: MessageId.make("message-own"), + ticketId: "ticket-cancel", + author: "user", + body: "Keep this comment.", + attachments: [], + createdAt: "2026-06-08T14:00:00.000Z", + }, + ], + }} + lanes={[{ key: "backlog", name: "Backlog", entry: "manual", pipelineStepCount: 0 }]} + onApprove={async () => undefined} + onEditMessage={onEditMessage} + onPostComment={async () => undefined} + onRunLane={() => undefined} + />, + ); + + await page.getByRole("button", { name: "Edit comment" }).click(); + await expect.element(page.getByLabelText("Edit comment")).toBeVisible(); + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect.element(page.getByText("Keep this comment.")).toBeVisible(); + expect(onEditMessage).not.toHaveBeenCalled(); + }); +}); + +describe("TicketDrawer PR row", () => { + it("renders the PR link with href/target/rel plus state and CI badges when pr is present", async () => { + const api = createApi(); + + await render( + <TicketDrawer + api={api} + detail={{ + ticket: { + ticketId: "ticket-pr", + boardId: "board-1", + title: "Ship the PR loop", + currentLaneKey: "review", + status: "running", + pr: { + number: 4242, + url: "https://github.com/org/repo/pull/4242", + state: "open", + ciState: "success", + }, + }, + steps: [], + messages: [], + }} + lanes={[{ key: "review", name: "Review", entry: "manual", pipelineStepCount: 0 }]} + onApprove={async () => undefined} + onRunLane={() => undefined} + />, + ); + + await expect.element(page.getByTestId("ticket-pr-row")).toBeVisible(); + + const link = page.getByTestId("ticket-pr-link"); + await expect.element(link).toBeVisible(); + const linkElement = link.element() as HTMLAnchorElement; + expect(linkElement.getAttribute("href")).toBe("https://github.com/org/repo/pull/4242"); + expect(linkElement.getAttribute("target")).toBe("_blank"); + expect(linkElement.getAttribute("rel")).toBe("noopener noreferrer"); + expect(linkElement.textContent).toContain("#4242"); + + await expect.element(page.getByTestId("ticket-pr-state")).toHaveTextContent("open"); + await expect.element(page.getByTestId("ticket-pr-ci-state")).toHaveTextContent("success"); + }); + + it("renders no PR row when pr is absent", async () => { + const api = createApi(); + + await render( + <TicketDrawer + api={api} + detail={{ + ticket: { + ticketId: "ticket-no-pr", + boardId: "board-1", + title: "No PR yet", + currentLaneKey: "review", + status: "idle", + }, + steps: [], + messages: [], + }} + lanes={[{ key: "review", name: "Review", entry: "manual", pipelineStepCount: 0 }]} + onApprove={async () => undefined} + onRunLane={() => undefined} + />, + ); + + await expect.element(page.getByText("No PR yet")).toBeVisible(); + expect(document.querySelector('[data-testid="ticket-pr-row"]')).toBeNull(); + }); +}); + +describe("TicketDrawer lane actions", () => { + it("renders action buttons with target hints and moves the ticket", async () => { + const api = createApi(); + const onMove = vi.fn(); + + await render( + <TicketDrawer + api={api} + detail={{ + ticket: { + ticketId: "ticket-actions", + boardId: "board-1", + title: "Ready ticket", + currentLaneKey: "owner_review", + status: "idle", + }, + steps: [], + messages: [], + }} + lanes={[ + { + key: "owner_review", + name: "Owner Review", + entry: "manual", + pipelineStepCount: 0, + actions: [ + { + label: "Approve & land", + to: "land", + hint: "Merge the ticket's work into your branch.", + }, + { label: "Send back", to: "implementation" }, + ], + }, + { key: "land", name: "Land", entry: "manual", pipelineStepCount: 1 }, + { key: "implementation", name: "Implementation", entry: "auto", pipelineStepCount: 2 }, + ]} + onApprove={async () => undefined} + onMove={onMove} + onRunLane={() => undefined} + />, + ); + + await expect.element(page.getByRole("button", { name: /Approve & land/ })).toBeVisible(); + await expect.element(page.getByText("→ Land")).toBeVisible(); + await expect.element(page.getByText("→ Implementation")).toBeVisible(); + + await page.getByRole("button", { name: /Approve & land/ }).click(); + expect(onMove).toHaveBeenCalledWith("land"); + + await page.getByRole("button", { name: /Send back/ }).click(); + expect(onMove).toHaveBeenCalledWith("implementation"); + }); +}); diff --git a/apps/web/src/components/board/TicketDrawer.test.tsx b/apps/web/src/components/board/TicketDrawer.test.tsx new file mode 100644 index 00000000000..4b2bd26cf51 --- /dev/null +++ b/apps/web/src/components/board/TicketDrawer.test.tsx @@ -0,0 +1,486 @@ +import { MessageId, ProjectId, TicketId } from "@t3tools/contracts"; +import type { ComponentType, ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { TicketDiffContent } from "./TicketDiff"; +import { TicketDrawer, isTicketSourceOwned } from "./TicketDrawer"; + +vi.mock("@pierre/diffs/react", () => { + const FileDiff = (props: { + fileDiff: { name?: string | null; prevName?: string | null }; + renderHeaderPrefix?: () => ReactNode; + }) => ( + <div data-testid="file-diff"> + {props.renderHeaderPrefix?.()} + {props.fileDiff.name ?? props.fileDiff.prevName ?? "diff"} + </div> + ); + + return { FileDiff }; +}); + +const ticketDetail = { + ticket: { + ticketId: "ticket-1", + boardId: "board-1", + title: "Review release blockers", + description: "Check the compatibility risk before shipping.", + currentLaneKey: "review", + status: "waiting_on_user", + }, + steps: [ + { + stepRunId: "step-1", + stepKey: "agent-review", + stepType: "agent", + status: "awaiting_user", + waitingReason: "Approve the proposed fix", + providerResponseKind: "user-input", + }, + { + stepRunId: "step-2", + stepKey: "ship", + stepType: "approval", + status: "awaiting_user", + waitingReason: "Ship this release?", + providerResponseKind: "request", + }, + ], + messages: [ + { + messageId: MessageId.make("message-agent"), + ticketId: "ticket-1", + stepRunId: "step-1", + author: "agent", + body: "Should I change the websocket payload guard?", + attachments: [], + createdAt: "2026-06-08T14:00:00.000Z", + }, + { + messageId: MessageId.make("message-user"), + ticketId: "ticket-1", + stepRunId: "step-1", + author: "user", + body: "Yes, preserve old clients too.", + attachments: [ + { + kind: "image", + id: "image-1", + name: "payload.png", + mimeType: "image/png", + sizeBytes: 7, + dataUrl: "data:image/png;base64,cGF5bG9hZA==", + }, + ], + createdAt: "2026-06-08T14:01:00.000Z", + }, + ], +} as const; + +describe("isTicketSourceOwned", () => { + it("returns false when syncedSource is absent", () => { + expect(isTicketSourceOwned({ syncedSource: undefined })).toBe(false); + }); + + it("returns true when syncedSource is present", () => { + expect( + isTicketSourceOwned({ + syncedSource: { provider: "github", url: "https://github.com/o/r/issues/1" }, + }), + ).toBe(true); + }); +}); + +describe("TicketDrawer", () => { + it("renders ticket metadata, the message thread, the reply composer, and approval gates", () => { + const markup = renderToStaticMarkup( + <TicketDrawer detail={ticketDetail} onApprove={async () => undefined} onRunLane={() => {}} />, + ); + + expect(markup).toContain("Review release blockers"); + expect(markup).toContain("Check the compatibility risk before shipping."); + expect(markup).toContain("agent-review"); + expect(markup).toContain("awaiting user"); + expect(markup).toContain("Approve the proposed fix"); + expect(markup).toContain("Should I change the websocket payload guard?"); + expect(markup).toContain("Yes, preserve old clients too."); + expect(markup).toContain("payload.png"); + expect(markup).toContain("Ticket reply"); + expect(markup).toContain("Send reply"); + expect(markup).toContain("Edit ticket"); + expect(markup).toContain("Approve"); + expect(markup).toContain("Reject"); + expect(markup).toContain("Run lane"); + }); + + it("renders an edited indicator for messages with editedAt and omits it otherwise", () => { + const markup = renderToStaticMarkup( + <TicketDrawer + detail={{ + ...ticketDetail, + messages: [ + { + messageId: MessageId.make("message-edited"), + ticketId: "ticket-1", + author: "user", + body: "Edited body text.", + attachments: [], + createdAt: "2026-06-08T14:01:00.000Z", + editedAt: "2026-06-08T14:05:00.000Z", + }, + { + messageId: MessageId.make("message-plain"), + ticketId: "ticket-1", + author: "user", + body: "Unedited body text.", + attachments: [], + createdAt: "2026-06-08T14:02:00.000Z", + }, + ], + }} + onApprove={async () => undefined} + onRunLane={() => {}} + />, + ); + + expect(markup).toContain("Edited body text."); + expect(markup).toContain("Unedited body text."); + expect(markup).toContain("· edited"); + // Only the edited message should carry the indicator. + expect(markup.match(/· edited/g)?.length).toBe(1); + }); + + it("shows an Edit button only for the user's own comments (stepRunId == null)", () => { + const markup = renderToStaticMarkup( + <TicketDrawer + detail={{ + ...ticketDetail, + messages: [ + { + messageId: MessageId.make("message-own"), + ticketId: "ticket-1", + author: "user", + body: "My own comment.", + attachments: [], + createdAt: "2026-06-08T14:00:00.000Z", + }, + { + messageId: MessageId.make("message-answer"), + ticketId: "ticket-1", + stepRunId: "step-1", + author: "user", + body: "Answer to an agent step.", + attachments: [], + createdAt: "2026-06-08T14:01:00.000Z", + }, + { + messageId: MessageId.make("message-agent"), + ticketId: "ticket-1", + author: "agent", + body: "Agent reply.", + attachments: [], + createdAt: "2026-06-08T14:02:00.000Z", + }, + ], + }} + onApprove={async () => undefined} + onEditMessage={async () => undefined} + onRunLane={() => {}} + />, + ); + + // Exactly one Edit-message button — for the standalone user comment only. + expect(markup.match(/aria-label="Edit comment"/g)?.length).toBe(1); + }); + + it("explains why the ticket is in its lane and lists the route history", () => { + const markup = renderToStaticMarkup( + <TicketDrawer + detail={{ + ...ticketDetail, + routeHistory: [ + { + occurredAt: "2026-06-08T13:00:00.000Z", + toLane: "implement", + source: "manual", + }, + { + occurredAt: "2026-06-08T14:00:00.000Z", + fromLane: "implement", + toLane: "review", + source: "lane_transition", + matchedTransitionIndex: 1, + pipelineResult: "success", + laneRunCount: 2, + steps: { + verdict: { status: "completed", exitCode: 0, verdict: "approve" }, + }, + }, + ], + }} + lanes={[ + { key: "implement", name: "Implementation", entry: "auto", pipelineStepCount: 1 }, + { key: "review", name: "Review", entry: "manual", pipelineStepCount: 0 }, + ]} + onApprove={async () => undefined} + onRunLane={() => {}} + />, + ); + + expect(markup).toContain("Why is this ticket here?"); + expect(markup).toContain("Implementation → Review"); + expect(markup).toContain("Matched transition #2"); + expect(markup).toContain("verdict: approve"); + expect(markup).toContain("Route history (2)"); + expect(markup).toContain("Moved manually"); + }); + + it("renders captured step output with a verdict badge", () => { + const markup = renderToStaticMarkup( + <TicketDrawer + detail={{ + ...ticketDetail, + steps: [ + { + stepRunId: "step-verdict", + stepKey: "review", + stepType: "agent", + status: "completed", + waitingReason: null, + output: { verdict: "revise", notes: "Tighten the error handling." }, + }, + ], + }} + onApprove={async () => undefined} + onRunLane={() => {}} + />, + ); + + expect(markup).toContain("verdict: revise"); + expect(markup).toContain("Tighten the error handling."); + }); + + it("shows approval actions instead of the reply composer for provider approval requests", () => { + const markup = renderToStaticMarkup( + <TicketDrawer + detail={{ + ...ticketDetail, + steps: [ + { + stepRunId: "step-provider-request", + stepKey: "agent-review", + stepType: "agent", + status: "awaiting_user", + waitingReason: "Approve this command?", + providerResponseKind: "request", + }, + ], + messages: [], + }} + onApprove={async () => undefined} + onRunLane={() => {}} + />, + ); + + expect(markup).toContain("Approve this command?"); + expect(markup).toContain("Approve"); + expect(markup).toContain("Reject"); + expect(markup).not.toContain("Ticket reply"); + expect(markup).not.toContain("Send reply"); + }); + + it("shows the reply composer for provider user-input requests", () => { + const markup = renderToStaticMarkup( + <TicketDrawer + detail={{ + ...ticketDetail, + steps: [ + { + stepRunId: "step-provider-question", + stepKey: "agent-review", + stepType: "agent", + status: "awaiting_user", + waitingReason: "Which API should I use?", + providerResponseKind: "user-input", + }, + ], + messages: [], + }} + onApprove={async () => undefined} + onRunLane={() => {}} + />, + ); + + expect(markup).toContain("Which API should I use?"); + expect(markup).toContain("Ticket reply"); + expect(markup).toContain("Send reply"); + expect(markup).not.toContain("Approve"); + expect(markup).not.toContain("Reject"); + }); + + it("renders ticket image attachments without direct data-url links", () => { + const markup = renderToStaticMarkup( + <TicketDrawer detail={ticketDetail} onApprove={async () => undefined} onRunLane={() => {}} />, + ); + + expect(markup).toContain('src="data:image/png;base64,cGF5bG9hZA=="'); + expect(markup).not.toContain('href="data:image/png'); + }); + + it("disables Run lane when the current lane has no manual pipeline", () => { + const markup = renderToStaticMarkup( + <TicketDrawer + detail={ticketDetail} + lanes={[ + { key: "review", name: "Review", entry: "manual", pipelineStepCount: 0 }, + { key: "implement", name: "Implement", entry: "auto", pipelineStepCount: 2 }, + ]} + onApprove={async () => undefined} + onRunLane={() => {}} + />, + ); + + expect(markup).toContain('title="This lane has no manual pipeline to run."'); + expect(markup).toMatch(/<button[^>]*disabled=""[^>]*>.*Run lane<\/button>/s); + }); + + it("renders script steps with read-only logs and operational badges", () => { + const Drawer = TicketDrawer as ComponentType< + Parameters<typeof TicketDrawer>[0] & { readonly projectId: ProjectId } + >; + const markup = renderToStaticMarkup( + <Drawer + api={ + { + terminal: { + attachHistory: () => () => undefined, + }, + } as never + } + projectId={ProjectId.make("project-1")} + detail={{ + ticket: { + ticketId: "ticket-1", + boardId: "board-1", + title: "Review release blockers", + currentLaneKey: "review", + status: "blocked", + }, + steps: [ + { + stepRunId: "step-running", + stepKey: "tests", + stepType: "script", + status: "running", + waitingReason: null, + blockedReason: null, + scriptThreadId: "script-thread-1", + terminalId: "script-terminal-1", + scriptStatus: "running", + exitCode: null, + signal: null, + }, + { + stepRunId: "step-failed", + stepKey: "lint", + stepType: "script", + status: "failed", + waitingReason: null, + blockedReason: null, + scriptThreadId: "script-thread-2", + terminalId: "script-terminal-2", + scriptStatus: "exited", + exitCode: 2, + signal: null, + }, + { + stepRunId: "step-blocked", + stepKey: "trust", + stepType: "script", + status: "blocked", + waitingReason: null, + blockedReason: "Project not trusted to run scripts", + scriptThreadId: null, + terminalId: null, + scriptStatus: null, + exitCode: null, + signal: null, + }, + ], + }} + lanes={[{ key: "review", name: "Review", entry: "manual", pipelineStepCount: 3 }]} + onApprove={async () => undefined} + onRunLane={() => {}} + />, + ); + + expect(markup).toContain("Script output"); + expect(markup).toContain("running"); + expect(markup).toContain("exit 2"); + expect(markup).toContain("blocked"); + expect(markup).toContain("Cancel"); + expect(markup).toContain("Trust this project & run"); + }); +}); + +describe("TicketDrawer synced-source badge", () => { + it("shows Synced from badge and hides Edit button when syncedSource is set", () => { + const markup = renderToStaticMarkup( + <TicketDrawer + detail={{ + ...ticketDetail, + syncedSource: { + provider: "github", + url: "https://github.com/owner/repo/issues/42", + }, + }} + onApprove={async () => undefined} + onRunLane={() => {}} + />, + ); + + expect(markup).toContain("Synced from github"); + expect(markup).toContain("https://github.com/owner/repo/issues/42"); + expect(markup).not.toContain("Edit ticket"); + }); + + it("shows Edit ticket button when syncedSource is absent", () => { + const markup = renderToStaticMarkup( + <TicketDrawer detail={ticketDetail} onApprove={async () => undefined} onRunLane={() => {}} />, + ); + + expect(markup).not.toContain("Synced from"); + expect(markup).toContain("Edit ticket"); + }); +}); + +describe("TicketDiffContent", () => { + it("renders file summaries and the parsed patch viewer", () => { + const markup = renderToStaticMarkup( + <TicketDiffContent + diff={{ + ticketId: TicketId.make("ticket-1"), + baseRef: "refs/workflow/tickets/ticket-1/base", + truncated: false, + files: [{ path: "src/workflow.ts", additions: 4, deletions: 1 }], + patch: + "diff --git a/src/workflow.ts b/src/workflow.ts\n" + + "index 1111111..2222222 100644\n" + + "--- a/src/workflow.ts\n" + + "+++ b/src/workflow.ts\n" + + "@@ -1 +1 @@\n" + + "-old\n" + + "+new\n", + }} + resolvedTheme="light" + />, + ); + + expect(markup).toContain("refs/workflow/tickets/ticket-1/base"); + expect(markup).toContain("src/workflow.ts"); + expect(markup).toContain("+4"); + expect(markup).toContain("-1"); + expect(markup).toContain("file-diff"); + }); +}); diff --git a/apps/web/src/components/board/TicketDrawer.tsx b/apps/web/src/components/board/TicketDrawer.tsx new file mode 100644 index 00000000000..54aa5c758d0 --- /dev/null +++ b/apps/web/src/components/board/TicketDrawer.tsx @@ -0,0 +1,1801 @@ +import { + ProjectId, + StepRunId, + type TicketAttachment, + ThreadId, + TicketId, + type EnvironmentApi, + type TerminalHistoryAttachStreamEvent, +} from "@t3tools/contracts"; +import { + CheckIcon, + ImageIcon, + Maximize2Icon, + Minimize2Icon, + PencilIcon, + PlayIcon, + SendIcon, + XIcon, +} from "lucide-react"; +import { type ChangeEvent, type FormEvent, useEffect, useState } from "react"; + +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { cn, randomUUID } from "~/lib/utils"; +import { stepUsageSummary } from "~/workflow/usageFormat"; + +import { + describeRouteDecision, + extractVerdict, + truncateLabel, + type RouteDecisionView, +} from "~/workflow/routeDecision"; + +import { readFileAsDataUrl } from "../ChatView.logic"; +import ChatMarkdown from "../ChatMarkdown"; +import { AgentSessionDialog } from "./AgentSessionDialog"; +import { MarkdownComposerField } from "./MarkdownComposerField"; +import { TicketArtifacts } from "./TicketArtifacts"; +import { StepActivityFeed } from "./StepActivityFeed"; +import { TicketDiff } from "./TicketDiff"; +import { WorkflowEditorFullscreen } from "./editor/WorkflowEditorFullscreen"; + +const SAFE_REPLY_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); + +type TicketDrawerAttachment = + | { + readonly kind: "image"; + readonly id: string; + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: number; + readonly dataUrl: string; + } + | { + readonly kind: "video" | "file"; + readonly id: string; + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: number; + readonly ref: string; + }; + +export interface TicketDrawerAnswerInput { + readonly stepRunId: string; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray<TicketAttachment> | undefined; +} + +export interface TicketDrawerEditInput { + readonly ticketId: string; + readonly title?: string | undefined; + readonly description?: string | undefined; +} + +export interface TicketDrawerDetail { + readonly ticket: { + readonly ticketId: string; + readonly boardId?: string | undefined; + readonly title: string; + readonly description?: string | undefined; + readonly currentLaneKey: string; + readonly status: string; + readonly pr?: + | { + readonly number: number; + readonly url: string; + readonly state: "open" | "merged" | "closed"; + readonly ciState?: "pending" | "success" | "failure" | undefined; + } + | undefined; + }; + readonly steps: ReadonlyArray<{ + readonly stepRunId: string; + readonly stepKey: string; + readonly stepType: string; + readonly attempt?: number | undefined; + readonly status: string; + readonly waitingReason: string | null; + readonly blockedReason?: string | null | undefined; + readonly providerResponseKind?: "request" | "user-input" | null | undefined; + readonly scriptThreadId?: string | null | undefined; + readonly terminalId?: string | null | undefined; + readonly scriptStatus?: string | null | undefined; + readonly exitCode?: number | null | undefined; + readonly signal?: number | null | undefined; + readonly startedAt?: string | undefined; + readonly finishedAt?: string | undefined; + readonly usage?: { readonly totalTokens?: number | undefined } | undefined; + readonly providerThreadId?: string | undefined; + readonly output?: unknown; + }>; + readonly routeHistory?: ReadonlyArray<RouteDecisionView> | undefined; + readonly messages?: ReadonlyArray<{ + readonly messageId: string; + readonly ticketId: string; + readonly stepRunId?: string | undefined; + readonly author: "agent" | "user"; + readonly body: string; + readonly attachments: ReadonlyArray<TicketDrawerAttachment>; + readonly createdAt: string; + readonly editedAt?: string | undefined; + }>; + readonly syncedSource?: + | { + readonly provider: string; + readonly url: string; + readonly assignees?: ReadonlyArray<string> | undefined; + readonly labels?: ReadonlyArray<string> | undefined; + } + | undefined; +} + +export interface TicketDrawerCommentInput { + readonly ticketId: string; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray<TicketAttachment> | undefined; +} + +/** Returns true when the ticket is owned by an external work-source sync and its + * title/description fields should be read-only in the UI. */ +export function isTicketSourceOwned(detail: Pick<TicketDrawerDetail, "syncedSource">): boolean { + return Boolean(detail.syncedSource); +} + +export interface TicketDrawerLaneAction { + readonly label: string; + readonly to: string; + readonly hint?: string | undefined; +} + +export interface TicketDrawerLane { + readonly key: string; + readonly name: string; + readonly entry: string; + readonly pipelineStepCount: number; + readonly actions?: ReadonlyArray<TicketDrawerLaneAction> | undefined; +} + +export function TicketDrawer({ + api, + detail, + lanes = [], + onAnswerStep, + onPostComment, + onEditMessage, + onApprove, + onEditTicket, + onMove, + onRunLane, + projectId, + cwd, +}: { + readonly api?: EnvironmentApi | undefined; + readonly detail: TicketDrawerDetail; + readonly lanes?: ReadonlyArray<TicketDrawerLane>; + readonly onAnswerStep?: ((input: TicketDrawerAnswerInput) => Promise<void>) | undefined; + readonly onPostComment?: ((input: TicketDrawerCommentInput) => Promise<void>) | undefined; + readonly onEditMessage?: ((messageId: string, body: string) => Promise<void>) | undefined; + readonly onApprove: (stepRunId: string, approved: boolean) => Promise<void>; + readonly onEditTicket?: ((input: TicketDrawerEditInput) => Promise<void>) | undefined; + readonly onMove?: ((toLane: string) => void) | undefined; + readonly onRunLane: () => void; + readonly projectId?: ProjectId | undefined; + readonly cwd?: string | undefined; +}) { + const sourceOwned = isTicketSourceOwned(detail); + const [fullscreen, setFullscreen] = useState(false); + const [editingTicket, setEditingTicket] = useState(false); + const [draftTitle, setDraftTitle] = useState(detail.ticket.title); + const [draftDescription, setDraftDescription] = useState(detail.ticket.description ?? ""); + const [editError, setEditError] = useState<string | null>(null); + const [editSubmitting, setEditSubmitting] = useState(false); + const [replyText, setReplyText] = useState(""); + const [replyAttachments, setReplyAttachments] = useState<ReadonlyArray<TicketDrawerAttachment>>( + [], + ); + const [replyError, setReplyError] = useState<string | null>(null); + const [replySubmitting, setReplySubmitting] = useState(false); + const [approvalSubmittingStepRunId, setApprovalSubmittingStepRunId] = useState<string | null>( + null, + ); + const [approvalError, setApprovalError] = useState<{ + readonly stepRunId: string; + readonly message: string; + } | null>(null); + const waitingStepCount = detail.steps.filter((step) => step.status === "awaiting_user").length; + const currentLane = lanes.find((lane) => lane.key === detail.ticket.currentLaneKey) ?? null; + const laneActions = currentLane?.actions ?? []; + const canRunLane = + currentLane !== null && currentLane.entry === "manual" && currentLane.pipelineStepCount > 0; + const runLaneTitle = canRunLane + ? `Run ${currentLane.name}` + : "This lane has no manual pipeline to run."; + const ticketDescription = detail.ticket.description?.trim() ?? ""; + const replyStep = detail.steps.find(isAwaitingUserInputStep) ?? null; + const canReply = replyStep !== null && detail.ticket.status === "waiting_on_user"; + const laneDisplayName = (key: string): string => + lanes.find((lane) => lane.key === key)?.name ?? key; + const routeHistory = detail.routeHistory ?? []; + const latestRouteEntry = routeHistory.at(-1); + const latestRouteDecision = + latestRouteEntry === undefined + ? null + : describeRouteDecision(latestRouteEntry, laneDisplayName); + + useEffect(() => { + if (editingTicket) { + return; + } + setDraftTitle(detail.ticket.title); + setDraftDescription(detail.ticket.description ?? ""); + }, [detail.ticket.description, detail.ticket.title, editingTicket]); + + const saveTicketEdit = async (event: FormEvent<HTMLFormElement>) => { + event.preventDefault(); + const title = draftTitle.trim(); + if (!title || !onEditTicket) { + return; + } + + setEditSubmitting(true); + setEditError(null); + try { + await onEditTicket({ + ticketId: detail.ticket.ticketId, + title, + description: draftDescription.trim(), + }); + setEditingTicket(false); + } catch (error) { + setEditError(error instanceof Error ? error.message : "Could not save ticket."); + } finally { + setEditSubmitting(false); + } + }; + + const attachReplyImages = async (event: ChangeEvent<HTMLInputElement>) => { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ""; + if (files.length === 0) { + return; + } + + const images = files.filter((file) => SAFE_REPLY_IMAGE_MIME_TYPES.has(file.type)); + if (images.length !== files.length) { + setReplyError("Only PNG, JPEG, GIF, or WebP image attachments are supported."); + } else { + setReplyError(null); + } + + const nextAttachments = await Promise.all( + images.map(async (file) => ({ + kind: "image" as const, + id: randomUUID(), + name: file.name || "image", + mimeType: file.type || "image/png", + sizeBytes: file.size, + dataUrl: await readFileAsDataUrl(file), + })), + ); + if (nextAttachments.length > 0) { + setReplyAttachments((current) => [...current, ...nextAttachments]); + } + }; + + const sendReply = async (event: FormEvent<HTMLFormElement>) => { + event.preventDefault(); + const text = replyText.trim(); + if (!text && replyAttachments.length === 0) { + return; + } + const attachmentsInput = + replyAttachments.length > 0 + ? { attachments: replyAttachments as ReadonlyArray<TicketAttachment> } + : {}; + + setReplySubmitting(true); + setReplyError(null); + try { + if (canReply && replyStep && onAnswerStep) { + await onAnswerStep({ + stepRunId: replyStep.stepRunId, + ...(text ? { text } : {}), + ...attachmentsInput, + }); + } else if (onPostComment) { + await onPostComment({ + ticketId: detail.ticket.ticketId, + ...(text ? { text } : {}), + ...attachmentsInput, + }); + } else { + return; + } + setReplyText(""); + setReplyAttachments([]); + } catch (error) { + setReplyError(error instanceof Error ? error.message : "Could not send message."); + } finally { + setReplySubmitting(false); + } + }; + + const submitApproval = async (stepRunId: string, approved: boolean) => { + setApprovalSubmittingStepRunId(stepRunId); + setApprovalError(null); + try { + await onApprove(stepRunId, approved); + } catch (error) { + setApprovalError({ + stepRunId, + message: error instanceof Error ? error.message : "Could not submit approval decision.", + }); + } finally { + setApprovalSubmittingStepRunId(null); + } + }; + + return ( + <aside className="flex h-full min-h-0 w-full flex-col bg-background"> + {/* Minimal header — always visible so the expand/collapse control is always reachable. */} + <header className="shrink-0 border-b border-border px-4 py-3"> + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0"> + {detail.syncedSource ? ( + <p className="mb-1"> + <a + href={detail.syncedSource.url} + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 rounded-sm border border-info/40 bg-info/8 px-1.5 py-0.5 text-[10px] font-medium text-info-foreground underline-offset-2 hover:underline" + data-testid="ticket-synced-source-badge" + > + Synced from {detail.syncedSource.provider} ↗ + </a> + </p> + ) : null} + <h2 className="truncate text-sm font-semibold text-foreground"> + {detail.ticket.title} + </h2> + <p className="mt-1 text-xs text-muted-foreground"> + {detail.ticket.currentLaneKey} / {formatStatusLabel(detail.ticket.status)} + </p> + {detail.ticket.pr !== undefined ? ( + <TicketPrBadges pr={detail.ticket.pr} rowClassName="mt-1" testIds /> + ) : null} + </div> + <div className="flex shrink-0 flex-col items-end gap-2"> + {waitingStepCount > 0 ? ( + <Badge variant="warning" size="sm"> + waiting on you + </Badge> + ) : null} + <div className="flex items-center gap-1.5"> + {!sourceOwned && !fullscreen ? ( + <Button + size="xs" + variant="outline" + disabled={!onEditTicket} + onClick={() => { + setEditError(null); + setEditingTicket(true); + }} + > + <PencilIcon className="size-3.5" /> + Edit ticket + </Button> + ) : null} + <Button + size="icon-xs" + variant="ghost" + aria-label="Expand ticket to full screen" + title="Full screen" + onClick={() => setFullscreen(true)} + > + <Maximize2Icon className="size-3.5" /> + </Button> + </div> + </div> + </div> + </header> + + {/* + * When fullscreen is true: render only the TicketFullscreen overlay. + * The heavy body (live thread subscriptions via StepActivityFeed, TicketDiff + * fetches, TicketArtifacts) must NOT be mounted at the same time as the + * fullscreen view to avoid duplicate live subscriptions and duplicate testids. + */} + {fullscreen ? ( + <TicketFullscreen + api={api} + detail={detail} + lanes={lanes} + laneDisplayName={laneDisplayName} + laneActions={laneActions} + canRunLane={canRunLane} + runLaneTitle={runLaneTitle} + routeHistory={routeHistory} + latestRouteDecision={latestRouteDecision} + ticketDescription={ticketDescription} + editState={ + editingTicket + ? { + draftTitle, + draftDescription, + editError, + editSubmitting, + setDraftTitle, + setDraftDescription, + saveTicketEdit, + cancelEdit: () => { + setDraftTitle(detail.ticket.title); + setDraftDescription(detail.ticket.description ?? ""); + setEditError(null); + setEditingTicket(false); + }, + } + : null + } + sourceOwned={sourceOwned} + onStartEdit={ + !sourceOwned + ? () => { + setEditError(null); + setEditingTicket(true); + } + : undefined + } + onEditTicket={onEditTicket} + replyState={{ + canReply, + replyText, + setReplyText, + replyAttachments, + setReplyAttachments, + replyError, + replySubmitting, + onAnswerStep, + onPostComment, + attachReplyImages, + sendReply, + }} + approvalState={{ + approvalSubmittingStepRunId, + approvalError, + submitApproval, + }} + waitingStepCount={waitingStepCount} + projectId={projectId} + cwd={cwd} + onEditMessage={onEditMessage} + onMove={onMove} + onRunLane={onRunLane} + onClose={() => setFullscreen(false)} + /> + ) : ( + <> + <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3"> + {editingTicket ? ( + <form className="space-y-2" onSubmit={saveTicketEdit}> + <label className="block space-y-1 text-xs font-medium text-muted-foreground"> + Ticket title + <Input + size="sm" + value={draftTitle} + disabled={sourceOwned || editSubmitting} + onChange={(event) => setDraftTitle(event.currentTarget.value)} + /> + </label> + <label className="block space-y-1 text-xs font-medium text-muted-foreground"> + Ticket description + <Textarea + size="sm" + value={draftDescription} + disabled={sourceOwned || editSubmitting} + onChange={(event) => setDraftDescription(event.currentTarget.value)} + /> + </label> + {editError ? ( + <p className="text-xs text-destructive-foreground">{editError}</p> + ) : null} + <div className="flex flex-wrap gap-2"> + <Button + size="xs" + type="submit" + disabled={!draftTitle.trim() || !onEditTicket || editSubmitting} + > + <CheckIcon className="size-3.5" /> + Save ticket + </Button> + <Button + size="xs" + type="button" + variant="outline" + disabled={editSubmitting} + onClick={() => { + setDraftTitle(detail.ticket.title); + setDraftDescription(detail.ticket.description ?? ""); + setEditError(null); + setEditingTicket(false); + }} + > + <XIcon className="size-3.5" /> + Cancel edit + </Button> + </div> + </form> + ) : ( + <TicketDescriptionView description={ticketDescription} density="compact" /> + )} + {latestRouteDecision ? ( + <section + className="rounded-md border border-info/40 bg-info/5 p-3" + data-testid="ticket-route-why" + > + <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Why is this ticket here? + </h3> + <p className="mt-1 text-sm font-medium text-foreground"> + {latestRouteDecision.title} + </p> + {latestRouteDecision.details.length > 0 ? ( + <p className="mt-0.5 text-xs leading-5 text-muted-foreground"> + {latestRouteDecision.details.join(" · ")} + </p> + ) : null} + <TicketRouteHistoryDetails + routeHistory={routeHistory} + laneDisplayName={laneDisplayName} + detailsClassName="mt-2" + /> + </section> + ) : null} + <TicketDiscussionSection + messages={detail.messages} + density="compact" + cwd={cwd} + onEditMessage={onEditMessage} + /> + + {canReply || onPostComment ? ( + <TicketReplyComposer + canReply={canReply} + replyText={replyText} + setReplyText={setReplyText} + replyAttachments={replyAttachments} + setReplyAttachments={setReplyAttachments} + replyError={replyError} + replySubmitting={replySubmitting} + onAnswerStep={onAnswerStep} + onPostComment={onPostComment} + attachReplyImages={attachReplyImages} + sendReply={sendReply} + cwd={cwd} + formClassName="p-3" + /> + ) : null} + + <section className="rounded-md border border-border/70 bg-card/35 p-3"> + <div className="mb-2 flex items-center justify-between gap-2"> + <h3 className="text-sm font-medium text-foreground">Steps</h3> + <span className="text-xs text-muted-foreground">{detail.steps.length}</span> + </div> + <ol className="space-y-2"> + {detail.steps.map((step) => ( + <TicketStepRow + key={step.stepRunId} + step={step} + api={api} + projectId={projectId} + approvalSubmittingStepRunId={approvalSubmittingStepRunId} + approvalError={approvalError} + stepOutputTestId="step-captured-output" + onRunLane={onRunLane} + submitApproval={submitApproval} + liClassName="p-2" + /> + ))} + </ol> + </section> + + {api ? <TicketArtifacts api={api} ticketId={detail.ticket.ticketId} /> : null} + {api ? <TicketDiff api={api} ticketId={TicketId.make(detail.ticket.ticketId)} /> : null} + </div> + <footer className="shrink-0 space-y-2 border-t border-border px-3 py-2"> + {onMove && laneActions.length > 0 ? ( + <div className="flex flex-wrap gap-2" data-testid="ticket-lane-actions"> + {laneActions.map((action) => { + const targetLane = lanes.find((lane) => lane.key === action.to); + const hint = [action.hint, targetLane ? `Moves to ${targetLane.name}.` : null] + .filter(Boolean) + .join(" "); + return ( + <Button + key={`${action.label}:${action.to}`} + size="sm" + variant="outline" + title={hint} + onClick={() => onMove(action.to)} + > + {action.label} + {targetLane ? ( + <span className="text-[11px] font-normal text-muted-foreground"> + → {targetLane.name} + </span> + ) : null} + </Button> + ); + })} + </div> + ) : null} + <div className="flex flex-wrap items-center gap-2"> + <Button size="sm" disabled={!canRunLane} title={runLaneTitle} onClick={onRunLane}> + <PlayIcon className="size-4" /> + Run lane + </Button> + {onMove && lanes.length > 0 ? ( + <label className="flex items-center gap-2 text-xs text-muted-foreground"> + Move + <select + className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground" + value={detail.ticket.currentLaneKey} + onChange={(event) => onMove(event.currentTarget.value)} + > + {lanes.map((lane) => ( + <option key={lane.key} value={lane.key}> + {lane.name} + </option> + ))} + </select> + </label> + ) : null} + </div> + </footer> + </> + )} + </aside> + ); +} + +function TicketAttachmentPreview({ attachment }: { readonly attachment: TicketDrawerAttachment }) { + if (attachment.kind === "image") { + return ( + <div className="overflow-hidden rounded-md border border-border/70 bg-background"> + <img src={attachment.dataUrl} alt={attachment.name} className="size-20 object-cover" /> + <span className="block max-w-24 truncate px-1.5 py-1 text-[10px] text-muted-foreground"> + {attachment.name} + </span> + </div> + ); + } + + return ( + <span className="rounded-md border border-border/70 bg-background px-2 py-1 text-xs text-muted-foreground"> + {attachment.name} + </span> + ); +} + +// --------------------------------------------------------------------------- +// Shared sub-components used by both TicketDrawer and TicketFullscreen. +// Extracting these prevents JSX duplication and ensures bug fixes/additions +// only need to happen in one place. +// --------------------------------------------------------------------------- + +/** Read-only description display. Used by both the drawer body and the fullscreen left column. + * `density="compact"` uses the drawer's tighter spacing (p-3, leading-5, h3). + * `density="spacious"` uses the fullscreen's roomier spacing (p-4, leading-6, h2). */ +function TicketDescriptionView({ + description, + density, +}: { + readonly description: string; + readonly density: "compact" | "spacious"; +}) { + if (!description) { + return null; + } + if (density === "spacious") { + return ( + <section + className="rounded-md border border-border/70 bg-card/35 p-4" + data-testid="ticket-description" + > + <h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Description + </h2> + <p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground"> + {description} + </p> + </section> + ); + } + return ( + <section + className="rounded-md border border-border/70 bg-card/35 p-3" + data-testid="ticket-description" + > + <h3 className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Description + </h3> + <p className="whitespace-pre-wrap break-words text-sm leading-5 text-foreground"> + {description} + </p> + </section> + ); +} + +type TicketPrShape = NonNullable<TicketDrawerDetail["ticket"]["pr"]>; + +/** The PR number link + state badge + optional CI-state badge row. + * Used in both the drawer header (with conditional `data-testid`s) and the + * fullscreen header (always with `data-testid`s). Pass `testIds` to render + * the `data-testid` attributes. `rowClassName` is applied to the outer `<p>`. */ +function TicketPrBadges({ + pr, + rowClassName, + testIds, +}: { + readonly pr: TicketPrShape; + readonly rowClassName?: string | undefined; + /** When true, renders `data-testid` attributes for automated tests. */ + readonly testIds?: boolean | undefined; +}) { + return ( + <p + className={cn("flex flex-wrap items-center gap-1.5 text-xs", rowClassName)} + data-testid={testIds ? "ticket-pr-row" : undefined} + > + <a + href={pr.url} + target="_blank" + rel="noopener noreferrer" + className="font-medium text-foreground underline-offset-2 hover:underline" + data-testid={testIds ? "ticket-pr-link" : undefined} + > + PR #{pr.number} + </a> + <span + className={cn( + "rounded-sm border px-1 py-0.5 text-[10px] font-medium", + pr.state === "merged" + ? "border-muted-foreground/30 text-muted-foreground" + : pr.state === "closed" + ? "border-muted-foreground/30 text-muted-foreground/70" + : "border-success/40 text-success-foreground", + )} + data-testid={testIds ? "ticket-pr-state" : undefined} + > + {pr.state} + </span> + {pr.ciState !== undefined ? ( + <span + className={cn( + "rounded-sm border px-1 py-0.5 text-[10px] font-medium", + pr.ciState === "failure" + ? "border-destructive/40 text-destructive-foreground" + : pr.ciState === "success" + ? "border-success/40 text-success-foreground" + : "border-muted-foreground/30 text-muted-foreground", + )} + data-testid={testIds ? "ticket-pr-ci-state" : undefined} + > + CI: {pr.ciState} + </span> + ) : null} + </p> + ); +} + +type DiscussionMessage = NonNullable<TicketDrawerDetail["messages"]>[number]; + +/** True when the viewer may edit a comment: it is their own free-form comment + * (`author === "user"`) and not an answer captured against an agent step. */ +function canEditDiscussionMessage(message: DiscussionMessage): boolean { + return message.author === "user" && message.stepRunId == null; +} + +/** The Discussion `<section>` with the message thread. + * `density="compact"` uses the drawer's tighter spacing (p-3 / ml-5). + * `density="spacious"` uses the fullscreen's roomier spacing (p-4 / ml-6). */ +function TicketDiscussionSection({ + messages, + density, + cwd, + onEditMessage, +}: { + readonly messages?: ReadonlyArray<DiscussionMessage> | undefined; + readonly density: "compact" | "spacious"; + readonly cwd?: string | undefined; + readonly onEditMessage?: ((messageId: string, body: string) => Promise<void>) | undefined; +}) { + const sectionPadding = density === "spacious" ? "p-4" : "p-3"; + const headerMargin = density === "spacious" ? "mb-3" : "mb-2"; + const itemPadding = density === "spacious" ? "p-3" : "p-2"; + const userIndent = density === "spacious" ? "ml-6" : "ml-5"; + const agentIndent = density === "spacious" ? "mr-6" : "mr-5"; + const Heading = density === "spacious" ? "h2" : "h3"; + const [editingMessageId, setEditingMessageId] = useState<string | null>(null); + return ( + <section className={cn("rounded-md border border-border/70 bg-card/35", sectionPadding)}> + <div className={cn("flex items-center justify-between gap-2", headerMargin)}> + <Heading className="text-sm font-medium text-foreground">Discussion</Heading> + <span className="text-xs text-muted-foreground">{messages?.length ?? 0}</span> + </div> + {messages && messages.length > 0 ? ( + <ol className="space-y-2"> + {messages.map((message) => ( + <li + key={message.messageId} + className={cn( + "rounded-md border border-border/60 bg-background/70", + itemPadding, + message.author === "user" && `${userIndent} bg-accent/20`, + message.author === "agent" && agentIndent, + )} + > + <div className="mb-1 flex items-center justify-between gap-2 text-[11px] text-muted-foreground"> + <span className="font-medium uppercase tracking-wide"> + {message.author === "agent" ? "Agent" : "You"} + </span> + <span className="flex items-center gap-1"> + <time dateTime={message.createdAt}> + {formatMessageTimestamp(message.createdAt)} + </time> + {message.editedAt ? ( + <span className="text-[11px] text-muted-foreground">· edited</span> + ) : null} + {onEditMessage && + canEditDiscussionMessage(message) && + editingMessageId !== message.messageId ? ( + <Button + size="icon-xs" + variant="ghost" + aria-label="Edit comment" + title="Edit comment" + onClick={() => setEditingMessageId(message.messageId)} + > + <PencilIcon className="size-3" /> + </Button> + ) : null} + </span> + </div> + {onEditMessage && editingMessageId === message.messageId ? ( + <DiscussionMessageEditForm + initialBody={message.body} + cwd={cwd} + onSave={(body) => onEditMessage(message.messageId, body)} + onClose={() => setEditingMessageId(null)} + /> + ) : ( + <> + {message.body ? ( + <ChatMarkdown + text={message.body} + cwd={cwd} + lineBreaks + className="text-sm leading-5" + /> + ) : null} + {message.attachments.length > 0 ? ( + <div className="mt-2 flex flex-wrap gap-2"> + {message.attachments.map((attachment) => ( + <TicketAttachmentPreview key={attachment.id} attachment={attachment} /> + ))} + </div> + ) : null} + </> + )} + </li> + ))} + </ol> + ) : ( + <p className="text-xs text-muted-foreground"> + No discussion yet — leave a note below for the agent or your future self. + </p> + )} + </section> + ); +} + +/** Inline edit form for a single discussion comment. Mirrors the reply + * composer's Write/Preview affordance and surfaces save failures inline. */ +function DiscussionMessageEditForm({ + initialBody, + cwd, + onSave, + onClose, +}: { + readonly initialBody: string; + readonly cwd?: string | undefined; + readonly onSave: (body: string) => Promise<void>; + readonly onClose: () => void; +}) { + const [draft, setDraft] = useState(initialBody); + const [error, setError] = useState<string | null>(null); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { + event.preventDefault(); + const body = draft.trim(); + if (!body) { + setError("Comment cannot be empty."); + return; + } + setSubmitting(true); + setError(null); + try { + await onSave(body); + onClose(); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : "Could not save the comment."); + } finally { + setSubmitting(false); + } + }; + + return ( + <form className="space-y-2" onSubmit={handleSubmit}> + <MarkdownComposerField + value={draft} + onChange={setDraft} + disabled={submitting} + ariaLabel="Edit comment" + cwd={cwd} + /> + {error ? <p className="text-xs text-destructive-foreground">{error}</p> : null} + <div className="flex flex-wrap gap-2"> + <Button size="xs" type="submit" disabled={submitting || !draft.trim()}> + <CheckIcon className="size-3.5" /> + Save + </Button> + <Button size="xs" type="button" variant="outline" disabled={submitting} onClick={onClose}> + <XIcon className="size-3.5" /> + Cancel + </Button> + </div> + </form> + ); +} + +/** The collapsible `<details>` route-history list rendered inside the "Why is + * this ticket here?" section. Both drawer and fullscreen share this block. */ +function TicketRouteHistoryDetails({ + routeHistory, + laneDisplayName, + detailsClassName, +}: { + readonly routeHistory: ReadonlyArray<RouteDecisionView>; + readonly laneDisplayName: (key: string) => string; + readonly detailsClassName?: string | undefined; +}) { + if (routeHistory.length <= 1) { + return null; + } + return ( + <details className={detailsClassName}> + <summary className="cursor-pointer text-xs text-muted-foreground select-none"> + Route history ({routeHistory.length}) + </summary> + <ol className="mt-2 space-y-1.5"> + {routeHistory + .map((entry) => describeRouteDecision(entry, laneDisplayName)) + .toReversed() + .map((described, index) => { + const entry = routeHistory[routeHistory.length - 1 - index]; + return ( + <li + key={`${entry?.occurredAt ?? index}-${index}`} + className="rounded-md border border-border/60 bg-background/70 p-2" + > + <div className="flex items-center justify-between gap-2"> + <span className="text-xs font-medium text-foreground">{described.title}</span> + {entry ? ( + <time dateTime={entry.occurredAt} className="text-[11px] text-muted-foreground"> + {formatMessageTimestamp(entry.occurredAt)} + </time> + ) : null} + </div> + {described.details.length > 0 ? ( + <p className="mt-0.5 text-[11px] leading-4 text-muted-foreground"> + {described.details.join(" · ")} + </p> + ) : null} + </li> + ); + })} + </ol> + </details> + ); +} + +/** The reply / comment composer `<form>`. Both drawer (`p-3`) and fullscreen + * (`p-4`) use this form with their own padding class passed via `formClassName`. */ +function TicketReplyComposer({ + canReply, + replyText, + setReplyText, + replyAttachments, + setReplyAttachments, + replyError, + replySubmitting, + onAnswerStep, + onPostComment, + attachReplyImages, + sendReply, + cwd, + formClassName, +}: { + readonly canReply: boolean; + readonly replyText: string; + readonly setReplyText: (value: string) => void; + readonly replyAttachments: ReadonlyArray<TicketDrawerAttachment>; + readonly setReplyAttachments: ( + updater: ( + current: ReadonlyArray<TicketDrawerAttachment>, + ) => ReadonlyArray<TicketDrawerAttachment>, + ) => void; + readonly replyError: string | null; + readonly replySubmitting: boolean; + readonly onAnswerStep?: ((input: TicketDrawerAnswerInput) => Promise<void>) | undefined; + readonly onPostComment?: ((input: TicketDrawerCommentInput) => Promise<void>) | undefined; + readonly attachReplyImages: (event: ChangeEvent<HTMLInputElement>) => Promise<void>; + readonly sendReply: (event: FormEvent<HTMLFormElement>) => Promise<void>; + readonly cwd?: string | undefined; + readonly formClassName?: string | undefined; +}) { + return ( + <form + className={cn( + "rounded-md border", + canReply ? "border-warning/40 bg-warning/5" : "border-border/70 bg-card/35", + formClassName, + )} + onSubmit={sendReply} + > + <MarkdownComposerField + value={replyText} + onChange={setReplyText} + disabled={replySubmitting} + label={canReply ? "Ticket reply" : "Add a comment"} + cwd={cwd} + /> + {replyAttachments.length > 0 ? ( + <div className="mt-2 flex flex-wrap gap-2"> + {replyAttachments.map((attachment) => ( + <div + key={attachment.id} + className="group relative overflow-hidden rounded-md border border-border/70 bg-background" + > + {attachment.kind === "image" ? ( + <img + src={attachment.dataUrl} + alt={attachment.name} + className="size-16 object-cover" + /> + ) : null} + <span className="block max-w-24 truncate px-1.5 py-1 text-[10px] text-muted-foreground"> + {attachment.name} + </span> + <Button + className="absolute right-1 top-1 bg-background/85" + size="icon-xs" + variant="ghost" + aria-label={`Remove ${attachment.name}`} + disabled={replySubmitting} + onClick={() => + setReplyAttachments((current) => + current.filter((candidate) => candidate.id !== attachment.id), + ) + } + > + <XIcon /> + </Button> + </div> + ))} + </div> + ) : null} + {replyError ? <p className="mt-2 text-xs text-destructive-foreground">{replyError}</p> : null} + <div className="mt-2 flex flex-wrap items-center gap-2"> + <label className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border border-input bg-background px-2 text-xs font-medium text-foreground shadow-xs/5 hover:bg-accent/50"> + <ImageIcon className="size-3.5" aria-hidden /> + Attach image + <input + className="sr-only" + type="file" + accept="image/png,image/jpeg,image/gif,image/webp" + multiple + disabled={replySubmitting} + onChange={attachReplyImages} + /> + </label> + <Button + size="xs" + type="submit" + disabled={ + (canReply ? !onAnswerStep : !onPostComment) || + replySubmitting || + (!replyText.trim() && replyAttachments.length === 0) + } + > + <SendIcon className="size-3.5" /> + {canReply ? "Send reply" : "Comment"} + </Button> + </div> + </form> + ); +} + +type StepRowStep = TicketDrawerDetail["steps"][number]; + +/** A single step row `<li>`. Shared between the drawer and the fullscreen right + * column. The `liClassName` lets each context supply its own padding. */ +function TicketStepRow({ + step, + api, + projectId, + approvalSubmittingStepRunId, + approvalError, + stepOutputTestId, + onRunLane, + submitApproval, + liClassName, +}: { + readonly step: StepRowStep; + readonly api?: EnvironmentApi | undefined; + readonly projectId?: ProjectId | undefined; + readonly approvalSubmittingStepRunId: string | null; + readonly approvalError: { readonly stepRunId: string; readonly message: string } | null; + /** data-testid applied to the step output `<div>`. Pass undefined to omit. */ + readonly stepOutputTestId?: string | undefined; + readonly onRunLane: () => void; + readonly submitApproval: (stepRunId: string, approved: boolean) => Promise<void>; + readonly liClassName?: string | undefined; +}) { + return ( + <li + className={cn( + "rounded-md border border-border/60 bg-background/70", + (step.status === "awaiting_user" || step.status === "blocked") && + "border-warning/45 bg-warning/5", + liClassName, + )} + > + <div className="flex items-center justify-between gap-2"> + <div className="min-w-0"> + <p className="truncate text-sm font-medium text-foreground">{step.stepKey}</p> + <p className="text-xs text-muted-foreground"> + {step.stepType} + {step.attempt !== undefined && step.attempt > 1 ? ` · attempt ${step.attempt}` : null} + {stepUsageSummary(step) !== null ? ` · ${stepUsageSummary(step)}` : null} + {step.startedAt ? ` · started ${formatMessageTimestamp(step.startedAt)}` : null} + </p> + </div> + <Badge size="sm" variant={stepBadgeVariant(step)}> + {formatStepBadgeLabel(step)} + </Badge> + </div> + {step.waitingReason ? ( + <p className="mt-2 text-xs leading-5 text-muted-foreground">{step.waitingReason}</p> + ) : null} + {step.blockedReason ? ( + <p className="mt-2 text-xs leading-5 text-muted-foreground">{step.blockedReason}</p> + ) : null} + {step.output !== undefined && step.output !== null ? ( + <div className="mt-2" data-testid={stepOutputTestId}> + {extractVerdict(step.output) !== null ? ( + <Badge + size="sm" + variant={extractVerdict(step.output) === "approve" ? "success" : "warning"} + > + verdict: {truncateLabel(extractVerdict(step.output) ?? "")} + </Badge> + ) : null} + <pre className="mt-1 max-h-40 overflow-auto rounded-md border border-border/60 bg-background/70 p-2 text-[11px] leading-4 text-muted-foreground"> + {JSON.stringify(step.output, null, 2)} + </pre> + </div> + ) : null} + {isScriptStepWithTerminal(step) ? <ScriptStepLogViewer api={api} step={step} /> : null} + {step.stepType === "agent" && + step.providerThreadId !== undefined && + (step.status === "running" || + step.status === "dispatch_requested" || + step.status === "awaiting_user") ? ( + <StepActivityFeed api={api} threadId={step.providerThreadId as never} live /> + ) : null} + {step.stepType === "agent" && step.providerThreadId !== undefined ? ( + <div className="mt-2"> + <AgentSessionDialog + api={api} + threadId={step.providerThreadId as never} + stepKey={step.stepKey} + /> + </div> + ) : null} + {isAwaitingApprovalRequestStep(step) ? ( + <div className="mt-2 flex flex-wrap gap-2"> + <Button + size="xs" + disabled={approvalSubmittingStepRunId === step.stepRunId} + onClick={() => { + void submitApproval(step.stepRunId, true); + }} + > + <CheckIcon className="size-3.5" /> + Approve + </Button> + <Button + size="xs" + variant="outline" + disabled={approvalSubmittingStepRunId === step.stepRunId} + onClick={() => { + void submitApproval(step.stepRunId, false); + }} + > + <XIcon className="size-3.5" /> + Reject + </Button> + {approvalError?.stepRunId === step.stepRunId ? ( + <p className="basis-full text-xs text-destructive-foreground"> + {approvalError.message} + </p> + ) : null} + </div> + ) : null} + {step.stepType === "script" && step.scriptStatus === "running" ? ( + <div className="mt-2 flex flex-wrap gap-2"> + <Button + size="xs" + variant="destructive-outline" + disabled={!api} + onClick={() => { + void api?.workflow.cancelStep({ + stepRunId: StepRunId.make(step.stepRunId), + }); + }} + > + <XIcon className="size-3.5" /> + Cancel + </Button> + </div> + ) : null} + {isTrustBlockedScriptStep(step) ? ( + <div className="mt-2 flex flex-wrap gap-2"> + <Button + size="xs" + disabled={!api || !projectId} + onClick={() => { + if (!api || !projectId) { + return; + } + void api.workflow.setProjectScriptTrust({ projectId, trusted: true }).then(onRunLane); + }} + > + <CheckIcon className="size-3.5" /> + Trust this project & run + </Button> + </div> + ) : null} + </li> + ); +} + +// --------------------------------------------------------------------------- +// Grouped prop shapes used by TicketFullscreen to reduce the call-site surface. +// --------------------------------------------------------------------------- + +interface TicketFullscreenEditState { + readonly draftTitle: string; + readonly draftDescription: string; + readonly editError: string | null; + readonly editSubmitting: boolean; + readonly setDraftTitle: (value: string) => void; + readonly setDraftDescription: (value: string) => void; + readonly saveTicketEdit: (event: FormEvent<HTMLFormElement>) => Promise<void>; + readonly cancelEdit: () => void; +} + +interface TicketFullscreenReplyState { + readonly canReply: boolean; + readonly replyText: string; + readonly setReplyText: (value: string) => void; + readonly replyAttachments: ReadonlyArray<TicketDrawerAttachment>; + readonly setReplyAttachments: ( + updater: ( + current: ReadonlyArray<TicketDrawerAttachment>, + ) => ReadonlyArray<TicketDrawerAttachment>, + ) => void; + readonly replyError: string | null; + readonly replySubmitting: boolean; + readonly onAnswerStep?: ((input: TicketDrawerAnswerInput) => Promise<void>) | undefined; + readonly onPostComment?: ((input: TicketDrawerCommentInput) => Promise<void>) | undefined; + readonly attachReplyImages: (event: ChangeEvent<HTMLInputElement>) => Promise<void>; + readonly sendReply: (event: FormEvent<HTMLFormElement>) => Promise<void>; +} + +interface TicketFullscreenApprovalState { + readonly approvalSubmittingStepRunId: string | null; + readonly approvalError: { readonly stepRunId: string; readonly message: string } | null; + readonly submitApproval: (stepRunId: string, approved: boolean) => Promise<void>; +} + +// --------------------------------------------------------------------------- +// Full-screen overlay — renders all ticket fields in a spacious multi-column +// layout. Reuses the drawer's inner sub-components and helper functions. +// Composes WorkflowEditorFullscreen for Escape-to-close, body-overflow lock, +// and focus-trap behaviour. +// --------------------------------------------------------------------------- + +function TicketFullscreen({ + api, + detail, + lanes, + laneDisplayName, + laneActions, + canRunLane, + runLaneTitle, + routeHistory, + latestRouteDecision, + ticketDescription, + editState, + sourceOwned, + onStartEdit, + onEditTicket, + replyState, + approvalState, + waitingStepCount, + projectId, + cwd, + onEditMessage, + onMove, + onRunLane, + onClose, +}: { + readonly api?: EnvironmentApi | undefined; + readonly detail: TicketDrawerDetail; + readonly lanes: ReadonlyArray<TicketDrawerLane>; + readonly laneDisplayName: (key: string) => string; + readonly laneActions: ReadonlyArray<TicketDrawerLaneAction>; + readonly canRunLane: boolean; + readonly runLaneTitle: string; + readonly routeHistory: ReadonlyArray<RouteDecisionView>; + readonly latestRouteDecision: ReturnType<typeof describeRouteDecision> | null; + readonly ticketDescription: string; + /** Non-null when the user has clicked "Edit ticket" in the drawer before opening fullscreen. */ + readonly editState: TicketFullscreenEditState | null; + readonly sourceOwned: boolean; + readonly onStartEdit?: (() => void) | undefined; + readonly onEditTicket?: ((input: TicketDrawerEditInput) => Promise<void>) | undefined; + readonly replyState: TicketFullscreenReplyState; + readonly approvalState: TicketFullscreenApprovalState; + readonly waitingStepCount: number; + readonly projectId?: ProjectId | undefined; + readonly cwd?: string | undefined; + readonly onEditMessage?: ((messageId: string, body: string) => Promise<void>) | undefined; + readonly onMove?: ((toLane: string) => void) | undefined; + readonly onRunLane: () => void; + readonly onClose: () => void; +}) { + const ticket = detail.ticket; + + return ( + <WorkflowEditorFullscreen open ariaLabel="Ticket detail" onClose={onClose}> + {/* Header */} + <header className="flex shrink-0 items-start justify-between gap-4 border-b border-border px-6 py-4"> + <div className="min-w-0 flex-1"> + {ticket.pr !== undefined ? ( + <TicketPrBadges pr={ticket.pr} rowClassName="mb-1" testIds /> + ) : null} + <h1 className="text-xl font-semibold text-foreground">{ticket.title}</h1> + <div className="mt-1 flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> + <span> + {laneDisplayName(ticket.currentLaneKey)} / {formatStatusLabel(ticket.status)} + </span> + {ticket.boardId ? ( + <span className="font-mono text-xs opacity-60">board:{ticket.boardId}</span> + ) : null} + {detail.syncedSource ? ( + <a + href={detail.syncedSource.url} + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 rounded-sm border border-info/40 bg-info/8 px-1.5 py-0.5 text-[10px] font-medium text-info-foreground underline-offset-2 hover:underline" + data-testid="ticket-synced-source-badge" + > + Synced from {detail.syncedSource.provider} ↗ + </a> + ) : null} + {detail.syncedSource?.assignees && detail.syncedSource.assignees.length > 0 ? ( + <span className="text-xs">Assignees: {detail.syncedSource.assignees.join(", ")}</span> + ) : null} + {detail.syncedSource?.labels && detail.syncedSource.labels.length > 0 ? ( + <span className="text-xs">Labels: {detail.syncedSource.labels.join(", ")}</span> + ) : null} + </div> + </div> + <div className="flex shrink-0 items-center gap-2"> + {waitingStepCount > 0 ? ( + <Badge variant="warning" size="sm"> + waiting on you + </Badge> + ) : null} + {!sourceOwned && onStartEdit ? ( + <Button size="xs" variant="outline" disabled={!onEditTicket} onClick={onStartEdit}> + <PencilIcon className="size-3.5" /> + Edit ticket + </Button> + ) : null} + <Button + size="icon-xs" + variant="ghost" + aria-label="Collapse ticket to drawer" + title="Exit full screen" + onClick={onClose} + > + <Minimize2Icon className="size-3.5" /> + </Button> + </div> + </header> + + {/* Body — two-column on wide screens */} + <div className="flex min-h-0 flex-1 flex-col overflow-auto lg:flex-row"> + {/* Left column: description, route, discussion, reply */} + <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto border-b border-border/60 p-6 lg:border-b-0 lg:border-r"> + {editState ? ( + <form className="space-y-2" onSubmit={editState.saveTicketEdit}> + <label className="block space-y-1 text-xs font-medium text-muted-foreground"> + Ticket title + <Input + size="sm" + value={editState.draftTitle} + disabled={sourceOwned || editState.editSubmitting} + onChange={(event) => editState.setDraftTitle(event.currentTarget.value)} + /> + </label> + <label className="block space-y-1 text-xs font-medium text-muted-foreground"> + Ticket description + <Textarea + size="sm" + value={editState.draftDescription} + disabled={sourceOwned || editState.editSubmitting} + onChange={(event) => editState.setDraftDescription(event.currentTarget.value)} + /> + </label> + {editState.editError ? ( + <p className="text-xs text-destructive-foreground">{editState.editError}</p> + ) : null} + <div className="flex flex-wrap gap-2"> + <Button + size="xs" + type="submit" + disabled={ + !editState.draftTitle.trim() || !onEditTicket || editState.editSubmitting + } + > + <CheckIcon className="size-3.5" /> + Save ticket + </Button> + <Button + size="xs" + type="button" + variant="outline" + disabled={editState.editSubmitting} + onClick={editState.cancelEdit} + > + <XIcon className="size-3.5" /> + Cancel edit + </Button> + </div> + </form> + ) : ( + <TicketDescriptionView description={ticketDescription} density="spacious" /> + )} + + {latestRouteDecision ? ( + <section + className="rounded-md border border-info/40 bg-info/5 p-4" + data-testid="ticket-route-why" + > + <h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Why is this ticket here? + </h2> + <p className="mt-1 text-sm font-medium text-foreground"> + {latestRouteDecision.title} + </p> + {latestRouteDecision.details.length > 0 ? ( + <p className="mt-0.5 text-xs leading-5 text-muted-foreground"> + {latestRouteDecision.details.join(" · ")} + </p> + ) : null} + <TicketRouteHistoryDetails + routeHistory={routeHistory} + laneDisplayName={laneDisplayName} + detailsClassName="mt-3" + /> + </section> + ) : null} + + {/* Discussion */} + <TicketDiscussionSection + messages={detail.messages} + density="spacious" + cwd={cwd} + onEditMessage={onEditMessage} + /> + + {/* Reply / comment composer */} + {replyState.canReply || replyState.onPostComment ? ( + <TicketReplyComposer + canReply={replyState.canReply} + replyText={replyState.replyText} + setReplyText={replyState.setReplyText} + replyAttachments={replyState.replyAttachments} + setReplyAttachments={replyState.setReplyAttachments} + replyError={replyState.replyError} + replySubmitting={replyState.replySubmitting} + onAnswerStep={replyState.onAnswerStep} + onPostComment={replyState.onPostComment} + attachReplyImages={replyState.attachReplyImages} + sendReply={replyState.sendReply} + cwd={cwd} + formClassName="p-4" + /> + ) : null} + </div> + + {/* Right column: steps, artifacts, diff, move controls */} + <div className="flex min-h-0 w-full flex-col gap-4 overflow-auto p-6 lg:w-[480px] xl:w-[560px]"> + {/* Steps */} + <section className="rounded-md border border-border/70 bg-card/35 p-4"> + <div className="mb-3 flex items-center justify-between gap-2"> + <h2 className="text-sm font-medium text-foreground">Steps</h2> + <span className="text-xs text-muted-foreground">{detail.steps.length}</span> + </div> + <ol className="space-y-2"> + {detail.steps.map((step) => ( + <TicketStepRow + key={step.stepRunId} + step={step} + api={api} + projectId={projectId} + approvalSubmittingStepRunId={approvalState.approvalSubmittingStepRunId} + approvalError={approvalState.approvalError} + stepOutputTestId="step-captured-output" + onRunLane={onRunLane} + submitApproval={approvalState.submitApproval} + liClassName="p-3" + /> + ))} + </ol> + </section> + + {api ? <TicketArtifacts api={api} ticketId={detail.ticket.ticketId} /> : null} + {api ? <TicketDiff api={api} ticketId={TicketId.make(detail.ticket.ticketId)} /> : null} + + {/* Lane actions + move controls */} + <section className="rounded-md border border-border/70 bg-card/35 p-4"> + <h2 className="mb-3 text-sm font-medium text-foreground">Lane controls</h2> + <div className="space-y-3"> + {onMove && laneActions.length > 0 ? ( + <div className="flex flex-wrap gap-2" data-testid="ticket-lane-actions"> + {laneActions.map((action) => { + const targetLane = lanes.find((lane) => lane.key === action.to); + const hint = [action.hint, targetLane ? `Moves to ${targetLane.name}.` : null] + .filter(Boolean) + .join(" "); + return ( + <Button + key={`${action.label}:${action.to}`} + size="sm" + variant="outline" + title={hint} + onClick={() => onMove(action.to)} + > + {action.label} + {targetLane ? ( + <span className="text-[11px] font-normal text-muted-foreground"> + → {targetLane.name} + </span> + ) : null} + </Button> + ); + })} + </div> + ) : null} + <div className="flex flex-wrap items-center gap-2"> + <Button size="sm" disabled={!canRunLane} title={runLaneTitle} onClick={onRunLane}> + <PlayIcon className="size-4" /> + Run lane + </Button> + {onMove && lanes.length > 0 ? ( + <label className="flex items-center gap-2 text-xs text-muted-foreground"> + Move + <select + className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground" + value={detail.ticket.currentLaneKey} + onChange={(event) => onMove(event.currentTarget.value)} + > + {lanes.map((lane) => ( + <option key={lane.key} value={lane.key}> + {lane.name} + </option> + ))} + </select> + </label> + ) : null} + </div> + </div> + </section> + </div> + </div> + </WorkflowEditorFullscreen> + ); +} + +function formatStatusLabel(status: string): string { + return status.replaceAll("_", " "); +} + +function formatMessageTimestamp(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +} + +function formatStepBadgeLabel(step: TicketDrawerDetail["steps"][number]): string { + if (step.stepType !== "script") { + return formatStatusLabel(step.status); + } + + switch (step.scriptStatus) { + case "running": + return "running"; + case "exited": + return typeof step.exitCode === "number" ? `exit ${step.exitCode}` : "exited"; + case "timeout": + return "timed out"; + case "cancelled": + return "cancelled"; + case null: + case undefined: + return formatStatusLabel(step.status); + default: + return formatStatusLabel(step.scriptStatus); + } +} + +function stepBadgeVariant(step: TicketDrawerDetail["steps"][number]) { + if (step.status === "awaiting_user" || step.status === "blocked") { + return "warning"; + } + if (step.status === "failed" || step.scriptStatus === "timeout") { + return "error"; + } + if (step.status === "completed") { + return "success"; + } + if (step.scriptStatus === "running" || step.status === "running") { + return "info"; + } + return "outline"; +} + +function isScriptStepWithTerminal( + step: TicketDrawerDetail["steps"][number], +): step is TicketDrawerDetail["steps"][number] & { + readonly scriptThreadId: string; + readonly terminalId: string; +} { + return ( + step.stepType === "script" && + typeof step.scriptThreadId === "string" && + step.scriptThreadId.length > 0 && + typeof step.terminalId === "string" && + step.terminalId.length > 0 + ); +} + +function isTrustBlockedScriptStep(step: TicketDrawerDetail["steps"][number]): boolean { + return ( + step.stepType === "script" && + step.status === "blocked" && + (step.blockedReason ?? "").toLowerCase().includes("not trusted") + ); +} + +function isAwaitingUserInputStep(step: TicketDrawerDetail["steps"][number]): boolean { + return step.status === "awaiting_user" && step.providerResponseKind === "user-input"; +} + +function isAwaitingApprovalRequestStep(step: TicketDrawerDetail["steps"][number]): boolean { + return ( + step.status === "awaiting_user" && + (step.providerResponseKind === "request" || + (step.stepType === "approval" && + (step.providerResponseKind === null || step.providerResponseKind === undefined))) + ); +} + +function ScriptStepLogViewer({ + api, + step, +}: { + readonly api?: EnvironmentApi | undefined; + readonly step: TicketDrawerDetail["steps"][number] & { + readonly scriptThreadId: string; + readonly terminalId: string; + }; +}) { + const [history, setHistory] = useState(""); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (!api) { + setHistory(""); + setError(null); + return; + } + + setHistory(""); + setError(null); + return api.terminal.attachHistory( + { + threadId: ThreadId.make(step.scriptThreadId), + terminalId: step.terminalId, + }, + (event) => { + applyHistoryEvent(event, setHistory, setError); + }, + ); + }, [api, step.scriptThreadId, step.terminalId]); + + return ( + <section className="mt-2 overflow-hidden rounded-md border border-border/60 bg-background"> + <div className="flex items-center justify-between gap-2 border-b border-border/60 px-2 py-1.5"> + <h4 className="text-xs font-medium text-foreground">Script output</h4> + <span className="truncate font-mono text-[10px] text-muted-foreground"> + {step.terminalId} + </span> + </div> + {error ? ( + <p className="px-2 py-2 text-xs text-destructive-foreground">{error}</p> + ) : ( + <pre className="max-h-64 min-h-16 overflow-auto whitespace-pre-wrap break-words p-2 font-mono text-[11px] leading-relaxed text-foreground/85"> + {history || "No output yet."} + </pre> + )} + </section> + ); +} + +function applyHistoryEvent( + event: TerminalHistoryAttachStreamEvent, + setHistory: (updater: string | ((current: string) => string)) => void, + setError: (error: string | null) => void, +) { + switch (event.type) { + case "snapshot": + setHistory(event.snapshot.history); + setError(null); + return; + case "output": + setHistory((current) => `${current}${event.data}`); + return; + case "cleared": + setHistory(""); + return; + case "error": + setError(event.message); + return; + case "exited": + case "closed": + case "activity": + return; + } +} diff --git a/apps/web/src/components/board/WebhookConfigDialog.tsx b/apps/web/src/components/board/WebhookConfigDialog.tsx new file mode 100644 index 00000000000..2f7ce48c1ac --- /dev/null +++ b/apps/web/src/components/board/WebhookConfigDialog.tsx @@ -0,0 +1,207 @@ +import type { WorkflowWebhookConfig } from "@t3tools/contracts"; +import { CopyIcon, RefreshCwIcon, WebhookIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; + +const exampleBody = JSON.stringify({ + name: "ci.passed", + ticketId: "<ticketId>", + deliveryId: "run-123", + payload: { status: "green" }, +}); + +export const webhookCurlExample = (url: string, token: string): string => + [ + `curl -X POST ${url} \\`, + ` -H 'x-t3-webhook-token: ${token}' \\`, + ` -H 'content-type: application/json' \\`, + ` -d '${exampleBody}'`, + ].join("\n"); + +/** + * Per-board webhook ingress config. The secret is shown exactly once — on + * first open (which provisions it) or after a rotation — and only its prefix + * afterwards. + */ +export function WebhookConfigDialog({ + disabled, + onFetchConfig, + open: controlledOpen, + onOpenChange, +}: { + readonly disabled: boolean; + readonly onFetchConfig: (rotate: boolean) => Promise<WorkflowWebhookConfig>; + readonly open?: boolean; + readonly onOpenChange?: (open: boolean) => void; +}) { + const isControlled = onOpenChange !== undefined; + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const open = isControlled ? (controlledOpen ?? false) : uncontrolledOpen; + const setOpen = (next: boolean) => { + if (isControlled) { + onOpenChange(next); + } else { + setUncontrolledOpen(next); + } + }; + const [config, setConfig] = useState<WorkflowWebhookConfig | null>(null); + const [error, setError] = useState<string | null>(null); + const [copied, setCopied] = useState(false); + const requestRef = useRef(0); + + const load = async (rotate: boolean) => { + const requestId = ++requestRef.current; + setError(null); + setCopied(false); + try { + const next = await onFetchConfig(rotate); + if (requestRef.current === requestId) { + setConfig(next); + } + } catch (cause) { + if (requestRef.current === requestId) { + setError(cause instanceof Error ? cause.message : "Failed to load the webhook config."); + } + } + }; + + // In controlled mode the parent owns the trigger, so the load that the + // self-contained trigger's onClick performed must fire when the dialog + // transitions to open. `config === null` guards against re-loading on + // unrelated re-renders. + useEffect(() => { + if (isControlled && open && config === null && error === null) { + void load(false); + } + }, [isControlled, open]); + + const origin = typeof window === "undefined" ? "" : window.location.origin; + const url = config === null ? "" : `${origin}${config.path}`; + const curl = + config === null ? "" : webhookCurlExample(url, config.token ?? "<token shown on rotate>"); + + return ( + <Dialog + open={open} + onOpenChange={(nextOpen) => { + setOpen(nextOpen); + if (!nextOpen) { + requestRef.current += 1; + // Never keep a revealed secret in memory after closing. + setConfig(null); + setCopied(false); + } + }} + > + {isControlled ? null : ( + <Button + type="button" + size="xs" + variant="outline" + disabled={disabled} + title="Let CI, PR automation, or cron move tickets on this board" + onClick={() => { + setOpen(true); + void load(false); + }} + > + <WebhookIcon className="size-3.5" /> + Webhook + </Button> + )} + <DialogPopup className="max-h-[calc(100dvh-2rem)] max-w-xl overflow-hidden"> + <div className="flex min-h-0 flex-col"> + <DialogHeader> + <DialogTitle>Board webhook</DialogTitle> + <DialogDescription> + External systems POST events here to move correlated tickets through their lane's + external-event matchers. + </DialogDescription> + </DialogHeader> + <div + className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 pt-1 pb-4" + data-testid="webhook-config" + > + {error !== null ? ( + <p className="text-xs text-destructive-foreground" role="alert"> + {error} + </p> + ) : config === null ? ( + <p className="text-sm text-muted-foreground">Loading…</p> + ) : ( + <> + <div className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Endpoint</span> + <code className="block truncate rounded-md border border-border/70 bg-muted/30 px-2.5 py-1.5 text-xs text-foreground"> + POST {url} + </code> + </div> + <div className="grid gap-1.5"> + <div className="flex items-center justify-between gap-2"> + <span className="text-xs font-medium text-foreground"> + Token (x-t3-webhook-token) + </span> + <Button size="xs" variant="outline" onClick={() => void load(true)}> + <RefreshCwIcon className="size-3" /> + Rotate + </Button> + </div> + {config.token !== undefined ? ( + <> + <code + className="block break-all rounded-md border border-warning/45 bg-warning/8 px-2.5 py-1.5 text-xs text-foreground" + data-testid="webhook-token" + > + {config.token} + </code> + <p className="text-[11px] text-warning"> + Copy it now — it is shown only this once. Rotating invalidates the old + token. + </p> + </> + ) : ( + <p className="text-xs text-muted-foreground"> + Active token starts with{" "} + <code className="text-foreground">{config.tokenPrefix ?? "?"}</code>… — the + full secret was shown when it was created. Rotate to issue a new one. + </p> + )} + </div> + <div className="grid gap-1.5"> + <div className="flex items-center justify-between gap-2"> + <span className="text-xs font-medium text-foreground">Example</span> + <Button + size="xs" + variant="ghost" + onClick={() => { + void navigator.clipboard?.writeText(curl).then(() => setCopied(true)); + }} + > + <CopyIcon className="size-3" /> + {copied ? "Copied" : "Copy"} + </Button> + </div> + <pre className="overflow-x-auto rounded-md border border-border/70 bg-muted/30 px-2.5 py-1.5 text-[11px] leading-4 text-foreground"> + {curl} + </pre> + <p className="text-[11px] text-muted-foreground"> + Correlate by <code>ticketId</code> or <code>branch</code> ("workflow/< + ticketId>"). Optional <code>deliveryId</code> deduplicates retries. + </p> + </div> + </> + )} + </div> + </div> + </DialogPopup> + </Dialog> + ); +} diff --git a/apps/web/src/components/board/editor/AutoPullCriteriaEditor.tsx b/apps/web/src/components/board/editor/AutoPullCriteriaEditor.tsx new file mode 100644 index 00000000000..995719da4d2 --- /dev/null +++ b/apps/web/src/components/board/editor/AutoPullCriteriaEditor.tsx @@ -0,0 +1,272 @@ +import { XIcon } from "lucide-react"; + +import { summarizeAutoPull, type AutoPullCriteria } from "@t3tools/contracts/workSource"; + +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; + +// ─── types ──────────────────────────────────────────────────────────────────── + +interface AutoPullCriteriaEditorProps { + /** The current criteria value (controlled). */ + readonly value: AutoPullCriteria; + /** Called whenever any field changes. */ + readonly onChange: (next: AutoPullCriteria) => void; + readonly disabled?: boolean; +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +/** Strip whitespace and deduplicate labels from the chip input. */ +function parseLabel(raw: string): string { + return raw.trim(); +} + +// ─── component ──────────────────────────────────────────────────────────────── + +/** + * Controlled editor for auto-pull criteria. + * + * Displays: + * - Label chips with any/all toggle + * - Assignee selector (none / anyone / specific login) + * - State selector (any / open / closed) + * + * Imports `AutoPullCriteria`, `compileAutoPullRule`, and `summarizeAutoPull` + * from `@t3tools/contracts/workSource` (Phase A helpers — already shipped). + */ +export function AutoPullCriteriaEditor({ + value, + onChange, + disabled = false, +}: AutoPullCriteriaEditorProps) { + // Derive summary from current criteria for display purposes + const summary = summarizeAutoPull(value); + + // ── Labels ────────────────────────────────────────────────────────────────── + + const labels = value.labels?.values ?? []; + const labelMode = value.labels?.mode ?? "any"; + + const handleAddLabel = (raw: string) => { + const label = parseLabel(raw); + if (!label || labels.includes(label)) return; + const next = [...labels, label]; + onChange({ + ...value, + labels: { mode: labelMode, values: next }, + }); + }; + + const handleRemoveLabel = (label: string) => { + const next = labels.filter((l) => l !== label); + if (next.length === 0) { + // Drop the labels key entirely when empty + const { labels: _labels, ...rest } = value; + onChange(rest); + } else { + onChange({ ...value, labels: { mode: labelMode, values: next } }); + } + }; + + const handleLabelModeToggle = (mode: "any" | "all") => { + if (!value.labels) return; + onChange({ ...value, labels: { ...value.labels, mode } }); + }; + + const handleLabelKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + const input = e.currentTarget; + handleAddLabel(input.value); + input.value = ""; + } + }; + + const handleLabelBlur = (e: React.FocusEvent<HTMLInputElement>) => { + const raw = e.currentTarget.value.trim(); + if (raw) { + handleAddLabel(raw); + e.currentTarget.value = ""; + } + }; + + // ── Assignee ──────────────────────────────────────────────────────────────── + + type AssigneeKind = "none" | "anyone" | "login"; + + const assigneeKind: AssigneeKind = + value.assignee === undefined ? "none" : value.assignee.kind === "anyone" ? "anyone" : "login"; + + const assigneeLogin = value.assignee?.kind === "login" ? value.assignee.value : ""; + + const handleAssigneeKindChange = (kind: AssigneeKind) => { + if (kind === "none") { + const { assignee: _assignee, ...rest } = value; + onChange(rest); + } else if (kind === "anyone") { + onChange({ ...value, assignee: { kind: "anyone" } }); + } else { + // switch to login mode — keep existing login if there was one + onChange({ ...value, assignee: { kind: "login", value: assigneeLogin } }); + } + }; + + const handleAssigneeLoginChange = (login: string) => { + onChange({ ...value, assignee: { kind: "login", value: login } }); + }; + + // ── State ─────────────────────────────────────────────────────────────────── + + type StateFilter = "any" | "open" | "closed"; + const stateFilter: StateFilter = value.state ?? "any"; + + const handleStateChange = (s: StateFilter) => { + if (s === "any") { + const { state: _state, ...rest } = value; + onChange(rest); + } else { + onChange({ ...value, state: s }); + } + }; + + // ── Render ────────────────────────────────────────────────────────────────── + + return ( + <div className="space-y-4"> + {/* Summary line */} + <p className="text-xs text-muted-foreground"> + <span className="font-medium text-foreground">Preview: </span> + {summary} + </p> + + {/* Labels */} + <div className="space-y-2"> + <div className="flex items-center justify-between gap-2"> + <span className="text-xs font-medium text-foreground">Labels</span> + {labels.length > 1 ? ( + <div + role="group" + aria-label="Label match mode" + className="flex items-center gap-1 text-xs text-muted-foreground" + > + <button + type="button" + disabled={disabled} + aria-pressed={labelMode === "any"} + onClick={() => handleLabelModeToggle("any")} + className={ + labelMode === "any" + ? "font-semibold text-foreground underline" + : "hover:text-foreground" + } + > + any + </button> + <span>/</span> + <button + type="button" + disabled={disabled} + aria-pressed={labelMode === "all"} + onClick={() => handleLabelModeToggle("all")} + className={ + labelMode === "all" + ? "font-semibold text-foreground underline" + : "hover:text-foreground" + } + > + all + </button> + </div> + ) : null} + </div> + + {/* Chip list */} + <div className="flex flex-wrap gap-1.5"> + {labels.map((label) => ( + <Badge key={label} variant="outline" className="gap-1 pr-1"> + {label} + <button + type="button" + aria-label={`Remove label ${label}`} + disabled={disabled} + onClick={() => handleRemoveLabel(label)} + className="opacity-60 hover:opacity-100 disabled:pointer-events-none" + > + <XIcon className="size-3" /> + </button> + </Badge> + ))} + </div> + + {/* Add label input */} + <Input + nativeInput + type="text" + placeholder="Add label (Enter or comma to add)" + disabled={disabled} + onKeyDown={handleLabelKeyDown} + onBlur={handleLabelBlur} + aria-label="Add label" + /> + <p className="text-[11px] text-muted-foreground"> + Press Enter or comma to add. Leave empty to match any label. + </p> + </div> + + {/* Assignee */} + <div className="grid gap-1"> + <label htmlFor="assignee-kind" className="text-xs font-medium text-foreground"> + Assignee + </label> + <select + id="assignee-kind" + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={assigneeKind} + disabled={disabled} + onChange={(e) => handleAssigneeKindChange(e.currentTarget.value as AssigneeKind)} + > + <option value="none">Any (no filter)</option> + <option value="anyone">Assigned to anyone</option> + <option value="login">Specific login</option> + </select> + {assigneeKind === "login" ? ( + <> + <label htmlFor="assignee-login" className="text-xs text-muted-foreground"> + GitHub username + </label> + <Input + nativeInput + id="assignee-login" + type="text" + placeholder="GitHub username" + value={assigneeLogin} + disabled={disabled} + onChange={(e) => handleAssigneeLoginChange(e.currentTarget.value)} + /> + </> + ) : null} + </div> + + {/* State */} + <fieldset className="space-y-2"> + <legend className="text-xs font-medium text-foreground">Issue state</legend> + <div className="flex gap-2"> + {(["any", "open", "closed"] as const).map((s) => ( + <Button + key={s} + type="button" + size="xs" + variant={stateFilter === s ? "default" : "outline"} + disabled={disabled} + onClick={() => handleStateChange(s)} + > + {s === "any" ? "Any" : s.charAt(0).toUpperCase() + s.slice(1)} + </Button> + ))} + </div> + </fieldset> + </div> + ); +} diff --git a/apps/web/src/components/board/editor/DryRunPanel.tsx b/apps/web/src/components/board/editor/DryRunPanel.tsx new file mode 100644 index 00000000000..ae1acadb14b --- /dev/null +++ b/apps/web/src/components/board/editor/DryRunPanel.tsx @@ -0,0 +1,150 @@ +import type { + WorkflowDefinitionEncoded, + WorkflowDryRunResult, + WorkflowDryRunScenario, +} from "@t3tools/contracts"; +import { PlayIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { describeDryRunEnd, describeDryRunHop } from "~/workflow/dryRunFormat"; + +const SCENARIOS: ReadonlyArray<{ readonly value: WorkflowDryRunScenario; readonly label: string }> = + [ + { value: "success", label: "All steps succeed" }, + { value: "failure", label: "All steps fail" }, + { value: "blocked", label: "All steps block" }, + ]; + +/** + * Simulates a hypothetical ticket through the definition currently in the + * editor (saved or not) and explains every hop. Catches dead ends and + * unbounded loops before any agent burns tokens on them. + */ +export function DryRunPanel({ + definition, + onDryRun, + onClose, +}: { + readonly definition: WorkflowDefinitionEncoded; + readonly onDryRun: (input: { + readonly startLane: string; + readonly scenario: WorkflowDryRunScenario; + }) => Promise<WorkflowDryRunResult>; + readonly onClose: () => void; +}) { + const lanes = definition.lanes.map((lane) => ({ + key: String(lane.key), + name: lane.name, + })); + const [startLane, setStartLane] = useState(lanes[0]?.key ?? ""); + const [scenario, setScenario] = useState<WorkflowDryRunScenario>("success"); + const [running, setRunning] = useState(false); + const [result, setResult] = useState<WorkflowDryRunResult | null>(null); + const [error, setError] = useState<string | null>(null); + const requestRef = useRef(0); + + useEffect(() => { + if (!lanes.some((lane) => lane.key === startLane)) { + setStartLane(lanes[0]?.key ?? ""); + } + }, [lanes, startLane]); + + const laneName = (key: string) => lanes.find((lane) => lane.key === key)?.name ?? key; + + const run = async () => { + const requestId = ++requestRef.current; + setRunning(true); + setError(null); + setResult(null); + try { + const next = await onDryRun({ startLane, scenario }); + if (requestRef.current === requestId) { + setResult(next); + } + } catch (cause) { + if (requestRef.current === requestId) { + setError(cause instanceof Error ? cause.message : "Dry run failed."); + } + } finally { + if (requestRef.current === requestId) { + setRunning(false); + } + } + }; + + return ( + <div className="border-b border-border bg-card/40 px-4 py-3" data-testid="dry-run-panel"> + <div className="flex flex-wrap items-end gap-3"> + <label className="grid gap-1 text-xs font-medium text-foreground"> + Start lane + <select + className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground" + value={startLane} + onChange={(event) => setStartLane(event.currentTarget.value)} + aria-label="Dry run start lane" + > + {lanes.map((lane) => ( + <option key={lane.key} value={lane.key}> + {lane.name} + </option> + ))} + </select> + </label> + <label className="grid gap-1 text-xs font-medium text-foreground"> + Scenario + <select + className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground" + value={scenario} + onChange={(event) => setScenario(event.currentTarget.value as WorkflowDryRunScenario)} + aria-label="Dry run scenario" + > + {SCENARIOS.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + </label> + <Button size="sm" disabled={running || startLane === ""} onClick={() => void run()}> + <PlayIcon className="size-3.5" /> + {running ? "Simulating…" : "Simulate"} + </Button> + <Button size="sm" variant="ghost" className="ml-auto" onClick={onClose}> + Close + </Button> + </div> + {error !== null ? ( + <p className="mt-2 text-xs text-destructive-foreground" role="alert"> + {error} + </p> + ) : null} + {result !== null ? ( + <div className="mt-3 space-y-1.5" data-testid="dry-run-result"> + {result.hops.length === 0 ? ( + <p className="text-xs text-muted-foreground">No hops — the ticket stays put.</p> + ) : ( + <ol className="space-y-1"> + {result.hops.map((hop, index) => ( + <li key={index} className="text-xs text-foreground"> + <span className="mr-1.5 inline-block w-5 text-right text-muted-foreground"> + {index + 1}. + </span> + {describeDryRunHop(hop, laneName)} + </li> + ))} + </ol> + )} + <p className="text-xs font-medium text-foreground" data-testid="dry-run-end"> + {describeDryRunEnd(result, laneName)} + </p> + {result.notes.map((note, index) => ( + <p key={index} className="text-[11px] text-warning"> + {note} + </p> + ))} + </div> + ) : null} + </div> + ); +} diff --git a/apps/web/src/components/board/editor/LaneForm.tsx b/apps/web/src/components/board/editor/LaneForm.tsx new file mode 100644 index 00000000000..0a84c1ff36a --- /dev/null +++ b/apps/web/src/components/board/editor/LaneForm.tsx @@ -0,0 +1,288 @@ +import type { WorkflowLintError } from "@t3tools/contracts"; +import { Trash2Icon } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { + addLaneAction, + lintErrorKey, + removeLane, + removeLaneAction, + renameLane, + setLaneColor, + setLaneEntry, + setLaneTerminal, + setLaneWipLimit, + updateLaneAction, + type WorkflowEditorModel, +} from "~/workflow/editorModel"; + +import { PipelineEditor } from "./PipelineEditor"; +import { RoutingEditor } from "./RoutingEditor"; +import { + lintErrorMatchesLane, + type WorkflowEditorMutation, + type WorkflowLaneEncoded, +} from "./WorkflowEditor"; + +export function LaneForm({ + model, + lane, + lanes, + lintErrors, + disabled = false, + onMutate, + onSelectLane, +}: { + readonly model: WorkflowEditorModel; + readonly lane: WorkflowLaneEncoded; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly lintErrors: ReadonlyArray<WorkflowLintError>; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; + readonly onSelectLane: (laneKey: string) => void; +}) { + const laneKey = String(lane.key); + const laneLintErrors = lintErrors.filter( + (lintError) => + lintErrorMatchesLane(lintError, laneKey) && + lintError.stepKey === undefined && + lintError.transitionIndex === undefined, + ); + + return ( + <section className="@container flex min-h-0 flex-col overflow-auto"> + <div className="space-y-4 p-4"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div className="min-w-0"> + <h3 className="truncate text-sm font-semibold text-foreground">{lane.name}</h3> + <p className="mt-1 text-xs text-muted-foreground">{laneKey}</p> + </div> + <Button + size="sm" + variant="destructive-outline" + disabled={disabled || model.definition.lanes.length <= 1} + onClick={() => { + onMutate((current) => removeLane(current, laneKey)); + const fallback = model.definition.lanes.find( + (candidate) => candidate.key !== lane.key, + ); + onSelectLane(String(fallback?.key ?? "")); + }} + > + <Trash2Icon className="size-4" /> + Remove lane + </Button> + </div> + {laneLintErrors.length > 0 ? ( + <ul className="rounded-md border border-warning/45 bg-warning/8 p-2 text-sm text-warning-foreground"> + {laneLintErrors.map((lintError) => ( + <li key={lintErrorKey(lintError)}>{lintError.message}</li> + ))} + </ul> + ) : null} + <div className="grid gap-3 @2xl:grid-cols-2"> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Lane name</span> + <Input + aria-label="Lane name" + value={lane.name} + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value; + onMutate((current) => renameLane(current, laneKey, value)); + }} + /> + </label> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Lane entry</span> + <select + aria-label="Lane entry" + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={lane.entry} + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value as WorkflowLaneEncoded["entry"]; + onMutate((current) => setLaneEntry(current, laneKey, value)); + }} + > + <option value="manual">manual</option> + <option value="auto">auto</option> + </select> + </label> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">WIP limit</span> + <Input + aria-label="WIP limit" + nativeInput + type="number" + min={1} + value={lane.wipLimit ?? ""} + disabled={disabled} + onChange={(event) => { + const raw = event.currentTarget.value; + onMutate((current) => + setLaneWipLimit(current, laneKey, raw === "" ? undefined : Number(raw)), + ); + }} + /> + </label> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Lane color</span> + <Input + aria-label="Lane color" + value={lane.color ?? ""} + placeholder="#3b82f6" + disabled={disabled} + onChange={(event) => { + const color = event.currentTarget.value.trim(); + onMutate((current) => setLaneColor(current, laneKey, color || undefined)); + }} + /> + </label> + <label className="flex items-center gap-2 text-sm text-foreground"> + <input + type="checkbox" + checked={lane.terminal ?? false} + disabled={disabled} + onChange={(event) => { + const checked = event.currentTarget.checked; + onMutate((current) => setLaneTerminal(current, laneKey, checked || undefined)); + }} + /> + Terminal lane + </label> + </div> + <LaneActionsEditor lane={lane} lanes={lanes} disabled={disabled} onMutate={onMutate} /> + <PipelineEditor + lane={lane} + lanes={lanes} + lintErrors={lintErrors} + disabled={disabled} + onMutate={onMutate} + /> + <RoutingEditor + lane={lane} + lanes={lanes} + lintErrors={lintErrors} + disabled={disabled} + onMutate={onMutate} + /> + </div> + </section> + ); +} + +function LaneActionsEditor({ + lane, + lanes, + disabled, + onMutate, +}: { + readonly lane: WorkflowLaneEncoded; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly disabled: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const laneKey = String(lane.key); + const actions = lane.actions ?? []; + return ( + <section className="space-y-3 border-t border-border pt-4"> + <div className="flex flex-wrap items-center justify-between gap-2"> + <div> + <h4 className="text-sm font-semibold text-foreground">Actions</h4> + <p className="text-xs text-muted-foreground"> + Buttons shown on tickets in this lane; clicking one moves the ticket. + </p> + </div> + <Button + size="xs" + variant="outline" + disabled={disabled} + onClick={() => onMutate((current) => addLaneAction(current, laneKey))} + > + Add action + </Button> + </div> + {actions.length === 0 ? ( + <p className="rounded-md border border-border/70 bg-muted/20 p-3 text-sm text-muted-foreground"> + No actions. Tickets here move via drag or the move menu. + </p> + ) : ( + <ol className="space-y-3"> + {actions.map((action, index) => ( + <li + key={index} + className="space-y-2 rounded-md border border-border/70 bg-muted/10 p-3" + > + <div className="flex items-center justify-between gap-2"> + <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> + Action {index + 1} + </span> + <Button + size="icon-xs" + variant="ghost" + aria-label={`Remove action ${index + 1} from lane ${laneKey}`} + disabled={disabled} + onClick={() => onMutate((current) => removeLaneAction(current, laneKey, index))} + > + <Trash2Icon className="size-3.5" /> + </Button> + </div> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Label</span> + <Input + aria-label={`Action ${index + 1} label in lane ${laneKey}`} + value={action.label} + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value; + onMutate((current) => + updateLaneAction(current, laneKey, index, { label: value as never }), + ); + }} + /> + </label> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Moves to</span> + <select + aria-label={`Action ${index + 1} target lane in lane ${laneKey}`} + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={String(action.to)} + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value; + onMutate((current) => + updateLaneAction(current, laneKey, index, { to: value as never }), + ); + }} + > + {lanes.map((target) => ( + <option key={String(target.key)} value={String(target.key)}> + {target.name} + </option> + ))} + </select> + </label> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Hint</span> + <Input + aria-label={`Action ${index + 1} hint in lane ${laneKey}`} + value={action.hint ?? ""} + placeholder="Shown as the button tooltip" + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value; + onMutate((current) => + updateLaneAction(current, laneKey, index, { hint: value }), + ); + }} + /> + </label> + </li> + ))} + </ol> + )} + </section> + ); +} diff --git a/apps/web/src/components/board/editor/LaneList.tsx b/apps/web/src/components/board/editor/LaneList.tsx new file mode 100644 index 00000000000..3667ae439a5 --- /dev/null +++ b/apps/web/src/components/board/editor/LaneList.tsx @@ -0,0 +1,81 @@ +import type { WorkflowLintError } from "@t3tools/contracts"; +import { PlusIcon } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; + +import { lintErrorMatchesLane, type WorkflowLaneEncoded } from "./WorkflowEditor"; + +export function LaneList({ + lanes, + lintErrors, + selectedLaneKey, + disabled = false, + onAdd, + onSelect, +}: { + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly lintErrors: ReadonlyArray<WorkflowLintError>; + readonly selectedLaneKey: string | null; + readonly disabled?: boolean; + readonly onAdd: () => void; + readonly onSelect: (laneKey: string) => void; +}) { + return ( + <nav className="flex min-h-0 flex-col gap-2 border-r border-border bg-muted/20 p-3 max-md:border-r-0 max-md:border-b"> + <div className="flex items-center justify-between gap-2"> + <h3 className="text-xs font-semibold uppercase tracking-normal text-muted-foreground"> + Lanes + </h3> + <Button + size="icon-xs" + variant="outline" + aria-label="Add lane" + disabled={disabled} + onClick={onAdd} + > + <PlusIcon className="size-3.5" /> + </Button> + </div> + <div className="min-h-0 space-y-1 overflow-auto"> + {lanes.map((lane) => { + const laneKey = String(lane.key); + const selected = laneKey === selectedLaneKey; + const hasLintError = lintErrors.some((lintError) => + lintErrorMatchesLane(lintError, laneKey), + ); + const pipelineCount = lane.pipeline?.length ?? 0; + return ( + <button + key={laneKey} + type="button" + aria-label={lane.name} + aria-current={selected ? "true" : undefined} + className={cn( + "w-full rounded-md border border-transparent px-2.5 py-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", + selected + ? "border-border bg-background shadow-xs" + : "hover:border-border/60 hover:bg-background/60", + hasLintError && "border-warning/60 bg-warning/8", + )} + onClick={() => onSelect(laneKey)} + > + <span className="block truncate text-sm font-medium text-foreground"> + {lane.name} + </span> + <span + className="mt-1 flex flex-wrap gap-1 text-[11px] text-muted-foreground" + aria-hidden="true" + > + <span>{lane.entry}</span> + <span>{pipelineCount} steps</span> + {lane.wipLimit === undefined ? null : <span>WIP {lane.wipLimit}</span>} + {lane.terminal ? <span>terminal</span> : null} + </span> + </button> + ); + })} + </div> + </nav> + ); +} diff --git a/apps/web/src/components/board/editor/OutboundSection.tsx b/apps/web/src/components/board/editor/OutboundSection.tsx new file mode 100644 index 00000000000..3465f678c62 --- /dev/null +++ b/apps/web/src/components/board/editor/OutboundSection.tsx @@ -0,0 +1,500 @@ +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import type { + OutboundConnectionView, + WorkflowDefinitionEncoded, + WorkflowLintError, +} from "@t3tools/contracts"; + +import { Button } from "~/components/ui/button"; +import { Spinner } from "~/components/ui/spinner"; +import { lintErrorKey } from "~/workflow/editorModel"; +import type { WorkflowEditorMutation } from "./WorkflowEditor"; + +// ─── types ─────────────────────────────────────────────────────────────────── + +type OutboundRuleEncoded = NonNullable<WorkflowDefinitionEncoded["outbound"]>[number]; + +type OutboundTrigger = "needs_attention" | "blocked" | "done" | "lane_entered"; +type OutboundFormatter = "generic" | "slack"; + +interface OutboundRuleDraft { + id: string; + on: OutboundTrigger; + when: string; // raw JSON string; empty = undefined + to: string; // connectionRef + as: OutboundFormatter; + enabled: boolean; +} + +// ─── helpers ───────────────────────────────────────────────────────────────── + +const TRIGGER_LABELS: Record<OutboundTrigger, string> = { + needs_attention: "Needs attention", + blocked: "Blocked", + done: "Done", + lane_entered: "Lane entered", +}; + +const FORMATTER_LABELS: Record<OutboundFormatter, string> = { + generic: "Generic", + slack: "Slack", +}; + +function newRuleDraft(): OutboundRuleDraft { + return { + id: `outbound-${Date.now()}`, + on: "done", + when: "", + to: "", + as: "generic", + enabled: true, + }; +} + +function ruleToDraft(rule: OutboundRuleEncoded): OutboundRuleDraft { + return { + id: String(rule.id), + on: rule.on as OutboundTrigger, + when: rule.when !== undefined ? JSON.stringify(rule.when, null, 2) : "", + to: String(rule.to), + as: rule.as as OutboundFormatter, + enabled: rule.enabled, + }; +} + +function draftToRule(draft: OutboundRuleDraft): OutboundRuleEncoded { + let when: unknown = undefined; + if (draft.when.trim()) { + try { + when = JSON.parse(draft.when) as unknown; + } catch { + // leave undefined if unparseable; server lint will surface the error + } + } + return { + id: draft.id as never, + on: draft.on, + ...(when !== undefined ? { when } : {}), + to: draft.to as never, + as: draft.as, + enabled: draft.enabled, + }; +} + +// ─── component ─────────────────────────────────────────────────────────────── + +export interface OutboundSectionProps { + readonly definition: WorkflowDefinitionEncoded; + readonly lintErrors: ReadonlyArray<WorkflowLintError>; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; + readonly listOutboundConnections: ( + input: Record<string, never>, + ) => Promise<{ readonly connections: ReadonlyArray<OutboundConnectionView> }>; +} + +export function OutboundSection({ + definition, + lintErrors, + disabled = false, + onMutate, + listOutboundConnections, +}: OutboundSectionProps) { + const [connections, setConnections] = useState<ReadonlyArray<OutboundConnectionView> | null>( + null, + ); + const [connectionsError, setConnectionsError] = useState<string | null>(null); + const [editingRuleId, setEditingRuleId] = useState<string | null>(null); + const [draftRule, setDraftRule] = useState<OutboundRuleDraft | null>(null); + + useEffect(() => { + let active = true; + setConnections(null); + setConnectionsError(null); + listOutboundConnections({}) + .then((result) => { + if (active) setConnections(result.connections); + }) + .catch((error: unknown) => { + if (active) + setConnectionsError( + error instanceof Error ? error.message : "Failed to load connections.", + ); + }); + return () => { + active = false; + }; + }, [listOutboundConnections]); + + const rules = definition.outbound ?? []; + + // Outbound lint errors have no laneKey / stepKey + const outboundLintErrors = lintErrors.filter( + (e) => + (e.code === "invalid_outbound" || e.code === "duplicate_outbound_id") && + e.laneKey === undefined && + e.stepKey === undefined, + ); + + const handleAdd = () => { + const draft = newRuleDraft(); + setDraftRule(draft); + setEditingRuleId(draft.id); + }; + + const handleEdit = (rule: OutboundRuleEncoded) => { + setDraftRule(ruleToDraft(rule)); + setEditingRuleId(String(rule.id)); + }; + + const handleSaveDraft = () => { + if (!draftRule) return; + const ruleId = draftRule.id; + const encoded = draftToRule(draftRule); + onMutate((model) => { + const current = model.definition.outbound ?? []; + const existingIndex = current.findIndex((r) => String(r.id) === ruleId); + const next = + existingIndex === -1 + ? [...current, encoded] + : current.map((r, i) => (i === existingIndex ? encoded : r)); + return { + ...model, + definition: { ...model.definition, outbound: next as never }, + dirty: true, + lintErrors: [], + }; + }); + setEditingRuleId(null); + setDraftRule(null); + }; + + const handleCancelDraft = () => { + setEditingRuleId(null); + setDraftRule(null); + }; + + const handleRemove = (ruleId: string) => { + onMutate((model) => { + const next = (model.definition.outbound ?? []).filter((r) => String(r.id) !== ruleId); + return { + ...model, + definition: { ...model.definition, outbound: next as never }, + dirty: true, + lintErrors: [], + }; + }); + if (editingRuleId === ruleId) { + setEditingRuleId(null); + setDraftRule(null); + } + }; + + return ( + <section className="space-y-3 border-t border-border pt-4"> + <div className="flex flex-wrap items-center justify-between gap-2"> + <div> + <h4 className="text-sm font-semibold text-foreground">Outbound Rules</h4> + <p className="text-xs text-muted-foreground"> + Send notifications to external services when tickets change state. + </p> + </div> + <Button size="xs" variant="outline" disabled={disabled} onClick={handleAdd}> + <PlusIcon className="size-3.5" /> + Add rule + </Button> + </div> + + {outboundLintErrors.length > 0 ? ( + <ul className="rounded-md border border-warning/45 bg-warning/8 p-2 text-sm text-warning-foreground"> + {outboundLintErrors.map((e) => ( + <li key={lintErrorKey(e)}>{e.message}</li> + ))} + </ul> + ) : null} + + {connectionsError ? ( + <p className="text-xs text-destructive">{connectionsError}</p> + ) : connections === null ? ( + <p className="flex items-center gap-1.5 text-xs text-muted-foreground"> + <Spinner className="size-3" /> + Loading connections… + </p> + ) : null} + + {rules.length === 0 && editingRuleId === null ? ( + <p className="rounded-md border border-border/70 bg-muted/20 p-3 text-sm text-muted-foreground"> + No outbound rules configured. Events will only be visible in t3code. + </p> + ) : ( + <ol className="space-y-3"> + {rules.map((rule) => { + const ruleId = String(rule.id); + const isEditing = editingRuleId === ruleId; + return ( + <li + key={ruleId} + className="space-y-2 rounded-md border border-border/70 bg-muted/10 p-3" + > + {isEditing && draftRule ? ( + <OutboundRuleForm + draft={draftRule} + connections={connections ?? []} + disabled={disabled} + onChange={setDraftRule} + onSave={handleSaveDraft} + onCancel={handleCancelDraft} + onRemove={() => handleRemove(ruleId)} + isNew={false} + /> + ) : ( + <OutboundRuleRow + rule={rule} + connections={connections ?? []} + disabled={disabled} + onEdit={() => handleEdit(rule)} + onRemove={() => handleRemove(ruleId)} + /> + )} + </li> + ); + })} + {editingRuleId !== null && + !rules.some((r) => String(r.id) === editingRuleId) && + draftRule ? ( + <li className="space-y-2 rounded-md border border-border/70 bg-muted/10 p-3"> + <OutboundRuleForm + draft={draftRule} + connections={connections ?? []} + disabled={disabled} + onChange={setDraftRule} + onSave={handleSaveDraft} + onCancel={handleCancelDraft} + onRemove={null} + isNew + /> + </li> + ) : null} + </ol> + )} + </section> + ); +} + +// ─── OutboundRuleRow ───────────────────────────────────────────────────────── + +function OutboundRuleRow({ + rule, + connections, + disabled, + onEdit, + onRemove, +}: { + readonly rule: OutboundRuleEncoded; + readonly connections: ReadonlyArray<OutboundConnectionView>; + readonly disabled: boolean; + readonly onEdit: () => void; + readonly onRemove: () => void; +}) { + const connection = connections.find((c) => c.connectionRef === String(rule.to)); + const connectionLabel = connection?.displayName ?? String(rule.to); + const triggerLabel = TRIGGER_LABELS[rule.on as OutboundTrigger] ?? String(rule.on); + + return ( + <div className="flex items-center justify-between gap-3"> + <div className="min-w-0 space-y-1"> + <p className="truncate text-sm font-medium text-foreground"> + {triggerLabel} → {connectionLabel} + </p> + <p className="text-xs text-muted-foreground"> + formatter: {String(rule.as)} + {!rule.enabled ? " · disabled" : null} + {rule.when !== undefined ? " · has condition" : null} + </p> + </div> + <div className="flex shrink-0 items-center gap-2"> + <Button + size="xs" + variant="outline" + disabled={disabled} + aria-label={`Edit ${triggerLabel} → ${connectionLabel}`} + onClick={onEdit} + > + Edit + </Button> + <Button + size="icon-xs" + variant="destructive-outline" + disabled={disabled} + aria-label={`Remove ${triggerLabel} → ${connectionLabel}`} + onClick={onRemove} + > + <Trash2Icon className="size-3.5" /> + </Button> + </div> + </div> + ); +} + +// ─── OutboundRuleForm ───────────────────────────────────────────────────────── + +function OutboundRuleForm({ + draft, + connections, + disabled, + onChange, + onSave, + onCancel, + onRemove, + isNew, +}: { + readonly draft: OutboundRuleDraft; + readonly connections: ReadonlyArray<OutboundConnectionView>; + readonly disabled: boolean; + readonly onChange: (next: OutboundRuleDraft) => void; + readonly onSave: () => void; + readonly onCancel: () => void; + readonly onRemove: (() => void) | null; + readonly isNew: boolean; +}) { + const whenParseError = + draft.when.trim() !== "" + ? (() => { + try { + JSON.parse(draft.when); + return null; + } catch { + return "Invalid JSON"; + } + })() + : null; + + return ( + <div className="space-y-3"> + <div className="flex items-center justify-between gap-2"> + <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> + {isNew ? "New outbound rule" : "Edit outbound rule"} + </span> + {onRemove ? ( + <Button + size="icon-xs" + variant="destructive-outline" + disabled={disabled} + aria-label="Remove outbound rule" + onClick={onRemove} + > + <Trash2Icon className="size-3.5" /> + </Button> + ) : null} + </div> + + {/* Trigger */} + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Trigger</span> + <select + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={draft.on} + disabled={disabled} + onChange={(e) => onChange({ ...draft, on: e.currentTarget.value as OutboundTrigger })} + > + {(Object.entries(TRIGGER_LABELS) as [OutboundTrigger, string][]).map(([value, label]) => ( + <option key={value} value={value}> + {label} + </option> + ))} + </select> + </label> + + {/* Destination connection */} + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Connection</span> + <select + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={draft.to} + disabled={disabled} + onChange={(e) => onChange({ ...draft, to: e.currentTarget.value })} + > + <option value="">— select connection —</option> + {connections.map((c) => ( + <option key={c.connectionRef} value={c.connectionRef}> + {c.displayName} + </option> + ))} + </select> + {connections.length === 0 ? ( + <p className="text-[11px] text-muted-foreground"> + No outbound connections.{" "} + <a href="/settings/outbound" className="underline hover:text-foreground"> + Add one in Settings → Outbound + </a> + . + </p> + ) : null} + </label> + + {/* Formatter */} + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Formatter</span> + <select + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={draft.as} + disabled={disabled} + onChange={(e) => onChange({ ...draft, as: e.currentTarget.value as OutboundFormatter })} + > + {(Object.entries(FORMATTER_LABELS) as [OutboundFormatter, string][]).map( + ([value, label]) => ( + <option key={value} value={value}> + {label} + </option> + ), + )} + </select> + </label> + + {/* Optional when predicate */} + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground"> + Condition (JSON-logic, optional) + </span> + <textarea + className="min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground disabled:opacity-50" + value={draft.when} + placeholder={'{"==": [{"var": "trigger"}, "done"]}'} + disabled={disabled} + onChange={(e) => onChange({ ...draft, when: e.currentTarget.value })} + spellCheck={false} + /> + {whenParseError ? ( + <p className="text-[11px] text-destructive">{whenParseError}</p> + ) : ( + <p className="text-[11px] text-muted-foreground"> + Leave blank to fire on every matching event. Variables: trigger, ticketId, boardId, + title, status, fromLane, toLane, isTerminal, reason. + </p> + )} + </label> + + {/* Enabled */} + <label className="flex items-center gap-2 text-sm text-foreground"> + <input + type="checkbox" + checked={draft.enabled} + disabled={disabled} + onChange={(e) => onChange({ ...draft, enabled: e.currentTarget.checked })} + /> + Enabled + </label> + + <div className="flex flex-wrap gap-2"> + <Button size="xs" disabled={disabled || whenParseError !== null} onClick={onSave}> + {isNew ? "Add rule" : "Save rule"} + </Button> + <Button size="xs" variant="outline" disabled={disabled} onClick={onCancel}> + Cancel + </Button> + </div> + </div> + ); +} diff --git a/apps/web/src/components/board/editor/PipelineEditor.tsx b/apps/web/src/components/board/editor/PipelineEditor.tsx new file mode 100644 index 00000000000..134654cc309 --- /dev/null +++ b/apps/web/src/components/board/editor/PipelineEditor.tsx @@ -0,0 +1,164 @@ +import type { WorkflowLintError } from "@t3tools/contracts"; +import { + BotIcon, + CheckSquareIcon, + GitMergeIcon, + GitPullRequestIcon, + ChevronDownIcon, + ChevronUpIcon, + TerminalIcon, + Trash2Icon, +} from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { addStep, lintErrorKey, removeStep, reorderStep } from "~/workflow/editorModel"; + +import { StepFields } from "./StepFields"; +import { + lintErrorMatchesStep, + type WorkflowEditorMutation, + type WorkflowLaneEncoded, +} from "./WorkflowEditor"; + +export function PipelineEditor({ + lane, + lanes, + lintErrors, + disabled = false, + onMutate, +}: { + readonly lane: WorkflowLaneEncoded; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly lintErrors: ReadonlyArray<WorkflowLintError>; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const laneKey = String(lane.key); + const pipeline = lane.pipeline ?? []; + + return ( + <section className="space-y-3 border-t border-border pt-4"> + <div className="flex flex-wrap items-center justify-between gap-2"> + <h4 className="text-sm font-semibold text-foreground">Pipeline</h4> + <div className="flex flex-wrap gap-2"> + <Button + size="xs" + variant="outline" + disabled={disabled} + onClick={() => onMutate((current) => addStep(current, laneKey, "agent"))} + > + <BotIcon className="size-3.5" /> + Agent + </Button> + <Button + size="xs" + variant="outline" + disabled={disabled} + onClick={() => onMutate((current) => addStep(current, laneKey, "script"))} + > + <TerminalIcon className="size-3.5" /> + Script + </Button> + <Button + size="xs" + variant="outline" + disabled={disabled} + onClick={() => onMutate((current) => addStep(current, laneKey, "approval"))} + > + <CheckSquareIcon className="size-3.5" /> + Approval + </Button> + <Button + size="xs" + variant="outline" + disabled={disabled} + onClick={() => onMutate((current) => addStep(current, laneKey, "merge"))} + > + <GitMergeIcon className="size-3.5" /> + Merge + </Button> + <Button + size="xs" + variant="outline" + disabled={disabled} + onClick={() => onMutate((current) => addStep(current, laneKey, "pullRequest"))} + > + <GitPullRequestIcon className="size-3.5" /> + Pull Request + </Button> + </div> + </div> + {pipeline.length === 0 ? ( + <p className="rounded-md border border-border/70 bg-muted/20 p-3 text-sm text-muted-foreground"> + No pipeline steps. + </p> + ) : ( + <ol className="space-y-3"> + {pipeline.map((step, index) => { + const stepKey = String(step.key); + const stepLintErrors = lintErrors.filter((lintError) => + lintErrorMatchesStep(lintError, laneKey, stepKey), + ); + return ( + <li key={stepKey} className="rounded-md border border-border/70 bg-card/35 p-3"> + <div className="mb-3 flex flex-wrap items-center justify-between gap-2"> + <div className="min-w-0"> + <p className="truncate text-sm font-medium text-foreground">{stepKey}</p> + <p className="text-xs text-muted-foreground">{step.type}</p> + </div> + <div className="flex gap-1"> + <Button + size="icon-xs" + variant="ghost" + aria-label={`Move ${stepKey} up`} + disabled={disabled || index === 0} + onClick={() => + onMutate((current) => reorderStep(current, laneKey, index, index - 1)) + } + > + <ChevronUpIcon className="size-3.5" /> + </Button> + <Button + size="icon-xs" + variant="ghost" + aria-label={`Move ${stepKey} down`} + disabled={disabled || index === pipeline.length - 1} + onClick={() => + onMutate((current) => reorderStep(current, laneKey, index, index + 1)) + } + > + <ChevronDownIcon className="size-3.5" /> + </Button> + <Button + size="icon-xs" + variant="ghost" + aria-label={`Remove ${stepKey}`} + disabled={disabled} + onClick={() => onMutate((current) => removeStep(current, laneKey, stepKey))} + > + <Trash2Icon className="size-3.5" /> + </Button> + </div> + </div> + {stepLintErrors.length > 0 ? ( + <ul className="mb-3 rounded-md border border-warning/45 bg-warning/8 p-2 text-sm text-warning-foreground"> + {stepLintErrors.map((lintError) => ( + <li key={lintErrorKey(lintError)}>{lintError.message}</li> + ))} + </ul> + ) : null} + <StepFields + laneKey={laneKey} + lanes={lanes} + step={step} + disabled={disabled} + onMutate={onMutate} + /> + </li> + ); + })} + </ol> + )} + </section> + ); +} diff --git a/apps/web/src/components/board/editor/RoutingEditor.tsx b/apps/web/src/components/board/editor/RoutingEditor.tsx new file mode 100644 index 00000000000..b2296628f95 --- /dev/null +++ b/apps/web/src/components/board/editor/RoutingEditor.tsx @@ -0,0 +1,427 @@ +import type { WorkflowLintError } from "@t3tools/contracts"; +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { Textarea } from "~/components/ui/textarea"; +import { Input } from "~/components/ui/input"; +import { + addLaneEvent, + addTransition, + adjustSelectionAfterTransitionRemoval, + lintErrorKey, + removeLaneEvent, + removeTransition, + setLaneOn, + updateLaneEvent, + updateTransition, +} from "~/workflow/editorModel"; + +import { + lintErrorMatchesTransition, + type WorkflowEditorMutation, + type WorkflowLaneEncoded, +} from "./WorkflowEditor"; + +export function RoutingEditor({ + lane, + lanes, + lintErrors, + disabled = false, + onMutate, +}: { + readonly lane: WorkflowLaneEncoded; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly lintErrors: ReadonlyArray<WorkflowLintError>; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const laneKey = String(lane.key); + const transitions = lane.transitions ?? []; + + return ( + <section className="space-y-3 border-t border-border pt-4"> + <div className="flex flex-wrap items-center justify-between gap-2"> + <h4 className="text-sm font-semibold text-foreground">Routing</h4> + <Button + size="xs" + variant="outline" + disabled={disabled} + onClick={() => onMutate((current) => addTransition(current, laneKey))} + > + <PlusIcon className="size-3.5" /> + Transition + </Button> + </div> + <div className="grid gap-3 @2xl:grid-cols-3"> + <LaneRouteSelect + label="Lane success route" + lanes={lanes} + value={lane.on?.success} + disabled={disabled} + onChange={(targetLaneKey) => { + onMutate((current) => setLaneOn(current, laneKey, "success", targetLaneKey)); + }} + /> + <LaneRouteSelect + label="Lane failure route" + lanes={lanes} + value={lane.on?.failure} + disabled={disabled} + onChange={(targetLaneKey) => { + onMutate((current) => setLaneOn(current, laneKey, "failure", targetLaneKey)); + }} + /> + <LaneRouteSelect + label="Lane blocked route" + lanes={lanes} + value={lane.on?.blocked} + disabled={disabled} + onChange={(targetLaneKey) => { + onMutate((current) => setLaneOn(current, laneKey, "blocked", targetLaneKey)); + }} + /> + </div> + {transitions.length === 0 ? ( + <p className="rounded-md border border-border/70 bg-muted/20 p-3 text-sm text-muted-foreground"> + No conditional transitions. + </p> + ) : ( + <ol className="space-y-3"> + {transitions.map((transition, index) => ( + <TransitionFields + key={transitionRowKey(index)} + laneKey={laneKey} + lanes={lanes} + transitionIndex={index} + transition={transition} + lintErrors={lintErrors.filter((lintError) => + lintErrorMatchesTransition(lintError, laneKey, index), + )} + disabled={disabled} + onMutate={onMutate} + /> + ))} + </ol> + )} + <LaneEventsEditor lane={lane} lanes={lanes} disabled={disabled} onMutate={onMutate} /> + </section> + ); +} + +/** + * External-event matchers: a webhook event with a matching name (and passing + * predicate over {event: {name, payload}}) moves a ticket sitting in this + * lane to the matcher's target. + */ +function LaneEventsEditor({ + lane, + lanes, + disabled = false, + onMutate, +}: { + readonly lane: WorkflowLaneEncoded; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const laneKey = String(lane.key); + const events = lane.onEvent ?? []; + + return ( + <div className="space-y-3 border-t border-border/60 pt-3" data-testid="lane-events-editor"> + <div className="flex flex-wrap items-center justify-between gap-2"> + <div> + <h5 className="text-sm font-semibold text-foreground">External events</h5> + <p className="text-xs text-muted-foreground"> + Webhook events (CI, PR automation, cron) that move tickets out of this lane. + </p> + </div> + <Button + size="xs" + variant="outline" + disabled={disabled} + onClick={() => onMutate((current) => addLaneEvent(current, laneKey))} + > + <PlusIcon className="size-3.5" /> + Event + </Button> + </div> + {events.length === 0 ? null : ( + <ol className="space-y-3"> + {events.map((event, index) => ( + <LaneEventFields + key={`lane-event-${index}`} + laneKey={laneKey} + lanes={lanes} + eventIndex={index} + event={event} + disabled={disabled} + onMutate={onMutate} + /> + ))} + </ol> + )} + </div> + ); +} + +function LaneEventFields({ + laneKey, + lanes, + event, + eventIndex, + disabled = false, + onMutate, +}: { + readonly laneKey: string; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly event: NonNullable<WorkflowLaneEncoded["onEvent"]>[number]; + readonly eventIndex: number; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const [whenDraft, setWhenDraft] = useState(() => + event.when === undefined ? "" : JSON.stringify(event.when, null, 2), + ); + const [whenError, setWhenError] = useState<string | null>(null); + + useEffect(() => { + setWhenDraft(event.when === undefined ? "" : JSON.stringify(event.when, null, 2)); + setWhenError(null); + }, [event.when]); + + return ( + <li className="rounded-md border border-border/70 bg-card/35 p-3"> + <div className="mb-3 flex flex-wrap items-center justify-between gap-2"> + <p className="text-sm font-medium text-foreground">Event {eventIndex + 1}</p> + <Button + size="icon-xs" + variant="ghost" + aria-label={`Remove event ${eventIndex + 1}`} + disabled={disabled} + onClick={() => onMutate((current) => removeLaneEvent(current, laneKey, eventIndex))} + > + <Trash2Icon className="size-3.5" /> + </Button> + </div> + <div className="grid gap-3 @2xl:grid-cols-[12rem_minmax(0,1fr)_12rem]"> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Event name</span> + <Input + aria-label={`Event ${eventIndex + 1} name`} + value={event.name} + placeholder="ci.passed" + disabled={disabled} + onChange={(changeEvent) => { + const name = changeEvent.currentTarget.value; + onMutate((current) => updateLaneEvent(current, laneKey, eventIndex, { name })); + }} + /> + </label> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground"> + Predicate JSON (optional, reads event.name / event.payload.*) + </span> + <Textarea + aria-label={`Event ${eventIndex + 1} predicate JSON`} + value={whenDraft} + placeholder='{"==": [{"var": "event.payload.status"}, "green"]}' + disabled={disabled} + rows={3} + onChange={(changeEvent) => { + const nextDraft = changeEvent.currentTarget.value; + setWhenDraft(nextDraft); + if (nextDraft.trim() === "") { + setWhenError(null); + onMutate((current) => + updateLaneEvent(current, laneKey, eventIndex, { when: null }), + ); + return; + } + let parsedWhen: unknown; + try { + parsedWhen = JSON.parse(nextDraft) as unknown; + } catch { + setWhenError("Predicate JSON is invalid."); + return; + } + setWhenError(null); + onMutate((current) => + updateLaneEvent(current, laneKey, eventIndex, { when: parsedWhen }), + ); + }} + /> + {whenError ? <span className="text-xs text-destructive">{whenError}</span> : null} + </label> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Moves to</span> + <select + aria-label={`Event ${eventIndex + 1} target lane`} + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={String(event.to)} + disabled={disabled} + onChange={(changeEvent) => { + const value = changeEvent.currentTarget.value; + onMutate((current) => updateLaneEvent(current, laneKey, eventIndex, { to: value })); + }} + > + {lanes.map((laneOption) => ( + <option key={String(laneOption.key)} value={String(laneOption.key)}> + {laneOption.name} + </option> + ))} + </select> + </label> + </div> + </li> + ); +} + +function LaneRouteSelect({ + label, + lanes, + value, + disabled = false, + onChange, +}: { + readonly label: string; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly value: string | undefined; + readonly disabled?: boolean; + readonly onChange: (targetLaneKey: string | undefined) => void; +}) { + return ( + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">{label}</span> + <select + aria-label={label} + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={value ?? ""} + disabled={disabled} + onChange={(event) => { + const targetLaneKey = event.currentTarget.value || undefined; + onChange(targetLaneKey); + }} + > + <option value="">No route</option> + {lanes.map((lane) => ( + <option key={String(lane.key)} value={String(lane.key)}> + {lane.name} + </option> + ))} + </select> + </label> + ); +} + +export function TransitionFields({ + laneKey, + lanes, + lintErrors, + onMutate, + transition, + transitionIndex, + disabled = false, +}: { + readonly laneKey: string; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly lintErrors: ReadonlyArray<WorkflowLintError>; + readonly onMutate: WorkflowEditorMutation; + readonly transition: NonNullable<WorkflowLaneEncoded["transitions"]>[number]; + readonly transitionIndex: number; + readonly disabled?: boolean; +}) { + const [whenDraft, setWhenDraft] = useState(() => JSON.stringify(transition.when, null, 2)); + const [whenError, setWhenError] = useState<string | null>(null); + + useEffect(() => { + setWhenDraft(JSON.stringify(transition.when, null, 2)); + setWhenError(null); + }, [transition.when]); + + return ( + <li className="rounded-md border border-border/70 bg-card/35 p-3"> + <div className="mb-3 flex flex-wrap items-center justify-between gap-2"> + <p className="text-sm font-medium text-foreground">Transition {transitionIndex + 1}</p> + <Button + size="icon-xs" + variant="ghost" + aria-label={`Remove transition ${transitionIndex + 1}`} + disabled={disabled} + onClick={() => + onMutate( + (current) => removeTransition(current, laneKey, transitionIndex), + (currentSelection) => + adjustSelectionAfterTransitionRemoval(currentSelection, laneKey, transitionIndex), + ) + } + > + <Trash2Icon className="size-3.5" /> + </Button> + </div> + {lintErrors.length > 0 ? ( + <ul className="mb-3 rounded-md border border-warning/45 bg-warning/8 p-2 text-sm text-warning-foreground"> + {lintErrors.map((lintError) => ( + <li key={lintErrorKey(lintError)}>{lintError.message}</li> + ))} + </ul> + ) : null} + <div className="grid gap-3 @2xl:grid-cols-[minmax(0,1fr)_12rem]"> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground"> + Transition {transitionIndex + 1} predicate JSON + </span> + <Textarea + aria-label={`Transition ${transitionIndex + 1} predicate JSON`} + value={whenDraft} + disabled={disabled} + onChange={(event) => { + const nextDraft = event.currentTarget.value; + setWhenDraft(nextDraft); + let parsedWhen: unknown; + try { + parsedWhen = JSON.parse(nextDraft) as unknown; + } catch { + setWhenError("Predicate JSON is invalid."); + return; + } + setWhenError(null); + onMutate((current) => + updateTransition(current, laneKey, transitionIndex, { when: parsedWhen }), + ); + }} + /> + {whenError ? <span className="text-xs text-destructive">{whenError}</span> : null} + </label> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Target lane</span> + <select + aria-label={`Transition ${transitionIndex + 1} target lane`} + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={String(transition.to)} + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value; + onMutate((current) => + updateTransition(current, laneKey, transitionIndex, { + to: value, + }), + ); + }} + > + {lanes.map((lane) => ( + <option key={String(lane.key)} value={String(lane.key)}> + {lane.name} + </option> + ))} + </select> + </label> + </div> + </li> + ); +} + +function transitionRowKey(index: number): string { + return `transition-${index}`; +} diff --git a/apps/web/src/components/board/editor/SourceWizard.tsx b/apps/web/src/components/board/editor/SourceWizard.tsx new file mode 100644 index 00000000000..e4ab383b116 --- /dev/null +++ b/apps/web/src/components/board/editor/SourceWizard.tsx @@ -0,0 +1,906 @@ +"use client"; + +import { PlusIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { + compileAutoPullRule, + decodeAutoPullRule, + effectiveAutoPullRule, + type AutoPullCriteria, + type WorkSourceConnectionView, +} from "@t3tools/contracts/workSource"; +import type { WorkflowSourceConfig } from "@t3tools/contracts/workSource"; + +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Spinner } from "~/components/ui/spinner"; +import { Switch } from "~/components/ui/switch"; +import { + Dialog, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { AutoPullCriteriaEditor } from "./AutoPullCriteriaEditor"; +import { + defaultAsanaSelector, + defaultGithubSelector, + defaultJiraSelector, + decodeSelectorDraft, + encodeSelector, + type AsanaSelectorDraft, + type GithubSelectorDraft, + type JiraSelectorDraft, + type SelectorDraft, +} from "./selectorDraft"; +import type { WorkflowLaneEncoded } from "./WorkflowEditor"; + +// ─── types ───────────────────────────────────────────────────────────────────── + +type Provider = "github" | "asana" | "jira"; + +type WizardStep = "provider" | "connection" | "scope" | "autoPull" | "lanes"; + +// ScopeDraft aliases selectorDraft.ts types so wizard-internal code stays readable. +type GithubScopeDraft = GithubSelectorDraft; +type AsanaScopeDraft = AsanaSelectorDraft; +type JiraScopeDraft = JiraSelectorDraft; +type ScopeDraft = SelectorDraft; + +interface WizardDraft { + /** Preserved on edit, freshly generated on create. */ + id: string; + provider: Provider; + connectionRef: string; + scope: ScopeDraft; + /** Whether the user has auto-pull toggled on. */ + autoPullOn: boolean; + /** Structured criteria when autoPullOn and the rule is decodable. null = ALWAYS. */ + autoPullCriteria: AutoPullCriteria | null; + /** + * Preserved verbatim when the rule is an advanced (non-decodable) jsonLogic rule + * that the user has not edited via the structured controls. + */ + advancedRule: unknown | undefined; + destinationLane: string; + closedLane: string; + /** Preserved from the original source on edit; undefined for new. */ + syncIntervalSec: number | undefined; +} + +// ─── helpers ─────────────────────────────────────────────────────────────────── + +function defaultGithubScope(): GithubScopeDraft { + return defaultGithubSelector(); +} + +function defaultAsanaScope(): AsanaScopeDraft { + return defaultAsanaSelector(); +} + +function defaultJiraScope(): JiraScopeDraft { + return defaultJiraSelector(); +} + +function initDraftFromSource(source: WorkflowSourceConfig, _firstLane: string): WizardDraft { + const effectiveRule = effectiveAutoPullRule(source); + const autoPullOn = effectiveRule !== null; + let autoPullCriteria: AutoPullCriteria | null = null; + let advancedRule: unknown | undefined = undefined; + + if (autoPullOn && effectiveRule !== null) { + const decoded = decodeAutoPullRule(effectiveRule); + if (decoded !== null) { + autoPullCriteria = decoded; + } else { + // Advanced rule the structured editor can't represent — preserve verbatim. + advancedRule = effectiveRule; + } + } + + return { + id: String(source.id), + provider: source.provider as Provider, + connectionRef: String(source.connectionRef), + scope: decodeSelectorDraft(source), + autoPullOn, + autoPullCriteria, + advancedRule, + destinationLane: String(source.destinationLane), + closedLane: String(source.closedLane), + syncIntervalSec: source.syncIntervalSec, + }; +} + +function newDraft(lanes: ReadonlyArray<WorkflowLaneEncoded>): WizardDraft { + const firstLane = String(lanes[0]?.key ?? ""); + // closedLane must be a terminal lane (enforced by the board lint), so default + // to the first terminal lane rather than the first lane. + const firstTerminalLane = String(lanes.find((lane) => lane.terminal === true)?.key ?? firstLane); + return { + id: `source-${Date.now()}`, + provider: "github", + connectionRef: "", + scope: { provider: "github", github: defaultGithubScope() }, + autoPullOn: false, + autoPullCriteria: null, + advancedRule: undefined, + destinationLane: firstLane, + closedLane: firstTerminalLane, + syncIntervalSec: undefined, + }; +} + +/** Build the final WorkflowSourceConfig from the wizard draft. */ +function buildSourceFromDraft(draft: WizardDraft): WorkflowSourceConfig { + // Determine the autoPull rule to persist: + let autoPull: WorkflowSourceConfig["autoPull"] | undefined = undefined; + if (draft.autoPullOn) { + if (draft.advancedRule !== undefined) { + // Advanced rule preserved verbatim (user did not switch to structured controls). + autoPull = { rule: draft.advancedRule }; + } else { + // Structured criteria: compile (null criteria → ALWAYS_RULE). + autoPull = { rule: compileAutoPullRule(draft.autoPullCriteria ?? {}) }; + } + } + + return { + id: draft.id as WorkflowSourceConfig["id"], + provider: draft.provider as WorkflowSourceConfig["provider"], + connectionRef: draft.connectionRef as WorkflowSourceConfig["connectionRef"], + selector: encodeSelector(draft.scope), + destinationLane: draft.destinationLane as WorkflowSourceConfig["destinationLane"], + closedLane: draft.closedLane as WorkflowSourceConfig["closedLane"], + ...(draft.syncIntervalSec !== undefined ? { syncIntervalSec: draft.syncIntervalSec } : {}), + ...(autoPull !== undefined ? { autoPull } : {}), + // Omit `enabled` — migration to autoPull is persisted above. + } as WorkflowSourceConfig; +} + +/** Per-step validation error or null when the step is complete enough to advance. */ +function stepValidationError(draft: WizardDraft, step: WizardStep): string | null { + switch (step) { + case "provider": + return null; + case "connection": + return draft.connectionRef.trim() === "" ? "Select or create a connection." : null; + case "scope": + if (draft.scope.provider === "github") { + const { owner, repo } = draft.scope.github; + if (owner.trim() === "" || repo.trim() === "") return "Owner and repo are required."; + } else if (draft.scope.provider === "jira") { + if (draft.scope.jira.projectKey.trim() === "") return "Project key is required."; + } else if (draft.scope.asana.projectGid.trim() === "") { + return "Project GID is required."; + } + return null; + case "autoPull": + case "lanes": + return null; + } +} + +// ─── step order ──────────────────────────────────────────────────────────────── + +const STEPS: WizardStep[] = ["provider", "connection", "scope", "autoPull", "lanes"]; + +function stepLabel(step: WizardStep): string { + switch (step) { + case "provider": + return "Provider"; + case "connection": + return "Connection"; + case "scope": + return "Scope"; + case "autoPull": + return "Auto-pull"; + case "lanes": + return "Lanes"; + } +} + +// ─── sub-components ──────────────────────────────────────────────────────────── + +function StepProvider({ + draft, + onChange, + disabled, +}: { + readonly draft: WizardDraft; + readonly onChange: (next: WizardDraft) => void; + readonly disabled: boolean; +}) { + const handleChange = (provider: Provider) => { + const scope: ScopeDraft = + provider === "github" + ? { provider: "github", github: defaultGithubScope() } + : provider === "jira" + ? { provider: "jira", jira: defaultJiraScope() } + : { provider: "asana", asana: defaultAsanaScope() }; + onChange({ ...draft, provider, connectionRef: "", scope }); + }; + + return ( + <div className="space-y-4"> + <p className="text-sm text-muted-foreground">Choose the issue tracker to pull work from.</p> + <div className="flex gap-3"> + {(["github", "asana", "jira"] as const).map((p) => ( + <button + key={p} + type="button" + disabled={disabled} + onClick={() => handleChange(p)} + className={[ + "flex-1 rounded-lg border px-4 py-3 text-sm font-medium transition-colors", + draft.provider === p + ? "border-primary bg-primary/8 text-primary" + : "border-border bg-background text-foreground hover:border-primary/50", + ].join(" ")} + > + {p === "github" ? "GitHub Issues" : p === "asana" ? "Asana Tasks" : "Jira"} + </button> + ))} + </div> + </div> + ); +} + +function StepConnection({ + draft, + connections, + connectionsLoading, + connectionsError, + createWorkSourceConnection, + onChange, + disabled, +}: { + readonly draft: WizardDraft; + readonly connections: ReadonlyArray<WorkSourceConnectionView>; + readonly connectionsLoading: boolean; + readonly connectionsError: string | null; + readonly createWorkSourceConnection: SourceWizardCreateConnection | undefined; + readonly onChange: (next: WizardDraft) => void; + readonly disabled: boolean; +}) { + const [addingNew, setAddingNew] = useState(false); + const [newDisplayName, setNewDisplayName] = useState(""); + const [newToken, setNewToken] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState<string | null>(null); + + const providerConnections = connections.filter((c) => c.provider === draft.provider); + + const handleCreateNew = async () => { + if (draft.provider === "jira") return; + if (!createWorkSourceConnection) return; + if (!newDisplayName.trim() || !newToken.trim()) return; + setCreating(true); + setCreateError(null); + try { + const created = await createWorkSourceConnection({ + provider: draft.provider, + displayName: newDisplayName.trim(), + token: newToken.trim(), + }); + onChange({ ...draft, connectionRef: created.connectionRef }); + setAddingNew(false); + setNewDisplayName(""); + setNewToken(""); + } catch (error: unknown) { + setCreateError(error instanceof Error ? error.message : "Failed to create connection."); + } finally { + setCreating(false); + } + }; + + return ( + <div className="space-y-4"> + <p className="text-sm text-muted-foreground"> + Select an existing {draft.provider} connection or add a new one. + </p> + + {connectionsLoading ? ( + <p className="flex items-center gap-1.5 text-xs text-muted-foreground"> + <Spinner className="size-3" /> + Loading connections… + </p> + ) : connectionsError ? ( + <p className="text-xs text-destructive">{connectionsError}</p> + ) : null} + + {providerConnections.length > 0 ? ( + <div className="space-y-1"> + {providerConnections.map((c) => ( + <button + key={c.connectionRef} + type="button" + disabled={disabled} + onClick={() => onChange({ ...draft, connectionRef: c.connectionRef })} + className={[ + "w-full rounded-md border px-3 py-2 text-left text-sm transition-colors", + draft.connectionRef === c.connectionRef + ? "border-primary bg-primary/8 text-primary" + : "border-border bg-background text-foreground hover:border-primary/50", + ].join(" ")} + > + {c.displayName} + <span className="ml-2 text-xs text-muted-foreground">{c.connectionRef}</span> + </button> + ))} + </div> + ) : ( + !connectionsLoading && ( + <p className="text-xs text-muted-foreground">No {draft.provider} connections yet.</p> + ) + )} + + {draft.provider === "jira" ? ( + <p className="text-xs text-muted-foreground"> + Add a Jira connection in Settings, then select it here. + </p> + ) : createWorkSourceConnection ? ( + addingNew ? ( + <div className="space-y-3 rounded-md border border-border/70 bg-muted/10 p-3"> + <p className="text-xs font-semibold text-muted-foreground"> + New {draft.provider} connection + </p> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Display name</span> + <Input + nativeInput + value={newDisplayName} + disabled={creating} + placeholder={draft.provider === "github" ? "My GitHub PAT" : "My Asana PAT"} + onChange={(e) => setNewDisplayName(e.currentTarget.value)} + /> + </label> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Personal access token</span> + <Input + nativeInput + type="password" + value={newToken} + disabled={creating} + placeholder={draft.provider === "github" ? "ghp_…" : "Paste your token"} + onChange={(e) => setNewToken(e.currentTarget.value)} + /> + </label> + {createError ? <p className="text-xs text-destructive">{createError}</p> : null} + <div className="flex gap-2"> + <Button + size="xs" + disabled={creating || !newDisplayName.trim() || !newToken.trim()} + onClick={() => void handleCreateNew()} + > + {creating ? ( + <> + <Spinner className="size-3" /> + Creating… + </> + ) : ( + "Create" + )} + </Button> + <Button + size="xs" + variant="outline" + disabled={creating} + onClick={() => { + setAddingNew(false); + setNewDisplayName(""); + setNewToken(""); + setCreateError(null); + }} + > + Cancel + </Button> + </div> + </div> + ) : ( + <Button + size="xs" + variant="outline" + disabled={disabled} + onClick={() => setAddingNew(true)} + > + <PlusIcon className="size-3.5" /> + Create new connection + </Button> + ) + ) : null} + </div> + ); +} + +function StepScope({ + draft, + onChange, + disabled, +}: { + readonly draft: WizardDraft; + readonly onChange: (next: WizardDraft) => void; + readonly disabled: boolean; +}) { + if (draft.scope.provider === "github") { + const g = draft.scope.github; + const updateG = (patch: Partial<GithubScopeDraft>) => + onChange({ + ...draft, + scope: { provider: "github", github: { ...g, ...patch } }, + }); + return ( + <div className="space-y-4"> + <p className="text-sm text-muted-foreground"> + Define which issues are visible in the import picker and eligible for auto-pull. + </p> + <div className="grid gap-3 sm:grid-cols-2"> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Owner *</span> + <Input + nativeInput + value={g.owner} + disabled={disabled} + placeholder="octocat" + onChange={(e) => updateG({ owner: e.currentTarget.value })} + /> + </label> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Repo *</span> + <Input + nativeInput + value={g.repo} + disabled={disabled} + placeholder="my-repo" + onChange={(e) => updateG({ repo: e.currentTarget.value })} + /> + </label> + <label className="grid gap-1 sm:col-span-2"> + <span className="text-xs font-medium text-foreground"> + Labels filter (comma-separated, optional) + </span> + <Input + nativeInput + value={g.labels} + disabled={disabled} + placeholder="bug, enhancement" + onChange={(e) => updateG({ labels: e.currentTarget.value })} + /> + <span className="text-[11px] text-muted-foreground"> + Limits which issues are fetched from GitHub. Leave empty to fetch all. + </span> + </label> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Assignee filter</span> + <Input + nativeInput + value={g.assignee} + disabled={disabled} + placeholder="octocat" + onChange={(e) => updateG({ assignee: e.currentTarget.value })} + /> + </label> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">State</span> + <select + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={g.state} + disabled={disabled} + onChange={(e) => updateG({ state: e.currentTarget.value as "all" | "open" })} + > + <option value="all">all</option> + <option value="open">open</option> + </select> + </label> + </div> + </div> + ); + } + + // Jira + if (draft.scope.provider === "jira") { + const j = draft.scope.jira; + const updateJ = (patch: Partial<JiraScopeDraft>) => + onChange({ + ...draft, + scope: { provider: "jira", jira: { ...j, ...patch } }, + }); + return ( + <div className="space-y-4"> + <p className="text-sm text-muted-foreground"> + Define which Jira issues are visible in the import picker and eligible for auto-pull. + </p> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Project key *</span> + <Input + nativeInput + value={j.projectKey} + disabled={disabled} + placeholder="ENG" + onChange={(e) => updateJ({ projectKey: e.currentTarget.value })} + /> + </label> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">JQL (optional)</span> + <Input + nativeInput + value={j.jql} + disabled={disabled} + placeholder="labels = backend" + onChange={(e) => updateJ({ jql: e.currentTarget.value })} + /> + <span className="text-[11px] text-muted-foreground"> + Optional — refine which issues are fetched. + </span> + </label> + </div> + ); + } + + // Asana + const a = draft.scope.asana; + const updateA = (patch: Partial<AsanaScopeDraft>) => + onChange({ + ...draft, + scope: { provider: "asana", asana: { ...a, ...patch } }, + }); + return ( + <div className="space-y-4"> + <p className="text-sm text-muted-foreground"> + Define which Asana tasks are visible in the import picker. + </p> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Project GID *</span> + <Input + nativeInput + value={a.projectGid} + disabled={disabled} + placeholder="1234567890" + onChange={(e) => updateA({ projectGid: e.currentTarget.value })} + /> + </label> + <label className="flex items-center gap-2 text-sm text-foreground"> + <input + type="checkbox" + checked={a.includeCompleted} + disabled={disabled} + onChange={(e) => updateA({ includeCompleted: e.currentTarget.checked })} + /> + Include completed tasks + </label> + </div> + ); +} + +function StepAutoPull({ + draft, + onChange, + disabled, +}: { + readonly draft: WizardDraft; + readonly onChange: (next: WizardDraft) => void; + readonly disabled: boolean; +}) { + return ( + <div className="space-y-4"> + <p className="text-sm text-muted-foreground"> + Auto-pull automatically creates board tickets for matching issues — without any manual + import step. Leave off to only use the import picker. + </p> + + <label className="flex items-center gap-3"> + <Switch + checked={draft.autoPullOn} + disabled={disabled} + aria-label="Enable auto-pull" + onCheckedChange={(checked) => + onChange({ + ...draft, + autoPullOn: checked, + // When turning off, clear advanced rule so a subsequent re-enable starts fresh. + advancedRule: checked ? draft.advancedRule : undefined, + }) + } + /> + <span className="text-sm font-medium text-foreground"> + {draft.autoPullOn ? "Auto-pull enabled" : "Auto-pull disabled (manual only)"} + </span> + </label> + + {draft.autoPullOn ? ( + draft.advancedRule !== undefined ? ( + <div className="rounded-md border border-border/70 bg-muted/10 p-3"> + <p className="text-xs font-semibold text-warning-foreground">Advanced rule</p> + <p className="mt-1 text-xs text-muted-foreground"> + This source uses a custom jsonLogic rule that the structured editor cannot represent. + The rule will be preserved as-is. Turn auto-pull off and back on to replace it with + the structured editor. + </p> + <pre className="mt-2 overflow-auto rounded bg-muted/40 p-2 text-[11px] text-foreground"> + {JSON.stringify(draft.advancedRule, null, 2)} + </pre> + </div> + ) : ( + <div className="space-y-2"> + <p className="text-xs font-medium text-foreground"> + Filter criteria{" "} + <span className="font-normal text-muted-foreground"> + (leave empty to auto-pull all issues) + </span> + </p> + <AutoPullCriteriaEditor + value={draft.autoPullCriteria ?? {}} + disabled={disabled} + onChange={(next) => onChange({ ...draft, autoPullCriteria: next })} + /> + </div> + ) + ) : null} + </div> + ); +} + +function StepLanes({ + draft, + lanes, + onChange, + disabled, +}: { + readonly draft: WizardDraft; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly onChange: (next: WizardDraft) => void; + readonly disabled: boolean; +}) { + // A closed issue routes to a terminal lane (board lint requires closedLane to + // be terminal), so only terminal lanes are valid choices here. + const terminalLanes = lanes.filter((lane) => lane.terminal === true); + return ( + <div className="space-y-4"> + <p className="text-sm text-muted-foreground"> + Choose where new tickets land and where closed issues route. + </p> + <div className="grid gap-3 sm:grid-cols-2"> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Destination lane</span> + <select + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={draft.destinationLane} + disabled={disabled} + onChange={(e) => onChange({ ...draft, destinationLane: e.currentTarget.value })} + > + {lanes.map((lane) => ( + <option key={String(lane.key)} value={String(lane.key)}> + {lane.name} + </option> + ))} + </select> + </label> + <label className="grid gap-1"> + <span className="text-xs font-medium text-foreground">Closed lane</span> + <select + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={draft.closedLane} + disabled={disabled || terminalLanes.length === 0} + onChange={(e) => onChange({ ...draft, closedLane: e.currentTarget.value })} + > + {terminalLanes.map((lane) => ( + <option key={String(lane.key)} value={String(lane.key)}> + {lane.name} + </option> + ))} + </select> + {terminalLanes.length === 0 ? ( + <span className="text-xs text-destructive-foreground"> + Add a terminal lane to route closed issues. + </span> + ) : null} + </label> + </div> + {draft.syncIntervalSec !== undefined ? ( + <p className="text-xs text-muted-foreground"> + Sync interval: {draft.syncIntervalSec}s (preserved from existing configuration). + </p> + ) : null} + </div> + ); +} + +// ─── SourceWizard ─────────────────────────────────────────────────────────────── + +/** + * Callback type for creating a new work-source connection INLINE from the wizard. + * Intentionally only "github" | "asana": the inline form collects just + * displayName + token, while Jira additionally requires a base URL (+ email for + * Cloud), so Jira connections are created in Settings and merely selected here. + */ +export type SourceWizardCreateConnection = (input: { + provider: "github" | "asana"; + displayName: string; + token: string; +}) => Promise<WorkSourceConnectionView>; + +export interface SourceWizardProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly mode: "create" | "edit"; + readonly initial?: WorkflowSourceConfig; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly listWorkSourceConnections: ( + input: Record<string, never>, + ) => Promise<ReadonlyArray<WorkSourceConnectionView>>; + readonly createWorkSourceConnection: SourceWizardCreateConnection | undefined; + readonly onSave: (source: WorkflowSourceConfig) => void; + /** When true, all step controls are rendered in read-only / disabled state. */ + readonly disabled?: boolean; +} + +export function SourceWizard({ + open, + onOpenChange, + mode, + initial, + lanes, + listWorkSourceConnections, + createWorkSourceConnection, + onSave, + disabled = false, +}: SourceWizardProps) { + // Draft — re-initialized each time the dialog opens. + const [draft, setDraft] = useState<WizardDraft>(() => + initial ? initDraftFromSource(initial, String(lanes[0]?.key ?? "")) : newDraft(lanes), + ); + const [currentStep, setCurrentStep] = useState<WizardStep>(STEPS[0]!); + const [connections, setConnections] = useState<ReadonlyArray<WorkSourceConnectionView> | null>( + null, + ); + const [connectionsLoading, setConnectionsLoading] = useState(false); + const [connectionsError, setConnectionsError] = useState<string | null>(null); + + // Re-initialize draft and load connections whenever the dialog transitions to open. + // Using an effect (rather than handleOpenChange's open branch) ensures this runs + // for both user-triggered opens (trigger button) and programmatic opens where the + // parent sets open=true directly — a controlled Dialog does NOT fire onOpenChange + // in the latter case. + useEffect(() => { + if (!open) return; + setDraft(initial ? initDraftFromSource(initial, String(lanes[0]?.key ?? "")) : newDraft(lanes)); + setCurrentStep(STEPS[0]!); + setConnectionsLoading(true); + setConnectionsError(null); + setConnections(null); + let active = true; + void listWorkSourceConnections({}) + .then((result) => { + if (active) { + setConnections(result); + setConnectionsLoading(false); + } + }) + .catch((error: unknown) => { + if (active) { + setConnectionsError( + error instanceof Error ? error.message : "Failed to load connections.", + ); + setConnectionsLoading(false); + } + }); + return () => { + active = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // Relay close events (escape / backdrop) to the parent; open events are handled + // by the useEffect above so we only forward close here. + const handleOpenChange = (next: boolean) => { + if (!next) onOpenChange(false); + }; + + const stepIndex = STEPS.indexOf(currentStep); + const isFirst = stepIndex === 0; + const isLast = stepIndex === STEPS.length - 1; + + const currentError = stepValidationError(draft, currentStep); + + const handleNext = () => { + if (currentError) return; + if (!isLast) { + setCurrentStep(STEPS[stepIndex + 1]!); + } + }; + + const handleBack = () => { + if (!isFirst) { + setCurrentStep(STEPS[stepIndex - 1]!); + } + }; + + const handleSave = () => { + // Validate all steps before saving. + for (const step of STEPS) { + const err = stepValidationError(draft, step); + if (err) return; + } + const source = buildSourceFromDraft(draft); + onSave(source); + onOpenChange(false); + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogPopup className="max-w-lg"> + <DialogHeader> + <DialogTitle>{mode === "create" ? "Add work source" : "Edit work source"}</DialogTitle> + {/* Step indicator */} + <nav aria-label="Wizard steps" className="flex items-center gap-1 text-xs"> + {STEPS.map((step, i) => ( + <span + key={step} + className={[ + "flex items-center gap-1", + i < stepIndex + ? "text-muted-foreground" + : i === stepIndex + ? "font-semibold text-foreground" + : "text-muted-foreground/50", + ].join(" ")} + > + {i > 0 ? <span className="text-muted-foreground/40">›</span> : null} + {stepLabel(step)} + </span> + ))} + </nav> + </DialogHeader> + + <DialogPanel className="min-h-[16rem] space-y-2"> + {currentStep === "provider" && ( + <StepProvider draft={draft} onChange={setDraft} disabled={disabled} /> + )} + {currentStep === "connection" && ( + <StepConnection + draft={draft} + connections={connections ?? []} + connectionsLoading={connectionsLoading} + connectionsError={connectionsError} + createWorkSourceConnection={createWorkSourceConnection} + onChange={setDraft} + disabled={disabled} + /> + )} + {currentStep === "scope" && ( + <StepScope draft={draft} onChange={setDraft} disabled={disabled} /> + )} + {currentStep === "autoPull" && ( + <StepAutoPull draft={draft} onChange={setDraft} disabled={disabled} /> + )} + {currentStep === "lanes" && ( + <StepLanes draft={draft} lanes={lanes} onChange={setDraft} disabled={disabled} /> + )} + {currentError ? <p className="text-[11px] text-destructive">{currentError}</p> : null} + </DialogPanel> + + <DialogFooter variant="bare"> + <Button + variant="outline" + size="sm" + onClick={isFirst ? () => onOpenChange(false) : handleBack} + > + {isFirst ? "Cancel" : "Back"} + </Button> + {isLast ? ( + <Button size="sm" disabled={currentError !== null} onClick={handleSave}> + {mode === "create" ? "Add source" : "Save source"} + </Button> + ) : ( + <Button size="sm" disabled={currentError !== null} onClick={handleNext}> + Next + </Button> + )} + </DialogFooter> + </DialogPopup> + </Dialog> + ); +} diff --git a/apps/web/src/components/board/editor/SourcesSection.tsx b/apps/web/src/components/board/editor/SourcesSection.tsx new file mode 100644 index 00000000000..8e507c66da7 --- /dev/null +++ b/apps/web/src/components/board/editor/SourcesSection.tsx @@ -0,0 +1,308 @@ +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { + decodeAutoPullRule, + effectiveAutoPullRule, + summarizeAutoPull, + type WorkSourceConnectionView, + type WorkflowSourceConfig, +} from "@t3tools/contracts/workSource"; +import type { WorkflowDefinitionEncoded, WorkflowLintError } from "@t3tools/contracts"; + +import { decodeSelectorDraft } from "./selectorDraft"; + +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { Spinner } from "~/components/ui/spinner"; +import { lintErrorKey } from "~/workflow/editorModel"; +import type { WorkflowEditorMutation, WorkflowLaneEncoded } from "./WorkflowEditor"; +import { SourceWizard, type SourceWizardCreateConnection } from "./SourceWizard"; + +// ─── types ─────────────────────────────────────────────────────────────────── + +type SourceEncoded = NonNullable<WorkflowDefinitionEncoded["sources"]>[number]; + +// ─── component ─────────────────────────────────────────────────────────────── + +export interface SourcesSectionProps { + readonly definition: WorkflowDefinitionEncoded; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly lintErrors: ReadonlyArray<WorkflowLintError>; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; + readonly listWorkSourceConnections: ( + input: Record<string, never>, + ) => Promise<ReadonlyArray<WorkSourceConnectionView>>; + /** + * When provided, the SourceWizard includes an inline connection-creation + * sub-form so the user can create a new connection without leaving the editor. + * When omitted the wizard still opens but only allows selecting an existing + * connection. + */ + readonly createWorkSourceConnection?: SourceWizardCreateConnection | undefined; + /** + * Increment this value to programmatically open the wizard in create mode + * (e.g. from the toolbar Sources button when the board has no sources). + * The effect fires whenever the value changes from 0 to a non-zero value. + */ + readonly triggerCreate?: number | undefined; +} + +export function SourcesSection({ + definition, + lanes, + lintErrors, + disabled = false, + onMutate, + listWorkSourceConnections, + createWorkSourceConnection, + triggerCreate, +}: SourcesSectionProps) { + const [connections, setConnections] = useState<ReadonlyArray<WorkSourceConnectionView> | null>( + null, + ); + const [connectionsError, setConnectionsError] = useState<string | null>(null); + const [wizardOpen, setWizardOpen] = useState(false); + const [wizardInitial, setWizardInitial] = useState<WorkflowSourceConfig | undefined>(undefined); + + useEffect(() => { + let active = true; + setConnections(null); + setConnectionsError(null); + listWorkSourceConnections({}) + .then((result) => { + if (active) setConnections(result); + }) + .catch((error: unknown) => { + if (active) + setConnectionsError( + error instanceof Error ? error.message : "Failed to load connections.", + ); + }); + return () => { + active = false; + }; + }, [listWorkSourceConnections]); + + // Open the create wizard when the toolbar Sources button fires triggerCreate. + useEffect(() => { + if (triggerCreate) { + setWizardInitial(undefined); + setWizardOpen(true); + } + }, [triggerCreate]); + + const sources = definition.sources ?? []; + + // Lint errors that mention a source (no laneKey, no stepKey) + const sourceLintErrors = lintErrors.filter( + (e) => e.laneKey === undefined && e.stepKey === undefined, + ); + + const openWizardCreate = () => { + setWizardInitial(undefined); + setWizardOpen(true); + }; + + const openWizardEdit = (source: SourceEncoded) => { + setWizardInitial(source as WorkflowSourceConfig); + setWizardOpen(true); + }; + + const handleWizardSave = (source: WorkflowSourceConfig) => { + const sourceId = String(source.id); + onMutate((model) => { + const current = model.definition.sources ?? []; + const existingIndex = current.findIndex((s) => String(s.id) === sourceId); + const next = + existingIndex === -1 + ? [...current, source] + : current.map((s, i) => (i === existingIndex ? source : s)); + return { + ...model, + definition: { ...model.definition, sources: next as never }, + dirty: true, + lintErrors: [], + }; + }); + }; + + const handleRemove = (sourceId: string) => { + onMutate((model) => { + const next = (model.definition.sources ?? []).filter((s) => String(s.id) !== sourceId); + return { + ...model, + definition: { ...model.definition, sources: next as never }, + dirty: true, + lintErrors: [], + }; + }); + }; + + return ( + <section className="space-y-3 border-t border-border pt-4"> + <SourceWizard + open={wizardOpen} + onOpenChange={setWizardOpen} + mode={wizardInitial !== undefined ? "edit" : "create"} + {...(wizardInitial !== undefined ? { initial: wizardInitial } : {})} + lanes={lanes} + listWorkSourceConnections={listWorkSourceConnections} + createWorkSourceConnection={createWorkSourceConnection} + disabled={disabled} + onSave={handleWizardSave} + /> + <div className="flex flex-wrap items-center justify-between gap-2"> + <div> + <h4 className="text-sm font-semibold text-foreground">Work Sources</h4> + <p className="text-xs text-muted-foreground"> + External issue trackers that create tickets automatically. + </p> + </div> + <Button size="xs" variant="outline" disabled={disabled} onClick={openWizardCreate}> + <PlusIcon className="size-3.5" /> + Add source + </Button> + </div> + + {sourceLintErrors.length > 0 ? ( + <ul className="rounded-md border border-warning/45 bg-warning/8 p-2 text-sm text-warning-foreground"> + {sourceLintErrors.map((e) => ( + <li key={lintErrorKey(e)}>{e.message}</li> + ))} + </ul> + ) : null} + + {connectionsError ? ( + <p className="text-xs text-destructive">{connectionsError}</p> + ) : connections === null ? ( + <p className="flex items-center gap-1.5 text-xs text-muted-foreground"> + <Spinner className="size-3" /> + Loading connections… + </p> + ) : null} + + {sources.length === 0 ? ( + <div className="rounded-md border border-border/70 bg-muted/20 p-3"> + <p className="text-sm text-muted-foreground"> + No sources configured. Tickets will only be created manually. + </p> + <Button + size="xs" + variant="outline" + disabled={disabled} + className="mt-2" + onClick={openWizardCreate} + > + <PlusIcon className="size-3.5" /> + Set up a source + </Button> + </div> + ) : ( + <ol className="space-y-3"> + {sources.map((source) => { + const sourceId = String(source.id); + return ( + <li + key={sourceId} + className="space-y-2 rounded-md border border-border/70 bg-muted/10 p-3" + > + <SourceRow + source={source} + connections={connections ?? []} + disabled={disabled} + onEdit={() => openWizardEdit(source)} + onRemove={() => handleRemove(sourceId)} + /> + </li> + ); + })} + </ol> + )} + </section> + ); +} + +// ─── SourceRow ──────────────────────────────────────────────────────────────── + +function SourceRow({ + source, + connections, + disabled, + onEdit, + onRemove, +}: { + readonly source: SourceEncoded; + readonly connections: ReadonlyArray<WorkSourceConnectionView>; + readonly disabled: boolean; + readonly onEdit: () => void; + readonly onRemove: () => void; +}) { + const connection = connections.find((c) => c.connectionRef === String(source.connectionRef)); + const connectionLabel = connection?.displayName ?? String(source.connectionRef); + + // Scope summary — provider-specific short description via shared decode helper + const selectorDraft = decodeSelectorDraft(source); + let scopeSummary = ""; + if (selectorDraft.provider === "github") { + const { owner, repo } = selectorDraft.github; + scopeSummary = owner && repo ? `${owner}/${repo}` : owner || repo || "—"; + } else if (selectorDraft.provider === "asana") { + const { projectGid } = selectorDraft.asana; + scopeSummary = projectGid ? `Project ${projectGid}` : "—"; + } else if (selectorDraft.provider === "jira") { + const { projectKey, jql } = selectorDraft.jira; + scopeSummary = projectKey ? (jql.trim() ? `${projectKey} · JQL` : projectKey) : "—"; + } + + // Auto-pull badge + const effectiveRule = effectiveAutoPullRule(source); + const isAuto = effectiveRule !== null; + const decodedRule = isAuto ? decodeAutoPullRule(effectiveRule) : null; + const autoPullSummary = isAuto + ? decodedRule !== null + ? summarizeAutoPull(decodedRule) + : "Active (advanced rule)" + : "Manual only"; + + return ( + <div className="flex items-center justify-between gap-3"> + <div className="min-w-0 space-y-1"> + <div className="flex flex-wrap items-center gap-1.5"> + <p className="truncate text-sm font-medium text-foreground"> + {source.provider} — {connectionLabel} + </p> + <Badge size="sm" variant={isAuto ? "success" : "outline"}> + {isAuto ? "Auto" : "Manual"} + </Badge> + </div> + <p className="text-xs text-muted-foreground"> + {scopeSummary ? scopeSummary + " · " : ""}→ {String(source.destinationLane)} · closed:{" "} + {String(source.closedLane)} + </p> + {isAuto ? <p className="text-[11px] text-muted-foreground">{autoPullSummary}</p> : null} + </div> + <div className="flex shrink-0 items-center gap-2"> + <Button + size="xs" + variant="outline" + disabled={disabled} + aria-label={`Edit ${source.provider} — ${connectionLabel}`} + onClick={onEdit} + > + Edit + </Button> + <Button + size="icon-xs" + variant="destructive-outline" + disabled={disabled} + aria-label={`Remove ${source.provider} — ${connectionLabel}`} + onClick={onRemove} + > + <Trash2Icon className="size-3.5" /> + </Button> + </div> + </div> + ); +} diff --git a/apps/web/src/components/board/editor/StepFields.browser.tsx b/apps/web/src/components/board/editor/StepFields.browser.tsx new file mode 100644 index 00000000000..b57026470e8 --- /dev/null +++ b/apps/web/src/components/board/editor/StepFields.browser.tsx @@ -0,0 +1,509 @@ +import "../../../index.css"; + +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + LaneKey, + ProviderDriverKind, + ProviderInstanceId, + StepKey, + type ServerConfig, + type ServerProvider, + type WorkflowDefinitionEncoded, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { page } from "vite-plus/test/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { createWorkflowEditorModel, type WorkflowEditorModel } from "~/workflow/editorModel"; +import { AppAtomRegistryProvider } from "~/rpc/atomRegistry"; +import { applyServerConfigEvent, resetServerStateForTests } from "~/rpc/serverState"; + +import { StepFields } from "./StepFields"; + +const claudeProvider: ServerProvider = { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + displayName: "Claude", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-09T00:00:00.000Z", + slashCommands: [], + skills: [], + models: [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + { id: "max", label: "max" }, + ], + }, + ], + }), + }, + ], +}; + +const serverConfig: ServerConfig = { + environment: { + environmentId: EnvironmentId.make("environment-local"), + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-access-token"], + sessionCookieName: "t3_session", + }, + cwd: "/tmp/workspace", + keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", + keybindings: [], + issues: [], + providers: [claudeProvider], + availableEditors: ["cursor"], + observability: { + logsDirectoryPath: "/tmp/workspace/.config/logs", + localTracingEnabled: true, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + }, + settings: DEFAULT_SERVER_SETTINGS, +}; + +const laneKey = LaneKey.make("run"); +const stepKey = StepKey.make("review"); + +const definition: WorkflowDefinitionEncoded = { + name: "Delivery", + lanes: [ + { + key: laneKey, + name: "Run", + entry: "auto", + pipeline: [ + { + key: stepKey, + type: "agent", + agent: { instance: "claudeAgent", model: "claude-opus-4-6" }, + instruction: "Review the diff.", + }, + ], + }, + ], +}; + +const agentStep = definition.lanes[0]!.pipeline![0]!; + +function seedProviders() { + applyServerConfigEvent({ version: 1, type: "snapshot", config: serverConfig }); +} + +function renderStepFields( + onMutate: (mutate: unknown) => void, + options: { disabled?: boolean } = {}, +) { + return render( + <AppAtomRegistryProvider> + <StepFields + laneKey={String(laneKey)} + lanes={definition.lanes} + step={agentStep} + disabled={options.disabled ?? false} + onMutate={onMutate} + /> + </AppAtomRegistryProvider>, + ); +} + +function findEffortTrigger() { + return Array.from(document.querySelectorAll("button")).find((button) => + /medium/i.test(button.textContent ?? ""), + ); +} + +describe("StepFields agent pickers", () => { + beforeEach(() => { + resetServerStateForTests(); + seedProviders(); + }); + + afterEach(() => { + resetServerStateForTests(); + }); + + it("renders the provider/model picker and the effort picker for an agent step", async () => { + const onMutate = vi.fn(); + renderStepFields(onMutate); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Opus 4.6"); + // The effort/traits trigger shows the current (default) reasoning value. + expect(text.toLowerCase()).toContain("medium"); + }); + }); + + it("writes the selected effort into the step's agent options", async () => { + const onMutate = vi.fn(); + renderStepFields(onMutate); + + await page.getByRole("button", { name: /medium/i }).click(); + await page.getByRole("menuitemradio", { name: "high" }).click(); + + await vi.waitFor(() => { + expect(onMutate).toHaveBeenCalled(); + }); + + const mutate = onMutate.mock.calls.at(-1)?.[0] as ( + m: WorkflowEditorModel, + ) => WorkflowEditorModel; + const next = mutate(createWorkflowEditorModel(definition)); + const nextStep = next.definition.lanes[0]?.pipeline?.[0]; + const options = nextStep?.type === "agent" ? nextStep.agent.options : undefined; + expect(options).toContainEqual({ id: "effort", value: "high" }); + }); + + it("disables the effort picker while the editor is busy", async () => { + const onMutate = vi.fn(); + renderStepFields(onMutate, { disabled: true }); + + await vi.waitFor(() => { + const effortTrigger = findEffortTrigger(); + expect(effortTrigger).toBeTruthy(); + expect(effortTrigger?.disabled).toBe(true); + }); + }); +}); + +describe("StepFields pullRequest step", () => { + const prOpenDefinition: WorkflowDefinitionEncoded = { + name: "Delivery", + lanes: [ + { + key: laneKey, + name: "Run", + entry: "auto", + pipeline: [ + { + key: stepKey, + type: "pullRequest", + action: "open", + }, + ], + }, + ], + }; + + const prOpenStep = prOpenDefinition.lanes[0]!.pipeline![0]!; + + it("renders base/draft/titleTemplate/bodyTemplate fields for action=open", async () => { + const onMutate = vi.fn(); + render( + <StepFields + laneKey={String(laneKey)} + lanes={prOpenDefinition.lanes} + step={prOpenStep} + disabled={false} + onMutate={onMutate} + />, + ); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Base branch"); + expect(text).toContain("PR title template"); + expect(text).toContain("PR body template"); + expect(text).toContain("Draft pull request"); + }); + }); + + it("writes base branch via updateStep", async () => { + const onMutate = vi.fn(); + render( + <StepFields + laneKey={String(laneKey)} + lanes={prOpenDefinition.lanes} + step={prOpenStep} + disabled={false} + onMutate={onMutate} + />, + ); + + await page.getByRole("textbox", { name: `Step ${String(stepKey)} base branch` }).fill("main"); + + await vi.waitFor(() => { + expect(onMutate).toHaveBeenCalled(); + }); + + const mutate = onMutate.mock.calls.at(-1)?.[0] as ( + m: WorkflowEditorModel, + ) => WorkflowEditorModel; + const next = mutate(createWorkflowEditorModel(prOpenDefinition)); + const nextStep = next.definition.lanes[0]?.pipeline?.[0]; + expect(nextStep?.type === "pullRequest" ? nextStep.base : undefined).toBe("main"); + }); + + it("switches to land action and emits the mutation", async () => { + const onMutate = vi.fn(); + render( + <StepFields + laneKey={String(laneKey)} + lanes={prOpenDefinition.lanes} + step={prOpenStep} + disabled={false} + onMutate={onMutate} + />, + ); + + await page + .getByRole("combobox", { name: `Step ${String(stepKey)} action` }) + .selectOptions("land"); + + await vi.waitFor(() => { + expect(onMutate).toHaveBeenCalled(); + }); + + const mutate = onMutate.mock.calls.at(-1)?.[0] as ( + m: WorkflowEditorModel, + ) => WorkflowEditorModel; + const next = mutate(createWorkflowEditorModel(prOpenDefinition)); + const nextStep = next.definition.lanes[0]?.pipeline?.[0]; + expect(nextStep?.type === "pullRequest" ? nextStep.action : undefined).toBe("land"); + }); + + it("renders strategy/deleteBranch fields for action=land", async () => { + const prLandDefinition: WorkflowDefinitionEncoded = { + ...prOpenDefinition, + lanes: [ + { + ...prOpenDefinition.lanes[0]!, + pipeline: [{ key: stepKey, type: "pullRequest", action: "land" }], + }, + ], + }; + const prLandStep = prLandDefinition.lanes[0]!.pipeline![0]!; + const onMutate = vi.fn(); + render( + <StepFields + laneKey={String(laneKey)} + lanes={prLandDefinition.lanes} + step={prLandStep} + disabled={false} + onMutate={onMutate} + />, + ); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Merge strategy"); + expect(text).toContain("Delete branch after merge"); + }); + }); +}); + +describe("StepFields continueSession", () => { + beforeEach(() => { + resetServerStateForTests(); + seedProviders(); + }); + + afterEach(() => { + resetServerStateForTests(); + }); + + it("renders the continue-session toggle and the handoff placeholder docs for an agent step", async () => { + const onMutate = vi.fn(); + renderStepFields(onMutate); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Continue session"); + expect(text).toContain("{{prev.output}}"); + expect(text).toContain("{{step.<key>.output}}"); + }); + }); + + it("writes continueSession when the toggle is checked", async () => { + const onMutate = vi.fn(); + renderStepFields(onMutate); + + await page + .getByRole("checkbox", { name: `Continue session for step ${String(stepKey)}` }) + .click(); + + await vi.waitFor(() => { + expect(onMutate).toHaveBeenCalled(); + }); + + const mutate = onMutate.mock.calls.at(-1)?.[0] as ( + m: WorkflowEditorModel, + ) => WorkflowEditorModel; + const next = mutate(createWorkflowEditorModel(definition)); + const nextStep = next.definition.lanes[0]?.pipeline?.[0]; + expect(nextStep?.type === "agent" ? nextStep.continueSession : undefined).toBe(true); + }); + + it("disables the continue-session toggle on a reviewer panel step", async () => { + const panelDefinition: WorkflowDefinitionEncoded = { + ...definition, + lanes: [ + { + ...definition.lanes[0]!, + pipeline: [{ ...agentStep, captureOutput: true, panel: 3 } as typeof agentStep], + }, + ], + }; + const panelStep = panelDefinition.lanes[0]!.pipeline![0]!; + const onMutate = vi.fn(); + render( + <AppAtomRegistryProvider> + <StepFields + laneKey={String(laneKey)} + lanes={panelDefinition.lanes} + step={panelStep} + disabled={false} + onMutate={onMutate} + /> + </AppAtomRegistryProvider>, + ); + + await vi.waitFor(() => { + const toggle = document.querySelector<HTMLInputElement>( + `input[aria-label="Continue session for step ${String(stepKey)}"]`, + ); + expect(toggle).toBeTruthy(); + expect(toggle?.disabled).toBe(true); + }); + }); +}); + +describe("StepFields retry controls", () => { + beforeEach(() => { + resetServerStateForTests(); + seedProviders(); + }); + + afterEach(() => { + resetServerStateForTests(); + }); + + it("enables retry with a max attempt count", async () => { + const onMutate = vi.fn(); + renderStepFields(onMutate); + + await page + .getByRole("combobox", { name: `Retries for step ${String(stepKey)}` }) + .selectOptions("3"); + + await vi.waitFor(() => { + expect(onMutate).toHaveBeenCalled(); + }); + + const mutate = onMutate.mock.calls.at(-1)?.[0] as ( + m: WorkflowEditorModel, + ) => WorkflowEditorModel; + const next = mutate(createWorkflowEditorModel(definition)); + const nextStep = next.definition.lanes[0]?.pipeline?.[0]; + expect(nextStep?.type === "agent" ? nextStep.retry : undefined).toEqual({ maxAttempts: 3 }); + }); + + it("seeds escalation from the step's agent when toggled on", async () => { + const retryDefinition: WorkflowDefinitionEncoded = { + ...definition, + lanes: [ + { + ...definition.lanes[0]!, + pipeline: [{ ...agentStep, retry: { maxAttempts: 2 } } as typeof agentStep], + }, + ], + }; + const retryStep = retryDefinition.lanes[0]!.pipeline![0]!; + const onMutate = vi.fn(); + render( + <AppAtomRegistryProvider> + <StepFields + laneKey={String(laneKey)} + lanes={retryDefinition.lanes} + step={retryStep} + disabled={false} + onMutate={onMutate} + /> + </AppAtomRegistryProvider>, + ); + + await page + .getByRole("checkbox", { name: `Escalate on retry for step ${String(stepKey)}` }) + .click(); + + await vi.waitFor(() => { + expect(onMutate).toHaveBeenCalled(); + }); + + const mutate = onMutate.mock.calls.at(-1)?.[0] as ( + m: WorkflowEditorModel, + ) => WorkflowEditorModel; + const next = mutate(createWorkflowEditorModel(retryDefinition)); + const nextStep = next.definition.lanes[0]?.pipeline?.[0]; + expect(nextStep?.type === "agent" ? nextStep.retry : undefined).toEqual({ + maxAttempts: 2, + escalate: { instance: "claudeAgent", model: "claude-opus-4-6" }, + }); + }); + + it("shows the retry select on script steps", async () => { + const scriptDefinition: WorkflowDefinitionEncoded = { + ...definition, + lanes: [ + { + ...definition.lanes[0]!, + pipeline: [{ key: stepKey, type: "script", run: "pnpm test" }], + }, + ], + }; + const scriptStep = scriptDefinition.lanes[0]!.pipeline![0]!; + const onMutate = vi.fn(); + render( + <AppAtomRegistryProvider> + <StepFields + laneKey={String(laneKey)} + lanes={scriptDefinition.lanes} + step={scriptStep} + disabled={false} + onMutate={onMutate} + /> + </AppAtomRegistryProvider>, + ); + + await page + .getByRole("combobox", { name: `Retries for step ${String(stepKey)}` }) + .selectOptions("2"); + + await vi.waitFor(() => { + expect(onMutate).toHaveBeenCalled(); + }); + + const mutate = onMutate.mock.calls.at(-1)?.[0] as ( + m: WorkflowEditorModel, + ) => WorkflowEditorModel; + const next = mutate(createWorkflowEditorModel(scriptDefinition)); + const nextStep = next.definition.lanes[0]?.pipeline?.[0]; + expect(nextStep?.type === "script" ? nextStep.retry : undefined).toEqual({ maxAttempts: 2 }); + }); +}); diff --git a/apps/web/src/components/board/editor/StepFields.tsx b/apps/web/src/components/board/editor/StepFields.tsx new file mode 100644 index 00000000000..8ff8297263a --- /dev/null +++ b/apps/web/src/components/board/editor/StepFields.tsx @@ -0,0 +1,873 @@ +import { LaneKey, type ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { ProviderModelPicker } from "~/components/chat/ProviderModelPicker"; +import { TraitsPicker } from "~/components/chat/TraitsPicker"; +import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { useSettings } from "~/hooks/useSettings"; +import { getAppModelOptionsForInstance, type AppModelOption } from "~/modelSelection"; +import { deriveProviderInstanceEntries, sortProviderInstanceEntries } from "~/providerInstances"; +import { useServerProviders } from "~/rpc/serverState"; +import { updateStep } from "~/workflow/editorModel"; + +import { + agentSelectionWithInstanceModel, + agentSelectionWithOptions, + escalationWithOptions, + retryWithEscalation, + retryWithMaxAttempts, + type StepRetryEncoded, +} from "./agentStepSelection"; +import type { + WorkflowEditorMutation, + WorkflowLaneEncoded, + WorkflowStepEncoded, +} from "./WorkflowEditor"; + +type RouteKind = "success" | "failure" | "blocked"; +type InstructionMode = "inline" | "file"; + +export function StepFields({ + laneKey, + lanes, + step, + disabled = false, + onMutate, +}: { + readonly laneKey: string; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly step: WorkflowStepEncoded; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const stepKey = String(step.key); + + return ( + <div className="space-y-3"> + {step.type === "agent" ? ( + <AgentStepFields + laneKey={laneKey} + lanes={lanes} + step={step} + disabled={disabled} + onMutate={onMutate} + /> + ) : null} + {step.type === "script" ? ( + <ScriptStepFields laneKey={laneKey} step={step} disabled={disabled} onMutate={onMutate} /> + ) : null} + {step.type === "approval" ? ( + <ApprovalStepFields laneKey={laneKey} step={step} disabled={disabled} onMutate={onMutate} /> + ) : null} + {step.type === "merge" ? ( + <MergeStepFields laneKey={laneKey} step={step} disabled={disabled} onMutate={onMutate} /> + ) : null} + {step.type === "pullRequest" ? ( + <PullRequestStepFields + laneKey={laneKey} + step={step} + disabled={disabled} + onMutate={onMutate} + /> + ) : null} + <div className="grid gap-3 @2xl:grid-cols-3"> + <StepRouteSelect + label={`Step ${stepKey} success route`} + lanes={lanes} + value={step.on?.success} + disabled={disabled} + onChange={(targetLaneKey) => + updateRoute(onMutate, laneKey, step, "success", targetLaneKey) + } + /> + <StepRouteSelect + label={`Step ${stepKey} failure route`} + lanes={lanes} + value={step.on?.failure} + disabled={disabled} + onChange={(targetLaneKey) => + updateRoute(onMutate, laneKey, step, "failure", targetLaneKey) + } + /> + <StepRouteSelect + label={`Step ${stepKey} blocked route`} + lanes={lanes} + value={step.on?.blocked} + disabled={disabled} + onChange={(targetLaneKey) => + updateRoute(onMutate, laneKey, step, "blocked", targetLaneKey) + } + /> + </div> + </div> + ); +} + +function AgentStepFields({ + laneKey, + lanes: _lanes, + step, + disabled = false, + onMutate, +}: { + readonly laneKey: string; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly step: Extract<WorkflowStepEncoded, { readonly type: "agent" }>; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const stepKey = String(step.key); + const isPanel = (step.panel ?? 0) >= 2; + const instructionMode: InstructionMode = typeof step.instruction === "string" ? "inline" : "file"; + const instructionValue = + typeof step.instruction === "string" ? step.instruction : step.instruction.file; + + const providers = useServerProviders(); + const settings = useSettings(); + const instanceEntries = useMemo( + () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)), + [providers], + ); + const modelOptionsByInstance = useMemo(() => { + const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>(); + for (const entry of instanceEntries) { + out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); + } + return out; + }, [instanceEntries, settings]); + // The agent instance is only a `TrimmedNonEmptyString` in the workflow + // contract, which is looser than the slug-validated `ProviderInstanceId` + // brand. Treat the stored value as a routing key (cast, not `.make`) so a + // board with a non-slug instance does not throw while rendering the editor. + const activeInstanceId = step.agent.instance as ProviderInstanceId; + const selectedEntry = instanceEntries.find((entry) => entry.instanceId === activeInstanceId); + const selectedOptions = step.agent.options as ReadonlyArray<ProviderOptionSelection> | undefined; + + return ( + <div className="grid gap-3 @2xl:grid-cols-2"> + <div className="grid gap-3 @2xl:col-span-2 @2xl:grid-cols-[10rem_minmax(0,1fr)]"> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Instruction mode</span> + <select + aria-label={`Instruction source for step ${stepKey}`} + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={instructionMode} + disabled={disabled} + onChange={(event) => { + const mode = event.currentTarget.value as InstructionMode; + onMutate((current) => + updateStep(current, laneKey, stepKey, { + instruction: mode === "file" ? { file: instructionValue } : instructionValue, + }), + ); + }} + > + <option value="inline">Inline</option> + <option value="file">File</option> + </select> + </label> + {instructionMode === "file" ? ( + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground"> + Step {stepKey} instruction file + </span> + <Input + aria-label={`Instruction file for step ${stepKey}`} + value={instructionValue} + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value; + onMutate((current) => + updateStep(current, laneKey, stepKey, { instruction: { file: value } }), + ); + }} + /> + </label> + ) : ( + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Step {stepKey} instruction</span> + <Textarea + aria-label={`Step ${stepKey} instruction`} + value={instructionValue} + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value; + onMutate((current) => + updateStep(current, laneKey, stepKey, { instruction: value }), + ); + }} + /> + </label> + )} + <p className="text-xs text-muted-foreground @2xl:col-span-2"> + Hand off a prior step's captured output with{" "} + <code className="font-mono">{"{{prev.output}}"}</code> (the preceding step) or{" "} + <code className="font-mono">{"{{step.<key>.output}}"}</code> (a named step, latest + completed pass). Large outputs spill to a per-ticket scratch file that never reaches the + PR. + </p> + </div> + <div className="grid gap-1.5 @2xl:col-span-2"> + <span className="text-xs font-medium text-foreground">Agent</span> + <div className="flex flex-wrap items-center gap-2"> + <ProviderModelPicker + activeInstanceId={activeInstanceId} + model={step.agent.model} + lockedProvider={null} + instanceEntries={instanceEntries} + modelOptionsByInstance={modelOptionsByInstance} + triggerVariant="outline" + disabled={disabled} + onInstanceModelChange={(instanceId, model) => { + onMutate((current) => + updateStep(current, laneKey, stepKey, { + agent: agentSelectionWithInstanceModel(step.agent, instanceId, model), + }), + ); + }} + /> + {selectedEntry ? ( + <TraitsPicker + provider={selectedEntry.driverKind} + instanceId={selectedEntry.instanceId} + models={selectedEntry.models} + model={step.agent.model} + modelOptions={selectedOptions} + prompt="" + onPromptChange={() => {}} + allowPromptInjectedEffort={false} + triggerVariant="outline" + disabled={disabled} + onModelOptionsChange={(nextOptions) => { + onMutate((current) => + updateStep(current, laneKey, stepKey, { + agent: agentSelectionWithOptions(step.agent, nextOptions), + }), + ); + }} + /> + ) : null} + </div> + </div> + <label className="flex items-center gap-2 text-sm text-foreground"> + <input + type="checkbox" + checked={step.captureOutput ?? false} + disabled={disabled} + onChange={(event) => { + const checked = event.currentTarget.checked; + onMutate((current) => + updateStep(current, laneKey, stepKey, { + captureOutput: checked || undefined, + // Panel requires captureOutput (lint) and its selector hides + // with it — never strand a stale value the user cannot see. + ...(checked ? {} : { panel: undefined }), + }), + ); + }} + /> + Capture output + </label> + <label className="grid gap-1 text-sm text-foreground"> + <span className="flex items-center gap-2"> + <input + type="checkbox" + aria-label={`Continue session for step ${stepKey}`} + checked={step.continueSession ?? false} + // A reviewer panel fans out N independent turns, so resuming a single + // shared session is ambiguous (lint also rejects it). Disable the + // toggle and clear any stale value when the step becomes a panel. + disabled={disabled || isPanel} + onChange={(event) => { + const checked = event.currentTarget.checked; + onMutate((current) => + updateStep(current, laneKey, stepKey, { + continueSession: checked || undefined, + }), + ); + }} + /> + Continue session + </span> + <span className="text-xs text-muted-foreground"> + Resume this agent's own provider session across the lane's steps and loops. + Requires a resumable provider (Codex, Claude, Grok, or Cursor) — other providers are + rejected when the board is validated. + </span> + </label> + {step.captureOutput === true ? ( + <label className="grid gap-1.5 text-sm text-foreground"> + <span className="text-xs font-medium">Reviewers (majority verdict)</span> + <select + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground disabled:opacity-64" + value={step.panel ?? ""} + disabled={disabled} + aria-label={`Reviewer panel for step ${stepKey}`} + onChange={(event) => { + const parsed = Number.parseInt(event.currentTarget.value, 10); + const panel = Number.isFinite(parsed) && parsed >= 2 ? parsed : undefined; + onMutate((current) => + updateStep(current, laneKey, stepKey, { + panel, + // A panel cannot resume a single shared session (lint rejects + // it); drop any stale flag the user can no longer toggle off. + ...(panel !== undefined ? { continueSession: undefined } : {}), + }), + ); + }} + > + <option value="">Single reviewer</option> + {[2, 3, 4, 5].map((count) => ( + <option key={count} value={count}> + {count} reviewers + </option> + ))} + </select> + </label> + ) : null} + <div className="grid gap-3 @2xl:col-span-2"> + <StepRetrySelect + stepKey={stepKey} + value={step.retry?.maxAttempts} + disabled={disabled} + onChange={(maxAttempts) => + onMutate((current) => + updateStep(current, laneKey, stepKey, { + retry: retryWithMaxAttempts(step.retry, maxAttempts), + }), + ) + } + /> + {step.retry !== undefined ? ( + <> + <label className="flex items-center gap-2 text-sm text-foreground"> + <input + type="checkbox" + aria-label={`Escalate on retry for step ${stepKey}`} + checked={step.retry.escalate !== undefined} + disabled={disabled} + onChange={(event) => { + const retry = step.retry; + if (retry === undefined) { + return; + } + const checked = event.currentTarget.checked; + onMutate((current) => + updateStep(current, laneKey, stepKey, { + retry: retryWithEscalation( + retry, + checked + ? { instance: step.agent.instance, model: step.agent.model } + : undefined, + ), + }), + ); + }} + /> + Escalate on retry + </label> + {step.retry.escalate !== undefined ? ( + <EscalationPicker + laneKey={laneKey} + stepKey={stepKey} + retry={step.retry} + escalate={step.retry.escalate} + disabled={disabled} + instanceEntries={instanceEntries} + modelOptionsByInstance={modelOptionsByInstance} + onMutate={onMutate} + /> + ) : null} + </> + ) : null} + </div> + </div> + ); +} + +function EscalationPicker({ + laneKey, + stepKey, + retry, + escalate, + disabled, + instanceEntries, + modelOptionsByInstance, + onMutate, +}: { + readonly laneKey: string; + readonly stepKey: string; + readonly retry: StepRetryEncoded; + readonly escalate: NonNullable<StepRetryEncoded["escalate"]>; + readonly disabled: boolean; + readonly instanceEntries: ReturnType<typeof sortProviderInstanceEntries>; + readonly modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<AppModelOption>>; + readonly onMutate: WorkflowEditorMutation; +}) { + const escalateInstanceId = (escalate.instance ?? "") as ProviderInstanceId; + const escalateEntry = instanceEntries.find((entry) => entry.instanceId === escalateInstanceId); + return ( + <div className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Escalate to</span> + <div className="flex flex-wrap items-center gap-2"> + <ProviderModelPicker + activeInstanceId={escalateInstanceId} + model={escalate.model ?? ""} + lockedProvider={null} + instanceEntries={instanceEntries} + modelOptionsByInstance={modelOptionsByInstance} + triggerVariant="outline" + disabled={disabled} + onInstanceModelChange={(instanceId, model) => { + onMutate((current) => + updateStep(current, laneKey, stepKey, { + retry: retryWithEscalation(retry, { ...escalate, instance: instanceId, model }), + }), + ); + }} + /> + {escalateEntry ? ( + <TraitsPicker + provider={escalateEntry.driverKind} + instanceId={escalateEntry.instanceId} + models={escalateEntry.models} + model={escalate.model ?? ""} + modelOptions={escalate.options as ReadonlyArray<ProviderOptionSelection> | undefined} + prompt="" + onPromptChange={() => {}} + allowPromptInjectedEffort={false} + triggerVariant="outline" + disabled={disabled} + onModelOptionsChange={(nextOptions) => { + onMutate((current) => + updateStep(current, laneKey, stepKey, { + retry: retryWithEscalation(retry, escalationWithOptions(escalate, nextOptions)), + }), + ); + }} + /> + ) : null} + </div> + </div> + ); +} + +function StepRetrySelect({ + stepKey, + value, + disabled, + onChange, +}: { + readonly stepKey: string; + readonly value: number | undefined; + readonly disabled: boolean; + readonly onChange: (maxAttempts: number | undefined) => void; +}) { + // Lint enforces 2..5, but the contract's `maxAttempts` is an unbounded Int, so + // a board authored outside this editor can hold an out-of-range value (e.g. 7). + // Render an extra option for that value so the select reflects what's stored + // instead of going blank; selecting any listed option still snaps back to 2..5. + const standardOptions = [2, 3, 4, 5]; + const showCustomOption = value !== undefined && !standardOptions.includes(value); + return ( + <label className="grid gap-1.5 @2xl:max-w-60"> + <span className="text-xs font-medium text-foreground">Retries</span> + <select + aria-label={`Retries for step ${stepKey}`} + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={value === undefined ? "" : String(value)} + disabled={disabled} + onChange={(event) => { + const raw = event.currentTarget.value; + onChange(raw === "" ? undefined : Number(raw)); + }} + > + <option value="">Off</option> + {standardOptions.map((count) => ( + <option key={count} value={count}> + {count} attempts + </option> + ))} + {showCustomOption ? <option value={value}>{value} attempts</option> : null} + </select> + </label> + ); +} + +function ScriptStepFields({ + laneKey, + step, + disabled = false, + onMutate, +}: { + readonly laneKey: string; + readonly step: Extract<WorkflowStepEncoded, { readonly type: "script" }>; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const stepKey = String(step.key); + return ( + <div className="grid gap-3 @2xl:grid-cols-2"> + <label className="grid gap-1.5 @2xl:col-span-2"> + <span className="text-xs font-medium text-foreground">Step {stepKey} command</span> + <Textarea + aria-label={`Step ${stepKey} command`} + value={step.run} + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value; + onMutate((current) => updateStep(current, laneKey, stepKey, { run: value })); + }} + /> + </label> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Timeout</span> + <Input + aria-label={`Step ${stepKey} timeout`} + value={step.timeout ?? ""} + placeholder="5 minutes" + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value.trim() || undefined; + onMutate((current) => + updateStep(current, laneKey, stepKey, { + timeout: value, + }), + ); + }} + /> + </label> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Working directory</span> + <Input + aria-label={`Step ${stepKey} cwd`} + value={step.cwd ?? ""} + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value.trim() || undefined; + onMutate((current) => + updateStep(current, laneKey, stepKey, { + cwd: value, + }), + ); + }} + /> + </label> + <label className="flex items-center gap-2 text-sm text-foreground"> + <input + type="checkbox" + checked={step.allowFailure ?? false} + disabled={disabled} + onChange={(event) => { + const checked = event.currentTarget.checked; + onMutate((current) => + updateStep(current, laneKey, stepKey, { + allowFailure: checked || undefined, + }), + ); + }} + /> + Allow failure + </label> + <StepRetrySelect + stepKey={stepKey} + value={step.retry?.maxAttempts} + disabled={disabled} + onChange={(maxAttempts) => + onMutate((current) => + updateStep(current, laneKey, stepKey, { + retry: retryWithMaxAttempts(step.retry, maxAttempts), + }), + ) + } + /> + </div> + ); +} + +function ApprovalStepFields({ + laneKey, + step, + disabled = false, + onMutate, +}: { + readonly laneKey: string; + readonly step: Extract<WorkflowStepEncoded, { readonly type: "approval" }>; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const stepKey = String(step.key); + return ( + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Step {stepKey} prompt</span> + <Textarea + aria-label={`Step ${stepKey} prompt`} + value={step.prompt ?? ""} + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value || undefined; + onMutate((current) => + updateStep(current, laneKey, stepKey, { + prompt: value, + }), + ); + }} + /> + </label> + ); +} + +function MergeStepFields({ + laneKey, + step, + disabled = false, + onMutate, +}: { + readonly laneKey: string; + readonly step: Extract<WorkflowStepEncoded, { readonly type: "merge" }>; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const stepKey = String(step.key); + return ( + <div className="grid gap-3 @2xl:grid-cols-2"> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Target branch</span> + <Input + aria-label={`Step ${stepKey} target branch`} + value={step.target ?? ""} + placeholder="Checked-out branch" + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value.trim() || undefined; + onMutate((current) => updateStep(current, laneKey, stepKey, { target: value })); + }} + /> + </label> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Commit message</span> + <Input + aria-label={`Step ${stepKey} commit message`} + value={step.commitMessage ?? ""} + placeholder="Ticket title (id)" + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value || undefined; + onMutate((current) => updateStep(current, laneKey, stepKey, { commitMessage: value })); + }} + /> + </label> + <p className="text-xs text-muted-foreground @2xl:col-span-2"> + Commits the ticket worktree's outstanding work, then merges it into the branch checked + out at the repo root. Conflicts or a dirty repo block the ticket instead of failing it. + </p> + </div> + ); +} + +function PullRequestStepFields({ + laneKey, + step, + disabled = false, + onMutate, +}: { + readonly laneKey: string; + readonly step: Extract<WorkflowStepEncoded, { readonly type: "pullRequest" }>; + readonly disabled?: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const stepKey = String(step.key); + return ( + <div className="grid gap-3 @2xl:grid-cols-2"> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Action</span> + <select + aria-label={`Step ${stepKey} action`} + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={step.action} + disabled={disabled} + onChange={(event) => { + const action = event.currentTarget.value as "open" | "land"; + onMutate((current) => updateStep(current, laneKey, stepKey, { action })); + }} + > + <option value="open">Open pull request</option> + <option value="land">Land pull request</option> + </select> + </label> + {step.action === "open" ? ( + <> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Base branch</span> + <Input + aria-label={`Step ${stepKey} base branch`} + value={step.base ?? ""} + placeholder="Default branch" + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value.trim() || undefined; + onMutate((current) => updateStep(current, laneKey, stepKey, { base: value })); + }} + /> + </label> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">PR title template</span> + <Input + aria-label={`Step ${stepKey} title template`} + value={step.titleTemplate ?? ""} + placeholder="{{ticket.title}}" + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value.trim() || undefined; + onMutate((current) => + updateStep(current, laneKey, stepKey, { titleTemplate: value }), + ); + }} + /> + </label> + <label className="grid gap-1.5 @2xl:col-span-2"> + <span className="text-xs font-medium text-foreground">PR body template</span> + <Textarea + aria-label={`Step ${stepKey} body template`} + value={step.bodyTemplate ?? ""} + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value || undefined; + onMutate((current) => + updateStep(current, laneKey, stepKey, { bodyTemplate: value }), + ); + }} + /> + </label> + <label className="flex items-center gap-2 text-sm text-foreground @2xl:col-span-2"> + <input + type="checkbox" + aria-label={`Step ${stepKey} draft`} + checked={step.draft ?? false} + disabled={disabled} + onChange={(event) => { + const checked = event.currentTarget.checked; + onMutate((current) => + updateStep(current, laneKey, stepKey, { draft: checked || undefined }), + ); + }} + /> + Draft pull request + </label> + <p className="text-xs text-muted-foreground @2xl:col-span-2"> + Pushes the ticket's branch and opens a pull request. Conflicts or missing remotes + block the ticket instead of failing it. + </p> + </> + ) : null} + {step.action === "land" ? ( + <> + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">Merge strategy</span> + <select + aria-label={`Step ${stepKey} merge strategy`} + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={step.strategy ?? ""} + disabled={disabled} + onChange={(event) => { + const value = event.currentTarget.value || undefined; + onMutate((current) => + updateStep(current, laneKey, stepKey, { + strategy: value as "squash" | "merge" | "rebase" | undefined, + }), + ); + }} + > + <option value="">Default</option> + <option value="squash">Squash</option> + <option value="merge">Merge</option> + <option value="rebase">Rebase</option> + </select> + </label> + <label className="flex items-center gap-2 text-sm text-foreground"> + <input + type="checkbox" + aria-label={`Step ${stepKey} delete branch`} + checked={step.deleteBranch !== false} + disabled={disabled} + onChange={(event) => { + const checked = event.currentTarget.checked; + onMutate((current) => + updateStep(current, laneKey, stepKey, { + deleteBranch: checked ? undefined : false, + }), + ); + }} + /> + Delete branch after merge + </label> + <p className="text-xs text-muted-foreground @2xl:col-span-2"> + Lands the ticket's PR via <code className="font-mono">gh pr merge</code>; red + checks or conflicts block the ticket instead of failing it. + </p> + </> + ) : null} + </div> + ); +} + +function StepRouteSelect({ + label, + lanes, + value, + disabled = false, + onChange, +}: { + readonly label: string; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly value: string | undefined; + readonly disabled?: boolean; + readonly onChange: (targetLaneKey: string | undefined) => void; +}) { + return ( + <label className="grid gap-1.5"> + <span className="text-xs font-medium text-foreground">{label}</span> + <select + aria-label={label} + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={value ?? ""} + disabled={disabled} + onChange={(event) => { + const targetLaneKey = event.currentTarget.value || undefined; + onChange(targetLaneKey); + }} + > + <option value="">No route</option> + {lanes.map((lane) => ( + <option key={String(lane.key)} value={String(lane.key)}> + {lane.name} + </option> + ))} + </select> + </label> + ); +} + +function updateRoute( + onMutate: WorkflowEditorMutation, + laneKey: string, + step: WorkflowStepEncoded, + kind: RouteKind, + targetLaneKey: string | undefined, +) { + const nextOn = { + ...step.on, + [kind]: targetLaneKey === undefined ? undefined : LaneKey.make(targetLaneKey), + }; + for (const key of ["success", "failure", "blocked"] as const) { + if (nextOn[key] === undefined) { + delete nextOn[key]; + } + } + onMutate((current) => + updateStep(current, laneKey, String(step.key), { + on: Object.keys(nextOn).length === 0 ? undefined : nextOn, + }), + ); +} diff --git a/apps/web/src/components/board/editor/WorkflowEditor.browser.tsx b/apps/web/src/components/board/editor/WorkflowEditor.browser.tsx new file mode 100644 index 00000000000..aef2d900539 --- /dev/null +++ b/apps/web/src/components/board/editor/WorkflowEditor.browser.tsx @@ -0,0 +1,947 @@ +import "../../../index.css"; + +import { + BoardId, + LaneKey, + ProjectId, + StepKey, + type BoardSnapshot, + type EnvironmentApi, + type WorkflowBoardVersionSummary, + type WorkflowDefinitionEncoded, + type WorkflowGetBoardVersionResult, + type WorkflowLintError, + type WorkflowSaveBoardDefinitionInput, +} from "@t3tools/contracts"; +import { page } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { useState } from "react"; +import { render } from "vitest-browser-react"; + +import { + addTransition, + createWorkflowEditorModel, + updateTransition, + type WorkflowEditorModel, +} from "~/workflow/editorModel"; + +import { RoutingEditor, TransitionFields } from "./RoutingEditor"; +import { WorkflowEditor } from "./WorkflowEditor"; + +const boardId = BoardId.make("project-web__delivery"); +const secondBoardId = BoardId.make("project-web__support"); +const projectId = ProjectId.make("project-web"); +const queueLaneKey = LaneKey.make("queue"); +const runLaneKey = LaneKey.make("run"); +const doneLaneKey = LaneKey.make("done"); +const reviewStepKey = StepKey.make("review"); +const triageLaneKey = LaneKey.make("triage"); +const resolvedLaneKey = LaneKey.make("resolved"); + +const definition = { + name: "Delivery", + lanes: [ + { key: queueLaneKey, name: "Queue", entry: "manual" }, + { + key: runLaneKey, + name: "Run", + entry: "auto", + pipeline: [ + { + key: reviewStepKey, + type: "agent", + agent: { instance: "codex_main", model: "gpt-5.5" }, + instruction: "Review the diff.", + captureOutput: true, + on: { success: doneLaneKey }, + }, + ], + transitions: [{ when: { var: "pipeline.result" }, to: doneLaneKey }], + on: { success: doneLaneKey }, + }, + { key: doneLaneKey, name: "Done", entry: "manual", terminal: true }, + ], +} satisfies WorkflowDefinitionEncoded; + +const fileBackedInstructionDefinition = { + ...definition, + lanes: definition.lanes.map((lane) => + lane.key !== runLaneKey + ? lane + : { + ...lane, + pipeline: lane.pipeline?.map((step) => + step.key !== reviewStepKey || step.type !== "agent" + ? step + : { ...step, instruction: { file: "prompts/review.md" } }, + ), + }, + ), +} satisfies WorkflowDefinitionEncoded; + +const secondDefinition = { + name: "Support", + lanes: [ + { key: triageLaneKey, name: "Triage", entry: "manual" }, + { key: resolvedLaneKey, name: "Resolved", entry: "manual", terminal: true }, + ], +} satisfies WorkflowDefinitionEncoded; + +const snapshot = { + projectId, + board: { + boardId, + name: "Delivery Edited", + lanes: [ + { key: queueLaneKey, name: "Queue", entry: "manual", pipelineStepCount: 0 }, + { key: runLaneKey, name: "Build", entry: "auto", pipelineStepCount: 1 }, + { key: doneLaneKey, name: "Done", entry: "manual", terminal: true, pipelineStepCount: 0 }, + ], + }, + tickets: [], +} satisfies BoardSnapshot; + +const createApi = ( + saveBoardDefinition: (input: WorkflowSaveBoardDefinitionInput) => Promise< + | { + readonly ok: true; + readonly definition: WorkflowDefinitionEncoded; + readonly versionHash: string; + readonly snapshot: BoardSnapshot; + } + | { readonly ok: false; readonly lintErrors: ReadonlyArray<WorkflowLintError> } + | { readonly ok: false; readonly conflict: true; readonly currentVersionHash: string } + >, + initialDefinition: WorkflowDefinitionEncoded = definition, + history?: { + readonly listBoardVersions?: + | (() => Promise<ReadonlyArray<WorkflowBoardVersionSummary>>) + | undefined; + readonly getBoardVersion?: + | ((input: { + readonly boardId: BoardId; + readonly versionId: number; + }) => Promise<WorkflowGetBoardVersionResult>) + | undefined; + }, +) => + ({ + workflow: { + listWorkSourceConnections: vi.fn(async () => []), + listOutboundConnections: vi.fn(async () => ({ connections: [] })), + getBoardDefinition: vi.fn(async () => ({ + definition: initialDefinition, + versionHash: "hash-before", + })), + saveBoardDefinition: vi.fn(saveBoardDefinition), + listBoardVersions: vi.fn(history?.listBoardVersions ?? (async () => [])), + getBoardVersion: vi.fn( + history?.getBoardVersion ?? + (async () => { + throw new Error("getBoardVersion not mocked"); + }), + ), + }, + }) as unknown as EnvironmentApi; + +const deferred = <T,>() => { + let resolve!: (value: T) => void; + const promise = new Promise<T>((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +}; + +const forceTextareaInput = (label: string, value: string) => { + const textarea = document.querySelector<HTMLTextAreaElement>( + `textarea[aria-label="${CSS.escape(label)}"]`, + ); + expect(textarea).not.toBeNull(); + if (!textarea) { + return; + } + textarea.disabled = false; + const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + valueSetter?.call(textarea, value); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, data: value })); +}; + +const forceSelectValue = (label: string, value: string) => { + const select = document.querySelector<HTMLSelectElement>( + `select[aria-label="${CSS.escape(label)}"]`, + ); + expect(select).not.toBeNull(); + if (!select) { + return; + } + select.value = value; + select.dispatchEvent(new Event("change", { bubbles: true })); +}; + +const openFormView = async () => { + await page.getByRole("button", { name: "Form", exact: true }).click(); + await expect + .element(page.getByRole("button", { name: "Form", exact: true })) + .toHaveAttribute("aria-pressed", "true"); +}; + +describe("WorkflowEditor", () => { + it("defaults to canvas, toggles with shared dirty selection, and saves from canvas", async () => { + const api = createApi(async (input) => ({ + ok: true, + definition: input.definition, + versionHash: "hash-after", + snapshot, + })); + + render(<WorkflowEditor api={api} boardId={boardId} />); + + await expect.element(page.getByRole("region", { name: "Workflow canvas" })).toBeInTheDocument(); + await expect.element(page.getByRole("group", { name: "Lane Run" })).toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Canvas", exact: true })) + .toHaveAttribute("aria-pressed", "true"); + + await openFormView(); + await expect + .element(page.getByRole("button", { name: "Run", exact: true })) + .toBeInTheDocument(); + await page.getByRole("button", { name: "Run", exact: true }).click(); + await page.getByLabelText("Lane name").fill("Build"); + await expect.element(page.getByText("Unsaved changes")).toBeInTheDocument(); + + await page.getByRole("button", { name: "Canvas", exact: true }).click(); + await expect.element(page.getByRole("group", { name: "Lane Build" })).toBeInTheDocument(); + await expect.element(page.getByLabelText("Lane name")).toHaveValue("Build"); + await page.getByLabelText("Lane name").fill("Canvas Build"); + + await openFormView(); + await expect.element(page.getByLabelText("Lane name")).toHaveValue("Canvas Build"); + + await page.getByRole("button", { name: "Canvas", exact: true }).click(); + await page.getByRole("button", { name: "Save workflow" }).click(); + + await vi.waitFor(() => { + expect(api.workflow.saveBoardDefinition).toHaveBeenCalledOnce(); + }); + const saveInput = vi.mocked(api.workflow.saveBoardDefinition).mock.calls[0]?.[0]; + expect(saveInput?.definition.lanes[1]?.name).toBe("Canvas Build"); + await expect.element(page.getByText("Version hash-after")).toBeInTheDocument(); + }); + + it("renders TransitionFields standalone and edits one transition through model mutations", async () => { + function TransitionHarness() { + const [model, setModel] = useState<WorkflowEditorModel>(() => + updateTransition(addTransition(createWorkflowEditorModel(definition), "run"), "run", 0, { + when: { "==": [{ var: "pipeline.result" }, "pass"] }, + to: "done", + }), + ); + const lane = model.definition.lanes.find((candidate) => candidate.key === runLaneKey); + const transition = lane?.transitions?.[0]; + if (!lane || !transition) { + return null; + } + + return ( + <TransitionFields + laneKey="run" + lanes={model.definition.lanes} + lintErrors={[]} + transition={transition} + transitionIndex={0} + onMutate={(mutate) => setModel((current) => mutate(current))} + /> + ); + } + + render(<TransitionHarness />); + + await expect + .element(page.getByLabelText("Transition 1 predicate JSON")) + .toHaveValue(JSON.stringify({ "==": [{ var: "pipeline.result" }, "pass"] }, null, 2)); + + await page.getByLabelText("Transition 1 target lane").selectOptions("queue"); + await expect.element(page.getByLabelText("Transition 1 target lane")).toHaveValue("queue"); + + const nextPredicate = JSON.stringify({ var: "ticket.priority" }, null, 2); + await page.getByLabelText("Transition 1 predicate JSON").fill(nextPredicate); + await expect + .element(page.getByLabelText("Transition 1 predicate JSON")) + .toHaveValue(nextPredicate); + }); + + it("renders duplicate transitions as independent editable rows without key collisions", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + function RoutingHarness() { + const [model, setModel] = useState<WorkflowEditorModel>(() => + createWorkflowEditorModel({ + ...definition, + lanes: definition.lanes.map((lane) => + lane.key === runLaneKey + ? { + ...lane, + transitions: [ + { when: { var: "pipeline.result" }, to: doneLaneKey }, + { when: { var: "pipeline.result" }, to: doneLaneKey }, + ], + } + : lane, + ), + }), + ); + const lane = model.definition.lanes.find((candidate) => candidate.key === runLaneKey); + if (!lane) { + return null; + } + + return ( + <RoutingEditor + lane={lane} + lanes={model.definition.lanes} + lintErrors={[]} + onMutate={(mutate) => setModel((current) => mutate(current))} + /> + ); + } + + render(<RoutingHarness />); + + const duplicateKeyWarnings = () => + consoleError.mock.calls.filter((call) => + call.some((part) => String(part).includes("Encountered two children with the same key")), + ); + + await expect + .element(page.getByLabelText("Transition 1 predicate JSON")) + .toHaveValue(JSON.stringify({ var: "pipeline.result" }, null, 2)); + await expect + .element(page.getByLabelText("Transition 2 predicate JSON")) + .toHaveValue(JSON.stringify({ var: "pipeline.result" }, null, 2)); + + const nextPredicate = JSON.stringify({ var: "ticket.priority" }, null, 2); + await page.getByLabelText("Transition 2 predicate JSON").fill(nextPredicate); + + await expect + .element(page.getByLabelText("Transition 1 predicate JSON")) + .toHaveValue(JSON.stringify({ var: "pipeline.result" }, null, 2)); + await expect + .element(page.getByLabelText("Transition 2 predicate JSON")) + .toHaveValue(nextPredicate); + expect(duplicateKeyWarnings()).toEqual([]); + } finally { + consoleError.mockRestore(); + } + }); + + it("keeps dirty edits when a parent rerenders with a fresh API wrapper", async () => { + const getBoardDefinition = vi.fn(async () => ({ definition, versionHash: "hash-before" })); + const saveBoardDefinition = vi.fn(async (input) => ({ + ok: true, + definition: input.definition, + versionHash: "hash-after", + snapshot, + })); + const createFreshApiWrapper = () => + ({ + workflow: { + listWorkSourceConnections: vi.fn(async () => []), + listOutboundConnections: vi.fn(async () => ({ connections: [] })), + getBoardDefinition, + saveBoardDefinition, + }, + }) as unknown as EnvironmentApi; + + const screen = await render(<WorkflowEditor api={createFreshApiWrapper()} boardId={boardId} />); + + await expect.element(page.getByRole("heading", { name: "Delivery" })).toBeInTheDocument(); + await openFormView(); + await page.getByRole("button", { name: "Run", exact: true }).click(); + await page.getByLabelText("Step review instruction").fill("Dirty review prompt."); + await screen.rerender(<WorkflowEditor api={createFreshApiWrapper()} boardId={boardId} />); + await new Promise((resolve) => setTimeout(resolve, 25)); + + await expect + .element(page.getByLabelText("Step review instruction")) + .toHaveValue("Dirty review prompt."); + await expect.element(page.getByText("Unsaved changes")).toBeInTheDocument(); + expect(getBoardDefinition).toHaveBeenCalledOnce(); + }); + + it("renders, edits, saves, and clears dirty state after a successful save", async () => { + const api = createApi(async (input) => ({ + ok: true, + definition: input.definition, + versionHash: "hash-after", + snapshot, + })); + const onSaved = vi.fn(); + + render(<WorkflowEditor api={api} boardId={boardId} onSaved={onSaved} />); + + await expect.element(page.getByRole("heading", { name: "Delivery" })).toBeInTheDocument(); + await openFormView(); + await expect + .element(page.getByRole("button", { name: "Run", exact: true })) + .toBeInTheDocument(); + await expect.element(page.getByText("review", { exact: true })).toBeInTheDocument(); + + await page.getByRole("button", { name: "Run", exact: true }).click(); + await page.getByLabelText("Lane name").fill("Build"); + await expect.element(page.getByText("Unsaved changes")).toBeInTheDocument(); + await page.getByLabelText("Step review instruction").fill("Updated review prompt."); + await expect.element(page.getByLabelText("Step review success route")).toHaveValue("done"); + await page.getByRole("button", { name: "Save workflow" }).click(); + + await vi.waitFor(() => { + expect(api.workflow.saveBoardDefinition).toHaveBeenCalledOnce(); + }); + const saveInput = vi.mocked(api.workflow.saveBoardDefinition).mock.calls[0]?.[0]; + expect(saveInput?.boardId).toBe(boardId); + expect(saveInput?.expectedVersionHash).toBe("hash-before"); + expect(saveInput?.definition.lanes[1]?.name).toBe("Build"); + const savedStep = saveInput?.definition.lanes[1]?.pipeline?.[0]; + expect(savedStep?.type).toBe("agent"); + if (savedStep?.type === "agent") { + expect(savedStep.instruction).toBe("Updated review prompt."); + expect(savedStep.on?.success).toBe("done"); + } + await expect.element(page.getByText("Unsaved changes")).not.toBeInTheDocument(); + await vi.waitFor(() => { + expect(onSaved).toHaveBeenCalledWith( + snapshot, + expect.objectContaining({ name: expect.any(String) }), + ); + }); + }); + + it("lists history, diffs a selected version, and saves a revert through the editor", async () => { + const oldDefinition = { + name: "Delivery v1", + lanes: [ + { key: queueLaneKey, name: "Queue", entry: "manual" }, + { key: doneLaneKey, name: "Done", entry: "manual", terminal: true }, + ], + } satisfies WorkflowDefinitionEncoded; + const versions = [ + { + versionId: 3, + versionHash: "hash-current", + source: "save", + createdAt: "2026-06-08T12:10:00.000Z", + isCurrent: true, + }, + { + versionId: 2, + versionHash: "hash-old", + source: "save", + createdAt: "2026-06-08T12:05:00.000Z", + isCurrent: false, + }, + ] satisfies WorkflowBoardVersionSummary[]; + const api = createApi( + async (input) => ({ + ok: true, + definition: input.definition, + versionHash: "hash-revert", + snapshot, + }), + definition, + { + listBoardVersions: async () => versions, + getBoardVersion: async (input) => ({ + versionId: input.versionId, + definition: oldDefinition, + versionHash: "hash-old", + source: "save", + createdAt: "2026-06-08T12:05:00.000Z", + }), + }, + ); + + render(<WorkflowEditor api={api} boardId={boardId} />); + + await expect.element(page.getByRole("heading", { name: "Delivery" })).toBeInTheDocument(); + await page.getByRole("button", { name: "History" }).click(); + await expect + .element(page.getByRole("heading", { name: "Version history" })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Version 3 current save" })) + .toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Revert version 3" })).toBeDisabled(); + + await page.getByRole("button", { name: "Version 2 save" }).click(); + await expect.element(page.getByText('- "name": "Delivery v1"')).toBeInTheDocument(); + await expect.element(page.getByText('+ "name": "Delivery"')).toBeInTheDocument(); + await page.getByRole("button", { name: "Revert version 2" }).click(); + + await expect.element(page.getByRole("heading", { name: "Delivery v1" })).toBeInTheDocument(); + await expect.element(page.getByText("Reverting to v2")).toBeInTheDocument(); + await expect.element(page.getByText("Unsaved changes")).toBeInTheDocument(); + + await page.getByRole("button", { name: "Save workflow" }).click(); + await vi.waitFor(() => { + expect(api.workflow.saveBoardDefinition).toHaveBeenCalledOnce(); + }); + const saveInput = vi.mocked(api.workflow.saveBoardDefinition).mock.calls[0]?.[0]; + expect(saveInput?.expectedVersionHash).toBe("hash-before"); + expect(saveInput?.source).toBe("revert"); + expect(saveInput?.definition.name).toBe("Delivery v1"); + }); + + it("disables reverting versions while unsaved edits are present", async () => { + const oldDefinition = { + name: "Delivery v1", + lanes: [ + { key: queueLaneKey, name: "Queue", entry: "manual" }, + { key: doneLaneKey, name: "Done", entry: "manual", terminal: true }, + ], + } satisfies WorkflowDefinitionEncoded; + const versions = [ + { + versionId: 3, + versionHash: "hash-current", + source: "save", + createdAt: "2026-06-08T12:10:00.000Z", + isCurrent: true, + }, + { + versionId: 2, + versionHash: "hash-old", + source: "save", + createdAt: "2026-06-08T12:05:00.000Z", + isCurrent: false, + }, + ] satisfies WorkflowBoardVersionSummary[]; + const api = createApi( + async (input) => ({ + ok: true, + definition: input.definition, + versionHash: "hash-revert", + snapshot, + }), + definition, + { + listBoardVersions: async () => versions, + getBoardVersion: async (input) => ({ + versionId: input.versionId, + definition: oldDefinition, + versionHash: "hash-old", + source: "save", + createdAt: "2026-06-08T12:05:00.000Z", + }), + }, + ); + + render(<WorkflowEditor api={api} boardId={boardId} />); + + await expect.element(page.getByRole("heading", { name: "Delivery" })).toBeInTheDocument(); + await openFormView(); + await page.getByRole("button", { name: "Run", exact: true }).click(); + await page.getByLabelText("Lane name").fill("Dirty Run"); + await expect.element(page.getByText("Unsaved changes")).toBeInTheDocument(); + + await page.getByRole("button", { name: "History" }).click(); + await expect + .element(page.getByRole("heading", { name: "Version history" })) + .toBeInTheDocument(); + await expect + .element(page.getByText("Save or discard changes before reverting.")) + .toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Revert version 2" })).toBeDisabled(); + }); + + it("does not apply an in-flight revert after newer edits make the editor dirty", async () => { + const oldDefinition = { + name: "Delivery v1", + lanes: [ + { key: queueLaneKey, name: "Queue", entry: "manual" }, + { key: doneLaneKey, name: "Done", entry: "manual", terminal: true }, + ], + } satisfies WorkflowDefinitionEncoded; + const versions = [ + { + versionId: 3, + versionHash: "hash-current", + source: "save", + createdAt: "2026-06-08T12:10:00.000Z", + isCurrent: true, + }, + { + versionId: 2, + versionHash: "hash-old", + source: "save", + createdAt: "2026-06-08T12:05:00.000Z", + isCurrent: false, + }, + ] satisfies WorkflowBoardVersionSummary[]; + const versionResult = deferred<WorkflowGetBoardVersionResult>(); + const api = createApi( + async (input) => ({ + ok: true, + definition: input.definition, + versionHash: "hash-revert", + snapshot, + }), + definition, + { + listBoardVersions: async () => versions, + getBoardVersion: () => versionResult.promise, + }, + ); + + render(<WorkflowEditor api={api} boardId={boardId} />); + + await expect.element(page.getByRole("heading", { name: "Delivery" })).toBeInTheDocument(); + await page.getByRole("button", { name: "History" }).click(); + await expect + .element(page.getByRole("heading", { name: "Version history" })) + .toBeInTheDocument(); + await page.getByRole("button", { name: "Revert version 2" }).click(); + await vi.waitFor(() => { + expect(api.workflow.getBoardVersion).toHaveBeenCalledOnce(); + }); + + await openFormView(); + await page.getByRole("button", { name: "Run", exact: true }).click(); + await page.getByLabelText("Lane name").fill("Dirty Run"); + await expect.element(page.getByText("Unsaved changes")).toBeInTheDocument(); + + versionResult.resolve({ + versionId: 2, + definition: oldDefinition, + versionHash: "hash-old", + source: "save", + createdAt: "2026-06-08T12:05:00.000Z", + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + await expect.element(page.getByLabelText("Lane name")).toHaveValue("Dirty Run"); + await expect + .element(page.getByRole("heading", { name: "Version history" })) + .toBeInTheDocument(); + await expect.element(page.getByText("Reverting to v2")).not.toBeInTheDocument(); + expect(api.workflow.saveBoardDefinition).not.toHaveBeenCalled(); + }); + + it("does not apply an in-flight revert after the editor changes boards", async () => { + const oldDefinition = { + name: "Delivery v1", + lanes: [ + { key: queueLaneKey, name: "Queue", entry: "manual" }, + { key: doneLaneKey, name: "Done", entry: "manual", terminal: true }, + ], + } satisfies WorkflowDefinitionEncoded; + const versions = [ + { + versionId: 3, + versionHash: "hash-current", + source: "save", + createdAt: "2026-06-08T12:10:00.000Z", + isCurrent: true, + }, + { + versionId: 2, + versionHash: "hash-old", + source: "save", + createdAt: "2026-06-08T12:05:00.000Z", + isCurrent: false, + }, + ] satisfies WorkflowBoardVersionSummary[]; + const versionResult = deferred<WorkflowGetBoardVersionResult>(); + const api = { + workflow: { + getBoardDefinition: vi.fn(async (input: { readonly boardId: BoardId }) => + input.boardId === secondBoardId + ? { definition: secondDefinition, versionHash: "hash-support" } + : { definition, versionHash: "hash-before" }, + ), + listWorkSourceConnections: vi.fn(async () => []), + listOutboundConnections: vi.fn(async () => ({ connections: [] })), + saveBoardDefinition: vi.fn(async (input: WorkflowSaveBoardDefinitionInput) => ({ + ok: true, + definition: input.definition, + versionHash: "hash-after", + snapshot, + })), + listBoardVersions: vi.fn(async () => versions), + getBoardVersion: vi.fn(() => versionResult.promise), + }, + } as unknown as EnvironmentApi; + + const screen = await render(<WorkflowEditor api={api} boardId={boardId} />); + + await expect.element(page.getByRole("heading", { name: "Delivery" })).toBeInTheDocument(); + await page.getByRole("button", { name: "History" }).click(); + await expect + .element(page.getByRole("heading", { name: "Version history" })) + .toBeInTheDocument(); + await page.getByRole("button", { name: "Revert version 2" }).click(); + await vi.waitFor(() => { + expect(api.workflow.getBoardVersion).toHaveBeenCalledOnce(); + }); + + await screen.rerender(<WorkflowEditor api={api} boardId={secondBoardId} />); + await expect.element(page.getByRole("heading", { name: "Support" })).toBeInTheDocument(); + + versionResult.resolve({ + versionId: 2, + definition: oldDefinition, + versionHash: "hash-old", + source: "save", + createdAt: "2026-06-08T12:05:00.000Z", + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + await expect.element(page.getByRole("heading", { name: "Support" })).toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "Delivery v1" })) + .not.toBeInTheDocument(); + await expect.element(page.getByText("Reverting to v2")).not.toBeInTheDocument(); + expect(api.workflow.saveBoardDefinition).not.toHaveBeenCalled(); + }); + + it("keeps newer dirty edits when an older in-flight save response returns", async () => { + const saveResult = deferred< + | { + readonly ok: true; + readonly definition: WorkflowDefinitionEncoded; + readonly versionHash: string; + readonly snapshot: BoardSnapshot; + } + | { readonly ok: false; readonly lintErrors: ReadonlyArray<WorkflowLintError> } + | { readonly ok: false; readonly conflict: true; readonly currentVersionHash: string } + >(); + let submittedDefinition: WorkflowDefinitionEncoded | null = null; + const api = createApi((input) => { + submittedDefinition = input.definition; + return saveResult.promise; + }); + + render(<WorkflowEditor api={api} boardId={boardId} />); + + await openFormView(); + await page.getByRole("button", { name: "Run", exact: true }).click(); + await page.getByLabelText("Step review instruction").fill("Submitted review prompt."); + await page.getByRole("button", { name: "Save workflow" }).click(); + + await vi.waitFor(() => { + expect(api.workflow.saveBoardDefinition).toHaveBeenCalledOnce(); + expect(submittedDefinition).not.toBeNull(); + }); + await expect.element(page.getByLabelText("Step review instruction")).toBeDisabled(); + await expect.element(page.getByLabelText("Lane name")).toBeDisabled(); + + forceTextareaInput("Step review instruction", "Newer review prompt."); + await expect + .element(page.getByLabelText("Step review instruction")) + .toHaveValue("Newer review prompt."); + saveResult.resolve({ + ok: true, + definition: submittedDefinition!, + versionHash: "hash-after", + snapshot, + }); + + await expect.element(page.getByText("Version hash-after")).toBeInTheDocument(); + await expect + .element(page.getByLabelText("Step review instruction")) + .toHaveValue("Newer review prompt."); + await expect.element(page.getByText("Unsaved changes")).toBeInTheDocument(); + }); + + it("does not apply an in-flight save after the editor changes boards", async () => { + const saveResult = deferred< + | { + readonly ok: true; + readonly definition: WorkflowDefinitionEncoded; + readonly versionHash: string; + readonly snapshot: BoardSnapshot; + } + | { readonly ok: false; readonly lintErrors: ReadonlyArray<WorkflowLintError> } + | { readonly ok: false; readonly conflict: true; readonly currentVersionHash: string } + >(); + let submittedDefinition: WorkflowDefinitionEncoded | null = null; + const onSaved = vi.fn(); + const api = { + workflow: { + getBoardDefinition: vi.fn(async (input: { readonly boardId: BoardId }) => + input.boardId === secondBoardId + ? { definition: secondDefinition, versionHash: "hash-support" } + : { definition, versionHash: "hash-before" }, + ), + listWorkSourceConnections: vi.fn(async () => []), + listOutboundConnections: vi.fn(async () => ({ connections: [] })), + saveBoardDefinition: vi.fn((input: WorkflowSaveBoardDefinitionInput) => { + submittedDefinition = input.definition; + return saveResult.promise; + }), + listBoardVersions: vi.fn(async () => []), + getBoardVersion: vi.fn(async () => { + throw new Error("getBoardVersion not mocked"); + }), + }, + } as unknown as EnvironmentApi; + + const screen = await render(<WorkflowEditor api={api} boardId={boardId} onSaved={onSaved} />); + + await openFormView(); + await page.getByRole("button", { name: "Run", exact: true }).click(); + await page.getByLabelText("Step review instruction").fill("Submitted review prompt."); + await page.getByRole("button", { name: "Save workflow" }).click(); + + await vi.waitFor(() => { + expect(api.workflow.saveBoardDefinition).toHaveBeenCalledOnce(); + expect(submittedDefinition).not.toBeNull(); + }); + + await screen.rerender(<WorkflowEditor api={api} boardId={secondBoardId} onSaved={onSaved} />); + await expect.element(page.getByRole("heading", { name: "Support" })).toBeInTheDocument(); + await expect.element(page.getByText("Version hash-support")).toBeInTheDocument(); + + saveResult.resolve({ + ok: true, + definition: submittedDefinition!, + versionHash: "hash-after", + snapshot, + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + await expect.element(page.getByRole("heading", { name: "Support" })).toBeInTheDocument(); + await expect.element(page.getByText("Version hash-support")).toBeInTheDocument(); + await expect.element(page.getByText("Version hash-after")).not.toBeInTheDocument(); + expect(onSaved).not.toHaveBeenCalled(); + }); + + it("keeps dirty edits and offers reload when save detects a newer board version", async () => { + const api = createApi(async () => ({ + ok: false, + conflict: true, + currentVersionHash: "hash-current", + })); + + render(<WorkflowEditor api={api} boardId={boardId} />); + + await openFormView(); + await page.getByRole("button", { name: "Run", exact: true }).click(); + await page.getByLabelText("Step review instruction").fill("Conflict review prompt."); + await page.getByRole("button", { name: "Save workflow" }).click(); + + await vi.waitFor(() => { + expect(api.workflow.saveBoardDefinition).toHaveBeenCalledOnce(); + }); + await expect + .element(page.getByText("This board changed elsewhere. Reload to review the latest version.")) + .toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Reload workflow" })).toBeInTheDocument(); + await expect + .element(page.getByLabelText("Step review instruction")) + .toHaveValue("Conflict review prompt."); + await expect.element(page.getByText("Unsaved changes")).toBeInTheDocument(); + await page.getByRole("button", { name: "Reload workflow" }).click(); + await vi.waitFor(() => { + expect(api.workflow.getBoardDefinition).toHaveBeenCalledTimes(2); + }); + }); + + it("preserves file-backed instruction shape and switches instruction modes", async () => { + const api = createApi( + async (input) => ({ + ok: true, + definition: input.definition, + versionHash: "hash-after", + snapshot, + }), + fileBackedInstructionDefinition, + ); + + render(<WorkflowEditor api={api} boardId={boardId} />); + + await openFormView(); + await page.getByRole("button", { name: "Run", exact: true }).click(); + await expect + .element(page.getByLabelText("Instruction source for step review")) + .toHaveValue("file"); + await page.getByLabelText("Instruction file for step review").fill("prompts/updated-review.md"); + await page.getByRole("button", { name: "Save workflow" }).click(); + + await vi.waitFor(() => { + expect(api.workflow.saveBoardDefinition).toHaveBeenCalledOnce(); + }); + const fileSaveInput = vi.mocked(api.workflow.saveBoardDefinition).mock.calls[0]?.[0]; + const fileSavedStep = fileSaveInput?.definition.lanes[1]?.pipeline?.[0]; + expect(fileSavedStep?.type).toBe("agent"); + if (fileSavedStep?.type === "agent") { + expect(fileSavedStep.instruction).toEqual({ file: "prompts/updated-review.md" }); + } + + forceSelectValue("Instruction source for step review", "inline"); + await page.getByLabelText("Step review instruction").fill("Inline review prompt."); + await page.getByRole("button", { name: "Save workflow" }).click(); + + await vi.waitFor(() => { + expect(api.workflow.saveBoardDefinition).toHaveBeenCalledTimes(2); + }); + const inlineSaveInput = vi.mocked(api.workflow.saveBoardDefinition).mock.calls[1]?.[0]; + const inlineSavedStep = inlineSaveInput?.definition.lanes[1]?.pipeline?.[0]; + expect(inlineSavedStep?.type).toBe("agent"); + if (inlineSavedStep?.type === "agent") { + expect(inlineSavedStep.instruction).toBe("Inline review prompt."); + } + }); + + it("blocks save with field validation errors before calling the RPC", async () => { + const api = createApi(async (input) => ({ + ok: true, + definition: input.definition, + versionHash: "hash-after", + snapshot, + })); + + render(<WorkflowEditor api={api} boardId={boardId} />); + + await openFormView(); + await page.getByRole("button", { name: "Run", exact: true }).click(); + await page.getByLabelText("Lane name").fill(""); + await page.getByRole("button", { name: "Save workflow" }).click(); + await new Promise((resolve) => setTimeout(resolve, 25)); + + await expect.element(page.getByText('Lane "run" name is required.')).toBeInTheDocument(); + await expect.element(page.getByText("Unsaved changes")).toBeInTheDocument(); + expect(api.workflow.saveBoardDefinition).not.toHaveBeenCalled(); + }); + + it("keeps dirty state and renders lint errors by lane, step, and transition", async () => { + const lintErrors = [ + { code: "invalid_wip_limit", message: "Run WIP must be at least 1", laneKey: runLaneKey }, + { + code: "unknown_provider_instance", + message: "Provider is missing", + laneKey: runLaneKey, + stepKey: reviewStepKey, + }, + { + code: "invalid_json_logic", + message: "Transition predicate is invalid", + laneKey: runLaneKey, + transitionIndex: 0, + }, + ] satisfies WorkflowLintError[]; + const api = createApi(async () => ({ ok: false, lintErrors })); + + render(<WorkflowEditor api={api} boardId={boardId} />); + + await openFormView(); + await page.getByRole("button", { name: "Run", exact: true }).click(); + await page.getByLabelText("Lane name").fill("Build"); + await page.getByRole("button", { name: "Save workflow" }).click(); + + await expect.element(page.getByText("Run WIP must be at least 1")).toBeInTheDocument(); + await expect.element(page.getByText("Provider is missing")).toBeInTheDocument(); + await expect.element(page.getByText("Transition predicate is invalid")).toBeInTheDocument(); + await expect.element(page.getByText("Unsaved changes")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/board/editor/WorkflowEditor.tsx b/apps/web/src/components/board/editor/WorkflowEditor.tsx new file mode 100644 index 00000000000..60a3e47e580 --- /dev/null +++ b/apps/web/src/components/board/editor/WorkflowEditor.tsx @@ -0,0 +1,703 @@ +import type { + BoardId, + BoardSnapshot, + EnvironmentApi, + WorkflowDefinitionEncoded, + WorkflowGetBoardVersionResult, + WorkflowLintError, +} from "@t3tools/contracts"; +import { LaneKey, WorkflowDefinition } from "@t3tools/contracts"; +import { formatSchemaError } from "@t3tools/shared/schemaJson"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import { + DatabaseIcon, + DownloadIcon, + FlaskConicalIcon, + HistoryIcon, + SaveIcon, + Undo2Icon, + XIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { downloadJson } from "~/workflow/downloadJson"; +import { + addLane, + createWorkflowEditorModel, + discardWorkflowChanges, + formatVersionTime, + lintErrorKey, + loadRevertedDefinition, + markWorkflowSavedIfUnchanged, + normalizeSelection, + setWorkflowLintErrors, + type WorkflowEditorModel, + type WorkflowEditorSelection, +} from "~/workflow/editorModel"; + +import { DryRunPanel } from "./DryRunPanel"; +import { LaneForm } from "./LaneForm"; +import { LaneList } from "./LaneList"; +import { OutboundSection } from "./OutboundSection"; +import { SourcesSection } from "./SourcesSection"; +import { CanvasView } from "./canvas/CanvasView"; +import { VersionHistoryPanel } from "./history/VersionHistoryPanel"; + +export type WorkflowLaneEncoded = WorkflowDefinitionEncoded["lanes"][number]; +export type WorkflowStepEncoded = NonNullable<WorkflowLaneEncoded["pipeline"]>[number]; +export type WorkflowEditorViewMode = "canvas" | "form"; +export type WorkflowEditorSelectionMutation = ( + selection: WorkflowEditorSelection | null, +) => WorkflowEditorSelection | null; +export type WorkflowEditorMutation = ( + mutate: (model: WorkflowEditorModel) => WorkflowEditorModel, + mutateSelection?: WorkflowEditorSelectionMutation, +) => void; + +export interface WorkflowEditorProps { + readonly api: EnvironmentApi; + readonly boardId: BoardId; + readonly onClose?: (() => void) | undefined; + readonly onSaved?: + | ((snapshot: BoardSnapshot, definition: WorkflowDefinitionEncoded) => void) + | undefined; + /** + * Increment this value to programmatically open the Sources wizard when the + * editor mounts (e.g. from the board's empty "no sources" CTA). The effect + * fires whenever the value changes to a non-zero value and the definition has + * loaded. Mirrors the same pattern used by SourcesSection.triggerCreate. + */ + readonly openSourcesWizardOnMount?: number | undefined; +} + +export const lintErrorMatchesLane = (lintError: WorkflowLintError, laneKey: string): boolean => + String(lintError.laneKey ?? "") === laneKey; + +export const lintErrorMatchesStep = ( + lintError: WorkflowLintError, + laneKey: string, + stepKey: string, +): boolean => + lintErrorMatchesLane(lintError, laneKey) && String(lintError.stepKey ?? "") === stepKey; + +export const lintErrorMatchesTransition = ( + lintError: WorkflowLintError, + laneKey: string, + transitionIndex: number, +): boolean => + lintErrorMatchesLane(lintError, laneKey) && lintError.transitionIndex === transitionIndex; + +const decodeWorkflowDefinitionForSave = Schema.decodeUnknownExit(WorkflowDefinition); + +export function WorkflowEditor({ + api, + boardId, + onClose, + onSaved, + openSourcesWizardOnMount, +}: WorkflowEditorProps) { + const getBoardDefinition = api.workflow.getBoardDefinition; + const mountedRef = useRef(false); + const currentBoardIdRef = useRef(boardId); + const [model, setModel] = useState<WorkflowEditorModel | null>(null); + const [selection, setSelection] = useState<WorkflowEditorSelection | null>(null); + const [viewMode, setViewMode] = useState<WorkflowEditorViewMode>("canvas"); + const [versionHash, setVersionHash] = useState<string | null>(null); + const [loadingError, setLoadingError] = useState<string | null>(null); + const [saveError, setSaveError] = useState<{ + readonly message: string; + readonly conflictVersionHash?: string; + } | null>(null); + const [clientValidationErrors, setClientValidationErrors] = useState<ReadonlyArray<string>>([]); + const [historyOpen, setHistoryOpen] = useState(false); + const [dryRunOpen, setDryRunOpen] = useState(false); + const [pendingRevert, setPendingRevert] = useState<{ + readonly versionId: number; + readonly createdAt: string; + } | null>(null); + const [saving, setSaving] = useState(false); + const [sourcesCreateTrigger, setSourcesCreateTrigger] = useState(0); + const sourcesRef = useRef<HTMLDivElement>(null); + // Tracks the latest model without being a reactive dep — used in the + // openSourcesWizardOnMount effect to snapshot whether the model has loaded. + const modelRef = useRef<WorkflowEditorModel | null>(null); + + currentBoardIdRef.current = boardId; + modelRef.current = model; + + const isCurrentBoardRequest = useCallback( + (requestBoardId: BoardId) => mountedRef.current && currentBoardIdRef.current === requestBoardId, + [], + ); + + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + }; + }, []); + + // Pending wizard trigger: set when the caller requests Sources wizard open + // before the board definition has loaded. Cleared once applied. + const pendingSourcesWizardTriggerRef = useRef(0); + + // Applies the "open Sources wizard" action: switches to form view, increments + // the trigger, and scrolls Sources into view. Stable across renders. + const applySourcesWizardTrigger = useCallback(() => { + setViewMode("form"); + setSourcesCreateTrigger((n) => n + 1); + setTimeout(() => { + sourcesRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }, 0); + }, []); + + const loadBoardDefinition = useCallback( + (isActive: () => boolean = () => true) => { + setModel(null); + setVersionHash(null); + setLoadingError(null); + setSaveError(null); + setClientValidationErrors([]); + setPendingRevert(null); + setSelection(null); + setSaving(false); + + void getBoardDefinition({ boardId }) + .then((result) => { + if (!isActive()) { + return; + } + setModel(createWorkflowEditorModel(result.definition)); + setVersionHash(result.versionHash); + setSelection(getDefaultSelection(result.definition)); + // If a Sources wizard request arrived before the definition loaded, + // apply it now that we have the model. + if (pendingSourcesWizardTriggerRef.current) { + pendingSourcesWizardTriggerRef.current = 0; + applySourcesWizardTrigger(); + } + }) + .catch((error: unknown) => { + if (!isActive()) { + return; + } + setLoadingError(error instanceof Error ? error.message : String(error)); + }); + }, + [boardId, getBoardDefinition, applySourcesWizardTrigger], + ); + + useEffect(() => { + let active = true; + loadBoardDefinition(() => active); + + return () => { + active = false; + }; + }, [loadBoardDefinition]); + + // When the caller increments openSourcesWizardOnMount (e.g. the board "Set + // up a source" CTA), open the Sources wizard. If the definition is already + // loaded, apply it immediately. Otherwise record it so the model-load path + // above picks it up when getBoardDefinition resolves. + useEffect(() => { + if (!openSourcesWizardOnMount) { + return; + } + if (modelRef.current) { + // Definition already loaded — apply now. + applySourcesWizardTrigger(); + } else { + // Definition still loading — record the pending request. + pendingSourcesWizardTriggerRef.current = openSourcesWizardOnMount; + } + }, [openSourcesWizardOnMount, applySourcesWizardTrigger]); + + const selectedLane = useMemo(() => { + if (!model) { + return null; + } + return ( + model.definition.lanes.find((lane) => String(lane.key) === selection?.laneKey) ?? + model.definition.lanes[0] ?? + null + ); + }, [model, selection]); + + const mutateModel: WorkflowEditorMutation = (mutate, mutateSelection) => { + setClientValidationErrors([]); + setModel((current) => { + if (!current) { + return current; + } + const next = mutate(current); + setSelection((currentSelection) => + normalizeSelection( + next, + mutateSelection ? mutateSelection(currentSelection) : currentSelection, + ), + ); + return next; + }); + }; + + const boardLintErrors = + model?.lintErrors.filter((lintError) => lintError.laneKey === undefined) ?? []; + + const handleDiscard = () => { + setSaveError(null); + setClientValidationErrors([]); + setPendingRevert(null); + setModel((current) => { + if (!current) { + return current; + } + const next = discardWorkflowChanges(current); + setSelection(getDefaultSelection(next.definition)); + return next; + }); + }; + + const handleRevertVersion = (version: WorkflowGetBoardVersionResult) => { + const requestBoardId = boardId; + if (!isCurrentBoardRequest(requestBoardId)) { + return; + } + + setSaveError(null); + setClientValidationErrors([]); + setModel((current) => { + if (!isCurrentBoardRequest(requestBoardId)) { + return current; + } + if (!current) { + return current; + } + if (current.dirty) { + setSaveError({ message: "Save or discard changes before reverting." }); + return current; + } + const next = loadRevertedDefinition(current, version.definition); + setPendingRevert({ versionId: version.versionId, createdAt: version.createdAt }); + setHistoryOpen(false); + setSelection(getDefaultSelection(next.definition)); + return next; + }); + }; + + const handleSave = async () => { + if (!model || saving) { + return; + } + + setSaveError(null); + const submittedDefinition = model.definition; + const validationErrors = validateWorkflowDefinitionForSave(submittedDefinition); + if (validationErrors.length > 0) { + setClientValidationErrors(validationErrors); + return; + } + + setClientValidationErrors([]); + const requestBoardId = boardId; + const submittedSource = model.pendingSaveSource; + setSaving(true); + try { + const result = await api.workflow.saveBoardDefinition({ + boardId: requestBoardId, + definition: submittedDefinition, + expectedVersionHash: versionHash ?? "", + ...(submittedSource === undefined ? {} : { source: submittedSource }), + }); + + if (!isCurrentBoardRequest(requestBoardId)) { + return; + } + + if (!result.ok) { + setClientValidationErrors([]); + if ("lintErrors" in result) { + setModel((current) => + current ? setWorkflowLintErrors(current, result.lintErrors) : current, + ); + return; + } + if ("conflict" in result) { + setSaveError({ + message: "This board changed elsewhere. Reload to review the latest version.", + conflictVersionHash: result.currentVersionHash, + }); + return; + } + return; + } + + setModel((current) => { + const next = current + ? markWorkflowSavedIfUnchanged(current, submittedDefinition, result.definition) + : createWorkflowEditorModel(result.definition); + setSelection( + (currentSelection) => + normalizeSelection(next, currentSelection) ?? getDefaultSelection(next.definition), + ); + return next; + }); + setVersionHash(result.versionHash); + setSaveError(null); + setPendingRevert(null); + onSaved?.(result.snapshot, result.definition); + } catch (error: unknown) { + if (!isCurrentBoardRequest(requestBoardId)) { + return; + } + setSaveError({ message: error instanceof Error ? error.message : String(error) }); + } finally { + if (isCurrentBoardRequest(requestBoardId)) { + setSaving(false); + } + } + }; + + if (loadingError) { + return ( + <aside className="flex h-full min-h-0 flex-col bg-background" aria-label="Workflow editor"> + <header className="flex items-center justify-between gap-3 border-b border-border px-4 py-3"> + <h2 className="text-sm font-semibold text-foreground">Workflow editor</h2> + {onClose ? ( + <Button size="icon-sm" variant="ghost" aria-label="Close editor" onClick={onClose}> + <XIcon className="size-4" /> + </Button> + ) : null} + </header> + <div className="p-4 text-sm text-destructive">{loadingError}</div> + </aside> + ); + } + + if (!model) { + return ( + <aside className="flex h-full min-h-0 flex-col bg-background" aria-label="Workflow editor"> + <header className="flex items-center justify-between gap-3 border-b border-border px-4 py-3"> + <h2 className="text-sm font-semibold text-foreground">Workflow editor</h2> + {onClose ? ( + <Button size="icon-sm" variant="ghost" aria-label="Close editor" onClick={onClose}> + <XIcon className="size-4" /> + </Button> + ) : null} + </header> + <div className="p-4 text-sm text-muted-foreground">Loading workflow...</div> + </aside> + ); + } + + const revertDisabledReason = model.dirty + ? "Save or discard changes before reverting." + : undefined; + + return ( + <aside className="flex h-full min-h-0 flex-col bg-background" aria-label="Workflow editor"> + <header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3"> + <div className="min-w-0"> + <h2 className="truncate text-sm font-semibold text-foreground"> + {model.definition.name} + </h2> + <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> + {model.dirty ? ( + <span className="font-medium text-warning">Unsaved changes</span> + ) : ( + <span>Saved</span> + )} + {versionHash ? <span>Version {versionHash}</span> : null} + </div> + </div> + <div className="flex shrink-0 items-center gap-2"> + <div + role="group" + aria-label="Workflow editor view" + className="flex rounded-lg border border-border bg-muted/30 p-0.5" + > + <Button + size="sm" + variant={viewMode === "canvas" ? "secondary" : "ghost"} + aria-pressed={viewMode === "canvas"} + onClick={() => setViewMode("canvas")} + > + Canvas + </Button> + <Button + size="sm" + variant={viewMode === "form" ? "secondary" : "ghost"} + aria-pressed={viewMode === "form"} + onClick={() => setViewMode("form")} + > + Form + </Button> + </div> + <Button + size="sm" + variant={dryRunOpen ? "secondary" : "outline"} + onClick={() => setDryRunOpen((open) => !open)} + > + <FlaskConicalIcon className="size-4" /> + Dry run + </Button> + <Button + size="sm" + variant={historyOpen ? "secondary" : "outline"} + onClick={() => setHistoryOpen((open) => !open)} + > + <HistoryIcon className="size-4" /> + History + </Button> + <Button + size="sm" + variant="outline" + aria-haspopup="dialog" + onClick={applySourcesWizardTrigger} + > + <DatabaseIcon className="size-4" /> + Sources + </Button> + <Button + size="sm" + variant="outline" + onClick={() => { + const name = model.baselineDefinition.name; + const slug = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "board"; + downloadJson(`${slug}.json`, model.baselineDefinition); + }} + > + <DownloadIcon className="size-4" /> + Export JSON + </Button> + <Button + size="sm" + variant="outline" + disabled={!model.dirty || saving} + onClick={handleDiscard} + > + <Undo2Icon className="size-4" /> + Discard + </Button> + <Button size="sm" disabled={!model.dirty || saving} onClick={() => void handleSave()}> + <SaveIcon className="size-4" /> + {saving ? "Saving..." : "Save workflow"} + </Button> + {onClose ? ( + <Button size="icon-sm" variant="ghost" aria-label="Close editor" onClick={onClose}> + <XIcon className="size-4" /> + </Button> + ) : null} + </div> + </header> + {pendingRevert ? ( + <div className="border-b border-border bg-warning/8 px-4 py-2 text-sm text-warning-foreground"> + Reverting to v{pendingRevert.versionId} ({formatVersionTime(pendingRevert.createdAt)}) - + review and Save to apply. + </div> + ) : null} + {dryRunOpen ? ( + <DryRunPanel + definition={model.definition} + onDryRun={(input) => + api.workflow.dryRunBoard({ + definition: model.definition, + startLane: LaneKey.make(input.startLane), + scenario: input.scenario, + }) + } + onClose={() => setDryRunOpen(false)} + /> + ) : null} + {historyOpen ? ( + <VersionHistoryPanel + api={api} + boardId={boardId} + currentDefinition={model.definition} + disabled={saving || model.dirty} + revertDisabledReason={revertDisabledReason} + onClose={() => setHistoryOpen(false)} + onRevert={handleRevertVersion} + /> + ) : null} + {saveError ? ( + <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-destructive/8 px-4 py-2 text-sm text-destructive"> + <span> + {saveError.message} + {saveError.conflictVersionHash ? ( + <span className="sr-only"> Current version {saveError.conflictVersionHash}</span> + ) : null} + </span> + {saveError.conflictVersionHash ? ( + <Button size="sm" variant="outline" onClick={() => loadBoardDefinition()}> + Reload workflow + </Button> + ) : null} + </div> + ) : null} + {clientValidationErrors.length > 0 ? ( + <div className="border-b border-border bg-warning/8 px-4 py-2"> + <ul className="space-y-1 text-sm text-warning-foreground"> + {clientValidationErrors.map((message) => ( + <li key={message}>{message}</li> + ))} + </ul> + </div> + ) : null} + {boardLintErrors.length > 0 ? ( + <div className="border-b border-border bg-warning/8 px-4 py-2"> + <ul className="space-y-1 text-sm text-warning-foreground"> + {boardLintErrors.map((lintError) => ( + <li key={lintErrorKey(lintError)}>{lintError.message}</li> + ))} + </ul> + </div> + ) : null} + {viewMode === "form" ? ( + <div className="grid min-h-0 flex-1 grid-cols-[minmax(13rem,16rem)_minmax(0,1fr)] overflow-hidden max-md:grid-cols-1"> + <LaneList + lanes={model.definition.lanes} + lintErrors={model.lintErrors} + selectedLaneKey={selectedLane ? String(selectedLane.key) : null} + disabled={saving} + onSelect={(laneKey) => setSelection({ kind: "lane", laneKey })} + onAdd={() => { + setModel((current) => { + if (!current) { + return current; + } + const next = addLane(current); + setSelection({ + kind: "lane", + laneKey: String(next.definition.lanes.at(-1)?.key ?? ""), + }); + return next; + }); + }} + /> + <div className="flex min-h-0 flex-col overflow-auto"> + {selectedLane ? ( + <LaneForm + model={model} + lane={selectedLane} + lanes={model.definition.lanes} + lintErrors={model.lintErrors} + disabled={saving} + onSelectLane={(laneKey) => setSelection({ kind: "lane", laneKey })} + onMutate={mutateModel} + /> + ) : ( + <div className="p-4 text-sm text-muted-foreground">Add a lane to start editing.</div> + )} + <div className="px-4 pb-4"> + <div ref={sourcesRef}> + <SourcesSection + definition={model.definition} + lanes={model.definition.lanes} + lintErrors={model.lintErrors} + disabled={saving} + onMutate={mutateModel} + listWorkSourceConnections={api.workflow.listWorkSourceConnections} + createWorkSourceConnection={api.workflow.createWorkSourceConnection} + triggerCreate={sourcesCreateTrigger} + /> + </div> + <OutboundSection + definition={model.definition} + lintErrors={model.lintErrors} + disabled={saving} + onMutate={mutateModel} + listOutboundConnections={api.workflow.listOutboundConnections} + /> + </div> + </div> + </div> + ) : ( + <CanvasView + model={model} + selection={selection} + disabled={saving} + onSelect={setSelection} + onMutate={mutateModel} + /> + )} + </aside> + ); +} + +function getDefaultSelection( + definition: WorkflowDefinitionEncoded, +): WorkflowEditorSelection | null { + const laneKey = + definition.lanes.find((lane) => (lane.pipeline?.length ?? 0) > 0)?.key ?? + definition.lanes[0]?.key; + return laneKey ? { kind: "lane", laneKey: String(laneKey) } : null; +} + +function validateWorkflowDefinitionForSave( + definition: WorkflowDefinitionEncoded, +): ReadonlyArray<string> { + const result = decodeWorkflowDefinitionForSave(definition, { errors: "all" }); + if (Exit.isSuccess(result)) { + return []; + } + + const fieldErrors = collectWorkflowFieldValidationErrors(definition); + return fieldErrors.length > 0 + ? fieldErrors + : [`Workflow definition is invalid: ${formatSchemaError(result.cause)}`]; +} + +function collectWorkflowFieldValidationErrors( + definition: WorkflowDefinitionEncoded, +): ReadonlyArray<string> { + const errors: string[] = []; + + for (const lane of definition.lanes) { + const laneKey = String(lane.key); + if (isBlank(lane.name)) { + errors.push(`Lane "${laneKey}" name is required.`); + } + if (lane.wipLimit !== undefined && !Number.isInteger(lane.wipLimit)) { + errors.push(`Lane "${laneKey}" WIP limit must be a whole number.`); + } + + for (const step of lane.pipeline ?? []) { + const stepKey = String(step.key); + if (step.type === "agent") { + if (isBlank(step.agent.instance)) { + errors.push(`Lane "${laneKey}" step "${stepKey}" agent instance is required.`); + } + if (isBlank(step.agent.model)) { + errors.push(`Lane "${laneKey}" step "${stepKey}" agent model is required.`); + } + if (typeof step.instruction === "object" && isBlank(step.instruction.file)) { + errors.push(`Lane "${laneKey}" step "${stepKey}" instruction file is required.`); + } + } + if (step.type === "script" && isBlank(step.run)) { + errors.push(`Lane "${laneKey}" step "${stepKey}" script command is required.`); + } + } + } + + if ( + definition.settings?.maxConcurrentTickets !== undefined && + !Number.isInteger(definition.settings.maxConcurrentTickets) + ) { + errors.push("Max concurrent tickets must be a whole number."); + } + + return errors; +} + +function isBlank(value: string): boolean { + return value.trim().length === 0; +} diff --git a/apps/web/src/components/board/editor/WorkflowEditorFullscreen.test.tsx b/apps/web/src/components/board/editor/WorkflowEditorFullscreen.test.tsx new file mode 100644 index 00000000000..abbc3a05ce3 --- /dev/null +++ b/apps/web/src/components/board/editor/WorkflowEditorFullscreen.test.tsx @@ -0,0 +1,30 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { WorkflowEditorFullscreen } from "./WorkflowEditorFullscreen"; + +describe("WorkflowEditorFullscreen", () => { + it("renders workflow editing as a full-screen surface instead of a side sheet", () => { + const markup = renderToStaticMarkup( + <WorkflowEditorFullscreen open onClose={() => {}}> + <div>Workflow editor content</div> + </WorkflowEditorFullscreen>, + ); + + expect(markup).toContain('role="dialog"'); + expect(markup).toContain('aria-modal="true"'); + expect(markup).toContain('data-workflow-editor-surface="fullscreen"'); + expect(markup).toContain("fixed inset-0"); + expect(markup).not.toContain('data-slot="sheet-popup"'); + }); + + it("does not render when closed", () => { + const markup = renderToStaticMarkup( + <WorkflowEditorFullscreen open={false} onClose={() => {}}> + <div>Workflow editor content</div> + </WorkflowEditorFullscreen>, + ); + + expect(markup).toBe(""); + }); +}); diff --git a/apps/web/src/components/board/editor/WorkflowEditorFullscreen.tsx b/apps/web/src/components/board/editor/WorkflowEditorFullscreen.tsx new file mode 100644 index 00000000000..71b8fe44416 --- /dev/null +++ b/apps/web/src/components/board/editor/WorkflowEditorFullscreen.tsx @@ -0,0 +1,132 @@ +import { type ReactNode, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export function WorkflowEditorFullscreen(props: { + readonly children: ReactNode; + readonly open: boolean; + readonly onClose: () => void; + /** Override the dialog's accessible name. Defaults to "Workflow editor". */ + readonly ariaLabel?: string | undefined; +}) { + const { ariaLabel = "Workflow editor", children, onClose, open } = props; + const containerRef = useRef<HTMLDivElement | null>(null); + + useEffect(() => { + if (!open) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose, open]); + + useEffect(() => { + if (!open) { + return; + } + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [open]); + + // Apply `inert` to the app root so screen-reader virtual/browse-mode cursors + // cannot reach background content while the dialog is open. This complements + // aria-modal="true" for assistive technologies (NVDA, JAWS, older VoiceOver) + // that do not honour aria-modal for inert-ing siblings automatically. + useEffect(() => { + if (!open) { + return; + } + const appRoot = document.getElementById("root"); + if (appRoot === null) { + return; + } + appRoot.setAttribute("inert", ""); + return () => { + appRoot.removeAttribute("inert"); + }; + }, [open]); + + // Focus management for the modal dialog: move focus into the dialog on open, + // trap Tab within it (so keyboard/AT users can't reach background UI), and + // restore focus to the previously-focused element on close. + useEffect(() => { + if (!open) { + return; + } + const previouslyFocused = document.activeElement as HTMLElement | null; + const container = containerRef.current; + container?.focus(); + + const handleTab = (event: KeyboardEvent) => { + if (event.key !== "Tab" || container === null) { + return; + } + const focusable = Array.from( + container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR), + ).filter((el) => el.offsetParent !== null || el === document.activeElement); + if (focusable.length === 0) { + event.preventDefault(); + container.focus(); + return; + } + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + const active = document.activeElement; + if (event.shiftKey) { + if (active === first || active === container) { + event.preventDefault(); + last.focus(); + } + } else if (active === last) { + event.preventDefault(); + first.focus(); + } + }; + + window.addEventListener("keydown", handleTab, true); + return () => { + window.removeEventListener("keydown", handleTab, true); + previouslyFocused?.focus?.(); + }; + }, [open]); + + if (!open) { + return null; + } + + const dialog = ( + <div + aria-label={ariaLabel} + aria-modal="true" + className="fixed inset-0 z-50 flex min-h-0 flex-col bg-background text-foreground wco:mt-[env(titlebar-area-height)] wco:h-[calc(100%-env(titlebar-area-height))]" + data-workflow-editor-surface="fullscreen" + ref={containerRef} + role="dialog" + tabIndex={-1} + > + <div className="flex min-h-0 flex-1 flex-col">{children}</div> + </div> + ); + + // Portal the dialog to document.body so it is a sibling of (not nested + // inside) the app root. Combined with the `inert` effect above, this ensures + // screen readers cannot reach background content in browse/virtual mode. + // Fallback: render inline when document is not available (SSR / test env). + if (typeof document === "undefined") { + return dialog; + } + return createPortal(dialog, document.body); +} diff --git a/apps/web/src/components/board/editor/agentStepSelection.test.ts b/apps/web/src/components/board/editor/agentStepSelection.test.ts new file mode 100644 index 00000000000..2bc2db51217 --- /dev/null +++ b/apps/web/src/components/board/editor/agentStepSelection.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { agentSelectionWithInstanceModel, agentSelectionWithOptions } from "./agentStepSelection"; + +const baseAgent = { + instance: "codex_main", + model: "gpt-5.5", + options: [{ id: "reasoningEffort", value: "high" as const }], +}; + +describe("agentSelectionWithInstanceModel", () => { + it("updates instance and model while preserving existing options", () => { + const next = agentSelectionWithInstanceModel(baseAgent, "claude_main", "claude-opus-4-6"); + expect(next).toEqual({ + instance: "claude_main", + model: "claude-opus-4-6", + options: [{ id: "reasoningEffort", value: "high" }], + }); + }); +}); + +describe("agentSelectionWithOptions", () => { + it("stores the provided option selections", () => { + const next = agentSelectionWithOptions({ instance: "codex_main", model: "gpt-5.5" }, [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]); + expect(next).toEqual({ + instance: "codex_main", + model: "gpt-5.5", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + }); + }); + + it("drops the options key when cleared to undefined", () => { + const next = agentSelectionWithOptions(baseAgent, undefined); + expect(next).toEqual({ instance: "codex_main", model: "gpt-5.5" }); + expect("options" in next).toBe(false); + }); + + it("drops the options key when given an empty selection", () => { + const next = agentSelectionWithOptions(baseAgent, []); + expect(next).toEqual({ instance: "codex_main", model: "gpt-5.5" }); + expect("options" in next).toBe(false); + }); +}); diff --git a/apps/web/src/components/board/editor/agentStepSelection.ts b/apps/web/src/components/board/editor/agentStepSelection.ts new file mode 100644 index 00000000000..6bc0b68a3af --- /dev/null +++ b/apps/web/src/components/board/editor/agentStepSelection.ts @@ -0,0 +1,91 @@ +import type { ProviderOptionSelection } from "@t3tools/contracts"; + +import type { WorkflowStepEncoded } from "./WorkflowEditor"; + +/** + * The encoded `agent` shape carried by an agent step in the workflow editor. + * Options are the canonical array of provider option selections (effort, + * thinking, fast mode, …) — the same shape the chat composer dispatches. + */ +export type AgentSelectionEncoded = Extract< + WorkflowStepEncoded, + { readonly type: "agent" } +>["agent"]; + +/** + * Apply an instance + model change from the provider/model picker, preserving + * any existing option selections. The effort picker only surfaces options valid + * for the active model, and the provider ignores unknown option ids, so stale + * selections after a model switch are harmless rather than something to discard. + */ +export function agentSelectionWithInstanceModel( + agent: AgentSelectionEncoded, + instance: string, + model: string, +): AgentSelectionEncoded { + return { ...agent, instance, model }; +} + +/** + * Apply an effort/traits change. An empty or absent selection drops the + * `options` key entirely so the persisted definition stays minimal and matches + * the "no options" shape rather than persisting an empty array. + */ +export function agentSelectionWithOptions( + agent: AgentSelectionEncoded, + options: ReadonlyArray<ProviderOptionSelection> | undefined, +): AgentSelectionEncoded { + if (options === undefined || options.length === 0) { + const { options: _dropped, ...rest } = agent; + return rest; + } + return { ...agent, options }; +} + +export type StepRetryEncoded = NonNullable< + Extract<WorkflowStepEncoded, { readonly type: "agent" }>["retry"] +>; + +/** + * Apply a retry attempt-count change. `undefined` disables retry entirely + * (drops the key); enabling retry preserves any existing escalation. + */ +export function retryWithMaxAttempts( + retry: StepRetryEncoded | undefined, + maxAttempts: number | undefined, +): StepRetryEncoded | undefined { + if (maxAttempts === undefined) { + return undefined; + } + return { ...retry, maxAttempts }; +} + +/** + * Toggle escalation on a retry policy. Enabling seeds the escalation with the + * step's current agent so the picker starts from a concrete selection. + */ +export function retryWithEscalation( + retry: StepRetryEncoded, + escalate: StepRetryEncoded["escalate"], +): StepRetryEncoded { + if (escalate === undefined) { + const { escalate: _dropped, ...rest } = retry; + return rest; + } + return { ...retry, escalate }; +} + +/** + * Apply an effort/traits change to the escalation selection, dropping empty + * option arrays the same way `agentSelectionWithOptions` does. + */ +export function escalationWithOptions( + escalate: NonNullable<StepRetryEncoded["escalate"]>, + options: Parameters<typeof agentSelectionWithOptions>[1], +): NonNullable<StepRetryEncoded["escalate"]> { + if (options === undefined || options.length === 0) { + const { options: _dropped, ...rest } = escalate; + return rest; + } + return { ...escalate, options }; +} diff --git a/apps/web/src/components/board/editor/autoPullCriteria.test.ts b/apps/web/src/components/board/editor/autoPullCriteria.test.ts new file mode 100644 index 00000000000..6f2833528b3 --- /dev/null +++ b/apps/web/src/components/board/editor/autoPullCriteria.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + compileAutoPullRule, + summarizeAutoPull, + type AutoPullCriteria, +} from "@t3tools/contracts/workSource"; + +// Pure binding test for D1 — verifies that the criteria shapes the component +// works with produce correct compiled rules and summaries. The component itself +// is a controlled React component; dialog-interaction harness doesn't exist +// (same precedent as the shipped import picker), so we test the pure layer. + +describe("AutoPullCriteria → compileAutoPullRule (binding verification)", () => { + it("editing labels any-of [XS] yields the expected compiled rule", () => { + const criteria: AutoPullCriteria = { labels: { mode: "any", values: ["XS"] } }; + expect(compileAutoPullRule(criteria)).toEqual({ in: ["XS", { var: "labels" }] }); + }); + + it("editing labels any-of [XS, S] yields an or-rule", () => { + const criteria: AutoPullCriteria = { labels: { mode: "any", values: ["XS", "S"] } }; + expect(compileAutoPullRule(criteria)).toEqual({ + or: [{ in: ["XS", { var: "labels" }] }, { in: ["S", { var: "labels" }] }], + }); + }); + + it("labels all-of [A, B] yields an and-of-ins rule", () => { + const criteria: AutoPullCriteria = { labels: { mode: "all", values: ["A", "B"] } }; + expect(compileAutoPullRule(criteria)).toEqual({ + and: [{ in: ["A", { var: "labels" }] }, { in: ["B", { var: "labels" }] }], + }); + }); + + it("assignee anyone yields bare var rule", () => { + const criteria: AutoPullCriteria = { assignee: { kind: "anyone" } }; + expect(compileAutoPullRule(criteria)).toEqual({ var: "assignees" }); + }); + + it("assignee login yields in-rule", () => { + const criteria: AutoPullCriteria = { assignee: { kind: "login", value: "octocat" } }; + expect(compileAutoPullRule(criteria)).toEqual({ in: ["octocat", { var: "assignees" }] }); + }); + + it("state open yields an equality rule", () => { + const criteria: AutoPullCriteria = { state: "open" }; + expect(compileAutoPullRule(criteria)).toEqual({ "==": [{ var: "state" }, "open"] }); + }); + + it("empty criteria compiles to ALWAYS_RULE (true)", () => { + const criteria: AutoPullCriteria = {}; + expect(compileAutoPullRule(criteria)).toBe(true); + }); +}); + +describe("summarizeAutoPull", () => { + it("returns a sensible string for labels any-of XS", () => { + const summary = summarizeAutoPull({ labels: { mode: "any", values: ["XS"] } }); + expect(typeof summary).toBe("string"); + expect(summary.length).toBeGreaterThan(0); + // Should mention the label somewhere + expect(summary).toContain("XS"); + }); + + it("returns 'All issues' for empty criteria", () => { + expect(summarizeAutoPull({})).toBe("All issues"); + }); + + it("returns 'Manual only' for null criteria", () => { + expect(summarizeAutoPull(null)).toBe("Manual only"); + }); + + it("returns a string for combined labels + state", () => { + const summary = summarizeAutoPull({ + labels: { mode: "any", values: ["XS", "S"] }, + state: "open", + }); + expect(summary).toContain("XS"); + expect(summary).toContain("open"); + }); +}); diff --git a/apps/web/src/components/board/editor/canvas/CanvasView.browser.tsx b/apps/web/src/components/board/editor/canvas/CanvasView.browser.tsx new file mode 100644 index 00000000000..e13480dfb4d --- /dev/null +++ b/apps/web/src/components/board/editor/canvas/CanvasView.browser.tsx @@ -0,0 +1,880 @@ +import "../../../../index.css"; + +import { LaneKey, StepKey, type WorkflowDefinitionEncoded } from "@t3tools/contracts"; +import { page } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { useState } from "react"; +import { render } from "vitest-browser-react"; + +import { + adjustSelectionAfterTransitionRemoval, + createWorkflowEditorModel, + normalizeSelection, + removeTransition, + type WorkflowEditorModel, + type WorkflowEditorSelection, +} from "~/workflow/editorModel"; + +import { CanvasView } from "./CanvasView"; +import { routeDndId } from "./RoutingHandles"; +import { deriveRoutingEdges, routingEdgeTestId } from "./RoutingEdges"; + +const queueLaneKey = LaneKey.make("queue"); +const runLaneKey = LaneKey.make("run"); +const doneLaneKey = LaneKey.make("done"); +const reviewStepKey = StepKey.make("review"); + +const definition = { + name: "Delivery", + lanes: [ + { key: queueLaneKey, name: "Queue", entry: "manual" }, + { + key: runLaneKey, + name: "Run", + entry: "auto", + wipLimit: 2, + pipeline: [ + { + key: reviewStepKey, + type: "agent", + agent: { instance: "codex_main", model: "gpt-5.5" }, + instruction: "Review the diff.", + on: { success: doneLaneKey }, + }, + ], + transitions: [{ when: { var: "ticket.priority" }, to: queueLaneKey }], + on: { success: doneLaneKey, failure: runLaneKey }, + }, + { key: doneLaneKey, name: "Done", entry: "manual", terminal: true }, + ], +} satisfies WorkflowDefinitionEncoded; + +const multiTransitionDefinition = { + ...definition, + lanes: definition.lanes.map((lane) => + lane.key === runLaneKey + ? { + ...lane, + transitions: [ + { when: { "==": [{ var: "ticket.status" }, "queued"] }, to: queueLaneKey }, + { when: { "==": [{ var: "ticket.status" }, "done"] }, to: doneLaneKey }, + { when: { "==": [{ var: "ticket.status" }, "retry"] }, to: queueLaneKey }, + ], + } + : lane, + ), +} satisfies WorkflowDefinitionEncoded; + +const duplicateTransitionDefinition = { + ...definition, + lanes: definition.lanes.map((lane) => + lane.key === runLaneKey + ? { + ...lane, + transitions: [ + { when: { var: "pipeline.result" }, to: doneLaneKey }, + { when: { var: "pipeline.result" }, to: doneLaneKey }, + ], + } + : lane, + ), +} satisfies WorkflowDefinitionEncoded; + +const collidingRouteHandleDefinition = { + name: "Colliding route handles", + lanes: [ + { + key: LaneKey.make("a:b"), + name: "Lane A Colon", + entry: "manual", + pipeline: [ + { + key: StepKey.make("c"), + type: "approval", + }, + ], + }, + { + key: LaneKey.make("a"), + name: "Lane A Plain", + entry: "manual", + pipeline: [ + { + key: StepKey.make("b:c"), + type: "approval", + }, + ], + }, + ], +} satisfies WorkflowDefinitionEncoded; + +const collidingEdgeIdentityDefinition = { + name: "Colliding edge identities", + lanes: [ + { key: LaneKey.make("target"), name: "Target", entry: "manual" }, + { + key: LaneKey.make("a:b"), + name: "Lane A Colon", + entry: "manual", + pipeline: [ + { + key: StepKey.make("c"), + type: "approval", + on: { success: LaneKey.make("target") }, + }, + ], + }, + { + key: LaneKey.make("a"), + name: "Lane A Plain", + entry: "manual", + pipeline: [ + { + key: StepKey.make("b:c"), + type: "approval", + on: { success: LaneKey.make("target") }, + }, + ], + }, + ], +} satisfies WorkflowDefinitionEncoded; + +const overlappingEdgeLabelDefinition = { + name: "Overlapping edge labels", + lanes: [ + { key: queueLaneKey, name: "Queue", entry: "manual" }, + { + key: runLaneKey, + name: "Run", + entry: "auto", + transitions: [{ when: { var: "ticket.priority" }, to: queueLaneKey }], + on: { success: queueLaneKey }, + }, + ], +} satisfies WorkflowDefinitionEncoded; + +const clickElementById = async (id: string) => { + await vi.waitFor(() => { + expect(document.getElementById(id)).not.toBeNull(); + }); + const element = document.getElementById(id); + element?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); +}; + +const edgeSelector = (parts: Parameters<typeof routingEdgeTestId>[0]): string => + `[data-testid=${CSS.escape(routingEdgeTestId(parts))}]`; + +describe("CanvasView", () => { + it("derives opaque edge ids for lane and step keys containing separators", () => { + const stepEdges = deriveRoutingEdges(collidingEdgeIdentityDefinition).filter( + (edge) => edge.edgeKind === "step-on", + ); + + expect(stepEdges.map((edge) => edge.id)).toEqual([ + routeDndId(["workflow-edge", "step-on", "a:b", "c", "success", "target"]), + routeDndId(["workflow-edge", "step-on", "a", "b:c", "success", "target"]), + ]); + expect(stepEdges.map((edge) => edge.testId)).toEqual([ + routeDndId(["workflow-edge-testid", "step-on", "a:b", "c", "success", "target"]), + routeDndId(["workflow-edge-testid", "step-on", "a", "b:c", "success", "target"]), + ]); + expect(new Set(stepEdges.map((edge) => edge.id)).size).toBe(2); + expect(new Set(stepEdges.map((edge) => edge.testId)).size).toBe(2); + }); + + it("renders lane cards, step blocks, precedence legend, route edges, and self-loops", async () => { + render( + <CanvasView + model={createWorkflowEditorModel(definition)} + selection={null} + disabled={false} + onMutate={() => {}} + onSelect={vi.fn()} + />, + ); + + await expect.element(page.getByRole("region", { name: "Workflow canvas" })).toBeInTheDocument(); + await expect.element(page.getByRole("group", { name: "Lane Run" })).toBeInTheDocument(); + await expect.element(page.getByText("entry auto")).toBeInTheDocument(); + await expect.element(page.getByText("WIP 2")).toBeInTheDocument(); + await expect.element(page.getByText("terminal")).toBeInTheDocument(); + await expect.element(page.getByRole("group", { name: "Step review" })).toBeInTheDocument(); + expect(document.querySelector('[data-step-type="agent"]')).not.toBeNull(); + await expect.element(page.getByText("Review the diff.")).toBeInTheDocument(); + + await expect.element(page.getByText("Routing precedence")).toBeInTheDocument(); + await expect + .element(page.getByText("Step routes > transitions > lane fallback")) + .toBeInTheDocument(); + + const stepEdge = document.querySelector( + edgeSelector(["step-on", "run", "review", "success", "done"]), + ); + const transitionEdge = document.querySelector( + edgeSelector(["transition", "run", "0", "queue"]), + ); + const laneEdge = document.querySelector(edgeSelector(["lane-on", "run", "success", "done"])); + const selfLoop = document.querySelector(edgeSelector(["lane-on", "run", "failure", "run"])); + + expect(stepEdge?.getAttribute("data-edge-kind")).toBe("step-on"); + expect(stepEdge?.getAttribute("data-precedence")).toBe("1"); + expect(transitionEdge?.getAttribute("data-edge-kind")).toBe("lane-transition"); + expect(transitionEdge?.getAttribute("data-precedence")).toBe("2"); + expect(transitionEdge?.getAttribute("aria-label")).toBe("Transition 1 from Run to Queue"); + expect(laneEdge?.getAttribute("data-edge-kind")).toBe("lane-on"); + expect(laneEdge?.getAttribute("data-precedence")).toBe("3"); + expect(laneEdge?.getAttribute("stroke-dasharray")).toBe("6 4"); + expect(selfLoop?.getAttribute("data-self-loop")).toBe("true"); + const edgeOrder = Array.from(document.querySelectorAll("svg path")).map((element) => + element.getAttribute("data-testid"), + ); + expect( + edgeOrder.indexOf(routingEdgeTestId(["lane-on", "run", "success", "done"])), + ).toBeLessThan(edgeOrder.indexOf(routingEdgeTestId(["transition", "run", "0", "queue"]))); + expect(edgeOrder.indexOf(routingEdgeTestId(["transition", "run", "0", "queue"]))).toBeLessThan( + edgeOrder.indexOf(routingEdgeTestId(["step-on", "run", "review", "success", "done"])), + ); + + await expect.element(page.getByText("#1")).toBeInTheDocument(); + expect( + Array.from(document.querySelectorAll("svg text")).some( + (element) => element.textContent === "success", + ), + ).toBe(true); + }); + + it("staggers labels for edges that share the same midpoint", async () => { + render( + <CanvasView + model={createWorkflowEditorModel(overlappingEdgeLabelDefinition)} + selection={null} + disabled={false} + onMutate={() => {}} + onSelect={vi.fn()} + />, + ); + + await vi.waitFor(() => { + expect( + document.querySelector(edgeSelector(["transition", "run", "0", "queue"])), + ).not.toBeNull(); + expect( + document.querySelector(edgeSelector(["lane-on", "run", "success", "queue"])), + ).not.toBeNull(); + }); + + const labelPositions = svgTextPositions(); + expect(labelPositions.get("#1")?.y).not.toBe(labelPositions.get("success")?.y); + }); + + it("dims edges unrelated to the selected lane and raises connected ones", async () => { + render( + <CanvasView + model={createWorkflowEditorModel(definition)} + selection={{ kind: "lane", laneKey: "queue" }} + disabled={false} + onMutate={() => {}} + onSelect={vi.fn()} + />, + ); + + await vi.waitFor(() => { + expect( + document.querySelector(edgeSelector(["transition", "run", "0", "queue"])), + ).not.toBeNull(); + }); + + const intoQueue = document.querySelector(edgeSelector(["transition", "run", "0", "queue"])); + const unrelated = document.querySelector( + edgeSelector(["step-on", "run", "review", "success", "done"]), + ); + expect(intoQueue?.closest("g")?.getAttribute("data-dimmed")).toBeNull(); + expect(unrelated?.closest("g")?.getAttribute("data-dimmed")).toBe("true"); + + // Dimmed edges render first so the selected lane's wiring sits on top. + const edgeOrder = Array.from(document.querySelectorAll("svg path")).map((element) => + element.getAttribute("data-testid"), + ); + expect( + edgeOrder.indexOf(routingEdgeTestId(["step-on", "run", "review", "success", "done"])), + ).toBeLessThan(edgeOrder.indexOf(routingEdgeTestId(["transition", "run", "0", "queue"]))); + }); + + it("selects canvas elements, renders a shared inspector, and adds lanes and steps", async () => { + function CanvasHarness() { + const [model, setModel] = useState<WorkflowEditorModel>(() => + createWorkflowEditorModel(definition), + ); + const [selection, setSelection] = useState<WorkflowEditorSelection | null>(null); + + return ( + <CanvasView + model={model} + selection={selection} + disabled={false} + onSelect={setSelection} + onMutate={(mutate, mutateSelection) => + setModel((current) => { + const next = mutate(current); + setSelection((currentSelection) => + normalizeSelection( + next, + mutateSelection ? mutateSelection(currentSelection) : currentSelection, + ), + ); + return next; + }) + } + /> + ); + } + + render(<CanvasHarness />); + + await clickElementById("lane-run"); + await expect.element(page.getByLabelText("Lane name")).toHaveValue("Run"); + + await clickElementById("step-run-review"); + await expect + .element(page.getByLabelText("Step review instruction")) + .toHaveValue("Review the diff."); + + document + .querySelector(edgeSelector(["transition", "run", "0", "queue"])) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await expect + .element(page.getByLabelText("Transition 1 predicate JSON")) + .toHaveValue(JSON.stringify({ var: "ticket.priority" }, null, 2)); + + await page.getByTestId("workflow-canvas-surface").click({ position: { x: 8, y: 260 } }); + await expect + .element(page.getByText("Select a lane, step, or route to edit.")) + .toBeInTheDocument(); + + await page.getByRole("button", { name: "Add lane" }).click(); + await expect.element(page.getByRole("group", { name: "Lane New lane" })).toBeInTheDocument(); + await expect.element(page.getByLabelText("Lane name")).toHaveValue("New lane"); + + await clickElementById("lane-run"); + await page.getByRole("button", { name: "Add agent step to Run" }).click(); + await expect.element(page.getByRole("group", { name: "Step agent" })).toBeInTheDocument(); + await expect.element(page.getByLabelText("Step agent instruction")).toBeInTheDocument(); + + await clickElementById("lane-run"); + await page.getByLabelText("Lane success route").selectOptions("queue"); + await expect + .element(page.getByText("Unsaved canvas changes", { exact: true })) + .toBeInTheDocument(); + expect( + document.querySelector(edgeSelector(["lane-on", "run", "success", "queue"])), + ).not.toBeNull(); + }); + + it("renders draggable lane routing handles and keeps inspector routing as the fallback", async () => { + function CanvasHarness() { + const [model, setModel] = useState<WorkflowEditorModel>(() => + createWorkflowEditorModel(definition), + ); + const [selection, setSelection] = useState<WorkflowEditorSelection | null>(null); + + return ( + <CanvasView + model={model} + selection={selection} + disabled={false} + onSelect={setSelection} + onMutate={(mutate, mutateSelection) => + setModel((current) => { + const next = mutate(current); + setSelection((currentSelection) => + normalizeSelection( + next, + mutateSelection ? mutateSelection(currentSelection) : currentSelection, + ), + ); + return next; + }) + } + /> + ); + } + + render(<CanvasHarness />); + + await expect + .element(page.getByRole("button", { name: "Drag success route from Run" })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Clear success route from Run" })) + .toBeInTheDocument(); + await expect.element(page.getByTestId("lane-drop-run")).toBeInTheDocument(); + + await clickElementById("lane-run"); + await page.getByLabelText("Lane blocked route").selectOptions("queue"); + expect( + document.querySelector(edgeSelector(["lane-on", "run", "blocked", "queue"])), + ).not.toBeNull(); + }); + + it("falls back to lane selection after removing the selected transition", async () => { + function CanvasHarness() { + const [model, setModel] = useState<WorkflowEditorModel>(() => + createWorkflowEditorModel(multiTransitionDefinition), + ); + const [selection, setSelection] = useState<WorkflowEditorSelection | null>(null); + + return ( + <CanvasView + model={model} + selection={selection} + disabled={false} + onSelect={setSelection} + onMutate={(mutate, mutateSelection) => + setModel((current) => { + const next = mutate(current); + setSelection((currentSelection) => + normalizeSelection( + next, + mutateSelection ? mutateSelection(currentSelection) : currentSelection, + ), + ); + return next; + }) + } + /> + ); + } + + render(<CanvasHarness />); + + await vi.waitFor(() => { + expect( + document.querySelector(edgeSelector(["transition", "run", "1", "done"])), + ).not.toBeNull(); + }); + document + .querySelector(edgeSelector(["transition", "run", "1", "done"])) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await expect + .element(page.getByLabelText("Transition 2 predicate JSON")) + .toHaveValue(JSON.stringify({ "==": [{ var: "ticket.status" }, "done"] }, null, 2)); + + await page.getByRole("button", { name: "Remove transition 2" }).click(); + + await expect.element(page.getByLabelText("Lane name")).toHaveValue("Run"); + }); + + it("keeps the transition inspector selected after editing the selected transition", async () => { + function CanvasHarness() { + const [model, setModel] = useState<WorkflowEditorModel>(() => + createWorkflowEditorModel(definition), + ); + const [selection, setSelection] = useState<WorkflowEditorSelection | null>(null); + + return ( + <CanvasView + model={model} + selection={selection} + disabled={false} + onSelect={setSelection} + onMutate={(mutate, mutateSelection) => + setModel((current) => { + const next = mutate(current); + setSelection((currentSelection) => + normalizeSelection( + next, + mutateSelection ? mutateSelection(currentSelection) : currentSelection, + ), + ); + return next; + }) + } + /> + ); + } + + render(<CanvasHarness />); + + await vi.waitFor(() => { + expect( + document.querySelector(edgeSelector(["transition", "run", "0", "queue"])), + ).not.toBeNull(); + }); + document + .querySelector(edgeSelector(["transition", "run", "0", "queue"])) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const nextPredicate = JSON.stringify({ var: "ticket.status" }, null, 2); + await page.getByLabelText("Transition 1 predicate JSON").fill(nextPredicate); + await expect + .element(page.getByLabelText("Transition 1 predicate JSON")) + .toHaveValue(nextPredicate); + + await page.getByLabelText("Transition 1 target lane").selectOptions("done"); + await expect.element(page.getByLabelText("Transition 1 target lane")).toHaveValue("done"); + }); + + it("falls back to the lane after removing the selected duplicate transition", async () => { + function CanvasHarness() { + const [model, setModel] = useState<WorkflowEditorModel>(() => + createWorkflowEditorModel(duplicateTransitionDefinition), + ); + const [selection, setSelection] = useState<WorkflowEditorSelection | null>(null); + + return ( + <CanvasView + model={model} + selection={selection} + disabled={false} + onSelect={setSelection} + onMutate={(mutate, mutateSelection) => + setModel((current) => { + const next = mutate(current); + setSelection((currentSelection) => + normalizeSelection( + next, + mutateSelection ? mutateSelection(currentSelection) : currentSelection, + ), + ); + return next; + }) + } + /> + ); + } + + render(<CanvasHarness />); + + await vi.waitFor(() => { + expect( + document.querySelector(edgeSelector(["transition", "run", "1", "done"])), + ).not.toBeNull(); + }); + document + .querySelector(edgeSelector(["transition", "run", "1", "done"])) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await expect + .element(page.getByLabelText("Transition 2 predicate JSON")) + .toHaveValue(JSON.stringify({ var: "pipeline.result" }, null, 2)); + + await page.getByRole("button", { name: "Remove transition 2" }).click(); + + await expect.element(page.getByLabelText("Lane name")).toHaveValue("Run"); + }); + + it("keeps the selected transition inspector after removing an earlier transition", async () => { + function CanvasHarness() { + const [model, setModel] = useState<WorkflowEditorModel>(() => + createWorkflowEditorModel(multiTransitionDefinition), + ); + const [selection, setSelection] = useState<WorkflowEditorSelection | null>(null); + + const removeFirstTransition = () => { + setModel((current) => { + const next = removeTransition(current, "run", 0); + setSelection((currentSelection) => + normalizeSelection( + next, + adjustSelectionAfterTransitionRemoval(currentSelection, "run", 0), + ), + ); + return next; + }); + }; + + return ( + <> + <button type="button" onClick={removeFirstTransition}> + Remove first transition + </button> + <CanvasView + model={model} + selection={selection} + disabled={false} + onSelect={setSelection} + onMutate={(mutate, mutateSelection) => + setModel((current) => { + const next = mutate(current); + setSelection((currentSelection) => + normalizeSelection( + next, + mutateSelection ? mutateSelection(currentSelection) : currentSelection, + ), + ); + return next; + }) + } + /> + </> + ); + } + + render(<CanvasHarness />); + + await vi.waitFor(() => { + expect( + document.querySelector(edgeSelector(["transition", "run", "1", "done"])), + ).not.toBeNull(); + }); + document + .querySelector(edgeSelector(["transition", "run", "1", "done"])) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await expect + .element(page.getByLabelText("Transition 2 predicate JSON")) + .toHaveValue(JSON.stringify({ "==": [{ var: "ticket.status" }, "done"] }, null, 2)); + + await page.getByRole("button", { name: "Remove first transition" }).click(); + + await expect + .element(page.getByLabelText("Transition 1 predicate JSON")) + .toHaveValue(JSON.stringify({ "==": [{ var: "ticket.status" }, "done"] }, null, 2)); + }); + + it("connects a step route by dragging a step handle onto a lane", async () => { + function CanvasHarness() { + const [model, setModel] = useState<WorkflowEditorModel>(() => + createWorkflowEditorModel(definition), + ); + const [selection, setSelection] = useState<WorkflowEditorSelection | null>(null); + + return ( + <CanvasView + model={model} + selection={selection} + disabled={false} + onSelect={setSelection} + onMutate={(mutate, mutateSelection) => + setModel((current) => { + const next = mutate(current); + setSelection((currentSelection) => + normalizeSelection( + next, + mutateSelection ? mutateSelection(currentSelection) : currentSelection, + ), + ); + return next; + }) + } + /> + ); + } + + render(<CanvasHarness />); + + await expect + .element(page.getByRole("button", { name: "Drag success route from step review in Run" })) + .toBeInTheDocument(); + + await page + .getByRole("button", { name: "Drag success route from step review in Run" }) + .dropTo(page.getByTestId("lane-drop-run")); + + await vi.waitFor(() => { + expect( + document.querySelector(edgeSelector(["step-on", "run", "review", "success", "run"])), + ).not.toBeNull(); + }); + expect( + document.querySelector(edgeSelector(["step-on", "run", "review", "success", "done"])), + ).toBeNull(); + }); + + it("routes the exact step handle when old colon-joined dnd ids would collide", async () => { + function CanvasHarness() { + const [model, setModel] = useState<WorkflowEditorModel>(() => + createWorkflowEditorModel(collidingRouteHandleDefinition), + ); + const [selection, setSelection] = useState<WorkflowEditorSelection | null>(null); + + return ( + <CanvasView + model={model} + selection={selection} + disabled={false} + onSelect={setSelection} + onMutate={(mutate, mutateSelection) => + setModel((current) => { + const next = mutate(current); + setSelection((currentSelection) => + normalizeSelection( + next, + mutateSelection ? mutateSelection(currentSelection) : currentSelection, + ), + ); + return next; + }) + } + /> + ); + } + + render(<CanvasHarness />); + + await expect + .element(page.getByRole("button", { name: "Drag success route from step c in Lane A Colon" })) + .toBeInTheDocument(); + await expect + .element( + page.getByRole("button", { name: "Drag success route from step b:c in Lane A Plain" }), + ) + .toBeInTheDocument(); + + await page + .getByRole("button", { name: "Drag success route from step c in Lane A Colon" }) + .dropTo(page.getByTestId("lane-drop-a:b")); + + await vi.waitFor(() => { + if (document.querySelector(edgeSelector(["step-on", "a:b", "c", "success", "a:b"]))) { + return; + } + throw new Error(`Expected first colliding route edge. Found edges: ${edgeTestIds()}`); + }); + expect( + document.querySelector(edgeSelector(["step-on", "a", "b:c", "success", "a:b"])), + ).toBeNull(); + + await page + .getByRole("button", { name: "Drag success route from step b:c in Lane A Plain" }) + .dropTo(page.getByTestId("lane-drop-a:b")); + + await vi.waitFor(() => { + if (document.querySelector(edgeSelector(["step-on", "a", "b:c", "success", "a"]))) { + return; + } + throw new Error(`Expected second colliding route edge. Found edges: ${edgeTestIds()}`); + }); + expect( + document.querySelector(edgeSelector(["step-on", "a:b", "c", "success", "a:b"])), + ).not.toBeNull(); + }); + + it("renders dotted action edges from a lane to its action targets", async () => { + const actionDefinition = { + ...definition, + lanes: definition.lanes.map((lane) => + lane.key === queueLaneKey + ? { + ...lane, + actions: [{ label: "Start work", to: runLaneKey, hint: "Kick off the pipeline." }], + } + : lane, + ), + } as WorkflowDefinitionEncoded; + const [model] = [createWorkflowEditorModel(actionDefinition)]; + + render( + <div style={{ width: 1100, height: 900 }}> + <CanvasView + model={model} + selection={null} + disabled={false} + onSelect={() => {}} + onMutate={() => {}} + /> + </div>, + ); + + const edge = await vi.waitFor(() => { + const element = document.querySelector<SVGPathElement>( + edgeSelector(["lane-action", "queue", "0", "run"]), + ); + if (!element) { + throw new Error("Expected queue action edge."); + } + return element; + }); + expect(edge.getAttribute("data-edge-kind")).toBe("lane-action"); + expect(edge.getAttribute("stroke-dasharray")).toBe("2 4"); + await expect.element(page.getByText("Start work").first()).toBeVisible(); + }); + + it("lays lanes out by routing depth and pins edges to measured anchors", async () => { + const [model] = [createWorkflowEditorModel(definition)]; + + render( + <div style={{ width: 1100, height: 900 }}> + <CanvasView + model={model} + selection={null} + disabled={false} + onSelect={() => {}} + onMutate={() => {}} + /> + </div>, + ); + + const edge = await vi.waitFor(() => { + const element = document.querySelector<SVGPathElement>( + edgeSelector(["lane-on", "run", "success", "done"]), + ); + if (!element) { + throw new Error("Expected run success lane edge."); + } + return element; + }); + + // Topological columns: queue and run are both roots (run's only inbound + // edge is a back-transition), so they stack in column 0; done sits one + // column to the right because run routes into it. + await vi.waitFor(() => { + const queueRect = document.getElementById("lane-queue")?.getBoundingClientRect(); + const runRect = document.getElementById("lane-run")?.getBoundingClientRect(); + const doneRect = document.getElementById("lane-done")?.getBoundingClientRect(); + if (!queueRect || !runRect || !doneRect) { + throw new Error("Expected lane rects."); + } + expect(Math.abs(runRect.left - queueRect.left)).toBeLessThan(1); + expect(runRect.top).toBeGreaterThan(queueRect.bottom); + expect(doneRect.left).toBeGreaterThan(runRect.right); + }); + + // Forward edges enter the target lane through its facing (left) edge. + await vi.waitFor(() => { + const endpoint = pathEndpoint(edge.getAttribute("d") ?? ""); + const surface = document.querySelector('[data-testid="workflow-canvas-surface"]'); + const done = document.getElementById("lane-done"); + if (!surface || !done) { + throw new Error("Expected canvas surface and done lane."); + } + const surfaceRect = surface.getBoundingClientRect(); + const doneRect = done.getBoundingClientRect(); + expect(Math.abs(endpoint.x - (doneRect.left - surfaceRect.left))).toBeLessThan(2); + expect(endpoint.y).toBeGreaterThan(doneRect.top - surfaceRect.top); + expect(endpoint.y).toBeLessThan(doneRect.bottom - surfaceRect.top); + }); + }); +}); + +function pathEndpoint(path: string): { x: number; y: number } { + const numbers = path.match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? []; + return { x: numbers.at(-2) ?? Number.NaN, y: numbers.at(-1) ?? Number.NaN }; +} + +function anchorCenter(anchorId: string): { x: number; y: number } { + const surface = document.querySelector('[data-testid="workflow-canvas-surface"]'); + const anchor = document.getElementById(anchorId); + if (!surface || !anchor) { + throw new Error(`Missing anchor test elements for ${anchorId}.`); + } + const surfaceRect = surface.getBoundingClientRect(); + const anchorRect = anchor.getBoundingClientRect(); + return { + x: anchorRect.left - surfaceRect.left + anchorRect.width / 2, + y: anchorRect.top - surfaceRect.top + anchorRect.height / 2, + }; +} + +function edgeTestIds(): string { + return Array.from(document.querySelectorAll("svg path")) + .map((element) => element.getAttribute("data-testid")) + .join(", "); +} + +function svgTextPositions(): Map<string, { x: string | null; y: string | null }> { + return new Map( + Array.from(document.querySelectorAll("svg text")).map((element) => [ + element.textContent ?? "", + { x: element.getAttribute("x"), y: element.getAttribute("y") }, + ]), + ); +} diff --git a/apps/web/src/components/board/editor/canvas/CanvasView.tsx b/apps/web/src/components/board/editor/canvas/CanvasView.tsx new file mode 100644 index 00000000000..195e859a67f --- /dev/null +++ b/apps/web/src/components/board/editor/canvas/CanvasView.tsx @@ -0,0 +1,580 @@ +import { LaneKey } from "@t3tools/contracts"; +import { + DndContext, + type DragEndEvent, + pointerWithin, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { PlusIcon, Undo2Icon } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { + addLane, + addStep, + setLaneOn, + updateStep, + type WorkflowEditorModel, + type WorkflowEditorSelection, +} from "~/workflow/editorModel"; + +import { LaneForm } from "../LaneForm"; +import { TransitionFields } from "../RoutingEditor"; +import { StepFields } from "../StepFields"; +import { LaneCard } from "./LaneCard"; +import { + LaneRouteClearDropZone, + readLaneMoveDragData, + resolveLaneRoutingDrop, + type LaneRoutingKind, +} from "./RoutingHandles"; +import { RoutingEdges, type CanvasAnchors } from "./RoutingEdges"; +import { + computeCanvasLayout, + LANE_CARD_WIDTH, + LANE_GAP_X, + LANE_GAP_Y, + type LaneHeights, + type LanePositions, +} from "./canvasLayout"; + +type WorkflowEditorSelectionMutation = ( + selection: WorkflowEditorSelection | null, +) => WorkflowEditorSelection | null; +type WorkflowEditorMutation = ( + mutate: (model: WorkflowEditorModel) => WorkflowEditorModel, + mutateSelection?: WorkflowEditorSelectionMutation, +) => void; +type WorkflowLaneEncoded = WorkflowEditorModel["definition"]["lanes"][number]; +type WorkflowStepType = NonNullable<WorkflowLaneEncoded["pipeline"]>[number]["type"]; + +export const canvasRouteCollisionDetection = pointerWithin; + +export interface CanvasViewProps { + readonly model: WorkflowEditorModel; + readonly selection: WorkflowEditorSelection | null; + readonly disabled?: boolean; + readonly onSelect: (selection: WorkflowEditorSelection | null) => void; + readonly onMutate: WorkflowEditorMutation; +} + +export function CanvasView({ + model, + selection, + disabled = false, + onSelect, + onMutate, +}: CanvasViewProps) { + const viewportRef = useRef<HTMLDivElement>(null); + const contentRef = useRef<HTMLDivElement>(null); + const [containerWidth, setContainerWidth] = useState( + LANE_CARD_WIDTH * Math.max(1, model.definition.lanes.length) + + LANE_GAP_X * Math.max(0, model.definition.lanes.length - 1), + ); + const [laneHeights, setLaneHeights] = useState<LaneHeights>({}); + const [lanePositions, setLanePositions] = useState<LanePositions>({}); + const [anchors, setAnchors] = useState<CanvasAnchors>({}); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + ); + const layout = useMemo( + () => computeCanvasLayout(model.definition, containerWidth, laneHeights, lanePositions), + [containerWidth, laneHeights, lanePositions, model.definition], + ); + const hasMovedLanes = Object.keys(lanePositions).length > 0; + const resetLaneLayout = useCallback(() => setLanePositions({}), []); + const layoutByLaneKey = useMemo( + () => new Map(layout.lanes.map((laneLayout) => [laneLayout.laneKey, laneLayout])), + [layout.lanes], + ); + const selectedStepKeyByLane = + selection?.kind === "step" ? { laneKey: selection.laneKey, stepKey: selection.stepKey } : null; + + const handleAddLane = () => { + onMutate((current) => { + const next = addLane(current); + const laneKey = String(next.definition.lanes.at(-1)?.key ?? ""); + if (laneKey) { + onSelect({ kind: "lane", laneKey }); + } + return next; + }); + }; + + const handleAddStep = (laneKey: string, type: WorkflowStepType) => { + onMutate((current) => { + const next = addStep(current, laneKey, type); + const lane = next.definition.lanes.find((candidate) => String(candidate.key) === laneKey); + const stepKey = String(lane?.pipeline?.at(-1)?.key ?? ""); + if (stepKey) { + onSelect({ kind: "step", laneKey, stepKey }); + } + return next; + }); + }; + + const handleSetLaneRoute = ( + laneKey: string, + kind: LaneRoutingKind, + targetLaneKey: string | undefined, + ) => { + const laneKeys = model.definition.lanes.map((lane) => String(lane.key)); + if (targetLaneKey !== undefined && !laneKeys.includes(targetLaneKey)) { + return; + } + onMutate((current) => setLaneOn(current, laneKey, kind, targetLaneKey)); + }; + + const handleSetStepRoute = ( + laneKey: string, + stepKey: string, + kind: LaneRoutingKind, + targetLaneKey: string | undefined, + ) => { + const laneKeys = model.definition.lanes.map((lane) => String(lane.key)); + if (targetLaneKey !== undefined && !laneKeys.includes(targetLaneKey)) { + return; + } + onMutate((current) => { + const lane = current.definition.lanes.find((candidate) => String(candidate.key) === laneKey); + const step = lane?.pipeline?.find((candidate) => String(candidate.key) === stepKey); + if (!step) { + return current; + } + const nextOn = { + ...step.on, + [kind]: targetLaneKey === undefined ? undefined : LaneKey.make(targetLaneKey), + }; + for (const routeKind of ["success", "failure", "blocked"] as const) { + if (nextOn[routeKind] === undefined) { + delete nextOn[routeKind]; + } + } + return updateStep(current, laneKey, stepKey, { + on: Object.keys(nextOn).length === 0 ? undefined : nextOn, + }); + }); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const move = readLaneMoveDragData(event.active.data.current); + if (move) { + const current = layoutByLaneKey.get(move.laneKey); + if (current && (event.delta.x !== 0 || event.delta.y !== 0)) { + setLanePositions((positions) => ({ + ...positions, + [move.laneKey]: { + x: Math.max(0, Math.round(current.x + event.delta.x)), + y: Math.max(0, Math.round(current.y + event.delta.y)), + }, + })); + } + return; + } + + const laneKeys = model.definition.lanes.map((lane) => String(lane.key)); + const drop = resolveLaneRoutingDrop( + laneKeys, + event.active.data.current, + event.over?.data.current ?? null, + ); + if (!drop) { + return; + } + if (drop.stepKey) { + handleSetStepRoute(drop.laneKey, drop.stepKey, drop.kind, drop.targetLaneKey); + return; + } + handleSetLaneRoute(drop.laneKey, drop.kind, drop.targetLaneKey); + }; + + const measureCanvasSize = useCallback(() => { + const viewport = viewportRef.current; + if (!viewport) { + return; + } + + const viewportRect = viewport.getBoundingClientRect(); + const nextWidth = Math.max(viewport.clientWidth, viewportRect.width, LANE_CARD_WIDTH); + setContainerWidth((current) => (Math.abs(current - nextWidth) > 0.5 ? nextWidth : current)); + + const nextLaneHeights: Record<string, number> = {}; + for (const lane of model.definition.lanes) { + const laneKey = String(lane.key); + const element = document.getElementById(`lane-${laneKey}`); + if (element) { + nextLaneHeights[laneKey] = Math.ceil(element.getBoundingClientRect().height); + } + } + setLaneHeights((current) => + shallowNumberRecordEqual(current, nextLaneHeights) ? current : nextLaneHeights, + ); + }, [model.definition]); + + const measureAnchors = useCallback(() => { + const content = contentRef.current; + if (!content) { + return; + } + + const contentRect = content.getBoundingClientRect(); + const nextAnchors: Record<string, { x: number; y: number }> = {}; + for (const element of content.querySelectorAll<HTMLElement>("[data-canvas-anchor][id]")) { + const rect = element.getBoundingClientRect(); + nextAnchors[element.id] = { + x: rect.left - contentRect.left + rect.width / 2, + y: rect.top - contentRect.top + rect.height / 2, + }; + } + setAnchors((current) => + shallowPointRecordEqual(current, nextAnchors) ? current : nextAnchors, + ); + }, []); + + useLayoutEffect(() => { + measureCanvasSize(); + const viewport = viewportRef.current; + const content = contentRef.current; + if (!viewport || !content) { + return undefined; + } + + const resizeObserver = new ResizeObserver(() => measureCanvasSize()); + resizeObserver.observe(viewport); + for (const lane of model.definition.lanes) { + const laneElement = document.getElementById(`lane-${String(lane.key)}`); + if (laneElement) { + resizeObserver.observe(laneElement); + } + } + + viewport.addEventListener("scroll", measureAnchors); + window.addEventListener("scroll", measureAnchors, true); + return () => { + resizeObserver.disconnect(); + viewport.removeEventListener("scroll", measureAnchors); + window.removeEventListener("scroll", measureAnchors, true); + }; + }, [measureAnchors, measureCanvasSize, model.definition.lanes]); + + useLayoutEffect(() => { + measureAnchors(); + }, [containerWidth, layout, measureAnchors]); + + return ( + <section + className="flex h-full min-h-0 flex-col bg-background" + aria-label="Workflow canvas" + role="region" + > + <DndContext + sensors={sensors} + collisionDetection={canvasRouteCollisionDetection} + onDragEnd={handleDragEnd} + > + <div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_minmax(20rem,30rem)] overflow-hidden max-lg:grid-cols-1"> + <div ref={viewportRef} className="min-h-0 overflow-auto p-4"> + <div + ref={contentRef} + data-testid="workflow-canvas-surface" + className="relative" + style={{ + minHeight: Math.max(layout.height + LANE_CARD_WIDTH / 2, 320), + minWidth: layout.width, + }} + onClick={() => onSelect(null)} + > + <RoutingEdges + definition={model.definition} + layout={layout} + anchors={anchors} + selection={selection} + onSelect={onSelect} + /> + <LaneRouteClearDropZone /> + {model.definition.lanes.map((lane) => { + const laneKey = String(lane.key); + const laneLayout = layoutByLaneKey.get(laneKey); + return laneLayout ? ( + <LaneCard + key={laneKey} + lane={lane} + layout={laneLayout} + selected={selection?.kind === "lane" && selection.laneKey === laneKey} + selectedStepKey={ + selectedStepKeyByLane?.laneKey === laneKey + ? selectedStepKeyByLane.stepKey + : undefined + } + disabled={disabled} + onSelect={() => onSelect({ kind: "lane", laneKey })} + onSelectStep={(stepKey) => onSelect({ kind: "step", laneKey, stepKey })} + onAddStep={(type) => handleAddStep(laneKey, type)} + onClearRoute={(kind) => handleSetLaneRoute(laneKey, kind, undefined)} + /> + ) : null; + })} + <div + className="absolute flex items-center justify-center rounded-md border border-dashed border-border/70 bg-muted/16 p-3" + style={{ + left: 0, + top: Math.max(layout.height + LANE_GAP_Y, 180), + width: LANE_CARD_WIDTH, + }} + onClick={(event) => event.stopPropagation()} + > + <Button size="sm" variant="outline" disabled={disabled} onClick={handleAddLane}> + <PlusIcon className="size-4" /> + Add lane + </Button> + </div> + </div> + </div> + <CanvasInspector + model={model} + selection={selection} + disabled={disabled} + onMutate={onMutate} + onSelect={onSelect} + /> + </div> + </DndContext> + <RoutingLegend canMoveReset={hasMovedLanes} onResetLayout={resetLaneLayout} /> + </section> + ); +} + +function CanvasInspector({ + model, + selection, + disabled, + onMutate, + onSelect, +}: { + readonly model: WorkflowEditorModel; + readonly selection: WorkflowEditorSelection | null; + readonly disabled: boolean; + readonly onMutate: WorkflowEditorMutation; + readonly onSelect: (selection: WorkflowEditorSelection | null) => void; +}) { + const lane = + selection === null + ? null + : (model.definition.lanes.find((candidate) => String(candidate.key) === selection.laneKey) ?? + null); + + return ( + <aside + aria-label="Canvas inspector" + className="flex min-h-0 flex-col border-l border-border bg-muted/12 max-lg:border-l-0 max-lg:border-t" + > + <header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3"> + <h3 className="text-sm font-semibold text-foreground">Inspector</h3> + {model.dirty ? ( + <span className="text-xs font-medium text-warning">Unsaved canvas changes</span> + ) : null} + </header> + {selection === null ? ( + <div className="p-4 text-sm text-muted-foreground"> + Select a lane, step, or route to edit. + </div> + ) : null} + {selection?.kind === "lane" && lane ? ( + <LaneForm + model={model} + lane={lane} + lanes={model.definition.lanes} + lintErrors={model.lintErrors} + disabled={disabled} + onMutate={onMutate} + onSelectLane={(laneKey) => onSelect({ kind: "lane", laneKey })} + /> + ) : null} + {selection?.kind === "step" && lane ? ( + <StepInspector + lane={lane} + stepKey={selection.stepKey} + lanes={model.definition.lanes} + disabled={disabled} + onMutate={onMutate} + /> + ) : null} + {selection?.kind === "transition" && lane ? ( + <TransitionInspector + lane={lane} + transitionIndex={selection.index} + lanes={model.definition.lanes} + lintErrors={model.lintErrors} + disabled={disabled} + onMutate={onMutate} + /> + ) : null} + </aside> + ); +} + +function StepInspector({ + lane, + stepKey, + lanes, + disabled, + onMutate, +}: { + readonly lane: WorkflowLaneEncoded; + readonly stepKey: string; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly disabled: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const laneKey = String(lane.key); + const step = lane.pipeline?.find((candidate) => String(candidate.key) === stepKey); + if (!step) { + return <div className="p-4 text-sm text-muted-foreground">Step no longer exists.</div>; + } + + return ( + <section className="@container min-h-0 overflow-auto p-4"> + <div className="mb-4"> + <h4 className="text-sm font-semibold text-foreground">{stepKey}</h4> + <p className="mt-1 text-xs text-muted-foreground"> + {lane.name} / {step.type} + </p> + </div> + <StepFields + laneKey={laneKey} + lanes={lanes} + step={step} + disabled={disabled} + onMutate={onMutate} + /> + </section> + ); +} + +function TransitionInspector({ + lane, + transitionIndex, + lanes, + lintErrors, + disabled, + onMutate, +}: { + readonly lane: WorkflowLaneEncoded; + readonly transitionIndex: number; + readonly lanes: ReadonlyArray<WorkflowLaneEncoded>; + readonly lintErrors: WorkflowEditorModel["lintErrors"]; + readonly disabled: boolean; + readonly onMutate: WorkflowEditorMutation; +}) { + const laneKey = String(lane.key); + const transition = lane.transitions?.[transitionIndex]; + if (!transition) { + return <div className="p-4 text-sm text-muted-foreground">Transition no longer exists.</div>; + } + + return ( + <section className="@container min-h-0 overflow-auto p-4"> + <ol className="space-y-3"> + <TransitionFields + laneKey={laneKey} + lanes={lanes} + transition={transition} + transitionIndex={transitionIndex} + lintErrors={lintErrors.filter( + (lintError) => + String(lintError.laneKey ?? "") === laneKey && + lintError.transitionIndex === transitionIndex, + )} + disabled={disabled} + onMutate={onMutate} + /> + </ol> + </section> + ); +} + +function RoutingLegend({ + canMoveReset, + onResetLayout, +}: { + readonly canMoveReset: boolean; + readonly onResetLayout: () => void; +}) { + return ( + <footer className="border-t border-border bg-muted/16 px-4 py-2"> + <div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground"> + <span className="font-semibold text-foreground">Routing precedence</span> + <span>Step routes > transitions > lane fallback</span> + <span className="inline-flex items-center gap-1"> + <span className="h-px w-6 bg-success" /> + success + </span> + <span className="inline-flex items-center gap-1"> + <span className="h-px w-6 bg-warning" /> + blocked + </span> + <span className="inline-flex items-center gap-1"> + <span className="h-px w-6 bg-destructive" /> + failure + </span> + <span className="inline-flex items-center gap-1"> + <span className="h-px w-6 bg-muted-foreground" /># transition + </span> + <span className="inline-flex items-center gap-1"> + <span className="h-px w-6 border-t border-dashed border-muted-foreground" /> + lane fallback + </span> + <span className="inline-flex items-center gap-1"> + <span className="h-px w-6 border-t border-dotted border-info" /> + action + </span> + {canMoveReset ? ( + <Button + size="sm" + variant="ghost" + className="ml-auto h-6 px-2" + onClick={onResetLayout} + data-testid="canvas-reset-layout" + > + <Undo2Icon className="size-3.5" /> + Reset layout + </Button> + ) : null} + </div> + </footer> + ); +} + +function shallowNumberRecordEqual(a: LaneHeights, b: LaneHeights): boolean { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + return ( + aKeys.length === bKeys.length && + aKeys.every((key) => { + const heightB = b[key]; + return heightB !== undefined && Math.abs((a[key] ?? 0) - heightB) <= 0.5; + }) + ); +} + +function shallowPointRecordEqual(a: CanvasAnchors, b: CanvasAnchors): boolean { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + return ( + aKeys.length === bKeys.length && + aKeys.every((key) => { + const pointA = a[key]; + const pointB = b[key]; + return ( + pointA !== undefined && + pointB !== undefined && + Math.abs(pointA.x - pointB.x) <= 0.5 && + Math.abs(pointA.y - pointB.y) <= 0.5 + ); + }) + ); +} diff --git a/apps/web/src/components/board/editor/canvas/LaneCard.tsx b/apps/web/src/components/board/editor/canvas/LaneCard.tsx new file mode 100644 index 00000000000..2b6ca773829 --- /dev/null +++ b/apps/web/src/components/board/editor/canvas/LaneCard.tsx @@ -0,0 +1,239 @@ +import { useDraggable } from "@dnd-kit/core"; +import { CSS } from "@dnd-kit/utilities"; +import type { WorkflowDefinitionEncoded } from "@t3tools/contracts"; +import { + BotIcon, + CheckSquareIcon, + GitMergeIcon, + GitPullRequestIcon, + GripVerticalIcon, + TerminalIcon, +} from "lucide-react"; +import { useCallback, type ReactNode } from "react"; + +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; + +import { + laneMoveDragId, + LaneRouteHandle, + type LaneMoveDragData, + type LaneRoutingKind, + useLaneDropTarget, +} from "./RoutingHandles"; +import { StepBlock } from "./StepBlock"; +import type { CanvasLaneLayout } from "./canvasLayout"; + +type WorkflowLaneEncoded = WorkflowDefinitionEncoded["lanes"][number]; +type WorkflowStepType = NonNullable<WorkflowLaneEncoded["pipeline"]>[number]["type"]; + +const routeKinds = ["success", "failure", "blocked"] as const satisfies readonly LaneRoutingKind[]; + +export function LaneCard({ + lane, + layout, + selected = false, + selectedStepKey, + disabled = false, + onSelect, + onSelectStep, + onAddStep, + onClearRoute, +}: { + readonly lane: WorkflowLaneEncoded; + readonly layout: CanvasLaneLayout; + readonly selected?: boolean; + readonly selectedStepKey?: string | undefined; + readonly disabled?: boolean; + readonly onSelect: () => void; + readonly onSelectStep: (stepKey: string) => void; + readonly onAddStep: (type: WorkflowStepType) => void; + readonly onClearRoute: (kind: LaneRoutingKind) => void; +}) { + const laneKey = String(lane.key); + const pipeline = lane.pipeline ?? []; + const { isOver, setNodeRef: setDropRef } = useLaneDropTarget(laneKey); + const { + attributes, + listeners, + setNodeRef: setDragRef, + setActivatorNodeRef, + transform, + isDragging, + } = useDraggable({ + id: laneMoveDragId(laneKey), + data: { type: "lane-move", laneKey } satisfies LaneMoveDragData, + }); + const setCardRef = useCallback( + (node: HTMLElement | null) => { + setDropRef(node); + setDragRef(node); + }, + [setDropRef, setDragRef], + ); + + return ( + <section + ref={setCardRef} + id={`lane-${laneKey}`} + data-testid={`lane-drop-${laneKey}`} + role="group" + aria-label={`Lane ${lane.name}`} + tabIndex={0} + className={cn( + "absolute flex cursor-pointer flex-col gap-3 rounded-md border border-border/70 bg-card p-3 shadow-xs outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring", + selected && "ring-2 ring-ring ring-offset-1 ring-offset-background", + isOver && "border-info/70 ring-2 ring-info/45", + isDragging && "z-30 cursor-grabbing shadow-lg", + )} + style={{ + left: layout.x, + top: layout.y, + width: layout.width, + transform: CSS.Translate.toString(transform), + zIndex: isDragging ? 30 : undefined, + }} + onClick={(event) => { + event.stopPropagation(); + onSelect(); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + onSelect(); + } + }} + > + <span + id={`lane-${laneKey}-target`} + data-canvas-anchor + aria-hidden="true" + className="absolute -left-1.5 top-1/2 size-3 -translate-y-1/2 rounded-full border border-border bg-background" + /> + {routeKinds.map((kind, index) => ( + <LaneRouteHandle + key={kind} + laneKey={laneKey} + laneName={lane.name} + kind={kind} + top={32 + index * 22} + hasRoute={lane.on?.[kind] !== undefined} + disabled={disabled} + onClear={() => onClearRoute(kind)} + /> + ))} + <header className="space-y-2"> + <div className="flex min-w-0 items-start gap-1.5"> + <button + ref={setActivatorNodeRef} + type="button" + data-testid={`lane-move-${laneKey}`} + aria-label={`Move lane ${lane.name}`} + className="-ml-1 mt-0.5 shrink-0 cursor-grab touch-none rounded-sm p-0.5 text-muted-foreground/60 outline-none transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing" + onClick={(event) => event.stopPropagation()} + {...attributes} + {...listeners} + > + <GripVerticalIcon className="size-3.5" /> + </button> + <div className="min-w-0 flex-1"> + <h3 className="truncate text-sm font-semibold text-foreground">{lane.name}</h3> + <p className="mt-0.5 truncate text-[11px] text-muted-foreground">{laneKey}</p> + </div> + </div> + <div className="flex flex-wrap gap-1"> + <LaneBadge>entry {lane.entry}</LaneBadge> + {lane.wipLimit === undefined ? null : <LaneBadge>WIP {lane.wipLimit}</LaneBadge>} + {lane.terminal ? <LaneBadge>terminal</LaneBadge> : null} + </div> + </header> + <div className="flex flex-col gap-2"> + {pipeline.length === 0 ? ( + <p className="rounded-md border border-dashed border-border/70 bg-muted/20 p-2 text-xs text-muted-foreground"> + No steps + </p> + ) : ( + pipeline.map((step) => { + const stepKey = String(step.key); + return ( + <StepBlock + key={stepKey} + laneKey={laneKey} + laneName={lane.name} + step={step} + selected={selectedStepKey === stepKey} + disabled={disabled} + onSelect={() => onSelectStep(stepKey)} + /> + ); + }) + )} + </div> + <div + className={cn( + "grid gap-1.5 rounded-md border border-dashed border-border/70 p-2 text-xs text-muted-foreground", + "bg-muted/12", + )} + onClick={(event) => event.stopPropagation()} + > + <p className="text-center font-medium">Add step</p> + <div className="grid grid-cols-5 gap-1"> + <Button + size="icon-xs" + variant="outline" + aria-label={`Add agent step to ${lane.name}`} + disabled={disabled} + onClick={() => onAddStep("agent")} + > + <BotIcon className="size-3.5" /> + </Button> + <Button + size="icon-xs" + variant="outline" + aria-label={`Add script step to ${lane.name}`} + disabled={disabled} + onClick={() => onAddStep("script")} + > + <TerminalIcon className="size-3.5" /> + </Button> + <Button + size="icon-xs" + variant="outline" + aria-label={`Add approval step to ${lane.name}`} + disabled={disabled} + onClick={() => onAddStep("approval")} + > + <CheckSquareIcon className="size-3.5" /> + </Button> + <Button + size="icon-xs" + variant="outline" + aria-label={`Add merge step to ${lane.name}`} + disabled={disabled} + onClick={() => onAddStep("merge")} + > + <GitMergeIcon className="size-3.5" /> + </Button> + <Button + size="icon-xs" + variant="outline" + aria-label={`Add pull request step to ${lane.name}`} + disabled={disabled} + onClick={() => onAddStep("pullRequest")} + > + <GitPullRequestIcon className="size-3.5" /> + </Button> + </div> + </div> + </section> + ); +} + +function LaneBadge({ children }: { readonly children: ReactNode }) { + return ( + <span className="rounded-sm border border-border/60 bg-background/70 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"> + {children} + </span> + ); +} diff --git a/apps/web/src/components/board/editor/canvas/RoutingEdges.test.ts b/apps/web/src/components/board/editor/canvas/RoutingEdges.test.ts new file mode 100644 index 00000000000..bfb9ded5a0a --- /dev/null +++ b/apps/web/src/components/board/editor/canvas/RoutingEdges.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { WorkflowDefinitionEncoded } from "@t3tools/contracts"; + +import { computeCanvasLayout } from "./canvasLayout"; +import { classifyEdge } from "./edgeRouting"; +import { channelRoutedEdgeIds, deriveRoutingEdges, layoutLabels } from "./RoutingEdges"; + +// A long forward span (a -> far) routes as a local detour. Lane "a" has three +// route edges (success/failure/blocked) plus intermediate lanes so the span +// crosses several columns. +const definition = { + name: "Spanning", + lanes: [ + { + key: "a", + name: "A", + entry: "manual", + on: { success: "far", failure: "far", blocked: "far" }, + }, + { key: "b", name: "B", entry: "manual", on: { success: "c" } }, + { key: "c", name: "C", entry: "manual", on: { success: "far" } }, + { key: "far", name: "Far", entry: "manual", terminal: true }, + ], +} as never as WorkflowDefinitionEncoded; + +const laneRect = (layout: ReturnType<typeof computeCanvasLayout>, laneKey: string) => { + const lane = layout.lanes.find((candidate) => candidate.laneKey === laneKey)!; + return { x: lane.x, y: lane.y, width: lane.width, height: lane.estimatedHeight }; +}; + +describe("channelRoutedEdgeIds", () => { + it("flags multi-column spans as channel (local detour) edges", () => { + const layout = computeCanvasLayout(definition, 1400); + const edges = deriveRoutingEdges(definition); + const channelIds = channelRoutedEdgeIds(edges, layout); + // The three a -> far route edges should all route through a local detour. + expect(channelIds.size).toBeGreaterThanOrEqual(3); + + const source = laneRect(layout, "a"); + const target = laneRect(layout, "far"); + expect(classifyEdge(source, target).kind).toBe("channel"); + }); +}); + +describe("layoutLabels", () => { + it("leaves a non-overlapping label at its position", () => { + const positions = layoutLabels([{ id: "only", x: 100, y: 200, w: 60 }]); + expect(positions.get("only")).toEqual({ x: 100, y: 200 }); + }); + + it("pushes an overlapping label down onto its own track", () => { + const positions = layoutLabels([ + { id: "build", x: 300, y: 400, w: 80 }, + { id: "retry", x: 320, y: 400, w: 80 }, // overlaps build horizontally, same y + ]); + const build = positions.get("build")!; + const retry = positions.get("retry")!; + // x is preserved; the second label is staggered below the first. + expect(retry.x).toBe(320); + expect(retry.y).toBeGreaterThan(build.y); + }); + + it("keeps far-apart labels on the same line", () => { + const positions = layoutLabels([ + { id: "left", x: 0, y: 300, w: 60 }, + { id: "right", x: 900, y: 300, w: 60 }, + ]); + expect(positions.get("left")!.y).toBe(positions.get("right")!.y); + }); +}); diff --git a/apps/web/src/components/board/editor/canvas/RoutingEdges.tsx b/apps/web/src/components/board/editor/canvas/RoutingEdges.tsx new file mode 100644 index 00000000000..d8b7fd10368 --- /dev/null +++ b/apps/web/src/components/board/editor/canvas/RoutingEdges.tsx @@ -0,0 +1,730 @@ +import { useEffect, useId, useMemo, useState } from "react"; + +import type { WorkflowDefinitionEncoded } from "@t3tools/contracts"; + +import type { WorkflowEditorSelection } from "~/workflow/editorModel"; + +import { cn } from "~/lib/utils"; + +import type { CanvasLayout } from "./canvasLayout"; +import { LANE_CARD_WIDTH } from "./canvasLayout"; +import { + classifyEdge, + clearBottomForSpan, + edgeEndpointSides, + packDetourLanes, + routeDetour, + routeEdge, + type DetourSpan, + type EdgeRect, +} from "./edgeRouting"; +import { routeDndId, ROUTE_KIND_LABEL_FILL_CLASS, ROUTE_KIND_STROKE_CLASS } from "./RoutingHandles"; + +type RouteKind = "success" | "failure" | "blocked"; + +// Direction particles travel at a constant speed regardless of edge length, so +// dur scales with the path length; the dot count scales too so spacing stays +// even on long detours without bunching on short hops. +const PARTICLE_SPEED = 70; // px per second +const PARTICLE_MIN = 2; +const PARTICLE_MAX = 6; +const PARTICLE_SPACING = 120; // px between dots (drives the count) + +export interface CanvasPoint { + readonly x: number; + readonly y: number; +} + +export type CanvasAnchors = Readonly<Record<string, CanvasPoint>>; + +interface RoutingEdge { + readonly id: string; + readonly testId: string; + readonly label: string; + readonly sourceLaneKey: string; + readonly targetLaneKey: string; + readonly sourceAnchorId: string; + readonly targetAnchorId: string; + readonly edgeKind: "step-on" | "lane-transition" | "lane-on" | "lane-action"; + readonly precedence: 1 | 2 | 3 | 4; + readonly displayLabel: string; + readonly routeKind: RouteKind | undefined; + readonly dashed: boolean; + readonly selfLoop: boolean; + readonly selection: WorkflowEditorSelection; +} + +const routeKinds = ["success", "failure", "blocked"] as const satisfies readonly RouteKind[]; + +// Reduced-motion fallback: when the user prefers reduced motion we drop the +// animated direction particles and show a static arrowhead instead. Browsers +// disagree on marker fill="context-stroke", so each edge color gets its own +// marker; currentColor inside a marker resolves against the marker's own class. +const EDGE_ARROW_MARKERS = [ + { id: "workflow-edge-arrow-success", className: "text-success" }, + { id: "workflow-edge-arrow-failure", className: "text-destructive" }, + { id: "workflow-edge-arrow-blocked", className: "text-warning" }, + { id: "workflow-edge-arrow-action", className: "text-info" }, + { id: "workflow-edge-arrow-muted", className: "text-muted-foreground" }, +] as const; + +const edgeArrowMarkerId = (edge: { + readonly edgeKind: RoutingEdge["edgeKind"]; + readonly routeKind: RouteKind | undefined; +}): string => { + if (edge.edgeKind === "lane-action") { + return "workflow-edge-arrow-action"; + } + switch (edge.routeKind) { + case "success": + return "workflow-edge-arrow-success"; + case "failure": + return "workflow-edge-arrow-failure"; + case "blocked": + return "workflow-edge-arrow-blocked"; + default: + return "workflow-edge-arrow-muted"; + } +}; + +const edgeColorClass = (edge: { + readonly edgeKind: RoutingEdge["edgeKind"]; + readonly routeKind: RouteKind | undefined; +}): string => { + if (edge.edgeKind === "lane-action") { + return "text-info"; + } + return edge.routeKind ? ROUTE_KIND_STROKE_CLASS[edge.routeKind] : "text-muted-foreground"; +}; + +const edgeLabelFillClass = (edge: { + readonly edgeKind: RoutingEdge["edgeKind"]; + readonly routeKind: RouteKind | undefined; +}): string => { + if (edge.edgeKind === "lane-action") { + return "fill-info"; + } + return edge.routeKind ? ROUTE_KIND_LABEL_FILL_CLASS[edge.routeKind] : "fill-muted-foreground"; +}; + +type RoutingEdgeIdParts = readonly [string, ...string[]]; + +const routingEdgeId = (parts: RoutingEdgeIdParts): string => + routeDndId(["workflow-edge", ...parts] as [string, ...string[]]); + +export const routingEdgeTestId = (parts: RoutingEdgeIdParts): string => + routeDndId(["workflow-edge-testid", ...parts] as [string, ...string[]]); + +function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(false); + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return; + } + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + setReduced(query.matches); + const onChange = (event: MediaQueryListEvent) => setReduced(event.matches); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, []); + return reduced; +} + +export function RoutingEdges({ + definition, + layout, + anchors, + selection, + onSelect, +}: { + readonly definition: WorkflowDefinitionEncoded; + readonly layout: CanvasLayout; + readonly anchors: CanvasAnchors; + readonly selection?: WorkflowEditorSelection | null | undefined; + readonly onSelect: (selection: WorkflowEditorSelection) => void; +}) { + const reactId = useId(); + const reducedMotion = useReducedMotion(); + const canvasHeight = Math.max(layout.height, 1); + + // The route/pack/label pipeline is O(edges^2) (detour packing + label + // de-collision). It depends only on the board geometry — definition, layout, + // anchors — never on `selection`, so memoize it: a lane selection/hover (which + // only changes focus dimming below) must not re-pay it on every render. + const edges = useMemo( + () => + [...deriveRoutingEdges(definition)].sort((left, right) => right.precedence - left.precedence), + [definition], + ); + const routes = useMemo(() => computeEdgeRoutes(edges, layout, anchors), [edges, layout, anchors]); + + // Pills are always shown, so resolve overlaps once: collect each label's box + // and de-collide it (vertical stagger) so labels never sit on top of each other. + const labelPositions = useMemo( + () => + layoutLabels( + edges.flatMap((edge) => { + const route = routes.get(edge.id); + if (!route) { + return []; + } + return [ + { + id: edge.id, + x: route.labelX, + y: route.labelY, + w: estimatePillWidth(edge.displayLabel), + }, + ]; + }), + ), + [edges, routes], + ); + + // With a lane selected, edges that neither leave nor enter it fade out so the + // selected lane's wiring is traceable. Focused edges render last (on top). + const focusLaneKey = selection?.laneKey ?? null; + const isFocused = (edge: RoutingEdge): boolean => + focusLaneKey === null || + edge.sourceLaneKey === focusLaneKey || + edge.targetLaneKey === focusLaneKey; + // Only the ordering (a stable sort) is selection-dependent, so this is the + // single cheap step that re-runs on focus change. `isFocused` is fully + // determined by `focusLaneKey`, so that + `edges` are the real deps. + const orderedEdges = useMemo(() => { + const focused = (edge: RoutingEdge): boolean => + focusLaneKey === null || + edge.sourceLaneKey === focusLaneKey || + edge.targetLaneKey === focusLaneKey; + return [...edges].sort((left, right) => Number(focused(left)) - Number(focused(right))); + }, [edges, focusLaneKey]); + + return ( + <svg + className="pointer-events-none absolute inset-0 overflow-visible" + width={layout.width} + height={canvasHeight} + aria-hidden={false} + > + <defs> + {EDGE_ARROW_MARKERS.map((marker) => ( + <marker + key={marker.id} + id={marker.id} + viewBox="0 0 10 10" + refX="9" + refY="5" + markerWidth="5" + markerHeight="5" + orient="auto-start-reverse" + className={marker.className} + > + <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" /> + </marker> + ))} + </defs> + {orderedEdges.map((edge, index) => { + const route = routes.get(edge.id); + if (!route) { + return null; + } + + const dimmed = !isFocused(edge); + const colorClass = edgeColorClass(edge); + const coreId = `${reactId}-core-${index}`; + const dash = edge.edgeKind === "lane-action" ? "2 4" : edge.dashed ? "6 4" : undefined; + const labelPos = labelPositions.get(edge.id) ?? { x: route.labelX, y: route.labelY }; + const particleCount = clamp( + Math.round(route.length / PARTICLE_SPACING), + PARTICLE_MIN, + PARTICLE_MAX, + ); + const particleDur = Math.max(0.6, route.length / PARTICLE_SPEED); + + return ( + <g + key={edge.id} + data-dimmed={dimmed ? "true" : undefined} + className={cn("transition-opacity duration-150", colorClass, dimmed && "opacity-15")} + > + {/* Glow tube: a wide soft halo + a brighter rim + the bright core line, + all in the edge color. Theme-safe (no blend mode) — reads as a glow + on dark and an emphasized soft line on light. */} + <path + d={route.d} + fill="none" + stroke="currentColor" + strokeWidth={10} + strokeOpacity={0.1} + strokeLinecap="round" + className="pointer-events-none" + /> + <path + d={route.d} + fill="none" + stroke="currentColor" + strokeWidth={5} + strokeOpacity={0.22} + strokeLinecap="round" + className="pointer-events-none" + /> + <path + id={coreId} + d={route.d} + fill="none" + stroke="currentColor" + strokeWidth={2} + strokeLinecap="round" + strokeDasharray={dash} + markerEnd={reducedMotion ? `url(#${edgeArrowMarkerId(edge)})` : undefined} + className="pointer-events-none" + /> + + {/* Direction: a stream of comet dots (colored halo + bright core) + flowing toward the target at constant speed. Falls back to the + static arrowhead above when the user prefers reduced motion. */} + {!reducedMotion && + Array.from({ length: particleCount }).map((_, dot) => { + const begin = `-${((dot * particleDur) / particleCount).toFixed(2)}s`; + return ( + <g key={dot} className="pointer-events-none"> + <circle r={4} fill="currentColor" fillOpacity={0.3}> + <animateMotion dur={`${particleDur}s`} repeatCount="indefinite" begin={begin}> + <mpath href={`#${coreId}`} xlinkHref={`#${coreId}`} /> + </animateMotion> + </circle> + <circle r={1.8} fill="currentColor"> + <animateMotion dur={`${particleDur}s`} repeatCount="indefinite" begin={begin}> + <mpath href={`#${coreId}`} xlinkHref={`#${coreId}`} /> + </animateMotion> + </circle> + </g> + ); + })} + + {/* Wide transparent hit target carrying the edge identity + click. */} + <path + data-testid={edge.testId} + data-edge-kind={edge.edgeKind} + data-precedence={edge.precedence} + data-self-loop={edge.selfLoop ? "true" : undefined} + aria-label={edge.label} + d={route.d} + fill="none" + stroke="transparent" + strokeWidth={14} + strokeDasharray={dash} + className="pointer-events-auto cursor-pointer" + style={{ pointerEvents: "stroke" }} + onClick={(event) => { + event.stopPropagation(); + onSelect(edge.selection); + }} + /> + + {/* On-line pill label (de-collided). The 0.8-opacity background lets + the line/dots show through so the label reads as sitting on it. */} + <g className="pointer-events-none"> + <rect + x={labelPos.x - estimatePillWidth(edge.displayLabel) / 2} + y={labelPos.y - 8} + width={estimatePillWidth(edge.displayLabel)} + height={16} + rx={5} + className="fill-background" + fillOpacity={0.8} + stroke="currentColor" + strokeOpacity={0.45} + strokeWidth={1} + /> + <text + x={labelPos.x} + y={labelPos.y} + textAnchor="middle" + dominantBaseline="central" + className={cn("text-[10px] font-medium", edgeLabelFillClass(edge))} + > + {edge.displayLabel} + </text> + </g> + </g> + ); + })} + </svg> + ); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function estimatePillWidth(label: string): number { + return label.length * 6 + 12; +} + +function laneRectFromLayout(layout: CanvasLayout, laneKey: string): EdgeRect | null { + const lane = layout.lanes.find((candidate) => candidate.laneKey === laneKey); + if (!lane) { + return null; + } + return { x: lane.x, y: lane.y, width: lane.width, height: lane.estimatedHeight }; +} + +interface EdgeRoute { + readonly d: string; + readonly labelX: number; + readonly labelY: number; + readonly length: number; +} + +// Ports are chosen from lane geometry (which sides actually face each other) +// rather than fixed handle positions. Channel (multi-column / back) edges route +// as local detours through a packed track just below the cards they span, so +// they never cut underneath intermediate cards or swing out to a far corridor. +function computeEdgeRoutes( + edges: ReadonlyArray<RoutingEdge>, + layout: CanvasLayout, + anchors: CanvasAnchors, +): ReadonlyMap<string, EdgeRoute> { + interface PlannedEdge { + readonly edge: RoutingEdge; + readonly source: EdgeRect; + readonly target: EdgeRect; + readonly sourceSideKey: string; + readonly targetSideKey: string; + readonly isChannel: boolean; + } + + const planned: PlannedEdge[] = []; + const routes = new Map<string, EdgeRoute>(); + const cards = layout.lanes.map((lane) => ({ + x: lane.x, + y: lane.y, + width: lane.width, + height: lane.estimatedHeight, + })); + const centerX = (rect: EdgeRect): number => rect.x + rect.width / 2; + + for (const edge of edges) { + if (edge.selfLoop) { + const source = anchorPoint( + edge.sourceAnchorId, + edge.sourceLaneKey, + layout, + anchors, + "source", + ); + const target = anchorPoint( + edge.targetAnchorId, + edge.targetLaneKey, + layout, + anchors, + "target", + ); + const midpoint = { x: source.x + 52, y: Math.min(source.y, target.y) - 42 }; + const d = selfLoopPath(source, target); + routes.set(edge.id, { + d, + labelX: midpoint.x, + labelY: midpoint.y, + length: Math.abs(source.y - midpoint.y) * 2 + 120, + }); + continue; + } + + const source = laneRectFromLayout(layout, edge.sourceLaneKey); + const target = laneRectFromLayout(layout, edge.targetLaneKey); + if (!source || !target) { + continue; + } + const sides = edgeEndpointSides(source, target); + planned.push({ + edge, + source, + target, + // Slots are allocated per physical card side counting BOTH source and + // target endpoints, so opposite-direction edges between the same two + // cards fan into adjacent ports instead of overlapping. + sourceSideKey: `${edge.sourceLaneKey}:${sides.source}`, + targetSideKey: `${edge.targetLaneKey}:${sides.target}`, + isChannel: classifyEdge(source, target).kind === "channel", + }); + } + + const sideCounts = new Map<string, number>(); + for (const plan of planned) { + sideCounts.set(plan.sourceSideKey, (sideCounts.get(plan.sourceSideKey) ?? 0) + 1); + sideCounts.set(plan.targetSideKey, (sideCounts.get(plan.targetSideKey) ?? 0) + 1); + } + + const sideSlots = new Map<string, number>(); + const takeSlot = (key: string): number => { + const slot = sideSlots.get(key) ?? 0; + sideSlots.set(key, slot + 1); + return slot; + }; + + // Pack channel edges into horizontal detour tracks. Spans use card centers + // (ignoring per-edge port fan-out) so this matches the depth canvasLayout + // reserves for the bottom of the surface. + const channelPlans = planned.filter((plan) => plan.isChannel); + const spans: DetourSpan[] = channelPlans.map((plan) => { + const left = Math.min(centerX(plan.source), centerX(plan.target)); + const right = Math.max(centerX(plan.source), centerX(plan.target)); + return { left, right, clearBottom: clearBottomForSpan(left, right, cards) }; + }); + const { lanes: detourLanes } = packDetourLanes(spans); + const laneYByEdge = new Map<string, number>(); + channelPlans.forEach((plan, index) => { + laneYByEdge.set(plan.edge.id, detourLanes[index] ?? 0); + }); + + for (const plan of planned) { + const portInput = { + source: plan.source, + target: plan.target, + sourceSlot: takeSlot(plan.sourceSideKey), + sourceCount: sideCounts.get(plan.sourceSideKey) ?? 1, + targetSlot: takeSlot(plan.targetSideKey), + targetCount: sideCounts.get(plan.targetSideKey) ?? 1, + }; + const route = plan.isChannel + ? routeDetour({ ...portInput, laneY: laneYByEdge.get(plan.edge.id) ?? 0 }) + : routeEdge(portInput); + routes.set(plan.edge.id, { + d: route.d, + labelX: route.labelX, + labelY: route.labelY, + length: route.length, + }); + } + + return routes; +} + +export function deriveRoutingEdges( + definition: WorkflowDefinitionEncoded, +): ReadonlyArray<RoutingEdge> { + const laneNames = new Map(definition.lanes.map((lane) => [String(lane.key), lane.name])); + const edges: RoutingEdge[] = []; + + for (const lane of definition.lanes) { + const laneKey = String(lane.key); + for (const step of lane.pipeline ?? []) { + const stepKey = String(step.key); + for (const kind of routeKinds) { + const targetLaneKey = step.on?.[kind]; + if (!targetLaneKey || !laneNames.has(String(targetLaneKey))) { + continue; + } + const targetKey = String(targetLaneKey); + edges.push({ + id: routingEdgeId(["step-on", laneKey, stepKey, kind, targetKey]), + testId: routingEdgeTestId(["step-on", laneKey, stepKey, kind, targetKey]), + label: `Step ${stepKey} ${kind} route from ${lane.name} to ${laneNames.get(targetKey)}`, + sourceLaneKey: laneKey, + targetLaneKey: targetKey, + sourceAnchorId: `step-${laneKey}-${stepKey}-on-${kind}`, + targetAnchorId: `lane-${targetKey}-target`, + edgeKind: "step-on", + precedence: 1, + displayLabel: kind, + routeKind: kind, + dashed: false, + selfLoop: laneKey === targetKey, + selection: { kind: "step", laneKey, stepKey }, + }); + } + } + + for (const [index, transition] of (lane.transitions ?? []).entries()) { + const targetKey = String(transition.to); + if (!laneNames.has(targetKey)) { + continue; + } + edges.push({ + id: routingEdgeId(["transition", laneKey, String(index), targetKey]), + testId: routingEdgeTestId(["transition", laneKey, String(index), targetKey]), + label: `Transition ${index + 1} from ${lane.name} to ${laneNames.get(targetKey)}`, + sourceLaneKey: laneKey, + targetLaneKey: targetKey, + sourceAnchorId: `lane-${laneKey}-on-success`, + targetAnchorId: `lane-${targetKey}-target`, + edgeKind: "lane-transition", + precedence: 2, + displayLabel: `#${index + 1}`, + routeKind: undefined, + dashed: false, + selfLoop: laneKey === targetKey, + selection: { + kind: "transition", + laneKey, + index, + }, + }); + } + + for (const [index, action] of (lane.actions ?? []).entries()) { + const targetKey = String(action.to); + if (!laneNames.has(targetKey)) { + continue; + } + edges.push({ + id: routingEdgeId(["lane-action", laneKey, String(index), targetKey]), + testId: routingEdgeTestId(["lane-action", laneKey, String(index), targetKey]), + label: `Action "${action.label}" from ${lane.name} to ${laneNames.get(targetKey)}`, + sourceLaneKey: laneKey, + targetLaneKey: targetKey, + sourceAnchorId: `lane-${laneKey}-action-${index}`, + targetAnchorId: `lane-${targetKey}-target`, + edgeKind: "lane-action", + precedence: 4, + displayLabel: action.label, + routeKind: undefined, + dashed: false, + selfLoop: laneKey === targetKey, + selection: { kind: "lane", laneKey }, + }); + } + + for (const kind of routeKinds) { + const targetLaneKey = lane.on?.[kind]; + if (!targetLaneKey || !laneNames.has(String(targetLaneKey))) { + continue; + } + const targetKey = String(targetLaneKey); + edges.push({ + id: routingEdgeId(["lane-on", laneKey, kind, targetKey]), + testId: routingEdgeTestId(["lane-on", laneKey, kind, targetKey]), + label: `Lane ${lane.name} ${kind} fallback route to ${laneNames.get(targetKey)}`, + sourceLaneKey: laneKey, + targetLaneKey: targetKey, + sourceAnchorId: `lane-${laneKey}-on-${kind}`, + targetAnchorId: `lane-${targetKey}-target`, + edgeKind: "lane-on", + precedence: 3, + displayLabel: kind, + routeKind: kind, + dashed: true, + selfLoop: laneKey === targetKey, + selection: { kind: "lane", laneKey }, + }); + } + } + + return edges; +} + +/** IDs of edges that route through a local detour (multi-column / back-edge). */ +export function channelRoutedEdgeIds( + edges: ReadonlyArray<RoutingEdge>, + layout: CanvasLayout, +): ReadonlySet<string> { + const ids = new Set<string>(); + for (const edge of edges) { + if (edge.selfLoop) { + continue; + } + const source = laneRectFromLayout(layout, edge.sourceLaneKey); + const target = laneRectFromLayout(layout, edge.targetLaneKey); + if (!source || !target) { + continue; + } + if (classifyEdge(source, target).kind === "channel") { + ids.add(edge.id); + } + } + return ids; +} + +export interface LabelBox { + readonly id: string; + readonly x: number; + readonly y: number; + readonly w: number; +} + +/** + * Resolve overlapping label pills: place each (in reading order) and, when it + * collides with an already-placed pill whose horizontal span overlaps, push it + * down past it. Vertical displacement keeps each pill near its (mostly vertical) + * detour line. Pure; returns the adjusted {x, y} per label id. + */ +export function layoutLabels( + boxes: ReadonlyArray<LabelBox>, +): ReadonlyMap<string, { readonly x: number; readonly y: number }> { + const PILL_HEIGHT = 16; + const PAD_X = 6; + const PAD_Y = 6; + const result = new Map<string, { x: number; y: number }>(); + const placed: Array<{ x: number; y: number; w: number }> = []; + const order = [...boxes].sort((a, b) => a.y - b.y || a.x - b.x); + for (const box of order) { + let y = box.y; + let conflict = true; + let guard = 0; + while (conflict && guard++ < 200) { + conflict = false; + for (const p of placed) { + const minX = (box.w + p.w) / 2 + PAD_X; + if (Math.abs(box.x - p.x) < minX && Math.abs(y - p.y) < PILL_HEIGHT + PAD_Y) { + y = p.y + PILL_HEIGHT + PAD_Y; + conflict = true; + break; + } + } + } + placed.push({ x: box.x, y, w: box.w }); + result.set(box.id, { x: box.x, y }); + } + return result; +} + +function anchorPoint( + anchorId: string, + laneKey: string, + layout: CanvasLayout, + anchors: CanvasAnchors, + role: "source" | "target", +): CanvasPoint { + const measured = anchors[anchorId]; + if (measured) { + return measured; + } + + const laneLayout = layout.lanes.find((lane) => lane.laneKey === laneKey); + if (!laneLayout) { + return { x: 0, y: 0 }; + } + + if (role === "target") { + return { x: laneLayout.x, y: laneLayout.y + laneLayout.estimatedHeight / 2 }; + } + + if (anchorId.includes("-action-")) { + // The action index is always the final segment; splitting on the LAST + // "-action-" keeps parsing correct even when laneKey itself contains it. + const segments = anchorId.split("-action-"); + const actionIndex = Number(segments[segments.length - 1] ?? "0"); + return { + x: laneLayout.x + LANE_CARD_WIDTH, + y: laneLayout.y + laneLayout.estimatedHeight - 18 - actionIndex * 12, + }; + } + if (anchorId.includes("-on-failure")) { + return { x: laneLayout.x + LANE_CARD_WIDTH, y: laneLayout.y + 56 }; + } + if (anchorId.includes("-on-blocked")) { + return { x: laneLayout.x + LANE_CARD_WIDTH, y: laneLayout.y + 74 }; + } + if (anchorId.startsWith("step-")) { + return { x: laneLayout.x + LANE_CARD_WIDTH, y: laneLayout.y + 110 }; + } + return { x: laneLayout.x + LANE_CARD_WIDTH, y: laneLayout.y + 38 }; +} + +function selfLoopPath(source: CanvasPoint, target: CanvasPoint): string { + const loopRight = source.x + 92; + const loopTop = Math.min(source.y, target.y) - 56; + return `M ${source.x} ${source.y} C ${loopRight} ${source.y}, ${loopRight} ${loopTop}, ${source.x + 28} ${loopTop} C ${target.x - 52} ${loopTop}, ${target.x - 52} ${target.y}, ${target.x} ${target.y}`; +} diff --git a/apps/web/src/components/board/editor/canvas/RoutingHandles.test.ts b/apps/web/src/components/board/editor/canvas/RoutingHandles.test.ts new file mode 100644 index 00000000000..ab870e353f7 --- /dev/null +++ b/apps/web/src/components/board/editor/canvas/RoutingHandles.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { canvasRouteCollisionDetection } from "./CanvasView"; +import { + laneDropId, + laneRouteDragId, + resolveLaneRoutingDrop, + type LaneDropData, + type RouteDragData, +} from "./RoutingHandles"; + +const laneKeys = ["queue", "run", "done"]; +const dragData = (laneKey: string, kind: RouteDragData["kind"]): RouteDragData => ({ + laneKey, + kind, +}); +const dropData = (laneKey: string): LaneDropData => ({ laneKey }); +const clearDropData = { clear: true } satisfies LaneDropData; + +describe("RoutingHandles", () => { + it("builds opaque lane drag and drop ids for keys with separators and spaces", () => { + expect(laneRouteDragId("source:lane with spaces", "success")).toBe( + JSON.stringify(["lane-route", "source:lane with spaces", "success"]), + ); + expect(laneDropId("source:lane with spaces")).toBe( + JSON.stringify(["lane-drop", "source:lane with spaces"]), + ); + }); + + it("resolves lane route drops, self-routes, empty drops, invalid targets, and clear drops", () => { + expect(resolveLaneRoutingDrop(laneKeys, dragData("run", "success"), dropData("done"))).toEqual({ + laneKey: "run", + kind: "success", + targetLaneKey: "done", + }); + expect(resolveLaneRoutingDrop(laneKeys, dragData("run", "failure"), dropData("run"))).toEqual({ + laneKey: "run", + kind: "failure", + targetLaneKey: "run", + }); + expect(resolveLaneRoutingDrop(laneKeys, dragData("run", "success"), null)).toBeNull(); + expect( + resolveLaneRoutingDrop(laneKeys, dragData("run", "success"), dropData("missing")), + ).toBeNull(); + expect(resolveLaneRoutingDrop(laneKeys, dragData("run", "success"), clearDropData)).toEqual({ + laneKey: "run", + kind: "success", + targetLaneKey: undefined, + }); + expect(resolveLaneRoutingDrop(laneKeys, "lane-route:run:success", dropData("done"))).toBeNull(); + }); + + it("preserves colon-containing lane keys when resolving route drops", () => { + expect( + resolveLaneRoutingDrop( + ["source:lane", "done"], + dragData("source:lane", "success"), + dropData("done"), + ), + ).toEqual({ + laneKey: "source:lane", + kind: "success", + targetLaneKey: "done", + }); + }); + + it("does not resolve a lane collision when the route pointer is over blank canvas", () => { + const laneRect = { top: 0, left: 0, right: 240, bottom: 120, width: 240, height: 120 }; + const collisions = canvasRouteCollisionDetection({ + active: { + id: "lane-route:run:success", + data: { current: undefined }, + rect: { current: { initial: null, translated: null } }, + }, + collisionRect: { top: 200, left: 320, right: 340, bottom: 220, width: 20, height: 20 }, + droppableContainers: [ + { + id: "lane-drop:done", + key: "lane-drop:done", + data: { current: undefined }, + disabled: false, + node: { current: null }, + rect: { current: laneRect }, + }, + ], + droppableRects: new Map([["lane-drop:done", laneRect]]), + pointerCoordinates: { x: 330, y: 210 }, + }); + + expect(collisions).toEqual([]); + }); +}); diff --git a/apps/web/src/components/board/editor/canvas/RoutingHandles.tsx b/apps/web/src/components/board/editor/canvas/RoutingHandles.tsx new file mode 100644 index 00000000000..4a780966f1b --- /dev/null +++ b/apps/web/src/components/board/editor/canvas/RoutingHandles.tsx @@ -0,0 +1,218 @@ +import { useDraggable, useDroppable } from "@dnd-kit/core"; +import { CSS } from "@dnd-kit/utilities"; +import { GitBranchIcon, XIcon } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; + +export type LaneRoutingKind = "success" | "failure" | "blocked"; + +export interface LaneRoutingDrop { + readonly laneKey: string; + readonly kind: LaneRoutingKind; + readonly stepKey?: string; + readonly targetLaneKey: string | undefined; +} + +export interface RouteDragData { + readonly laneKey: string; + readonly kind: LaneRoutingKind; + readonly stepKey?: string; +} + +export interface LaneDropData { + readonly laneKey?: string; + readonly clear?: boolean; +} + +export const routeDndId = (parts: readonly [string, ...string[]]): string => JSON.stringify(parts); + +export const laneRouteDragId = (laneKey: string, kind: LaneRoutingKind): string => + routeDndId(["lane-route", laneKey, kind]); + +export const laneDropId = (laneKey: string): string => routeDndId(["lane-drop", laneKey]); + +export const laneRouteClearDropId = routeDndId(["lane-route-clear"]); + +export interface LaneMoveDragData { + readonly type: "lane-move"; + readonly laneKey: string; +} + +export const laneMoveDragId = (laneKey: string): string => routeDndId(["lane-move", laneKey]); + +export const readLaneMoveDragData = (value: unknown): LaneMoveDragData | null => { + if (!value || typeof value !== "object") { + return null; + } + const data = value as Partial<LaneMoveDragData>; + return data.type === "lane-move" && typeof data.laneKey === "string" + ? { type: "lane-move", laneKey: data.laneKey } + : null; +}; + +const routeKinds = ["success", "failure", "blocked"] as const satisfies readonly LaneRoutingKind[]; + +/** + * Route-kind color language shared by the routing edges and the connection handles: + * success = green, blocked = yellow, failure = red. + */ +export const ROUTE_KIND_HANDLE_CLASS = { + success: "border-success/70 text-success", + failure: "border-destructive/70 text-destructive", + blocked: "border-warning/70 text-warning", +} satisfies Record<LaneRoutingKind, string>; + +export const ROUTE_KIND_STROKE_CLASS = { + success: "text-success", + failure: "text-destructive", + blocked: "text-warning", +} satisfies Record<LaneRoutingKind, string>; + +export const ROUTE_KIND_LABEL_FILL_CLASS = { + success: "fill-success", + failure: "fill-destructive", + blocked: "fill-warning", +} satisfies Record<LaneRoutingKind, string>; + +export const resolveLaneRoutingDrop = ( + laneKeys: ReadonlyArray<string>, + activeData: unknown, + overData: unknown, +): LaneRoutingDrop | null => { + const active = readRouteDragData(activeData); + if (!active || !laneKeys.includes(active.laneKey) || !overData) { + return null; + } + + const drop = readLaneDropData(overData); + if (drop?.clear) { + return { ...active, targetLaneKey: undefined }; + } + + const targetLaneKey = drop?.laneKey; + if (!targetLaneKey || !laneKeys.includes(targetLaneKey)) { + return null; + } + + return { ...active, targetLaneKey }; +}; + +export function LaneRouteHandle({ + laneKey, + laneName, + kind, + top, + hasRoute, + disabled = false, + onClear, +}: { + readonly laneKey: string; + readonly laneName: string; + readonly kind: LaneRoutingKind; + readonly top: number; + readonly hasRoute: boolean; + readonly disabled?: boolean; + readonly onClear: () => void; +}) { + const { attributes, isDragging, listeners, setNodeRef, transform } = useDraggable({ + id: laneRouteDragId(laneKey, kind), + data: { laneKey, kind } satisfies RouteDragData, + disabled, + }); + + return ( + <div + className="absolute -right-4 z-10 flex items-center gap-1" + style={{ top }} + onClick={(event) => event.stopPropagation()} + > + <button + ref={setNodeRef} + id={`lane-${laneKey}-on-${kind}`} + type="button" + data-canvas-anchor + aria-label={`Drag ${kind} route from ${laneName}`} + disabled={disabled} + className={cn( + "size-5 rounded-full border border-border bg-background text-muted-foreground shadow-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", + hasRoute && ROUTE_KIND_HANDLE_CLASS[kind], + isDragging && "opacity-80", + )} + style={{ transform: CSS.Translate.toString(transform) }} + {...attributes} + {...listeners} + > + <GitBranchIcon className="mx-auto size-3" /> + </button> + {hasRoute ? ( + <Button + size="icon-xs" + variant="ghost" + aria-label={`Clear ${kind} route from ${laneName}`} + disabled={disabled} + onClick={(event) => { + event.stopPropagation(); + onClear(); + }} + > + <XIcon className="size-3" /> + </Button> + ) : null} + </div> + ); +} + +export function useLaneDropTarget(laneKey: string) { + return useDroppable({ id: laneDropId(laneKey), data: { laneKey } satisfies LaneDropData }); +} + +export function LaneRouteClearDropZone() { + const { isOver, setNodeRef } = useDroppable({ + id: laneRouteClearDropId, + data: { clear: true } satisfies LaneDropData, + }); + + return ( + <div + ref={setNodeRef} + className={cn( + "absolute right-0 top-0 z-10 rounded-md border border-dashed border-border/70 bg-background/90 px-2 py-1 text-[11px] text-muted-foreground shadow-xs", + isOver && "border-destructive/70 text-destructive", + )} + > + Drop route here to clear + </div> + ); +} + +function isLaneRoutingKind(value: string | undefined): value is LaneRoutingKind { + return routeKinds.some((kind) => kind === value); +} + +function readRouteDragData(value: unknown): RouteDragData | null { + if (!value || typeof value !== "object") { + return null; + } + const data = value as Partial<RouteDragData>; + if (typeof data.laneKey !== "string" || !isLaneRoutingKind(data.kind)) { + return null; + } + if (data.stepKey !== undefined && typeof data.stepKey !== "string") { + return null; + } + return data.stepKey === undefined + ? { laneKey: data.laneKey, kind: data.kind } + : { laneKey: data.laneKey, kind: data.kind, stepKey: data.stepKey }; +} + +function readLaneDropData(value: unknown): LaneDropData | null { + if (!value || typeof value !== "object") { + return null; + } + const data = value as Partial<LaneDropData>; + if (data.clear) { + return { clear: true }; + } + return typeof data.laneKey === "string" ? { laneKey: data.laneKey } : null; +} diff --git a/apps/web/src/components/board/editor/canvas/StepBlock.tsx b/apps/web/src/components/board/editor/canvas/StepBlock.tsx new file mode 100644 index 00000000000..2a090463d37 --- /dev/null +++ b/apps/web/src/components/board/editor/canvas/StepBlock.tsx @@ -0,0 +1,163 @@ +import { useDraggable } from "@dnd-kit/core"; +import { CSS } from "@dnd-kit/utilities"; +import type { WorkflowDefinitionEncoded } from "@t3tools/contracts"; +import { GitBranchIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; + +import { + routeDndId, + ROUTE_KIND_HANDLE_CLASS, + type LaneRoutingKind, + type RouteDragData, +} from "./RoutingHandles"; + +type WorkflowLaneEncoded = WorkflowDefinitionEncoded["lanes"][number]; +type WorkflowStepEncoded = NonNullable<WorkflowLaneEncoded["pipeline"]>[number]; + +const routeKinds = ["success", "failure", "blocked"] as const satisfies readonly LaneRoutingKind[]; + +const stepTypeClasses = { + agent: "border-info/45 bg-info/8 text-info-foreground", + script: "border-warning/45 bg-warning/8 text-warning-foreground", + approval: "border-success/45 bg-success/8 text-success-foreground", + merge: "border-primary/45 bg-primary/8 text-foreground", + pullRequest: "border-foreground/45 bg-foreground/8 text-foreground", +} satisfies Record<WorkflowStepEncoded["type"], string>; + +export function StepBlock({ + laneKey, + laneName, + step, + selected = false, + disabled = false, + onSelect, +}: { + readonly laneKey: string; + readonly laneName: string; + readonly step: WorkflowStepEncoded; + readonly selected?: boolean; + readonly disabled?: boolean; + readonly onSelect: () => void; +}) { + const stepKey = String(step.key); + const summary = summarizeStep(step); + + return ( + <div + id={`step-${laneKey}-${stepKey}`} + role="group" + aria-label={`Step ${stepKey}`} + data-step-type={step.type} + tabIndex={0} + className={cn( + "relative cursor-pointer rounded-md border px-2.5 py-2 text-left outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring", + stepTypeClasses[step.type], + selected && "ring-2 ring-ring ring-offset-1 ring-offset-background", + )} + onClick={(event) => { + event.stopPropagation(); + onSelect(); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + onSelect(); + } + }} + > + <div className="flex items-start justify-between gap-2"> + <div className="min-w-0"> + <p className="truncate text-xs font-semibold text-foreground">{stepKey}</p> + <p className="mt-0.5 text-[10px] font-medium uppercase tracking-normal">{step.type}</p> + </div> + <span className="rounded-sm border border-border/60 bg-background/70 px-1.5 py-0.5 text-[10px] text-muted-foreground"> + {step.type} + </span> + </div> + {summary ? ( + <p className="mt-1 line-clamp-1 text-[11px] text-muted-foreground">{summary}</p> + ) : null} + {routeKinds.map((kind, index) => ( + <StepRouteHandle + key={kind} + laneKey={laneKey} + laneName={laneName} + stepKey={stepKey} + kind={kind} + top={22 + index * 11} + hasRoute={step.on?.[kind] !== undefined} + disabled={disabled} + /> + ))} + </div> + ); +} + +const stepRouteDragId = (laneKey: string, stepKey: string, kind: LaneRoutingKind): string => + routeDndId(["step-route", laneKey, stepKey, kind]); + +function StepRouteHandle({ + laneKey, + laneName, + stepKey, + kind, + top, + hasRoute, + disabled, +}: { + readonly laneKey: string; + readonly laneName: string; + readonly stepKey: string; + readonly kind: LaneRoutingKind; + readonly top: number; + readonly hasRoute: boolean; + readonly disabled: boolean; +}) { + const { attributes, isDragging, listeners, setNodeRef, transform } = useDraggable({ + id: stepRouteDragId(laneKey, stepKey, kind), + data: { laneKey, stepKey, kind } satisfies RouteDragData, + disabled, + }); + + return ( + <button + ref={setNodeRef} + id={`step-${laneKey}-${stepKey}-on-${kind}`} + type="button" + data-canvas-anchor + aria-label={`Drag ${kind} route from step ${stepKey} in ${laneName}`} + disabled={disabled} + className={cn( + "absolute -right-1.5 size-3 rounded-full border border-border bg-background text-muted-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", + hasRoute && ROUTE_KIND_HANDLE_CLASS[kind], + isDragging && "opacity-80", + )} + style={{ top, transform: CSS.Translate.toString(transform) }} + onClick={(event) => event.stopPropagation()} + {...attributes} + {...listeners} + > + <GitBranchIcon className="mx-auto size-2" /> + </button> + ); +} + +function summarizeStep(step: WorkflowStepEncoded): string { + if (step.type === "agent") { + return typeof step.instruction === "string" ? step.instruction : step.instruction.file; + } + if (step.type === "script") { + return step.run; + } + if (step.type === "merge") { + return step.target !== undefined + ? `Merge into ${step.target}` + : "Merge into checked-out branch"; + } + if (step.type === "pullRequest") { + return step.action === "land" ? "Land pull request" : "Open pull request"; + } + return step.prompt ?? "Approval required"; +} diff --git a/apps/web/src/components/board/editor/canvas/canvasLayout.test.ts b/apps/web/src/components/board/editor/canvas/canvasLayout.test.ts new file mode 100644 index 00000000000..8825bfb4842 --- /dev/null +++ b/apps/web/src/components/board/editor/canvas/canvasLayout.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { WorkflowDefinitionEncoded } from "@t3tools/contracts"; + +import { computeCanvasLayout, LANE_CARD_WIDTH, LANE_GAP_X, LANE_GAP_Y } from "./canvasLayout"; + +const definition = { + name: "Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual", on: { success: "run" } }, + { + key: "run", + name: "Run", + entry: "auto", + pipeline: [{ key: "review", type: "approval" }], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +} satisfies WorkflowDefinitionEncoded; + +const COLUMN = LANE_CARD_WIDTH + LANE_GAP_X; + +describe("canvasLayout", () => { + it("layers lanes by routing depth and stacks same-depth lanes vertically", () => { + const layout = computeCanvasLayout(definition, 400, { + queue: 120, + run: 180, + needs: 100, + done: 100, + }); + + expect(layout.lanes.map((lane) => [lane.laneKey, lane.x, lane.y, lane.width])).toEqual([ + ["queue", 0, 0, LANE_CARD_WIDTH], + ["run", COLUMN, 0, LANE_CARD_WIDTH], + ["needs", COLUMN * 2, 0, LANE_CARD_WIDTH], + ["done", COLUMN * 2, 100 + LANE_GAP_Y, LANE_CARD_WIDTH], + ]); + expect(layout.height).toBe(100 + LANE_GAP_Y + 100); + // The canvas grows horizontally past the container instead of wrapping + // and destroying the topology. + expect(layout.width).toBe(COLUMN * 2 + LANE_CARD_WIDTH); + }); + + it("ignores loops when layering", () => { + const looping = { + name: "Loop", + lanes: [ + { key: "queue", name: "Queue", entry: "manual", on: { success: "run" } }, + { + key: "run", + name: "Run", + entry: "auto", + transitions: [{ when: { "<": [{ var: "lane.runCount" }, 3] }, to: "run" }], + on: { success: "done" }, + actions: [{ label: "Back", to: "queue" }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + } as never as WorkflowDefinitionEncoded; + + const layout = computeCanvasLayout(looping, 400, { queue: 100, run: 100, done: 100 }); + expect(layout.lanes.map((lane) => [lane.laneKey, lane.x])).toEqual([ + ["queue", 0], + ["run", COLUMN], + ["done", COLUMN * 2], + ]); + }); + + it("honors per-lane position overrides and leaves other lanes in their slots", () => { + const layout = computeCanvasLayout( + definition, + 400, + { queue: 120, run: 180, needs: 100, done: 100 }, + { run: { x: 600, y: 400 } }, + ); + + expect(layout.lanes.map((lane) => [lane.laneKey, lane.x, lane.y])).toEqual([ + ["queue", 0, 0], + ["run", 600, 400], + ["needs", COLUMN * 2, 0], + ["done", COLUMN * 2, 100 + LANE_GAP_Y], + ]); + }); + + it("keeps a dropped lane exactly at its drop point, and reserves detour depth below", () => { + // A back-edge (run -> queue) now routes as a local detour below the cards, + // so the layout reserves extra height below the card band instead of + // insetting the lanes from the top. + const looping = { + name: "Loop", + lanes: [ + { key: "queue", name: "Queue", entry: "manual", on: { success: "run" } }, + { key: "mid", name: "Mid", entry: "manual", on: { success: "run" } }, + { key: "run", name: "Run", entry: "auto", on: { failure: "queue" } }, + ], + } as never as WorkflowDefinitionEncoded; + const heights = { queue: 100, mid: 100, run: 100 }; + + const initial = computeCanvasLayout(looping, 1200, heights); + const run = initial.lanes.find((lane) => lane.laneKey === "run"); + // mid stacks below queue (col 0, row 1); the back-edge detour reserves below. + const cardBand = 100 + LANE_GAP_Y + 100; + expect(initial.height).toBeGreaterThan(cardBand); + // Lanes are NOT inset from the top — the card band starts at y = 0. + expect(Math.min(...initial.lanes.map((lane) => lane.y))).toBe(0); + + // Simulate a drag: the drop handler stores rendered position + delta. + const dropped = { x: (run?.x ?? 0) + 30, y: (run?.y ?? 0) + 40 }; + const next = computeCanvasLayout(looping, 1200, heights, { run: dropped }); + const moved = next.lanes.find((lane) => lane.laneKey === "run"); + expect(moved?.x).toBe(dropped.x); + expect(moved?.y).toBe(dropped.y); + + // Re-laying out with the same override stays byte-stable (no creep). + const again = computeCanvasLayout(looping, 1200, heights, { run: dropped }); + expect(again.lanes.find((lane) => lane.laneKey === "run")?.y).toBe(dropped.y); + }); + + it("expands the canvas bounds to fit a lane moved beyond the layout", () => { + const layout = computeCanvasLayout( + definition, + LANE_CARD_WIDTH, + { queue: 120, run: 180, needs: 100, done: 100 }, + { done: { x: 900, y: 700 } }, + ); + + expect(layout.width).toBeGreaterThanOrEqual(900 + LANE_CARD_WIDTH); + expect(layout.height).toBeGreaterThanOrEqual(700 + 100); + }); +}); diff --git a/apps/web/src/components/board/editor/canvas/canvasLayout.ts b/apps/web/src/components/board/editor/canvas/canvasLayout.ts new file mode 100644 index 00000000000..783812b9290 --- /dev/null +++ b/apps/web/src/components/board/editor/canvas/canvasLayout.ts @@ -0,0 +1,226 @@ +import type { WorkflowDefinitionEncoded } from "@t3tools/contracts"; + +import { + classifyEdge, + clearBottomForSpan, + packDetourLanes, + type DetourSpan, + type EdgeRect, +} from "./edgeRouting"; + +export { LANE_CARD_WIDTH, LANE_GAP_X, LANE_GAP_Y } from "./edgeRouting"; +import { LANE_CARD_WIDTH, LANE_GAP_X, LANE_GAP_Y } from "./edgeRouting"; + +const LANE_BASE_HEIGHT = 132; +const STEP_BLOCK_HEIGHT = 58; +// Slack reserved below the deepest local-detour track for its stroke and any +// label pill that de-collision nudges below the line. +const DETOUR_BOTTOM_MARGIN = 48; + +export interface CanvasLaneLayout { + readonly laneKey: string; + readonly x: number; + readonly y: number; + readonly width: number; + readonly estimatedHeight: number; +} + +export interface CanvasLayout { + readonly lanes: ReadonlyArray<CanvasLaneLayout>; + readonly width: number; + readonly height: number; +} + +export type LaneHeights = Readonly<Record<string, number>>; + +export interface LanePosition { + readonly x: number; + readonly y: number; +} + +/** + * Local, non-persisted per-lane position overrides. When a lane has an override + * it is placed at that absolute position instead of its auto-flow slot; the + * auto-flow cursor for the remaining lanes is unaffected (the moved lane simply + * vacates its slot). Used purely to let the reader rearrange the canvas while + * inspecting a workflow — it is never written back to the board file. + */ +export type LanePositions = Readonly<Record<string, LanePosition>>; + +export const estimateLaneHeight = (lane: WorkflowDefinitionEncoded["lanes"][number]): number => + LANE_BASE_HEIGHT + (lane.pipeline?.length ?? 0) * STEP_BLOCK_HEIGHT; + +// Layered left-to-right layout: a lane's column is its longest forward path +// from a root, following step routes, transitions, lane fallbacks, and +// actions. Only edges that point from an earlier-defined lane to a +// later-defined one count — definition order encodes the author's intended +// flow, so loops (bounded review re-entry, "back to backlog" actions) are +// treated as back-edges and never smear the graph. Lanes sharing a column +// stack vertically in definition order. +const laneDepths = (definition: WorkflowDefinitionEncoded): ReadonlyMap<string, number> => { + const laneOrder = new Map(definition.lanes.map((lane, index) => [String(lane.key), index])); + const depths = new Map<string, number>(); + + const forwardTargets = (lane: WorkflowDefinitionEncoded["lanes"][number]): Set<string> => { + const laneKey = String(lane.key); + const laneIndex = laneOrder.get(laneKey) ?? 0; + const targets = new Set<string>(); + const add = (to: unknown) => { + if (to === undefined) { + return; + } + const target = String(to); + const targetIndex = laneOrder.get(target); + if (targetIndex !== undefined && targetIndex > laneIndex) { + targets.add(target); + } + }; + for (const step of lane.pipeline ?? []) { + add(step.on?.success); + add(step.on?.failure); + add(step.on?.blocked); + } + for (const transition of lane.transitions ?? []) { + add(transition.to); + } + add(lane.on?.success); + add(lane.on?.failure); + add(lane.on?.blocked); + for (const action of lane.actions ?? []) { + add(action.to); + } + return targets; + }; + + for (const lane of definition.lanes) { + const laneKey = String(lane.key); + const depth = depths.get(laneKey) ?? 0; + depths.set(laneKey, depth); + for (const target of forwardTargets(lane)) { + depths.set(target, Math.max(depths.get(target) ?? 0, depth + 1)); + } + } + + return depths; +}; + +export const computeCanvasLayout = ( + definition: WorkflowDefinitionEncoded, + containerWidth: number, + laneHeights: LaneHeights = {}, + lanePositions: LanePositions = {}, +): CanvasLayout => { + const availableWidth = Math.max(LANE_CARD_WIDTH, Math.floor(containerWidth)); + const depths = laneDepths(definition); + const columnCursorY = new Map<number, number>(); + const slots = new Map<string, { x: number; y: number; height: number; overridden: boolean }>(); + + for (const lane of definition.lanes) { + const laneKey = String(lane.key); + const laneHeight = laneHeights[laneKey] ?? estimateLaneHeight(lane); + const column = depths.get(laneKey) ?? 0; + const slotX = column * (LANE_CARD_WIDTH + LANE_GAP_X); + const slotY = columnCursorY.get(column) ?? 0; + columnCursorY.set(column, slotY + laneHeight + LANE_GAP_Y); + const override = lanePositions[laneKey]; + slots.set(laneKey, { + x: override?.x ?? slotX, + y: override?.y ?? slotY, + height: laneHeight, + overridden: override !== undefined, + }); + } + + const lanes: CanvasLaneLayout[] = []; + let maxWidth = LANE_CARD_WIDTH; + let maxBottom = 0; + + for (const lane of definition.lanes) { + const laneKey = String(lane.key); + const slot = slots.get(laneKey); + if (!slot) { + continue; + } + lanes.push({ + laneKey, + x: slot.x, + y: slot.y, + width: LANE_CARD_WIDTH, + estimatedHeight: slot.height, + }); + maxWidth = Math.max(maxWidth, slot.x + LANE_CARD_WIDTH); + maxBottom = Math.max(maxBottom, slot.y + slot.height); + } + + // Reserve room below the cards for the deepest local detour track. Channel + // edges (multi-column spans / back-edges) drop into a packed lane just below + // the cards they pass over; without this the bottom-most detour would be + // clipped off the scrollable surface. + const detourExtent = computeDetourExtent(definition, slots); + + return { + lanes, + width: Math.max(maxWidth, availableWidth), + height: lanes.length === 0 ? 0 : Math.max(maxBottom, detourExtent + DETOUR_BOTTOM_MARGIN), + }; +}; + +/** + * Deepest local-detour track Y across all channel edges, computed from the same + * span/packing primitives the renderer uses (card centers, ignoring per-edge + * port fan-out) so the reserved depth matches the router's bottom-most line. + * Enumeration MUST mirror `deriveRoutingEdges` in RoutingEdges.tsx. + */ +const computeDetourExtent = ( + definition: WorkflowDefinitionEncoded, + slots: ReadonlyMap<string, { readonly x: number; readonly y: number; readonly height: number }>, +): number => { + const rectOf = (laneKey: string): EdgeRect | null => { + const slot = slots.get(laneKey); + return slot ? { x: slot.x, y: slot.y, width: LANE_CARD_WIDTH, height: slot.height } : null; + }; + const cards: EdgeRect[] = []; + for (const [, slot] of slots) { + cards.push({ x: slot.x, y: slot.y, width: LANE_CARD_WIDTH, height: slot.height }); + } + const centerX = (rect: EdgeRect): number => rect.x + rect.width / 2; + + const spans: DetourSpan[] = []; + const addSpan = (fromKey: string, to: unknown) => { + const targetKey = String(to); + if (targetKey === fromKey) { + return; + } + const source = rectOf(fromKey); + const target = rectOf(targetKey); + if (!source || !target) { + return; + } + if (classifyEdge(source, target).kind !== "channel") { + return; + } + const left = Math.min(centerX(source), centerX(target)); + const right = Math.max(centerX(source), centerX(target)); + spans.push({ left, right, clearBottom: clearBottomForSpan(left, right, cards) }); + }; + + for (const lane of definition.lanes) { + const laneKey = String(lane.key); + for (const step of lane.pipeline ?? []) { + addSpan(laneKey, step.on?.success); + addSpan(laneKey, step.on?.failure); + addSpan(laneKey, step.on?.blocked); + } + for (const transition of lane.transitions ?? []) { + addSpan(laneKey, transition.to); + } + addSpan(laneKey, lane.on?.success); + addSpan(laneKey, lane.on?.failure); + addSpan(laneKey, lane.on?.blocked); + for (const action of lane.actions ?? []) { + addSpan(laneKey, action.to); + } + } + + return spans.length === 0 ? 0 : packDetourLanes(spans).extent; +}; diff --git a/apps/web/src/components/board/editor/canvas/edgeRouting.test.ts b/apps/web/src/components/board/editor/canvas/edgeRouting.test.ts new file mode 100644 index 00000000000..13a6963f596 --- /dev/null +++ b/apps/web/src/components/board/editor/canvas/edgeRouting.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + classifyEdge, + clearBottomForSpan, + DETOUR_CLEARANCE, + DETOUR_TRACK_GAP, + edgeEndpointSides, + packDetourLanes, + routeDetour, + routeEdge, + type DetourSpan, + type EdgeRect, +} from "./edgeRouting"; + +const rect = (x: number, y: number, width = 240, height = 140): EdgeRect => ({ + x, + y, + width, + height, +}); + +const pathNumbers = (d: string): number[] => d.match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? []; +const yNumbers = (d: string): number[] => pathNumbers(d).filter((_, index) => index % 2 === 1); + +describe("classifyEdge", () => { + it("classifies adjacent columns as forward", () => { + expect(classifyEdge(rect(0, 0), rect(312, 0))).toEqual({ kind: "forward" }); + }); + + it("classifies stacked lanes as vertical", () => { + expect(classifyEdge(rect(0, 0), rect(0, 300))).toEqual({ kind: "vertical" }); + }); + + it("classifies multi-column spans as a channel (local detour)", () => { + expect(classifyEdge(rect(0, 0), rect(936, 0))).toEqual({ kind: "channel" }); + }); + + it("classifies back-edges as a channel (local detour)", () => { + expect(classifyEdge(rect(936, 0), rect(0, 0))).toEqual({ kind: "channel" }); + }); +}); + +describe("edgeEndpointSides", () => { + it("maps forward edges to right -> left", () => { + expect(edgeEndpointSides(rect(0, 0), rect(312, 0))).toEqual({ + source: "right", + target: "left", + }); + }); + + it("maps stacked lanes to their travel sides", () => { + expect(edgeEndpointSides(rect(0, 0), rect(0, 300))).toEqual({ + source: "bottom", + target: "top", + }); + }); + + it("maps channel detours through both bottoms", () => { + expect(edgeEndpointSides(rect(936, 0), rect(0, 0))).toEqual({ + source: "bottom", + target: "bottom", + }); + expect(edgeEndpointSides(rect(0, 0), rect(936, 100))).toEqual({ + source: "bottom", + target: "bottom", + }); + }); +}); + +describe("routeEdge (forward / vertical)", () => { + it("fans out parallel forward edges across distinct ports", () => { + const shared = { source: rect(0, 0), target: rect(312, 0), targetSlot: 0, targetCount: 1 }; + const first = routeEdge({ ...shared, sourceSlot: 0, sourceCount: 2 }); + const second = routeEdge({ ...shared, sourceSlot: 1, sourceCount: 2 }); + expect(pathNumbers(first.d)[1]).not.toBe(pathNumbers(second.d)[1]); + }); + + it("connects stacked lanes bottom-to-top", () => { + const route = routeEdge({ + source: rect(0, 0, 240, 140), + target: rect(0, 300, 240, 140), + sourceSlot: 0, + sourceCount: 1, + targetSlot: 0, + targetCount: 1, + }); + const numbers = pathNumbers(route.d); + expect(numbers[1]).toBe(140); // leaves source bottom + expect(numbers.at(-1)).toBe(300); // enters target top + }); +}); + +describe("routeDetour", () => { + it("runs along the assigned track and labels on that line", () => { + const route = routeDetour({ + source: rect(936, 0), + target: rect(0, 0), + sourceSlot: 0, + sourceCount: 1, + targetSlot: 0, + targetCount: 1, + laneY: 220, + }); + const ys = yNumbers(route.d); + expect(Math.max(...ys)).toBe(220); // deepest point is the track + expect(route.labelY).toBe(220); // pill sits on the track line + }); + + it("drops from the source bottom and rises to the target bottom", () => { + const route = routeDetour({ + source: rect(0, 0, 240, 140), + target: rect(600, 0, 240, 140), + sourceSlot: 0, + sourceCount: 1, + targetSlot: 0, + targetCount: 1, + laneY: 220, + }); + const numbers = pathNumbers(route.d); + expect(numbers[1]).toBe(140); // M starts at source bottom + expect(numbers.at(-1)).toBe(140); // L ends at target bottom + }); +}); + +describe("clearBottomForSpan", () => { + it("returns the bottom-most card the run passes over", () => { + const cards = [rect(0, 0, 240, 140), rect(300, 0, 240, 300), rect(900, 0, 240, 140)]; + // a run from x=120 to x=420 passes over the first two cards (the taller wins) + expect(clearBottomForSpan(120, 420, cards)).toBe(300); + }); + + it("returns 0 when the run clears no cards", () => { + expect(clearBottomForSpan(2000, 2200, [rect(0, 0)])).toBe(0); + }); +}); + +describe("packDetourLanes", () => { + it("keeps a single detour at its own clearance (no bump)", () => { + const { lanes, extent } = packDetourLanes([{ left: 0, right: 500, clearBottom: 140 }]); + expect(lanes[0]).toBe(140 + DETOUR_CLEARANCE); + expect(extent).toBe(140 + DETOUR_CLEARANCE); + }); + + it("stacks two overlapping detours into separate tracks", () => { + const spans: DetourSpan[] = [ + { left: 0, right: 500, clearBottom: 140 }, + { left: 100, right: 600, clearBottom: 140 }, + ]; + const { lanes } = packDetourLanes(spans); + expect(Math.abs(lanes[0]! - lanes[1]!)).toBe(DETOUR_TRACK_GAP); + }); + + it("lets non-overlapping detours share a track", () => { + const spans: DetourSpan[] = [ + { left: 0, right: 200, clearBottom: 140 }, + { left: 900, right: 1100, clearBottom: 140 }, + ]; + const { lanes } = packDetourLanes(spans); + expect(lanes[0]).toBe(lanes[1]); + }); +}); diff --git a/apps/web/src/components/board/editor/canvas/edgeRouting.ts b/apps/web/src/components/board/editor/canvas/edgeRouting.ts new file mode 100644 index 00000000000..ad93e14c2d7 --- /dev/null +++ b/apps/web/src/components/board/editor/canvas/edgeRouting.ts @@ -0,0 +1,235 @@ +export const LANE_CARD_WIDTH = 240; +export const LANE_GAP_X = 72; +export const LANE_GAP_Y = 48; + +// Fan-out spacing between parallel edges that share one card side. +const PORT_SPACING = 18; +// Local-detour geometry: how far below the cards a detour's first track sits, +// and the vertical gap between stacked detour tracks. +export const DETOUR_CLEARANCE = 28; +export const DETOUR_TRACK_GAP = 24; +// Horizontal padding when deciding whether a card sits under a detour's run, and +// when deciding whether two detour runs overlap (so they pack into tracks). +const DETOUR_CARD_PAD = 10; +const DETOUR_OVERLAP_PAD = 20; +// Corner radius for the orthogonal detour elbows. +const DETOUR_RADIUS = 10; + +export interface EdgeRect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface RoutedEdgePath { + readonly d: string; + readonly labelX: number; + readonly labelY: number; + /** Approximate path length, for pacing direction particles at constant speed. */ + readonly length: number; +} + +export type EdgeGeometry = + | { readonly kind: "forward" } + | { readonly kind: "vertical" } + | { readonly kind: "channel" }; + +/** + * Classify how an edge should travel based on where its lanes actually sit: + * - forward: target is in the next column to the right — a direct curve + * between facing sides stays in the column gap and reads cleanly. + * - vertical: lanes overlap horizontally (same column) — a short curve + * between bottom and top edges. + * - channel: anything longer (multi-column spans and back-edges) detours + * locally through the clear space just below the cards it spans, hugging + * that row rather than escaping to a global corridor at the band edge. + */ +export const classifyEdge = (source: EdgeRect, target: EdgeRect): EdgeGeometry => { + const horizontalOverlap = + Math.min(source.x + source.width, target.x + target.width) > Math.max(source.x, target.x); + if (horizontalOverlap) { + return { kind: "vertical" }; + } + const forwardGap = target.x - (source.x + source.width); + if (forwardGap >= 0 && forwardGap <= LANE_GAP_X + LANE_CARD_WIDTH / 2) { + return { kind: "forward" }; + } + return { kind: "channel" }; +}; + +export type CardSide = "left" | "right" | "top" | "bottom"; + +/** + * The physical card side each endpoint of an edge attaches to. Several edge + * geometries share a side, so port slots must be allocated per side — not per + * geometry kind — or edges of different kinds stack on one point. Channel + * detours leave and re-enter through the BOTTOM (they drop into the local lane + * below the cards), so they share the bottom slot group with vertical-down + * edges and fan out cleanly. + */ +export const edgeEndpointSides = ( + source: EdgeRect, + target: EdgeRect, +): { readonly source: CardSide; readonly target: CardSide } => { + const geometry = classifyEdge(source, target); + if (geometry.kind === "forward") { + return { source: "right", target: "left" }; + } + if (geometry.kind === "vertical") { + const goingDown = target.y >= source.y + source.height; + return goingDown ? { source: "bottom", target: "top" } : { source: "top", target: "bottom" }; + } + return { source: "bottom", target: "bottom" }; +}; + +const portOffset = (slot: number, count: number): number => (slot - (count - 1) / 2) * PORT_SPACING; + +const center = (rect: EdgeRect): number => rect.x + rect.width / 2; + +export interface EdgeRouteInput { + readonly source: EdgeRect; + readonly target: EdgeRect; + /** Slot/count along the chosen source side, to fan out parallel edges. */ + readonly sourceSlot: number; + readonly sourceCount: number; + readonly targetSlot: number; + readonly targetCount: number; +} + +/** + * Route a forward (next-column) or vertical (same-column) edge as a short + * cubic curve between the facing card sides. Channel (multi-column / back) edges + * are routed by {@link routeDetour} instead, which needs a packed track Y. + */ +export const routeEdge = (input: EdgeRouteInput): RoutedEdgePath => { + const geometry = classifyEdge(input.source, input.target); + + if (geometry.kind === "vertical") { + const goingDown = input.target.y >= input.source.y + input.source.height; + const sx = center(input.source) + portOffset(input.sourceSlot, input.sourceCount); + const tx = center(input.target) + portOffset(input.targetSlot, input.targetCount); + const sy = goingDown ? input.source.y + input.source.height : input.source.y; + const ty = goingDown ? input.target.y : input.target.y + input.target.height; + const delta = Math.max(24, Math.abs(ty - sy) / 2); + const sign = goingDown ? 1 : -1; + return { + d: `M ${sx} ${sy} C ${sx} ${sy + sign * delta}, ${tx} ${ty - sign * delta}, ${tx} ${ty}`, + labelX: (sx + tx) / 2, + labelY: (sy + ty) / 2, + length: Math.hypot(tx - sx, ty - sy) * 1.15, + }; + } + + // forward (also the fallback): curve between facing right/left sides. + const sx = input.source.x + input.source.width; + const sy = + input.source.y + input.source.height / 2 + portOffset(input.sourceSlot, input.sourceCount); + const tx = input.target.x; + const ty = + input.target.y + input.target.height / 2 + portOffset(input.targetSlot, input.targetCount); + const delta = Math.max(32, (tx - sx) / 2); + return { + d: `M ${sx} ${sy} C ${sx + delta} ${sy}, ${tx - delta} ${ty}, ${tx} ${ty}`, + labelX: (sx + tx) / 2, + labelY: (sy + ty) / 2, + length: Math.hypot(tx - sx, ty - sy) * 1.15, + }; +}; + +/** + * Route a channel (multi-column / back) edge as an orthogonal local detour: drop + * out of the source's bottom, run along a horizontal track `laneY` (assigned by + * {@link packDetourLanes} so parallel detours never overlap), then rise into the + * target's bottom. `laneY` sits just below the cards the run passes over, so the + * line hugs that row instead of swinging out to the band edge. + */ +export const routeDetour = (input: EdgeRouteInput & { readonly laneY: number }): RoutedEdgePath => { + const r = DETOUR_RADIUS; + const sx = center(input.source) + portOffset(input.sourceSlot, input.sourceCount); + const tx = center(input.target) + portOffset(input.targetSlot, input.targetCount); + const sy = input.source.y + input.source.height; + const ty = input.target.y + input.target.height; + const y = input.laneY; + const dirH = Math.sign(tx - sx) || 1; + return { + d: [ + `M ${sx} ${sy}`, + `L ${sx} ${y - r}`, + `Q ${sx} ${y} ${sx + dirH * r} ${y}`, + `L ${tx - dirH * r} ${y}`, + `Q ${tx} ${y} ${tx} ${y - r}`, + `L ${tx} ${ty}`, + ].join(" "), + labelX: (sx + tx) / 2, + labelY: y, + length: y - sy + Math.abs(tx - sx) + (y - ty), + }; +}; + +/** + * The bottom-most card edge under a detour's horizontal run. A detour track must + * sit below every card it passes over (not just its endpoints) or it would cut + * through a taller mid-card. Returns 0 when the run clears no cards. + */ +export const clearBottomForSpan = ( + left: number, + right: number, + cards: ReadonlyArray<EdgeRect>, +): number => { + let bottom = 0; + for (const card of cards) { + if (card.x + card.width > left - DETOUR_CARD_PAD && card.x < right + DETOUR_CARD_PAD) { + bottom = Math.max(bottom, card.y + card.height); + } + } + return bottom; +}; + +export interface DetourSpan { + /** Left/right x of the detour's horizontal run (the two drop points). */ + readonly left: number; + readonly right: number; + /** Bottom-most card under the run (from {@link clearBottomForSpan}). */ + readonly clearBottom: number; +} + +/** + * Assign each detour a horizontal track Y. Each detour starts as high as the + * cards it spans allow (tight), then is bumped down one track gap whenever it + * would collide with an already-placed detour whose horizontal run overlaps — + * so parallel back-edges stack into clear lanes instead of landing on top of + * each other. Returns the per-span track Y (input order) and the deepest track + * (so the layout can reserve room below the cards for it). + */ +export const packDetourLanes = ( + spans: ReadonlyArray<DetourSpan>, +): { readonly lanes: ReadonlyArray<number>; readonly extent: number } => { + const order = spans + .map((span, index) => ({ span, index })) + .sort((a, b) => a.span.left - b.span.left); + const lanes = new Array<number>(spans.length).fill(0); + const placed: Array<{ left: number; right: number; y: number }> = []; + let extent = 0; + for (const { span, index } of order) { + let y = span.clearBottom + DETOUR_CLEARANCE; + let bump = true; + let guard = 0; + while (bump && guard++ < 200) { + bump = false; + for (const p of placed) { + const xOverlap = + span.left < p.right + DETOUR_OVERLAP_PAD && p.left < span.right + DETOUR_OVERLAP_PAD; + if (xOverlap && Math.abs(p.y - y) < DETOUR_TRACK_GAP - 1) { + y = p.y + DETOUR_TRACK_GAP; + bump = true; + break; + } + } + } + lanes[index] = y; + placed.push({ left: span.left, right: span.right, y }); + extent = Math.max(extent, y); + } + return { lanes, extent }; +}; diff --git a/apps/web/src/components/board/editor/history/DiffView.browser.tsx b/apps/web/src/components/board/editor/history/DiffView.browser.tsx new file mode 100644 index 00000000000..463d21cc155 --- /dev/null +++ b/apps/web/src/components/board/editor/history/DiffView.browser.tsx @@ -0,0 +1,35 @@ +import "../../../../index.css"; + +import type { WorkflowDefinitionEncoded } from "@t3tools/contracts"; +import { page } from "vite-plus/test/browser"; +import { describe, expect, it } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { DiffView } from "./DiffView"; + +const versionDefinition = { + name: "Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +} satisfies WorkflowDefinitionEncoded; + +const invalidCurrentDefinition = { + ...versionDefinition, + lanes: [{ key: "queue", name: "", entry: "manual" }], +} satisfies WorkflowDefinitionEncoded; + +describe("DiffView", () => { + it("renders a diff for an invalid current draft without throwing", async () => { + render( + <DiffView + currentDefinition={invalidCurrentDefinition} + versionDefinition={versionDefinition} + />, + ); + + await expect.element(page.getByLabelText("Version diff")).toBeInTheDocument(); + await expect.element(page.getByText(/"name": ""/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/board/editor/history/DiffView.test.ts b/apps/web/src/components/board/editor/history/DiffView.test.ts new file mode 100644 index 00000000000..c2f2620cecf --- /dev/null +++ b/apps/web/src/components/board/editor/history/DiffView.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { diffCanonicalJson } from "./DiffView"; + +describe("diffCanonicalJson", () => { + it("returns no lines for identical canonical JSON", () => { + expect( + diffCanonicalJson('{\n "name": "Delivery"\n}\n', '{\n "name": "Delivery"\n}\n'), + ).toEqual([]); + }); + + it("marks removed and added lines between version and current JSON", () => { + expect( + diffCanonicalJson('{\n "name": "Delivery v1"\n}\n', '{\n "name": "Delivery"\n}\n'), + ).toEqual([ + { kind: "context", text: "{" }, + { kind: "removed", text: ' "name": "Delivery v1"' }, + { kind: "added", text: ' "name": "Delivery"' }, + { kind: "context", text: "}" }, + ]); + }); +}); diff --git a/apps/web/src/components/board/editor/history/DiffView.tsx b/apps/web/src/components/board/editor/history/DiffView.tsx new file mode 100644 index 00000000000..03be2541e3a --- /dev/null +++ b/apps/web/src/components/board/editor/history/DiffView.tsx @@ -0,0 +1,126 @@ +import type { WorkflowDefinitionEncoded } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { canonicalizeDefinitionJson } from "~/workflow/editorModel"; + +type DiffLineKind = "context" | "removed" | "added"; + +interface DiffLine { + readonly kind: DiffLineKind; + readonly text: string; +} + +export interface DiffViewProps { + readonly currentDefinition: WorkflowDefinitionEncoded; + readonly versionDefinition: WorkflowDefinitionEncoded; +} + +export function DiffView({ currentDefinition, versionDefinition }: DiffViewProps) { + const diffLines = useMemo( + () => + diffCanonicalJson( + canonicalizeDefinitionJson(versionDefinition), + canonicalizeDefinitionJson(currentDefinition), + ), + [currentDefinition, versionDefinition], + ); + const keyedDiffLines = useMemo(() => addDiffLineKeys(diffLines), [diffLines]); + + if (diffLines.length === 0) { + return ( + <div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-sm text-muted-foreground"> + No changes in canonical JSON. + </div> + ); + } + + return ( + <pre + aria-label="Version diff" + className="max-h-80 overflow-auto rounded-md border border-border bg-background p-3 text-xs leading-5 text-foreground" + > + {keyedDiffLines.map((line) => ( + <div + key={line.key} + className={ + line.kind === "added" + ? "bg-success/10 text-success-foreground" + : line.kind === "removed" + ? "bg-destructive/10 text-destructive" + : "text-muted-foreground" + } + > + {line.kind === "added" ? "+ " : line.kind === "removed" ? "- " : " "} + {line.text} + </div> + ))} + </pre> + ); +} + +export function diffCanonicalJson( + versionJson: string, + currentJson: string, +): ReadonlyArray<DiffLine> { + const versionLines = splitLines(versionJson); + const currentLines = splitLines(currentJson); + if (arraysEqual(versionLines, currentLines)) { + return []; + } + + const lengths = Array.from({ length: versionLines.length + 1 }, () => + Array<number>(currentLines.length + 1).fill(0), + ); + for (let left = versionLines.length - 1; left >= 0; left -= 1) { + for (let right = currentLines.length - 1; right >= 0; right -= 1) { + lengths[left]![right] = + versionLines[left] === currentLines[right] + ? lengths[left + 1]![right + 1]! + 1 + : Math.max(lengths[left + 1]![right]!, lengths[left]![right + 1]!); + } + } + + const lines: DiffLine[] = []; + let left = 0; + let right = 0; + while (left < versionLines.length && right < currentLines.length) { + if (versionLines[left] === currentLines[right]) { + lines.push({ kind: "context", text: versionLines[left]! }); + left += 1; + right += 1; + } else if (lengths[left + 1]![right]! >= lengths[left]![right + 1]!) { + lines.push({ kind: "removed", text: versionLines[left]! }); + left += 1; + } else { + lines.push({ kind: "added", text: currentLines[right]! }); + right += 1; + } + } + while (left < versionLines.length) { + lines.push({ kind: "removed", text: versionLines[left]! }); + left += 1; + } + while (right < currentLines.length) { + lines.push({ kind: "added", text: currentLines[right]! }); + right += 1; + } + return lines; +} + +const splitLines = (value: string): ReadonlyArray<string> => { + const lines = value.split("\n"); + return lines.at(-1) === "" ? lines.slice(0, -1) : lines; +}; + +const arraysEqual = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean => + left.length === right.length && left.every((line, index) => line === right[index]); + +const addDiffLineKeys = (lines: ReadonlyArray<DiffLine>) => { + const seen = new Map<string, number>(); + return lines.map((line) => { + const baseKey = `${line.kind}:${line.text}`; + const count = (seen.get(baseKey) ?? 0) + 1; + seen.set(baseKey, count); + return { ...line, key: `${baseKey}:${count}` }; + }); +}; diff --git a/apps/web/src/components/board/editor/history/VersionHistoryPanel.tsx b/apps/web/src/components/board/editor/history/VersionHistoryPanel.tsx new file mode 100644 index 00000000000..478d8600cd5 --- /dev/null +++ b/apps/web/src/components/board/editor/history/VersionHistoryPanel.tsx @@ -0,0 +1,218 @@ +import type { + BoardId, + EnvironmentApi, + WorkflowBoardVersionSummary, + WorkflowDefinitionEncoded, + WorkflowGetBoardVersionResult, +} from "@t3tools/contracts"; +import { XIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { formatVersionTime } from "~/workflow/editorModel"; + +import { DiffView } from "./DiffView"; + +export interface VersionHistoryPanelProps { + readonly api: EnvironmentApi; + readonly boardId: BoardId; + readonly currentDefinition: WorkflowDefinitionEncoded; + readonly disabled?: boolean | undefined; + readonly revertDisabledReason?: string | undefined; + readonly onClose: () => void; + readonly onRevert: (version: WorkflowGetBoardVersionResult) => void; +} + +export function VersionHistoryPanel({ + api, + boardId, + currentDefinition, + disabled, + revertDisabledReason, + onClose, + onRevert, +}: VersionHistoryPanelProps) { + const [versions, setVersions] = useState<ReadonlyArray<WorkflowBoardVersionSummary>>([]); + const [selectedSummary, setSelectedSummary] = useState<WorkflowBoardVersionSummary | null>(null); + const [selectedVersion, setSelectedVersion] = useState<WorkflowGetBoardVersionResult | null>( + null, + ); + const [loadingVersions, setLoadingVersions] = useState(true); + const [loadingVersionId, setLoadingVersionId] = useState<number | null>(null); + const [error, setError] = useState<string | null>(null); + // Selecting another version invalidates in-flight loads so a slow older + // response can never overwrite the newer selection's preview. + const loadRequestRef = useRef(0); + + const listBoardVersions = api.workflow.listBoardVersions; + const getBoardVersion = api.workflow.getBoardVersion; + const revertDisabledHintId = "workflow-version-history-revert-disabled-hint"; + + useEffect(() => { + let active = true; + setLoadingVersions(true); + setError(null); + setSelectedSummary(null); + setSelectedVersion(null); + + void listBoardVersions({ boardId }) + .then((result) => { + if (!active) { + return; + } + setVersions(result); + }) + .catch((cause: unknown) => { + if (!active) { + return; + } + setError(cause instanceof Error ? cause.message : String(cause)); + }) + .finally(() => { + if (active) { + setLoadingVersions(false); + } + }); + + return () => { + active = false; + }; + }, [boardId, listBoardVersions]); + + const loadVersion = useCallback( + async (summary: WorkflowBoardVersionSummary): Promise<WorkflowGetBoardVersionResult | null> => { + const requestId = ++loadRequestRef.current; + setError(null); + setSelectedSummary(summary); + setSelectedVersion(null); + setLoadingVersionId(summary.versionId); + try { + const version = await getBoardVersion({ boardId, versionId: summary.versionId }); + if (loadRequestRef.current !== requestId) { + return null; + } + setSelectedVersion(version); + return version; + } catch (cause: unknown) { + if (loadRequestRef.current === requestId) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + return null; + } finally { + if (loadRequestRef.current === requestId) { + setLoadingVersionId(null); + } + } + }, + [boardId, getBoardVersion], + ); + + const revertVersion = useCallback( + async (summary: WorkflowBoardVersionSummary) => { + if (summary.isCurrent || disabled) { + return; + } + const version = + selectedVersion?.versionId === summary.versionId + ? selectedVersion + : await loadVersion(summary); + if (version) { + onRevert(version); + } + }, + [disabled, loadVersion, onRevert, selectedVersion], + ); + + return ( + <section + aria-label="Workflow version history" + className="border-b border-border bg-muted/15 px-4 py-3" + > + <div className="mb-3 flex items-center justify-between gap-3"> + <h3 className="text-sm font-semibold text-foreground">Version history</h3> + <Button size="icon-sm" variant="ghost" aria-label="Close history" onClick={onClose}> + <XIcon className="size-4" /> + </Button> + </div> + {revertDisabledReason ? ( + <div id={revertDisabledHintId} className="mb-3 text-xs text-muted-foreground"> + {revertDisabledReason} + </div> + ) : null} + + {loadingVersions ? ( + <div className="text-sm text-muted-foreground">Loading versions...</div> + ) : error ? ( + <div className="text-sm text-destructive">{error}</div> + ) : versions.length === 0 ? ( + <div className="text-sm text-muted-foreground">No versions recorded.</div> + ) : ( + <div className="grid min-h-0 gap-3 lg:grid-cols-[minmax(16rem,20rem)_minmax(0,1fr)]"> + <div className="space-y-2"> + {versions.map((version) => ( + <div + key={version.versionId} + className="flex items-center gap-2 rounded-md border border-border bg-background p-2" + > + <Button + type="button" + variant={selectedSummary?.versionId === version.versionId ? "secondary" : "ghost"} + className="min-w-0 flex-1 justify-start" + aria-label={`Version ${version.versionId}${version.isCurrent ? " current" : ""} ${version.source}`} + onClick={() => { + void loadVersion(version); + }} + > + <span className="truncate"> + v{version.versionId} {version.source} + {version.isCurrent ? " current" : ""} + </span> + </Button> + <Button + type="button" + size="sm" + variant="outline" + disabled={disabled || version.isCurrent || loadingVersionId === version.versionId} + title={disabled && revertDisabledReason ? revertDisabledReason : undefined} + aria-describedby={ + disabled && revertDisabledReason ? revertDisabledHintId : undefined + } + aria-label={`Revert version ${version.versionId}`} + onClick={() => { + void revertVersion(version); + }} + > + Revert + </Button> + </div> + ))} + </div> + + <div className="min-w-0"> + {selectedSummary ? ( + <div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> + <span>v{selectedSummary.versionId}</span> + <span>{selectedSummary.source}</span> + <time dateTime={selectedSummary.createdAt}> + {formatVersionTime(selectedSummary.createdAt)} + </time> + </div> + ) : null} + {loadingVersionId ? ( + <div className="text-sm text-muted-foreground">Loading version...</div> + ) : selectedVersion ? ( + <DiffView + currentDefinition={currentDefinition} + versionDefinition={selectedVersion.definition} + /> + ) : ( + <div className="rounded-md border border-border bg-background px-3 py-2 text-sm text-muted-foreground"> + Select a version to preview changes. + </div> + )} + </div> + </div> + )} + </section> + ); +} diff --git a/apps/web/src/components/board/editor/selectorDraft.test.ts b/apps/web/src/components/board/editor/selectorDraft.test.ts new file mode 100644 index 00000000000..ed7b5efd80e --- /dev/null +++ b/apps/web/src/components/board/editor/selectorDraft.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; + +import { + encodeSelector, + decodeSelectorDraft, + defaultJiraSelector, +} from "./selectorDraft"; + +describe("selectorDraft — Jira", () => { + it("encodes a Jira draft with projectKey and jql", () => { + const encoded = encodeSelector({ + provider: "jira", + jira: { projectKey: "ENG", jql: "labels = backend" }, + }); + expect(encoded).toEqual({ projectKey: "ENG", jql: "labels = backend" }); + }); + + it("omits jql key when jql is empty", () => { + const encoded = encodeSelector({ + provider: "jira", + jira: { projectKey: "ENG", jql: "" }, + }); + expect(encoded).toEqual({ projectKey: "ENG" }); + expect(encoded).not.toHaveProperty("jql"); + }); + + it("omits jql key when jql is whitespace only", () => { + const encoded = encodeSelector({ + provider: "jira", + jira: { projectKey: "ENG", jql: " " }, + }); + expect(encoded).toEqual({ projectKey: "ENG" }); + expect(encoded).not.toHaveProperty("jql"); + }); + + it("trims projectKey on encode", () => { + const encoded = encodeSelector({ + provider: "jira", + jira: { projectKey: " ENG ", jql: "" }, + }); + expect(encoded).toEqual({ projectKey: "ENG" }); + }); + + it("decodes a Jira source into a draft", () => { + const draft = decodeSelectorDraft({ + provider: "jira", + selector: { projectKey: "ENG" }, + }); + expect(draft).toEqual({ + provider: "jira", + jira: { projectKey: "ENG", jql: "" }, + }); + }); + + it("decodes a Jira source with jql into a draft", () => { + const draft = decodeSelectorDraft({ + provider: "jira", + selector: { projectKey: "ENG", jql: "labels = backend" }, + }); + expect(draft).toEqual({ + provider: "jira", + jira: { projectKey: "ENG", jql: "labels = backend" }, + }); + }); + + it("defaults missing fields to empty strings when decoding", () => { + const draft = decodeSelectorDraft({ + provider: "jira", + selector: null, + }); + expect(draft).toEqual({ + provider: "jira", + jira: { projectKey: "", jql: "" }, + }); + }); + + it("defaultJiraSelector returns empty strings", () => { + expect(defaultJiraSelector()).toEqual({ projectKey: "", jql: "" }); + }); +}); diff --git a/apps/web/src/components/board/editor/selectorDraft.ts b/apps/web/src/components/board/editor/selectorDraft.ts new file mode 100644 index 00000000000..c0545c8d491 --- /dev/null +++ b/apps/web/src/components/board/editor/selectorDraft.ts @@ -0,0 +1,124 @@ +/** + * Shared selector-draft helpers used by SourceWizard (multi-step dialog). + * Keeping them here makes them easy to reuse if additional consumers are added + * and prevents provider selector shapes from drifting. + */ + +import type { WorkflowSourceConfig } from "@t3tools/contracts/workSource"; + +// ─── types ─────────────────────────────────────────────────────────────────── + +export interface GithubSelectorDraft { + owner: string; + repo: string; + /** Comma-separated label names as the user typed them. */ + labels: string; + assignee: string; + state: "all" | "open"; +} + +export interface AsanaSelectorDraft { + projectGid: string; + includeCompleted: boolean; +} + +export interface JiraSelectorDraft { + projectKey: string; + jql: string; +} + +export type SelectorDraft = + | { provider: "github"; github: GithubSelectorDraft } + | { provider: "asana"; asana: AsanaSelectorDraft } + | { provider: "jira"; jira: JiraSelectorDraft }; + +// ─── defaults ──────────────────────────────────────────────────────────────── + +export function defaultGithubSelector(): GithubSelectorDraft { + return { owner: "", repo: "", labels: "", assignee: "", state: "all" }; +} + +export function defaultAsanaSelector(): AsanaSelectorDraft { + return { projectGid: "", includeCompleted: true }; +} + +export function defaultJiraSelector(): JiraSelectorDraft { + return { projectKey: "", jql: "" }; +} + +// ─── encode ────────────────────────────────────────────────────────────────── + +/** Convert a UI draft into the raw JSON stored in WorkflowSourceConfig.selector. */ +export function encodeSelector(draft: SelectorDraft): unknown { + if (draft.provider === "github") { + const d = draft.github; + return { + owner: d.owner, + repo: d.repo, + ...(d.labels.trim() + ? { + labels: d.labels + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + } + : {}), + ...(d.assignee.trim() ? { assignee: d.assignee.trim() } : {}), + state: d.state, + }; + } + if (draft.provider === "jira") { + const d = draft.jira; + return { + projectKey: d.projectKey.trim(), + ...(d.jql.trim() ? { jql: d.jql.trim() } : {}), + }; + } + const d = draft.asana; + return { projectGid: d.projectGid, includeCompleted: d.includeCompleted }; +} + +// ─── decode ────────────────────────────────────────────────────────────────── + +/** + * Reconstruct a UI SelectorDraft from a persisted WorkflowSourceConfig. + * `WorkflowSourceConfig` and the `SourceEncoded` alias used in SourcesSection + * (`NonNullable<WorkflowDefinitionEncoded["sources"]>[number]`) are + * structurally identical — they share the same Zod/Effect schema — so a + * single function covers both callers. + */ +export function decodeSelectorDraft( + source: Pick<WorkflowSourceConfig, "provider" | "selector">, +): SelectorDraft { + const raw = source.selector as Record<string, unknown> | null | undefined; + if (source.provider === "github") { + const labelsRaw = Array.isArray(raw?.["labels"]) ? (raw["labels"] as string[]).join(", ") : ""; + return { + provider: "github", + github: { + owner: typeof raw?.["owner"] === "string" ? raw["owner"] : "", + repo: typeof raw?.["repo"] === "string" ? raw["repo"] : "", + labels: labelsRaw, + assignee: typeof raw?.["assignee"] === "string" ? raw["assignee"] : "", + state: raw?.["state"] === "open" ? "open" : "all", + }, + }; + } + if (source.provider === "jira") { + return { + provider: "jira", + jira: { + projectKey: typeof raw?.["projectKey"] === "string" ? raw["projectKey"] : "", + jql: typeof raw?.["jql"] === "string" ? raw["jql"] : "", + }, + }; + } + return { + provider: "asana", + asana: { + projectGid: typeof raw?.["projectGid"] === "string" ? raw["projectGid"] : "", + includeCompleted: + typeof raw?.["includeCompleted"] === "boolean" ? raw["includeCompleted"] : true, + }, + }; +} diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 5a32a780289..285200e479b 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -206,6 +206,7 @@ export interface TraitsMenuContentProps { allowPromptInjectedEffort?: boolean; triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; triggerClassName?: string; + disabled?: boolean; } export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ @@ -356,6 +357,7 @@ export const TraitsPicker = memo(function TraitsPicker({ allowPromptInjectedEffort = true, triggerVariant, triggerClassName, + disabled = false, ...persistence }: TraitsMenuContentProps & TraitsPersistence) { const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -405,6 +407,9 @@ export const TraitsPicker = memo(function TraitsPicker({ <Menu open={isMenuOpen} onOpenChange={(open) => { + if (disabled) { + return; + } setIsMenuOpen(open); }} > @@ -413,6 +418,7 @@ export const TraitsPicker = memo(function TraitsPicker({ <Button size="sm" variant={triggerVariant ?? "ghost"} + disabled={disabled} className={cn( isCodexStyle ? "min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:max-w-48 sm:px-3 [&_svg]:mx-0" diff --git a/apps/web/src/components/settings/ConnectionsSettings.scopes.test.ts b/apps/web/src/components/settings/ConnectionsSettings.scopes.test.ts new file mode 100644 index 00000000000..eee89f3d2d3 --- /dev/null +++ b/apps/web/src/components/settings/ConnectionsSettings.scopes.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { AuthAdministrativeScopes } from "@t3tools/contracts"; + +import { PAIRING_SCOPE_OPTIONS } from "./ConnectionsSettings"; + +// Drift guard: the pairing-link scope picker must offer exactly the scopes an +// administrator can delegate. If a new scope is added to the auth scope set +// (AuthAdministrativeScopes) but not surfaced here, it becomes invisible and +// unmanageable in the UI — which is how workflow:read / workflow:operate ended +// up missing, blocking workflow-board creation for freshly-paired clients. +describe("PAIRING_SCOPE_OPTIONS", () => { + it("covers exactly the administrative (delegatable) scope set", () => { + const pickerScopes = PAIRING_SCOPE_OPTIONS.map((option) => option.scope).sort(); + const adminScopes = [...AuthAdministrativeScopes].sort(); + expect(pickerScopes).toEqual(adminScopes); + }); + + it("has no duplicate scope entries", () => { + const pickerScopes = PAIRING_SCOPE_OPTIONS.map((option) => option.scope); + expect(new Set(pickerScopes).size).toBe(pickerScopes.length); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 0e54ceedbb5..020dde0e7ef 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -20,6 +20,8 @@ import { AuthReviewWriteScope, AuthStandardClientScopes, AuthTerminalOperateScope, + AuthWorkflowOperateScope, + AuthWorkflowReadScope, type AuthClientSession, type AuthEnvironmentScope, type AuthPairingLink, @@ -147,7 +149,11 @@ function formatAccessTimestamp(value: string): string { return accessTimestampFormatter.format(parsed); } -const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ +// Exported for the drift-guard test: this picker MUST cover every scope an +// administrator can delegate (AuthAdministrativeScopes). A scope added to the +// scope set but forgotten here becomes invisible/unmanageable in the pairing UI +// (the bug that hid workflow:read/operate). The guard test asserts exact parity. +export const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ readonly scope: AuthEnvironmentScope; readonly title: string; readonly description: string; @@ -162,6 +168,16 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ title: "Operate tasks", description: "Start tasks and perform changes in the environment.", }, + { + scope: AuthWorkflowReadScope, + title: "View workflow boards", + description: "Read workflow boards, lanes, tickets, and templates.", + }, + { + scope: AuthWorkflowOperateScope, + title: "Operate workflow boards", + description: "Create boards and tickets, move tickets, and answer steps.", + }, { scope: AuthTerminalOperateScope, title: "Use terminals", diff --git a/apps/web/src/components/settings/OutboundConnectionsSettings.tsx b/apps/web/src/components/settings/OutboundConnectionsSettings.tsx new file mode 100644 index 00000000000..642f7a530cb --- /dev/null +++ b/apps/web/src/components/settings/OutboundConnectionsSettings.tsx @@ -0,0 +1,341 @@ +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import type { OutboundConnectionView } from "@t3tools/contracts"; + +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Spinner } from "~/components/ui/spinner"; +import { toastManager, stackedThreadToast } from "~/components/ui/toast"; +import { + Dialog, + DialogClose, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, + DialogDescription, + DialogTrigger, +} from "~/components/ui/dialog"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { readEnvironmentApi } from "~/environmentApi"; +import { usePrimaryEnvironmentId } from "~/environments/primary"; + +const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; + +// ─── connection row ─────────────────────────────────────────────────────────── + +function ConnectionRow({ + connection, + isDeleting, + onDelete, +}: { + readonly connection: OutboundConnectionView; + readonly isDeleting: boolean; + readonly onDelete: (connectionRef: string) => void; +}) { + const [confirmOpen, setConfirmOpen] = useState(false); + + return ( + <div className={ITEM_ROW_CLASSNAME}> + <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> + <div className="min-w-0 space-y-0.5"> + <p className="text-sm font-medium text-foreground">{connection.displayName}</p> + <p className="text-xs text-muted-foreground"> + {connection.kind} · ref: {connection.connectionRef} + </p> + </div> + <AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}> + <Button + size="xs" + variant="destructive-outline" + disabled={isDeleting} + onClick={() => setConfirmOpen(true)} + > + {isDeleting ? ( + <> + <Spinner className="size-3" /> + Removing… + </> + ) : ( + <> + <Trash2Icon className="size-3.5" /> + Remove + </> + )} + </Button> + <AlertDialogPopup className="max-w-sm"> + <AlertDialogHeader> + <AlertDialogTitle>Remove connection?</AlertDialogTitle> + <AlertDialogDescription> + Boards using “{connection.displayName}” will stop sending outbound + events. Existing sent events are unaffected. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> + <Button + variant="destructive" + onClick={() => { + setConfirmOpen(false); + onDelete(connection.connectionRef); + }} + > + Remove connection + </Button> + </AlertDialogFooter> + </AlertDialogPopup> + </AlertDialog> + </div> + </div> + ); +} + +// ─── add-connection dialog ──────────────────────────────────────────────────── + +function AddConnectionDialog({ + onAdd, +}: { + readonly onAdd: (input: { + kind: "webhook" | "slack"; + displayName: string; + url: string; + }) => Promise<void>; +}) { + const [open, setOpen] = useState(false); + const [kind, setKind] = useState<"webhook" | "slack">("webhook"); + const [displayName, setDisplayName] = useState(""); + const [url, setUrl] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState<string | null>(null); + + const reset = () => { + setKind("webhook"); + setDisplayName(""); + setUrl(""); + setSubmitError(null); + }; + + const handleSubmit = async () => { + if (!displayName.trim() || !url.trim()) return; + setSubmitting(true); + setSubmitError(null); + try { + await onAdd({ kind, displayName: displayName.trim(), url: url.trim() }); + reset(); + setOpen(false); + } catch (error: unknown) { + setSubmitError(error instanceof Error ? error.message : "An unknown error occurred."); + } finally { + setSubmitting(false); + } + }; + + return ( + <Dialog + open={open} + onOpenChange={(next) => { + setOpen(next); + if (!next) reset(); + }} + > + <DialogTrigger + render={ + <Button size="xs" variant="default"> + <PlusIcon className="size-3" /> + Add connection + </Button> + } + /> + <DialogPopup className="max-w-md"> + <DialogHeader> + <DialogTitle>Add outbound connection</DialogTitle> + <DialogDescription> + Enter the destination URL for outbound events. The URL is stored server-side and never + displayed again after saving. + </DialogDescription> + </DialogHeader> + <DialogPanel className="space-y-4"> + <label className="block"> + <span className="mb-1.5 block text-xs font-medium text-foreground">Kind</span> + <select + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={kind} + disabled={submitting} + onChange={(e) => setKind(e.currentTarget.value as "webhook" | "slack")} + > + <option value="webhook">Webhook</option> + <option value="slack">Slack</option> + </select> + </label> + <label className="block"> + <span className="mb-1.5 block text-xs font-medium text-foreground">Display name</span> + <Input + value={displayName} + disabled={submitting} + placeholder={kind === "webhook" ? "My webhook" : "My Slack channel"} + autoFocus + onChange={(e) => setDisplayName(e.currentTarget.value)} + /> + </label> + <label className="block"> + <span className="mb-1.5 block text-xs font-medium text-foreground"> + {kind === "webhook" ? "Webhook URL" : "Slack incoming webhook URL"} + </span> + <Input + type="url" + value={url} + disabled={submitting} + placeholder={ + kind === "webhook" ? "https://example.com/hook" : "https://hooks.slack.com/…" + } + onChange={(e) => setUrl(e.currentTarget.value)} + /> + <p className="mt-1 text-[11px] text-muted-foreground"> + {kind === "webhook" + ? "Must be an https:// URL. Private/internal addresses are rejected." + : "Use an Incoming Webhook URL from your Slack app configuration."} + </p> + </label> + {submitError !== null && <p className="text-sm text-destructive">{submitError}</p>} + </DialogPanel> + <DialogFooter variant="bare"> + <DialogClose render={<Button variant="outline" disabled={submitting} />}> + Cancel + </DialogClose> + <Button + disabled={submitting || !displayName.trim() || !url.trim()} + onClick={() => void handleSubmit()} + > + {submitting ? "Adding…" : "Add connection"} + </Button> + </DialogFooter> + </DialogPopup> + </Dialog> + ); +} + +// ─── main settings component ───────────────────────────────────────────────── + +export function OutboundConnectionsSettings() { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const api = primaryEnvironmentId ? readEnvironmentApi(primaryEnvironmentId) : undefined; + const workflowApi = api?.workflow; + + const [connections, setConnections] = useState<ReadonlyArray<OutboundConnectionView> | null>( + null, + ); + const [loadError, setLoadError] = useState<string | null>(null); + const [deletingRef, setDeletingRef] = useState<string | null>(null); + + const loadConnections = useCallback(() => { + if (!workflowApi) return; + setLoadError(null); + workflowApi + .listOutboundConnections({}) + .then((result) => setConnections(result.connections)) + .catch((error: unknown) => { + setLoadError(error instanceof Error ? error.message : "Failed to load connections."); + }); + }, [workflowApi]); + + useEffect(() => { + loadConnections(); + }, [loadConnections]); + + const handleAdd = useCallback( + async (input: { kind: "webhook" | "slack"; displayName: string; url: string }) => { + if (!workflowApi) throw new Error("No environment connected."); + const { connection } = await workflowApi.createOutboundConnection(input); + setConnections((current) => (current ? [...current, connection] : [connection])); + toastManager.add({ type: "success", title: "Connection added" }); + }, + [workflowApi], + ); + + const handleDelete = useCallback( + (connectionRef: string) => { + if (!workflowApi) return; + setDeletingRef(connectionRef); + workflowApi + .deleteOutboundConnection({ connectionRef }) + .then(() => { + setConnections((current) => + current ? current.filter((c) => c.connectionRef !== connectionRef) : current, + ); + toastManager.add({ type: "success", title: "Connection removed" }); + }) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not remove connection", + description: error instanceof Error ? error.message : "An unknown error occurred.", + }), + ); + }) + .finally(() => setDeletingRef(null)); + }, + [workflowApi], + ); + + if (!workflowApi) { + return ( + <SettingsPageContainer> + <SettingsSection title="Outbound Connections"> + <div className={ITEM_ROW_CLASSNAME}> + <p className="text-sm text-muted-foreground">No environment connected.</p> + </div> + </SettingsSection> + </SettingsPageContainer> + ); + } + + return ( + <SettingsPageContainer> + <SettingsSection + title="Outbound Connections" + headerAction={<AddConnectionDialog onAdd={handleAdd} />} + > + {loadError ? ( + <div className={ITEM_ROW_CLASSNAME}> + <p className="text-sm text-destructive">{loadError}</p> + </div> + ) : connections === null ? ( + <div className={ITEM_ROW_CLASSNAME}> + <p className="flex items-center gap-1.5 text-sm text-muted-foreground"> + <Spinner className="size-3.5" /> + Loading… + </p> + </div> + ) : connections.length === 0 ? ( + <div className={ITEM_ROW_CLASSNAME}> + <p className="text-sm text-muted-foreground"> + No outbound connections yet. Add one to start sending board events to webhooks or + Slack. + </p> + </div> + ) : ( + connections.map((connection) => ( + <ConnectionRow + key={connection.connectionRef} + connection={connection} + isDeleting={deletingRef === connection.connectionRef} + onDelete={handleDelete} + /> + )) + )} + </SettingsSection> + </SettingsPageContainer> + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f..4efd992631d 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -6,7 +6,9 @@ import { GitBranchIcon, KeyboardIcon, Link2Icon, + SendIcon, Settings2Icon, + WorkflowIcon, } from "lucide-react"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; @@ -28,6 +30,8 @@ export type SettingsSectionPath = | "/settings/providers" | "/settings/source-control" | "/settings/connections" + | "/settings/work-sources" + | "/settings/outbound" | "/settings/archived"; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ @@ -40,6 +44,8 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, + { label: "Work Sources", to: "/settings/work-sources", icon: WorkflowIcon }, + { label: "Outbound", to: "/settings/outbound", icon: SendIcon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/components/settings/WorkSourceConnectionsSettings.tsx b/apps/web/src/components/settings/WorkSourceConnectionsSettings.tsx new file mode 100644 index 00000000000..932cde682f1 --- /dev/null +++ b/apps/web/src/components/settings/WorkSourceConnectionsSettings.tsx @@ -0,0 +1,411 @@ +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import type { WorkSourceConnectionView } from "@t3tools/contracts/workSource"; + +import { + buildConnectionInput, + isConnectionFormValid, + type ConnectionFormState, + type CreateConnectionInput, +} from "~/workflow/jiraConnectionForm"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Spinner } from "~/components/ui/spinner"; +import { toastManager, stackedThreadToast } from "~/components/ui/toast"; +import { + Dialog, + DialogClose, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, + DialogDescription, + DialogTrigger, +} from "~/components/ui/dialog"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { readEnvironmentApi } from "~/environmentApi"; +import { usePrimaryEnvironmentId } from "~/environments/primary"; + +const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; + +// ─── connection row ─────────────────────────────────────────────────────────── + +function ConnectionRow({ + connection, + isDeleting, + onDelete, +}: { + readonly connection: WorkSourceConnectionView; + readonly isDeleting: boolean; + readonly onDelete: (connectionRef: string) => void; +}) { + const [confirmOpen, setConfirmOpen] = useState(false); + + return ( + <div className={ITEM_ROW_CLASSNAME}> + <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> + <div className="min-w-0 space-y-0.5"> + <p className="text-sm font-medium text-foreground">{connection.displayName}</p> + <p className="text-xs text-muted-foreground"> + {connection.provider} · ref: {connection.connectionRef} + </p> + </div> + <AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}> + <Button + size="xs" + variant="destructive-outline" + disabled={isDeleting} + onClick={() => setConfirmOpen(true)} + > + {isDeleting ? ( + <> + <Spinner className="size-3" /> + Removing… + </> + ) : ( + <> + <Trash2Icon className="size-3.5" /> + Remove + </> + )} + </Button> + <AlertDialogPopup className="max-w-sm"> + <AlertDialogHeader> + <AlertDialogTitle>Remove connection?</AlertDialogTitle> + <AlertDialogDescription> + Boards using “{connection.displayName}” will stop syncing new work + items. Existing synced tickets are unaffected. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> + <Button + variant="destructive" + onClick={() => { + setConfirmOpen(false); + onDelete(connection.connectionRef); + }} + > + Remove connection + </Button> + </AlertDialogFooter> + </AlertDialogPopup> + </AlertDialog> + </div> + </div> + ); +} + +// ─── add-connection dialog ──────────────────────────────────────────────────── + +function AddConnectionDialog({ + onAdd, +}: { + readonly onAdd: (input: CreateConnectionInput) => Promise<void>; +}) { + const [open, setOpen] = useState(false); + const [provider, setProvider] = useState<ConnectionFormState["provider"]>("github"); + const [displayName, setDisplayName] = useState(""); + const [token, setToken] = useState(""); + const [jiraDeployment, setJiraDeployment] = useState<ConnectionFormState["jiraDeployment"]>("cloud"); + const [baseUrl, setBaseUrl] = useState(""); + const [email, setEmail] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const formState: ConnectionFormState = { + provider, + displayName, + token, + jiraDeployment, + baseUrl, + email, + }; + const valid = isConnectionFormValid(formState); + + const reset = () => { + setProvider("github"); + setDisplayName(""); + setToken(""); + setJiraDeployment("cloud"); + setBaseUrl(""); + setEmail(""); + }; + + const handleSubmit = async () => { + if (!valid) return; + setSubmitting(true); + try { + await onAdd(buildConnectionInput(formState)); + reset(); + setOpen(false); + } catch (error: unknown) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not add connection", + description: error instanceof Error ? error.message : "An unknown error occurred.", + }), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + <Dialog + open={open} + onOpenChange={(next) => { + setOpen(next); + if (!next) reset(); + }} + > + <DialogTrigger + render={ + <Button size="xs" variant="default"> + <PlusIcon className="size-3" /> + Add connection + </Button> + } + /> + <DialogPopup className="max-w-md"> + <DialogHeader> + <DialogTitle>Add work-source connection</DialogTitle> + <DialogDescription> + Enter a personal access token (PAT) for the provider. The token is stored server-side + and used only for syncing issues/tasks. + </DialogDescription> + </DialogHeader> + <DialogPanel className="space-y-4"> + <label className="block"> + <span className="mb-1.5 block text-xs font-medium text-foreground">Provider</span> + <select + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={provider} + disabled={submitting} + onChange={(e) => setProvider(e.currentTarget.value as ConnectionFormState["provider"])} + > + <option value="github">GitHub</option> + <option value="asana">Asana</option> + <option value="jira">Jira</option> + </select> + </label> + {provider === "jira" && ( + <> + <label className="block"> + <span className="mb-1.5 block text-xs font-medium text-foreground">Deployment</span> + <select + className="h-8.5 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground" + value={jiraDeployment} + disabled={submitting} + onChange={(e) => + setJiraDeployment(e.currentTarget.value as ConnectionFormState["jiraDeployment"]) + } + > + <option value="cloud">Jira Cloud</option> + <option value="server">Server / Data Center</option> + </select> + </label> + <label className="block"> + <span className="mb-1.5 block text-xs font-medium text-foreground">Base URL</span> + <Input + value={baseUrl} + disabled={submitting} + placeholder={ + jiraDeployment === "cloud" ? "https://acme.atlassian.net" : "https://jira.mycompany.com" + } + onChange={(e) => setBaseUrl(e.currentTarget.value)} + /> + </label> + {jiraDeployment === "cloud" && ( + <label className="block"> + <span className="mb-1.5 block text-xs font-medium text-foreground">Email</span> + <Input + type="email" + value={email} + disabled={submitting} + placeholder="you@example.com" + onChange={(e) => setEmail(e.currentTarget.value)} + /> + </label> + )} + </> + )} + <label className="block"> + <span className="mb-1.5 block text-xs font-medium text-foreground">Display name</span> + <Input + value={displayName} + disabled={submitting} + placeholder={provider === "github" ? "My GitHub PAT" : provider === "jira" ? "My Jira Connection" : "My Asana PAT"} + autoFocus + onChange={(e) => setDisplayName(e.currentTarget.value)} + /> + </label> + <label className="block"> + <span className="mb-1.5 block text-xs font-medium text-foreground"> + {provider === "jira" && jiraDeployment === "cloud" + ? "Atlassian API token" + : "Personal access token"} + </span> + <Input + type="password" + value={token} + disabled={submitting} + placeholder={ + provider === "github" + ? "ghp_…" + : provider === "jira" + ? jiraDeployment === "cloud" + ? "Atlassian API token" + : "Personal access token" + : 'Paste your token (no "Bearer" prefix)' + } + onChange={(e) => setToken(e.currentTarget.value)} + /> + <p className="mt-1 text-[11px] text-muted-foreground"> + {provider === "github" + ? "Needs repo:read and issues:read scopes." + : provider === "jira" + ? jiraDeployment === "cloud" + ? "Use an Atlassian API token from your account settings." + : "Use a personal access token from your Jira profile." + : "Use a personal access token from your Asana profile."} + </p> + </label> + </DialogPanel> + <DialogFooter variant="bare"> + <DialogClose render={<Button variant="outline" disabled={submitting} />}> + Cancel + </DialogClose> + <Button disabled={submitting || !valid} onClick={() => void handleSubmit()}> + {submitting ? "Adding…" : "Add connection"} + </Button> + </DialogFooter> + </DialogPopup> + </Dialog> + ); +} + +// ─── main settings component ───────────────────────────────────────────────── + +export function WorkSourceConnectionsSettings() { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const api = primaryEnvironmentId ? readEnvironmentApi(primaryEnvironmentId) : undefined; + const workflowApi = api?.workflow; + + const [connections, setConnections] = useState<ReadonlyArray<WorkSourceConnectionView> | null>( + null, + ); + const [loadError, setLoadError] = useState<string | null>(null); + const [deletingRef, setDeletingRef] = useState<string | null>(null); + + const loadConnections = useCallback(() => { + if (!workflowApi) return; + setLoadError(null); + workflowApi + .listWorkSourceConnections({}) + .then((result) => setConnections(result)) + .catch((error: unknown) => { + setLoadError(error instanceof Error ? error.message : "Failed to load connections."); + }); + }, [workflowApi]); + + useEffect(() => { + loadConnections(); + }, [loadConnections]); + + const handleAdd = useCallback( + async (input: CreateConnectionInput) => { + if (!workflowApi) throw new Error("No environment connected."); + const created = await workflowApi.createWorkSourceConnection(input); + setConnections((current) => (current ? [...current, created] : [created])); + toastManager.add({ type: "success", title: "Connection added" }); + }, + [workflowApi], + ); + + const handleDelete = useCallback( + (connectionRef: string) => { + if (!workflowApi) return; + setDeletingRef(connectionRef); + workflowApi + .deleteWorkSourceConnection({ connectionRef }) + .then(() => { + setConnections((current) => + current ? current.filter((c) => c.connectionRef !== connectionRef) : current, + ); + toastManager.add({ type: "success", title: "Connection removed" }); + }) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not remove connection", + description: error instanceof Error ? error.message : "An unknown error occurred.", + }), + ); + }) + .finally(() => setDeletingRef(null)); + }, + [workflowApi], + ); + + if (!workflowApi) { + return ( + <SettingsPageContainer> + <SettingsSection title="Work-Source Connections"> + <div className={ITEM_ROW_CLASSNAME}> + <p className="text-sm text-muted-foreground">No environment connected.</p> + </div> + </SettingsSection> + </SettingsPageContainer> + ); + } + + return ( + <SettingsPageContainer> + <SettingsSection + title="Work-Source Connections" + headerAction={<AddConnectionDialog onAdd={handleAdd} />} + > + {loadError ? ( + <div className={ITEM_ROW_CLASSNAME}> + <p className="text-sm text-destructive">{loadError}</p> + </div> + ) : connections === null ? ( + <div className={ITEM_ROW_CLASSNAME}> + <p className="flex items-center gap-1.5 text-sm text-muted-foreground"> + <Spinner className="size-3.5" /> + Loading… + </p> + </div> + ) : connections.length === 0 ? ( + <div className={ITEM_ROW_CLASSNAME}> + <p className="text-sm text-muted-foreground"> + No work-source connections yet. Add one to start syncing GitHub issues, Asana tasks, or Jira issues. + </p> + </div> + ) : ( + connections.map((connection) => ( + <ConnectionRow + key={connection.connectionRef} + connection={connection} + isDeleting={deletingRef === connection.connectionRef} + onDelete={handleDelete} + /> + )) + )} + </SettingsSection> + </SettingsPageContainer> + ); +} diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index ae373ac94f9..6d29d7218ee 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -11,6 +11,8 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { open: (input) => rpcClient.terminal.open(input as never), attach: (input, callback, options) => rpcClient.terminal.attach(input as never, callback, options), + attachHistory: (input, callback, options) => + rpcClient.terminal.attachHistory(input as never, callback, options), write: (input) => rpcClient.terminal.write(input as never), resize: (input) => rpcClient.terminal.resize(input as never), clear: (input) => rpcClient.terminal.clear(input as never), @@ -80,6 +82,54 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { onEvent: (callback, options) => rpcClient.preview.onEvent(callback, options), subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), }, + workflow: { + listBoards: rpcClient.workflow.listBoards, + createBoard: rpcClient.workflow.createBoard, + importBoard: rpcClient.workflow.importBoard, + createWorkflowBoard: rpcClient.workflow.createWorkflowBoard, + generateWorkflowDraft: rpcClient.workflow.generateWorkflowDraft, + listBoardTemplates: rpcClient.workflow.listBoardTemplates, + deleteBoard: rpcClient.workflow.deleteBoard, + renameBoard: rpcClient.workflow.renameBoard, + getBoard: rpcClient.workflow.getBoard, + getBoardDefinition: rpcClient.workflow.getBoardDefinition, + saveBoardDefinition: rpcClient.workflow.saveBoardDefinition, + listBoardVersions: rpcClient.workflow.listBoardVersions, + getBoardVersion: rpcClient.workflow.getBoardVersion, + subscribeBoard: (input, callback, options) => + rpcClient.workflow.subscribeBoard(input, callback, options), + createTicket: rpcClient.workflow.createTicket, + editTicket: rpcClient.workflow.editTicket, + moveTicket: rpcClient.workflow.moveTicket, + runLane: rpcClient.workflow.runLane, + resolveApproval: rpcClient.workflow.resolveApproval, + answerTicketStep: rpcClient.workflow.answerTicketStep, + postTicketMessage: rpcClient.workflow.postTicketMessage, + editTicketMessage: rpcClient.workflow.editTicketMessage, + setProjectScriptTrust: rpcClient.workflow.setProjectScriptTrust, + cancelStep: rpcClient.workflow.cancelStep, + getTicketDetail: rpcClient.workflow.getTicketDetail, + getTicketDiff: rpcClient.workflow.getTicketDiff, + intakeTickets: rpcClient.workflow.intakeTickets, + listTicketArtifacts: rpcClient.workflow.listTicketArtifacts, + getWebhookConfig: rpcClient.workflow.getWebhookConfig, + getBoardDigest: rpcClient.workflow.getBoardDigest, + getBoardMetrics: rpcClient.workflow.getBoardMetrics, + dryRunBoard: rpcClient.workflow.dryRunBoard, + listWorkSourceConnections: rpcClient.workflow.listWorkSourceConnections, + createWorkSourceConnection: rpcClient.workflow.createWorkSourceConnection, + deleteWorkSourceConnection: rpcClient.workflow.deleteWorkSourceConnection, + listOutboundConnections: rpcClient.workflow.listOutboundConnections, + createOutboundConnection: rpcClient.workflow.createOutboundConnection, + deleteOutboundConnection: rpcClient.workflow.deleteOutboundConnection, + proposeBoardImprovement: rpcClient.workflow.proposeBoardImprovement, + listBoardProposals: rpcClient.workflow.listBoardProposals, + getBoardProposal: rpcClient.workflow.getBoardProposal, + resolveBoardProposal: rpcClient.workflow.resolveBoardProposal, + revertBoardProposal: rpcClient.workflow.revertBoardProposal, + listImportableWorkItems: rpcClient.workflow.listImportableWorkItems, + importWorkItems: rpcClient.workflow.importWorkItems, + }, }; } diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index ae879c671f5..1915ecc27dc 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -217,6 +217,8 @@ function makeFixtureState(): AppState { [primaryEnvId]: primaryEnvState, [remoteEnvId]: remoteEnvState, }, + boardStateById: {}, + boardsByScopedProjectKey: {}, }; } diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 8d7cec99043..1d3c7bf3097 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -96,6 +96,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { terminal: { open: vi.fn(), attach: vi.fn(() => () => undefined), + attachHistory: vi.fn(() => () => undefined), write: vi.fn(), resize: vi.fn(), clear: vi.fn(), @@ -157,6 +158,54 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { review: { getDiffPreview: vi.fn(), }, + workflow: { + listBoards: vi.fn(), + createBoard: vi.fn(), + createWorkflowBoard: vi.fn(), + generateWorkflowDraft: vi.fn(), + listBoardTemplates: vi.fn(), + deleteBoard: vi.fn(), + renameBoard: vi.fn(), + getBoard: vi.fn(), + getBoardDefinition: vi.fn(), + saveBoardDefinition: vi.fn(), + listBoardVersions: vi.fn(), + getBoardVersion: vi.fn(), + subscribeBoard: vi.fn(() => () => undefined), + createTicket: vi.fn(), + editTicket: vi.fn(), + moveTicket: vi.fn(), + runLane: vi.fn(), + resolveApproval: vi.fn(), + answerTicketStep: vi.fn(), + postTicketMessage: vi.fn(), + editTicketMessage: vi.fn(), + cancelStep: vi.fn(), + setProjectScriptTrust: vi.fn(), + getTicketDetail: vi.fn(), + getTicketDiff: vi.fn(), + intakeTickets: vi.fn(), + listTicketArtifacts: vi.fn(), + getWebhookConfig: vi.fn(), + getBoardDigest: vi.fn(), + getBoardMetrics: vi.fn(), + dryRunBoard: vi.fn(), + listNeedsAttentionTickets: vi.fn(), + listWorkSourceConnections: vi.fn(), + createWorkSourceConnection: vi.fn(), + deleteWorkSourceConnection: vi.fn(), + listOutboundConnections: vi.fn(), + createOutboundConnection: vi.fn(), + deleteOutboundConnection: vi.fn(), + importBoard: vi.fn(), + proposeBoardImprovement: vi.fn(), + listBoardProposals: vi.fn(), + getBoardProposal: vi.fn(), + resolveBoardProposal: vi.fn(), + revertBoardProposal: vi.fn(), + listImportableWorkItems: vi.fn(), + importWorkItems: vi.fn(), + }, server: { getConfig: vi.fn(), refreshProviders: vi.fn(), diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 893a79da194..cab9a9b695b 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -34,10 +34,24 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even } const terminalAttachListeners = new Set<(event: TerminalAttachStreamEvent) => void>(); +const terminalHistoryAttachListeners = new Set<(event: TerminalHistoryAttachEvent) => void>(); const terminalMetadataListeners = new Set<(event: TerminalMetadataStreamEvent) => void>(); const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); +interface TerminalHistoryAttachEvent { + readonly type: "snapshot" | "output"; + readonly snapshot?: { + readonly threadId: string; + readonly terminalId: string; + readonly history: string; + readonly status: string | null; + }; + readonly threadId?: string; + readonly terminalId?: string; + readonly data?: string; +} + const rpcClientMock = { dispose: vi.fn(), terminal: { @@ -45,6 +59,9 @@ const rpcClientMock = { attach: vi.fn((_input: unknown, listener: (event: TerminalAttachStreamEvent) => void) => registerListener(terminalAttachListeners, listener), ), + attachHistory: vi.fn((_input: unknown, listener: (event: TerminalHistoryAttachEvent) => void) => + registerListener(terminalHistoryAttachListeners, listener), + ), write: vi.fn(), resize: vi.fn(), clear: vi.fn(), @@ -111,6 +128,23 @@ const rpcClientMock = { review: { getDiffPreview: vi.fn(), }, + workflow: { + listBoards: vi.fn(), + createBoard: vi.fn(), + renameBoard: vi.fn(), + getBoard: vi.fn(), + getBoardDefinition: vi.fn(), + saveBoardDefinition: vi.fn(), + subscribeBoard: vi.fn(() => () => undefined), + createTicket: vi.fn(), + moveTicket: vi.fn(), + runLane: vi.fn(), + resolveApproval: vi.fn(), + cancelStep: vi.fn(), + setProjectScriptTrust: vi.fn(), + getTicketDetail: vi.fn(), + getTicketDiff: vi.fn(), + }, server: { getConfig: vi.fn(), refreshProviders: vi.fn(), @@ -358,6 +392,7 @@ beforeEach(() => { vi.clearAllMocks(); showContextMenuFallbackMock.mockReset(); terminalAttachListeners.clear(); + terminalHistoryAttachListeners.clear(); terminalMetadataListeners.clear(); shellStreamListeners.clear(); gitStatusListeners.clear(); @@ -386,15 +421,29 @@ describe("wsApi", () => { expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); }); - it("forwards terminal attach, metadata, and shell stream events", async () => { + it("forwards terminal attach, history attach, metadata, and shell stream events", async () => { const { createEnvironmentApi } = await import("./environmentApi"); const api = createEnvironmentApi(rpcClientMock as never); const onTerminalAttachEvent = vi.fn(); + const onTerminalHistoryAttachEvent = vi.fn(); const onTerminalMetadataEvent = vi.fn(); const onShellEvent = vi.fn(); api.terminal.attach({ threadId: "thread-1", terminalId: "terminal-1" }, onTerminalAttachEvent); + const attachHistory = ( + api.terminal as unknown as { + readonly attachHistory: ( + input: { readonly threadId: string; readonly terminalId: string }, + callback: (event: TerminalHistoryAttachEvent) => void, + ) => () => void; + } + ).attachHistory; + expect(typeof attachHistory).toBe("function"); + attachHistory( + { threadId: "script-thread-1", terminalId: "script-terminal-1" }, + onTerminalHistoryAttachEvent, + ); api.terminal.onMetadata(onTerminalMetadataEvent); api.orchestration.subscribeShell(onShellEvent); @@ -406,6 +455,17 @@ describe("wsApi", () => { } satisfies TerminalAttachStreamEvent; emitEvent(terminalAttachListeners, terminalAttachEvent); + const terminalHistoryAttachEvent = { + type: "snapshot", + snapshot: { + threadId: "script-thread-1", + terminalId: "script-terminal-1", + history: "script output\n", + status: null, + }, + } satisfies TerminalHistoryAttachEvent; + emitEvent(terminalHistoryAttachListeners, terminalHistoryAttachEvent); + const terminalMetadataEvent = { type: "upsert", terminal: { @@ -443,6 +503,7 @@ describe("wsApi", () => { emitEvent(shellStreamListeners, shellEvent); expect(onTerminalAttachEvent).toHaveBeenCalledWith(terminalAttachEvent); + expect(onTerminalHistoryAttachEvent).toHaveBeenCalledWith(terminalHistoryAttachEvent); expect(onTerminalMetadataEvent).toHaveBeenCalledWith(terminalMetadataEvent); expect(onShellEvent).toHaveBeenCalledWith(shellEvent); }); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..19c9cb3eccd 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -13,14 +13,17 @@ import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' +import { Route as SettingsWorkSourcesRouteImport } from './routes/settings.work-sources' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsOutboundRouteImport } from './routes/settings.outbound' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' +import { Route as ChatEnvironmentIdBoardRouteImport } from './routes/_chat.$environmentId.board' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' const SettingsRoute = SettingsRouteImport.update({ @@ -42,6 +45,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) +const SettingsWorkSourcesRoute = SettingsWorkSourcesRouteImport.update({ + id: '/work-sources', + path: '/work-sources', + getParentRoute: () => SettingsRoute, +} as any) const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ id: '/source-control', path: '/source-control', @@ -52,6 +60,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) +const SettingsOutboundRoute = SettingsOutboundRouteImport.update({ + id: '/outbound', + path: '/outbound', + getParentRoute: () => SettingsRoute, +} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -82,6 +95,11 @@ const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ path: '/draft/$draftId', getParentRoute: () => ChatRoute, } as any) +const ChatEnvironmentIdBoardRoute = ChatEnvironmentIdBoardRouteImport.update({ + id: '/$environmentId/board', + path: '/$environmentId/board', + getParentRoute: () => ChatRoute, +} as any) const ChatEnvironmentIdThreadIdRoute = ChatEnvironmentIdThreadIdRouteImport.update({ id: '/$environmentId/$threadId', @@ -98,9 +116,12 @@ export interface FileRoutesByFullPath { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/outbound': typeof SettingsOutboundRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute + '/settings/work-sources': typeof SettingsWorkSourcesRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute + '/$environmentId/board': typeof ChatEnvironmentIdBoardRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRoutesByTo { @@ -111,10 +132,13 @@ export interface FileRoutesByTo { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/outbound': typeof SettingsOutboundRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute + '/settings/work-sources': typeof SettingsWorkSourcesRoute '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute + '/$environmentId/board': typeof ChatEnvironmentIdBoardRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRoutesById { @@ -127,10 +151,13 @@ export interface FileRoutesById { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/outbound': typeof SettingsOutboundRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute + '/settings/work-sources': typeof SettingsWorkSourcesRoute '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute + '/_chat/$environmentId/board': typeof ChatEnvironmentIdBoardRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRouteTypes { @@ -144,9 +171,12 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/outbound' | '/settings/providers' | '/settings/source-control' + | '/settings/work-sources' | '/$environmentId/$threadId' + | '/$environmentId/board' | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo to: @@ -157,10 +187,13 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/outbound' | '/settings/providers' | '/settings/source-control' + | '/settings/work-sources' | '/' | '/$environmentId/$threadId' + | '/$environmentId/board' | '/draft/$draftId' id: | '__root__' @@ -172,10 +205,13 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/outbound' | '/settings/providers' | '/settings/source-control' + | '/settings/work-sources' | '/_chat/' | '/_chat/$environmentId/$threadId' + | '/_chat/$environmentId/board' | '/_chat/draft/$draftId' fileRoutesById: FileRoutesById } @@ -215,6 +251,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } + '/settings/work-sources': { + id: '/settings/work-sources' + path: '/work-sources' + fullPath: '/settings/work-sources' + preLoaderRoute: typeof SettingsWorkSourcesRouteImport + parentRoute: typeof SettingsRoute + } '/settings/source-control': { id: '/settings/source-control' path: '/source-control' @@ -229,6 +272,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } + '/settings/outbound': { + id: '/settings/outbound' + path: '/outbound' + fullPath: '/settings/outbound' + preLoaderRoute: typeof SettingsOutboundRouteImport + parentRoute: typeof SettingsRoute + } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -271,6 +321,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatDraftDraftIdRouteImport parentRoute: typeof ChatRoute } + '/_chat/$environmentId/board': { + id: '/_chat/$environmentId/board' + path: '/$environmentId/board' + fullPath: '/$environmentId/board' + preLoaderRoute: typeof ChatEnvironmentIdBoardRouteImport + parentRoute: typeof ChatRoute + } '/_chat/$environmentId/$threadId': { id: '/_chat/$environmentId/$threadId' path: '/$environmentId/$threadId' @@ -284,12 +341,14 @@ declare module '@tanstack/react-router' { interface ChatRouteChildren { ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute + ChatEnvironmentIdBoardRoute: typeof ChatEnvironmentIdBoardRoute ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute } const ChatRouteChildren: ChatRouteChildren = { ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, + ChatEnvironmentIdBoardRoute: ChatEnvironmentIdBoardRoute, ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, } @@ -301,8 +360,10 @@ interface SettingsRouteChildren { SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsOutboundRoute: typeof SettingsOutboundRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute + SettingsWorkSourcesRoute: typeof SettingsWorkSourcesRoute } const SettingsRouteChildren: SettingsRouteChildren = { @@ -311,8 +372,10 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsOutboundRoute: SettingsOutboundRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, + SettingsWorkSourcesRoute: SettingsWorkSourcesRoute, } const SettingsRouteWithChildren = SettingsRoute._addFileChildren( diff --git a/apps/web/src/routes/-boardRouteState.test.ts b/apps/web/src/routes/-boardRouteState.test.ts new file mode 100644 index 00000000000..f03d2090446 --- /dev/null +++ b/apps/web/src/routes/-boardRouteState.test.ts @@ -0,0 +1,237 @@ +import { + MessageId, + StepRunId, + type EnvironmentApi, + type TicketAttachment, + TicketId, +} from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + filterBoardStateByQuery, + getBoardRouteEmptyState, + submitTicketAnswerFromBoardRoute, + submitTicketEditFromBoardRoute, + submitTicketMessageEditFromBoardRoute, +} from "./_chat.$environmentId.board"; + +describe("getBoardRouteEmptyState", () => { + it("distinguishes no selection from a missing requested board", () => { + expect(getBoardRouteEmptyState({ boardId: null, boardLoadError: null })).toEqual({ + title: "No board selected.", + description: null, + }); + + expect( + getBoardRouteEmptyState({ + boardId: "project-1__missing" as never, + boardLoadError: "Workflow board project-1__missing was not found", + }), + ).toEqual({ + title: "Board not found.", + description: "Workflow board project-1__missing was not found", + }); + }); +}); + +describe("board route ticket actions", () => { + it("returns the answer RPC promise and reloads only after it resolves", async () => { + let resolveAnswer: (() => void) | undefined; + const rpcPromise = new Promise<void>((resolve) => { + resolveAnswer = resolve; + }); + const api = { + workflow: { + answerTicketStep: vi.fn(() => rpcPromise), + }, + } as unknown as EnvironmentApi; + const reloadTicketDetail = vi.fn(); + const attachments = [] satisfies ReadonlyArray<TicketAttachment>; + + const result = submitTicketAnswerFromBoardRoute( + api, + { + stepRunId: "step-awaiting", + text: "Use the compatibility guard.", + attachments, + }, + reloadTicketDetail, + ); + + expect(api.workflow.answerTicketStep).toHaveBeenCalledWith({ + stepRunId: StepRunId.make("step-awaiting"), + text: "Use the compatibility guard.", + attachments, + }); + expect(reloadTicketDetail).not.toHaveBeenCalled(); + + resolveAnswer?.(); + await expect(result).resolves.toBeUndefined(); + expect(reloadTicketDetail).toHaveBeenCalledOnce(); + }); + + it("propagates answer RPC failures without reloading", async () => { + const api = { + workflow: { + answerTicketStep: vi.fn(async () => { + throw new Error("answer failed"); + }), + }, + } as unknown as EnvironmentApi; + const reloadTicketDetail = vi.fn(); + + await expect( + submitTicketAnswerFromBoardRoute( + api, + { stepRunId: "step-awaiting", text: "Try again." }, + reloadTicketDetail, + ), + ).rejects.toThrow("answer failed"); + expect(reloadTicketDetail).not.toHaveBeenCalled(); + }); + + it("rejects ticket actions when the environment API is unavailable", async () => { + await expect( + submitTicketAnswerFromBoardRoute( + null, + { stepRunId: "step-awaiting", text: "Try again." }, + vi.fn(), + ), + ).rejects.toThrow("Environment API unavailable."); + + await expect( + submitTicketEditFromBoardRoute( + undefined, + { ticketId: "ticket-1", title: "Updated" }, + vi.fn(), + ), + ).rejects.toThrow("Environment API unavailable."); + }); + + it("returns the edit RPC promise and reloads only after it resolves", async () => { + const api = { + workflow: { + editTicket: vi.fn(async () => undefined), + }, + } as unknown as EnvironmentApi; + const reloadTicketDetail = vi.fn(); + + await expect( + submitTicketEditFromBoardRoute( + api, + { + ticketId: "ticket-1", + title: "Retitle", + description: "", + }, + reloadTicketDetail, + ), + ).resolves.toBeUndefined(); + + expect(api.workflow.editTicket).toHaveBeenCalledWith({ + ticketId: TicketId.make("ticket-1"), + title: "Retitle", + description: "", + }); + expect(reloadTicketDetail).toHaveBeenCalledOnce(); + }); + + it("edits a ticket message and reloads only after the RPC resolves", async () => { + let resolveEdit: (() => void) | undefined; + const rpcPromise = new Promise<void>((resolve) => { + resolveEdit = resolve; + }); + const api = { + workflow: { + editTicketMessage: vi.fn(() => rpcPromise), + }, + } as unknown as EnvironmentApi; + const reloadTicketDetail = vi.fn(); + + const result = submitTicketMessageEditFromBoardRoute( + api, + { ticketId: "ticket-1", messageId: "message-1", body: "Updated body." }, + reloadTicketDetail, + ); + + expect(api.workflow.editTicketMessage).toHaveBeenCalledWith({ + ticketId: TicketId.make("ticket-1"), + messageId: MessageId.make("message-1"), + body: "Updated body.", + }); + expect(reloadTicketDetail).not.toHaveBeenCalled(); + + resolveEdit?.(); + await expect(result).resolves.toBeUndefined(); + expect(reloadTicketDetail).toHaveBeenCalledOnce(); + }); + + it("rejects ticket message edits when the environment API is unavailable", async () => { + await expect( + submitTicketMessageEditFromBoardRoute( + null, + { ticketId: "ticket-1", messageId: "message-1", body: "Updated body." }, + vi.fn(), + ), + ).rejects.toThrow("Environment API unavailable."); + }); +}); + +describe("filterBoardStateByQuery", () => { + const state = { + projectId: "p1", + boardId: "b1", + boardName: "Board", + lanes: [ + { + key: "work", + name: "Work", + entry: "auto", + pipelineStepCount: 1, + admittedTicketIds: ["t1", "t2"], + queuedTicketIds: ["t3"], + }, + ], + ticketIds: ["t1", "t2", "t3"], + ticketById: { + t1: { + ticketId: "t1", + title: "Fix login flow", + currentLaneKey: "work", + status: "running", + }, + t2: { + ticketId: "t2", + title: "Polish dashboard", + description: "Charts misalign on login", + currentLaneKey: "work", + status: "idle", + }, + t3: { + ticketId: "t3", + title: "Unrelated chore", + currentLaneKey: "work", + status: "queued", + queuedAt: "2026-06-09T00:00:00.000Z", + }, + }, + }; + + it("returns the same state for an empty query", () => { + expect(filterBoardStateByQuery(state, " ")).toBe(state); + }); + + it("matches titles and descriptions case-insensitively", () => { + const filtered = filterBoardStateByQuery(state, "LOGIN"); + expect(filtered.ticketIds).toEqual(["t1", "t2"]); + expect(filtered.lanes[0]?.admittedTicketIds).toEqual(["t1", "t2"]); + expect(filtered.lanes[0]?.queuedTicketIds).toEqual([]); + }); + + it("filters queued tickets too", () => { + const filtered = filterBoardStateByQuery(state, "chore"); + expect(filtered.ticketIds).toEqual(["t3"]); + expect(filtered.lanes[0]?.admittedTicketIds).toEqual([]); + expect(filtered.lanes[0]?.queuedTicketIds).toEqual(["t3"]); + }); +}); diff --git a/apps/web/src/routes/-workflowEditorSurface.test.ts b/apps/web/src/routes/-workflowEditorSurface.test.ts new file mode 100644 index 00000000000..792b7576fcb --- /dev/null +++ b/apps/web/src/routes/-workflowEditorSurface.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vite-plus/test"; + +import boardRouteSource from "./_chat.$environmentId.board.tsx?raw"; + +describe("workflow editor route surface", () => { + it("mounts workflow editing in a full-screen surface, not the right panel sheet", () => { + expect(boardRouteSource).toContain("WorkflowEditorFullscreen"); + expect(boardRouteSource).not.toMatch(/<RightPanelSheet[\s\S]*<WorkflowEditor/); + }); +}); diff --git a/apps/web/src/routes/_chat.$environmentId.board.tsx b/apps/web/src/routes/_chat.$environmentId.board.tsx new file mode 100644 index 00000000000..5cbe142f294 --- /dev/null +++ b/apps/web/src/routes/_chat.$environmentId.board.tsx @@ -0,0 +1,909 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { scopeProjectRef } from "@t3tools/client-runtime"; +import { + type AgentSelection, + type BoardSnapshot, + BoardId, + EnvironmentId, + type EnvironmentApi, + LaneKey, + MessageId, + ProjectId, + StepRunId, + type TicketAttachment, + TicketId, + type WorkflowDefinitionEncoded, + type WorkflowTicketDetailView, +} from "@t3tools/contracts"; +import { DatabaseIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { BoardHeaderControls } from "../components/board/BoardHeaderControls"; +import { BoardView } from "../components/board/BoardView"; +import { WorkflowEditor } from "../components/board/editor/WorkflowEditor"; +import { WorkflowEditorFullscreen } from "../components/board/editor/WorkflowEditorFullscreen"; +import { TicketDrawer } from "../components/board/TicketDrawer"; +import { RightPanelSheet } from "../components/RightPanelSheet"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { readEnvironmentApi } from "../environmentApi"; +import { boardCacheKey, selectProjectByRef, useStore } from "../store"; +import { countNeedsAttention } from "../workflow/agingFormat"; +import { useNowTick } from "../workflow/useNowTick"; +import { emptyBoardState, type BoardState } from "../workflow/boardState"; +import { + answerTicketStep, + createTicket, + editTicket, + editTicketMessage, + moveTicket, + postTicketMessage, + resolveApproval, + subscribeBoard, +} from "../workflow/boardRpc"; + +export interface BoardRouteSearch { + readonly boardId?: string | undefined; + /** Deep-link target: opens this ticket's drawer on load (notifications/webhooks). */ + readonly ticket?: string | undefined; +} + +export interface BoardRouteEmptyState { + readonly title: string; + readonly description: string | null; +} + +export function getBoardRouteEmptyState(input: { + readonly boardId: BoardId | null; + readonly boardLoadError: string | null; +}): BoardRouteEmptyState | null { + if (!input.boardId) { + return { + title: "No board selected.", + description: null, + }; + } + + if (input.boardLoadError) { + return { + title: "Board not found.", + description: input.boardLoadError, + }; + } + + return null; +} + +const parseBoardRouteSearch = (search: Record<string, unknown>): BoardRouteSearch => { + const boardId = typeof search.boardId === "string" ? search.boardId.trim() : ""; + const ticket = typeof search.ticket === "string" ? search.ticket.trim() : ""; + return { ...(boardId ? { boardId } : {}), ...(ticket ? { ticket } : {}) }; +}; + +export interface BoardRouteAnswerInput { + readonly stepRunId: string; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray<TicketAttachment> | undefined; +} + +export interface BoardRouteEditInput { + readonly ticketId: string; + readonly title?: string | undefined; + readonly description?: string | undefined; +} + +export interface BoardRouteMessageEditInput { + readonly ticketId: string; + readonly messageId: string; + readonly body: string; +} + +const environmentApiUnavailable = () => new Error("Environment API unavailable."); + +// Max consecutive 2s polls while waiting for a running agent step's dispatch +// thread to appear (~30s). Bounds the self-re-arming detail poll so a stalled +// dispatch can't refetch getTicketDetail forever for every open drawer. +const MAX_THREAD_POLL_ATTEMPTS = 15; + +export const submitTicketAnswerFromBoardRoute = ( + api: EnvironmentApi | null | undefined, + input: BoardRouteAnswerInput, + reloadTicketDetail: () => void, +): Promise<void> => { + if (!api) { + return Promise.reject(environmentApiUnavailable()); + } + + return answerTicketStep(api, { + stepRunId: StepRunId.make(input.stepRunId), + ...(input.text === undefined ? {} : { text: input.text }), + ...(input.attachments === undefined ? {} : { attachments: input.attachments }), + }).then(reloadTicketDetail); +}; + +export const submitTicketEditFromBoardRoute = ( + api: EnvironmentApi | null | undefined, + input: BoardRouteEditInput, + reloadTicketDetail: () => void, +): Promise<void> => { + if (!api) { + return Promise.reject(environmentApiUnavailable()); + } + + return editTicket(api, { + ticketId: TicketId.make(input.ticketId), + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.description === undefined ? {} : { description: input.description }), + }).then(reloadTicketDetail); +}; + +export const submitTicketMessageEditFromBoardRoute = ( + api: EnvironmentApi | null | undefined, + input: BoardRouteMessageEditInput, + reloadTicketDetail: () => void, +): Promise<void> => { + if (!api) { + return Promise.reject(environmentApiUnavailable()); + } + + return editTicketMessage(api, { + ticketId: TicketId.make(input.ticketId), + messageId: MessageId.make(input.messageId), + body: input.body, + }).then(reloadTicketDetail); +}; + +function WorkflowBoardRouteView() { + const { environmentId: rawEnvironmentId } = Route.useParams(); + const { boardId: rawBoardId, ticket: rawTicket } = Route.useSearch(); + const [selectedTicketId, setSelectedTicketId] = useState<TicketId | null>(null); + const [ticketDetail, setTicketDetail] = useState<WorkflowTicketDetailView | null>(null); + const [ticketDetailError, setTicketDetailError] = useState<string | null>(null); + const [ticketDetailReloadKey, setTicketDetailReloadKey] = useState(0); + const [boardLoadError, setBoardLoadError] = useState<string | null>(null); + const [boardHasSources, setBoardHasSources] = useState(false); + const [editorOpen, setEditorOpen] = useState(false); + // Incremented each time the board-level "Set up a source" CTA is clicked. + // Passed to WorkflowEditor so it can open the Sources wizard on mount. + const [editorSourcesTrigger, setEditorSourcesTrigger] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const ticketStatusRef = useRef(new Map<string, string>()); + const selectedTicketIdRef = useRef<TicketId | null>(null); + selectedTicketIdRef.current = selectedTicketId; + const lastDetailTicketIdRef = useRef<string | null>(null); + // Bounds the "wait for the dispatch thread" poll below so a step that never + // gets a providerThreadId (stalled dispatch) can't re-poll getTicketDetail + // every 2s for the lifetime of the open drawer. + const threadPollAttemptsRef = useRef(0); + const environmentId = useMemo(() => EnvironmentId.make(rawEnvironmentId), [rawEnvironmentId]); + const boardId = useMemo(() => (rawBoardId ? BoardId.make(rawBoardId) : null), [rawBoardId]); + const routeApi = readEnvironmentApi(environmentId); + const state = useStore((store) => + boardId + ? (store.boardStateById[boardCacheKey(environmentId, boardId)] ?? emptyBoardState) + : emptyBoardState, + ); + const ticketCwd = useStore((store) => { + const boardState = boardId + ? store.boardStateById[boardCacheKey(environmentId, boardId)] + : undefined; + const projectId = boardState?.projectId; + return projectId + ? selectProjectByRef(store, scopeProjectRef(environmentId, ProjectId.make(projectId)))?.cwd + : undefined; + }); + const emptyState = getBoardRouteEmptyState({ boardId, boardLoadError }); + + useEffect(() => { + setBoardLoadError(null); + if (!boardId) { + setEditorOpen(false); + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) { + setBoardLoadError("Environment API unavailable."); + return; + } + + let cancelled = false; + void api.workflow.getBoard({ boardId }).then( + () => { + if (!cancelled) { + setBoardLoadError(null); + } + }, + (error: unknown) => { + if (!cancelled) { + setBoardLoadError(errorMessage(error)); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [boardId, environmentId]); + + useEffect(() => { + setBoardHasSources(false); + if (!boardId) { + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + + let cancelled = false; + void api.workflow.getBoardDefinition({ boardId }).then( + ({ definition }) => { + if (!cancelled) { + setBoardHasSources((definition.sources?.length ?? 0) > 0); + } + }, + () => { + // Silently ignore: the button simply won't appear if the definition + // can't be loaded (e.g. network error, board not found). + if (!cancelled) { + setBoardHasSources(false); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [boardId, environmentId]); + + useEffect(() => { + // The ticket drawer selection (and its detail/error state) is scoped to a + // single board/environment. When either changes, close the drawer so it + // can't linger open on a ticket that isn't part of the current board. + setSelectedTicketId(null); + setTicketDetail(null); + setTicketDetailError(null); + }, [boardId, environmentId]); + + useEffect(() => { + // Deep link: a notification/webhook URL targets a specific ticket via the + // `ticket` search param. Seed the drawer selection from it — declared AFTER + // the board-switch reset above so it isn't immediately cleared on load. + if (rawTicket) { + setSelectedTicketId(TicketId.make(rawTicket)); + } else { + // The `ticket` param is absent (e.g. back/forward navigation away from a + // deep link) — treat its removal as authoritative and close the drawer so + // stale detail can't linger. This effect re-runs only when rawTicket/board/ + // env change, so an in-app manual selection (which doesn't touch the param) + // is never clobbered. + setSelectedTicketId(null); + } + }, [rawTicket, boardId, environmentId]); + + useEffect(() => { + if (!boardId) { + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + + // Drop any ticket statuses carried over from a previously-viewed board so + // stale ticket IDs can't fire spurious status-change toasts after a switch. + ticketStatusRef.current.clear(); + + return subscribeBoard(api, environmentId, boardId, { + onSnapshot: (snapshot) => { + // Re-seed from scratch so the first ticket-stream update after a + // snapshot reads as a transition (or not) against fresh statuses, and + // so a re-snapshot for a new board never leaves stale entries behind. + ticketStatusRef.current.clear(); + for (const ticket of snapshot.tickets) { + ticketStatusRef.current.set(ticket.ticketId, ticket.status); + } + }, + onTicketUpdate: (ticket) => { + if (ticket.ticketId === selectedTicketIdRef.current) { + setTicketDetailReloadKey((key) => key + 1); + } + const previousStatus = ticketStatusRef.current.get(ticket.ticketId); + ticketStatusRef.current.set(ticket.ticketId, ticket.status); + notifyTicketStatusChange(ticket, previousStatus, selectedTicketIdRef.current); + }, + }); + }, [boardId, environmentId]); + + useEffect(() => { + // A running agent step gets its dispatch thread shortly after StepStarted + // is broadcast; poll the detail briefly so the live activity feed appears + // without waiting for the next workflow event. + if (!ticketDetail) { + return; + } + const needsThread = ticketDetail.steps.some( + (step) => + step.stepType === "agent" && + (step.status === "running" || step.status === "dispatch_requested") && + step.providerThreadId === undefined, + ); + if (!needsThread) { + // Thread arrived (or the step left the running/dispatch state): reset the + // budget so a later step in the same ticket gets a fresh window. + threadPollAttemptsRef.current = 0; + return; + } + // Cap the poll. The thread normally appears within a couple of seconds; if a + // dispatch stalls and never projects a providerThreadId, stop after ~30s + // (workflow-event broadcasts still refresh the detail) instead of polling + // getTicketDetail forever for every open drawer. + if (threadPollAttemptsRef.current >= MAX_THREAD_POLL_ATTEMPTS) { + return; + } + const timer = setTimeout(() => { + threadPollAttemptsRef.current += 1; + setTicketDetailReloadKey((key) => key + 1); + }, 2_000); + return () => clearTimeout(timer); + }, [ticketDetail]); + + const visibleState = useMemo( + () => filterBoardStateByQuery(state, searchQuery), + [state, searchQuery], + ); + + useEffect(() => { + if (!selectedTicketId) { + lastDetailTicketIdRef.current = null; + setTicketDetail(null); + setTicketDetailError(null); + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) { + setTicketDetail(null); + setTicketDetailError("Environment API unavailable."); + return; + } + + let cancelled = false; + // Only clear the rendered detail when the selection actually changed + // (scoped to the environment/board so stale detail never survives a + // navigation); same-ticket revalidation keeps the previous detail (and + // the drawer's in-progress state) while the refresh is in flight. + const detailKey = `${environmentId}:${boardId ?? ""}:${selectedTicketId}`; + if (lastDetailTicketIdRef.current !== detailKey) { + lastDetailTicketIdRef.current = detailKey; + // New ticket selected: restart the thread-poll budget. + threadPollAttemptsRef.current = 0; + setTicketDetail(null); + } + setTicketDetailError(null); + + void api.workflow.getTicketDetail({ ticketId: selectedTicketId }).then( + (detail) => { + if (!cancelled) { + setTicketDetail(detail); + } + }, + (error: unknown) => { + if (!cancelled) { + setTicketDetailError(errorMessage(error)); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [environmentId, boardId, selectedTicketId, ticketDetailReloadKey]); + + const handleMove = useCallback( + (ticketId: string, toLane: string): Promise<void> => { + const api = readEnvironmentApi(environmentId); + if (!api) { + return Promise.resolve(); + } + + // moveTicket fails on a not-found ticket (e.g. it was deleted, or already + // moved by another client between render and drop). The drag/drop onMove + // contract is fire-and-forget, so catch here: surface a brief toast and + // refresh the board snapshot (the ticket may be gone or in a new lane) + // instead of leaking an unhandled rejection or showing a scary error. + return moveTicket(api, TicketId.make(ticketId), LaneKey.make(toLane)).then(undefined, () => { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Couldn't move ticket", + description: "It may have already moved or been deleted. Refreshing the board.", + }), + ); + if (boardId) { + void api.workflow.getBoard({ boardId }).then(undefined, () => undefined); + } + }); + }, + [environmentId, boardId], + ); + const handleOpenTicket = useCallback((ticketId: string) => { + setEditorOpen(false); + setSelectedTicketId(TicketId.make(ticketId)); + }, []); + const closeTicketDrawer = useCallback(() => { + setSelectedTicketId(null); + }, []); + const reloadTicketDetail = useCallback(() => { + setTicketDetailReloadKey((key) => key + 1); + }, []); + const handleApprove = useCallback( + (stepRunId: string, approved: boolean): Promise<void> => { + const api = readEnvironmentApi(environmentId); + if (!api) { + return Promise.reject(environmentApiUnavailable()); + } + + return resolveApproval(api, StepRunId.make(stepRunId), approved).then(reloadTicketDetail); + }, + [environmentId, reloadTicketDetail], + ); + const handleAnswerStep = useCallback( + (input: BoardRouteAnswerInput): Promise<void> => { + const api = readEnvironmentApi(environmentId); + return submitTicketAnswerFromBoardRoute(api, input, reloadTicketDetail); + }, + [environmentId, reloadTicketDetail], + ); + const handlePostComment = useCallback( + (input: { + readonly ticketId: string; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray<TicketAttachment> | undefined; + }): Promise<void> => { + const api = readEnvironmentApi(environmentId); + if (!api) { + return Promise.reject(environmentApiUnavailable()); + } + return postTicketMessage(api, { + ticketId: TicketId.make(input.ticketId), + ...(input.text === undefined ? {} : { text: input.text }), + ...(input.attachments === undefined ? {} : { attachments: input.attachments }), + }).then(reloadTicketDetail); + }, + [environmentId, reloadTicketDetail], + ); + const handleEditTicket = useCallback( + (input: BoardRouteEditInput): Promise<void> => { + const api = readEnvironmentApi(environmentId); + return submitTicketEditFromBoardRoute(api, input, reloadTicketDetail); + }, + [environmentId, reloadTicketDetail], + ); + const handleEditMessage = useCallback( + (messageId: string, body: string): Promise<void> => { + if (!selectedTicketId) { + return Promise.reject(environmentApiUnavailable()); + } + const api = readEnvironmentApi(environmentId); + return submitTicketMessageEditFromBoardRoute( + api, + { ticketId: selectedTicketId, messageId, body }, + reloadTicketDetail, + ); + }, + [environmentId, reloadTicketDetail, selectedTicketId], + ); + const handleRunLane = useCallback(() => { + if (!selectedTicketId) { + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + + // Mirror handleMove: a runLane RPC can reject (lane not runnable, script + // trust revoked between render and click, server error). Surface a toast + // and still reload the detail so the drawer reflects current state, instead + // of leaking an unhandled rejection with no user feedback. + void api.workflow.runLane({ ticketId: selectedTicketId }).then(reloadTicketDetail, (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't run the lane", + description: actionErrorMessage(error), + }), + ); + reloadTicketDetail(); + }); + }, [environmentId, reloadTicketDetail, selectedTicketId]); + const handleDrawerMove = useCallback( + (toLane: string): Promise<void> => { + if (!selectedTicketId) { + return Promise.resolve(); + } + + // Await the move RPC before reloading the detail so the drawer doesn't + // briefly render the stale lane/actions while the move commits. + return handleMove(selectedTicketId, toLane).then(reloadTicketDetail); + }, + [handleMove, reloadTicketDetail, selectedTicketId], + ); + const handleCreateTicket = useCallback( + (input: { + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: string; + readonly dependsOn?: ReadonlyArray<string> | undefined; + readonly tokenBudget?: number | undefined; + }) => { + if (!boardId) { + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + + // The New-ticket form closes its dialog synchronously after calling this, + // so a rejected create (validation, duplicate, budget, server error) would + // otherwise be fully silent. Surface a toast on failure instead of leaking + // an unhandled rejection. + void createTicket(api, { + boardId, + title: input.title, + ...(input.description === undefined ? {} : { description: input.description }), + initialLane: LaneKey.make(input.initialLane), + ...(input.dependsOn === undefined || input.dependsOn.length === 0 + ? {} + : { dependsOn: input.dependsOn.map((ticketId) => TicketId.make(ticketId)) }), + ...(input.tokenBudget === undefined ? {} : { tokenBudget: input.tokenBudget }), + }).then(undefined, (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Couldn't create "${input.title}"`, + description: actionErrorMessage(error), + }), + ); + }); + }, + [boardId, environmentId], + ); + const handleCreateTicketAsync = useCallback( + async (input: { + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: string; + readonly dependsOn?: ReadonlyArray<string> | undefined; + }) => { + if (!boardId) { + throw new Error("No board selected."); + } + const api = readEnvironmentApi(environmentId); + if (!api) { + throw new Error("Environment API unavailable."); + } + const created = await createTicket(api, { + boardId, + title: input.title, + ...(input.description === undefined ? {} : { description: input.description }), + initialLane: LaneKey.make(input.initialLane), + ...(input.dependsOn === undefined || input.dependsOn.length === 0 + ? {} + : { dependsOn: input.dependsOn.map((ticketId) => TicketId.make(ticketId)) }), + }); + return created.ticketId as string; + }, + [boardId, environmentId], + ); + const handleProposeTickets = useCallback( + async (braindump: string, agent: AgentSelection) => { + if (!boardId) { + throw new Error("No board selected."); + } + const api = readEnvironmentApi(environmentId); + if (!api) { + throw new Error("Environment API unavailable."); + } + const result = await api.workflow.intakeTickets({ boardId, braindump, agent }); + return result.proposals; + }, + [boardId, environmentId], + ); + const handleFetchDigest = useCallback(async () => { + if (!boardId) { + throw new Error("No board selected."); + } + const api = readEnvironmentApi(environmentId); + if (!api) { + throw new Error("Environment API unavailable."); + } + return await api.workflow.getBoardDigest({ boardId }); + }, [boardId, environmentId]); + const handleFetchMetrics = useCallback( + async (windowDays: 1 | 7 | 30) => { + if (!boardId) { + throw new Error("No board selected."); + } + const api = readEnvironmentApi(environmentId); + if (!api) { + throw new Error("Environment API unavailable."); + } + return await api.workflow.getBoardMetrics({ boardId, windowDays }); + }, + [boardId, environmentId], + ); + const handleFetchWebhookConfig = useCallback( + async (rotate: boolean) => { + if (!boardId) { + throw new Error("No board selected."); + } + const api = readEnvironmentApi(environmentId); + if (!api) { + throw new Error("Environment API unavailable."); + } + return await api.workflow.getWebhookConfig({ boardId, ...(rotate ? { rotate } : {}) }); + }, + [boardId, environmentId], + ); + const attentionNow = useNowTick(60_000); + const needsAttentionCount = useMemo( + () => + countNeedsAttention( + state.ticketIds + .map((ticketId) => state.ticketById[ticketId]) + .filter((ticket) => ticket !== undefined), + attentionNow, + ), + [state.ticketIds, state.ticketById, attentionNow], + ); + const handleRefresh = useCallback(() => { + if (!boardId) { + return; + } + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + void api.workflow.getBoard({ boardId }).then(undefined, () => undefined); + }, [boardId, environmentId]); + + const handleToggleWorkflowEditor = useCallback(() => { + setEditorOpen((open) => { + const nextOpen = !open; + if (nextOpen) { + setSelectedTicketId(null); + } + return nextOpen; + }); + }, []); + + /** Opens the editor directly to the Sources wizard (board empty-state CTA). */ + const handleOpenEditorToSources = useCallback(() => { + setSelectedTicketId(null); + setEditorOpen(true); + setEditorSourcesTrigger((n) => n + 1); + }, []); + const handleWorkflowSaved = useCallback( + (snapshot: BoardSnapshot, definition: WorkflowDefinitionEncoded) => { + useStore.getState().applyBoardStreamItem(environmentId, snapshot.board.boardId, { + kind: "snapshot", + snapshot, + }); + void routeApi?.workflow + .listBoards({ projectId: snapshot.projectId }) + .then((entries) => + useStore + .getState() + .setProjectBoards(scopeProjectRef(environmentId, snapshot.projectId), entries), + ); + // Derive whether the board now has sources from the saved definition + // rather than assuming any save implies sources exist — a lane rename or + // settings change triggers onSaved too, and must not dismiss the CTA. + setBoardHasSources((definition.sources?.length ?? 0) > 0); + }, + [environmentId, routeApi], + ); + const closeWorkflowEditor = useCallback(() => { + setEditorOpen(false); + }, []); + + return ( + <> + <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh"> + <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background"> + <header className="flex min-h-11 shrink-0 items-center gap-2 border-b border-border px-3"> + <SidebarTrigger className="size-7 shrink-0 md:hidden" /> + <div className="min-w-0"> + <h1 className="truncate text-sm font-medium text-foreground"> + {state.boardName || "Workflow Board"} + </h1> + </div> + {boardId ? ( + <Input + aria-label="Search tickets" + className="ml-auto h-7 w-44 max-w-[40vw] md:w-56" + placeholder="Search tickets…" + value={searchQuery} + onChange={(event) => setSearchQuery(event.currentTarget.value)} + /> + ) : null} + <BoardHeaderControls + boardId={boardId} + lanes={state.lanes} + tickets={state.ticketIds.map((ticketId) => ({ + ticketId, + title: state.ticketById[ticketId]?.title ?? ticketId, + }))} + workflowEditorOpen={editorOpen} + api={routeApi} + onCreateTicket={handleCreateTicket} + onProposeTickets={handleProposeTickets} + onCreateTicketAsync={handleCreateTicketAsync} + onToggleWorkflowEditor={handleToggleWorkflowEditor} + needsAttentionCount={needsAttentionCount} + onFetchDigest={handleFetchDigest} + onFetchMetrics={handleFetchMetrics} + onFetchWebhookConfig={handleFetchWebhookConfig} + boardHasSources={boardHasSources} + onRefresh={handleRefresh} + /> + </header> + {emptyState ? ( + <div className="flex min-h-0 flex-1 items-center justify-center px-4 text-center text-sm text-muted-foreground"> + <div className="max-w-md space-y-1"> + <div>{emptyState.title}</div> + {emptyState.description ? ( + <div className="text-xs text-muted-foreground/80">{emptyState.description}</div> + ) : null} + </div> + </div> + ) : ( + <div className="flex min-h-0 flex-1 flex-col"> + <BoardView state={visibleState} onMove={handleMove} onOpen={handleOpenTicket} /> + {boardId && !boardHasSources ? ( + <div className="flex shrink-0 items-center justify-between gap-3 border-t border-border bg-muted/20 px-4 py-2"> + <p className="text-xs text-muted-foreground"> + No sources configured. Tickets from GitHub Issues or Asana can be pulled in + automatically. + </p> + <Button + size="xs" + variant="outline" + className="shrink-0" + onClick={handleOpenEditorToSources} + > + <DatabaseIcon className="size-3.5" /> + Set up a source + </Button> + </div> + ) : null} + </div> + )} + </div> + </SidebarInset> + <WorkflowEditorFullscreen open={editorOpen && boardId !== null} onClose={closeWorkflowEditor}> + {boardId && routeApi ? ( + <WorkflowEditor + key={boardId} + api={routeApi} + boardId={boardId} + onClose={closeWorkflowEditor} + onSaved={handleWorkflowSaved} + openSourcesWizardOnMount={editorSourcesTrigger} + /> + ) : ( + <div className="flex h-full items-center justify-center px-4 text-sm text-muted-foreground"> + Environment API unavailable. + </div> + )} + </WorkflowEditorFullscreen> + <RightPanelSheet open={selectedTicketId !== null} onClose={closeTicketDrawer}> + {ticketDetail ? ( + <TicketDrawer + api={routeApi} + detail={ticketDetail} + lanes={state.lanes} + onAnswerStep={handleAnswerStep} + onPostComment={handlePostComment} + onEditMessage={handleEditMessage} + onApprove={handleApprove} + onEditTicket={handleEditTicket} + onMove={handleDrawerMove} + onRunLane={handleRunLane} + projectId={state.projectId ? ProjectId.make(state.projectId) : undefined} + cwd={ticketCwd} + /> + ) : ( + <div className="flex h-full items-center justify-center px-4 text-sm text-muted-foreground"> + {ticketDetailError ?? "Loading ticket..."} + </div> + )} + </RightPanelSheet> + </> + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unable to load ticket detail."; +} + +/** Error text for a failed action (create/run) toast, with a neutral fallback. */ +function actionErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Something went wrong. Please try again."; +} + +export function filterBoardStateByQuery(state: BoardState, query: string): BoardState { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) { + return state; + } + const matches = (ticketId: string): boolean => { + const ticket = state.ticketById[ticketId]; + if (!ticket) { + return false; + } + return ( + ticket.title.toLowerCase().includes(needle) || + (ticket.description?.toLowerCase().includes(needle) ?? false) + ); + }; + return { + ...state, + ticketIds: state.ticketIds.filter(matches), + lanes: state.lanes.map((lane) => ({ + ...lane, + admittedTicketIds: lane.admittedTicketIds.filter(matches), + queuedTicketIds: lane.queuedTicketIds.filter(matches), + })), + }; +} + +function notifyTicketStatusChange( + ticket: { readonly ticketId: string; readonly title: string; readonly status: string }, + previousStatus: string | undefined, + openTicketId: TicketId | null, +): void { + if ( + previousStatus === undefined || + previousStatus === ticket.status || + openTicketId === ticket.ticketId + ) { + return; + } + if (ticket.status === "waiting_on_user") { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: `"${ticket.title}" is waiting on you`, + description: "Open the ticket to answer or approve.", + }), + ); + return; + } + if (ticket.status === "failed" || ticket.status === "blocked") { + // Pipeline failures with no route project as "blocked", so both statuses + // mean the same thing to the user: this ticket needs attention. + toastManager.add( + stackedThreadToast({ + type: "error", + title: `"${ticket.title}" needs attention`, + description: "Open the ticket to see what went wrong.", + }), + ); + } +} + +export const Route = createFileRoute("/_chat/$environmentId/board")({ + validateSearch: parseBoardRouteSearch, + component: WorkflowBoardRouteView, +}); diff --git a/apps/web/src/routes/settings.outbound.tsx b/apps/web/src/routes/settings.outbound.tsx new file mode 100644 index 00000000000..fce2a0dfee9 --- /dev/null +++ b/apps/web/src/routes/settings.outbound.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { OutboundConnectionsSettings } from "../components/settings/OutboundConnectionsSettings"; + +export const Route = createFileRoute("/settings/outbound")({ + component: OutboundConnectionsSettings, +}); diff --git a/apps/web/src/routes/settings.work-sources.tsx b/apps/web/src/routes/settings.work-sources.tsx new file mode 100644 index 00000000000..59b9b6ab48d --- /dev/null +++ b/apps/web/src/routes/settings.work-sources.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { WorkSourceConnectionsSettings } from "../components/settings/WorkSourceConnectionsSettings"; + +export const Route = createFileRoute("/settings/work-sources")({ + component: WorkSourceConnectionsSettings, +}); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 2fe06d518d2..ad2d0fc82a7 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -56,6 +56,8 @@ function withActiveEnvironmentState( return { activeEnvironmentId, environmentStateById, + boardStateById: {}, + boardsByScopedProjectKey: {}, }; } @@ -262,6 +264,8 @@ describe("environment state removal", () => { [remoteEnvironmentId]: removedState, [localEnvironmentId]: keptState, }, + boardStateById: {}, + boardsByScopedProjectKey: {}, }; const next = removeEnvironmentState(state, remoteEnvironmentId); @@ -421,6 +425,8 @@ describe("setThreadBranch", () => { [localEnvironmentId]: environmentStateOf(makeState(localThread), localEnvironmentId), [remoteEnvironmentId]: environmentStateOf(makeState(remoteThread), remoteEnvironmentId), }, + boardStateById: {}, + boardsByScopedProjectKey: {}, }; const next = setThreadBranch( diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 9a9b05f92f2..52e59bd61c2 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,4 +1,7 @@ import type { + BoardId, + BoardListEntry, + BoardStreamItem, EnvironmentId, MessageId, OrchestrationCheckpointSummary, @@ -18,6 +21,7 @@ import type { ScopedProjectRef, ScopedThreadRef, } from "@t3tools/contracts"; +import { scopedProjectKey } from "@t3tools/client-runtime"; import { isProviderDriverKind, ProviderDriverKind } from "@t3tools/contracts"; import type { ThreadId, TurnId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -36,6 +40,11 @@ import { } from "./types"; import { sanitizeThreadErrorMessage } from "./rpc/transportError"; import { getThreadFromEnvironmentState } from "./threadDerivation"; +import { + applyBoardStreamItem as reduceBoardStreamItem, + emptyBoardState, + type BoardState, +} from "./workflow/boardState"; const isProviderDriverKindValue = Schema.is(ProviderDriverKind); export interface EnvironmentState { @@ -98,6 +107,8 @@ export interface EnvironmentState { export interface AppState { activeEnvironmentId: EnvironmentId | null; environmentStateById: Record<string, EnvironmentState>; + boardStateById: Record<string, BoardState>; + boardsByScopedProjectKey: Record<string, ReadonlyArray<BoardListEntry>>; } const initialEnvironmentState: EnvironmentState = { @@ -123,6 +134,8 @@ const initialEnvironmentState: EnvironmentState = { const initialState: AppState = { activeEnvironmentId: null, environmentStateById: {}, + boardStateById: {}, + boardsByScopedProjectKey: {}, }; const MAX_THREAD_MESSAGES = 2_000; @@ -1910,6 +1923,15 @@ export function selectThreadIdsByProjectRef( : EMPTY_THREAD_IDS; } +const EMPTY_BOARD_LIST: ReadonlyArray<BoardListEntry> = []; + +export function selectBoardsForProject( + state: AppState, + projectRef: ScopedProjectRef, +): ReadonlyArray<BoardListEntry> { + return state.boardsByScopedProjectKey[scopedProjectKey(projectRef)] ?? EMPTY_BOARD_LIST; +} + export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { if (state.activeEnvironmentId === null) { return state; @@ -1975,11 +1997,18 @@ export function removeEnvironmentState(state: AppState, environmentId: Environme } const { [environmentId]: _removed, ...environmentStateById } = state.environmentStateById; + // Drop any board caches scoped to this environment so they can't leak into a + // future environment that reuses the same id. + const boardPrefix = `${environmentId}:`; + const boardStateById = Object.fromEntries( + Object.entries(state.boardStateById).filter(([key]) => !key.startsWith(boardPrefix)), + ); return { ...state, activeEnvironmentId: state.activeEnvironmentId === environmentId ? null : state.activeEnvironmentId, environmentStateById, + boardStateById, }; } @@ -2006,6 +2035,46 @@ export function setThreadBranch( return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); } +/** + * Cache key for `boardStateById`. Board ids are only unique within an + * environment, so the cache must be scoped by environment too — otherwise + * switching environments can briefly flash another env's lanes/tickets for a + * board that happens to share an id until a fresh snapshot arrives. + */ +export function boardCacheKey(environmentId: EnvironmentId, boardId: BoardId): string { + return `${environmentId}:${boardId}`; +} + +export function applyWorkflowBoardStreamItem( + state: AppState, + environmentId: EnvironmentId, + boardId: BoardId, + item: BoardStreamItem, +): AppState { + const key = boardCacheKey(environmentId, boardId); + return { + ...state, + boardStateById: { + ...state.boardStateById, + [key]: reduceBoardStreamItem(state.boardStateById[key] ?? emptyBoardState, item), + }, + }; +} + +export function applyBoardList( + state: AppState, + projectRef: ScopedProjectRef, + entries: ReadonlyArray<BoardListEntry>, +): AppState { + return { + ...state, + boardsByScopedProjectKey: { + ...state.boardsByScopedProjectKey, + [scopedProjectKey(projectRef)]: entries, + }, + }; +} + interface AppStore extends AppState { setActiveEnvironmentId: (environmentId: EnvironmentId) => void; removeEnvironmentState: (environmentId: EnvironmentId) => void; @@ -2026,6 +2095,12 @@ interface AppStore extends AppState { branch: string | null, worktreePath: string | null, ) => void; + applyBoardStreamItem: ( + environmentId: EnvironmentId, + boardId: BoardId, + item: BoardStreamItem, + ) => void; + setProjectBoards: (projectRef: ScopedProjectRef, entries: ReadonlyArray<BoardListEntry>) => void; } export const useStore = create<AppStore>((set) => ({ @@ -2047,4 +2122,8 @@ export const useStore = create<AppStore>((set) => ({ setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadRef, branch, worktreePath) => set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), + applyBoardStreamItem: (environmentId, boardId, item) => + set((state) => applyWorkflowBoardStreamItem(state, environmentId, boardId, item)), + setProjectBoards: (projectRef, entries) => + set((state) => applyBoardList(state, projectRef, entries)), })); diff --git a/apps/web/src/vite-raw.d.ts b/apps/web/src/vite-raw.d.ts new file mode 100644 index 00000000000..8e6dc861ff1 --- /dev/null +++ b/apps/web/src/vite-raw.d.ts @@ -0,0 +1,4 @@ +declare module "*?raw" { + const source: string; + export default source; +} diff --git a/apps/web/src/workflow/agingFormat.test.ts b/apps/web/src/workflow/agingFormat.test.ts new file mode 100644 index 00000000000..88c7869ccb6 --- /dev/null +++ b/apps/web/src/workflow/agingFormat.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { countNeedsAttention, ticketAging } from "./agingFormat.ts"; + +const NOW = Date.parse("2026-06-10T12:00:00.000Z"); +const minutesAgo = (minutes: number) => new Date(NOW - minutes * 60 * 1000).toISOString(); + +describe("ticketAging", () => { + it("ignores healthy and fresh tickets", () => { + expect(ticketAging({ status: "running", updatedAt: minutesAgo(600) }, NOW)).toBeNull(); + expect(ticketAging({ status: "waiting_on_user", updatedAt: minutesAgo(5) }, NOW)).toBeNull(); + expect(ticketAging({ status: "waiting_on_user" }, NOW)).toBeNull(); + }); + + it("warns after 30 minutes and alerts after 2 hours", () => { + const warn = ticketAging({ status: "waiting_on_user", updatedAt: minutesAgo(45) }, NOW); + expect(warn?.level).toBe("warn"); + expect(warn?.label).toContain("needs you"); + + const alert = ticketAging({ status: "blocked", updatedAt: minutesAgo(180) }, NOW); + expect(alert?.level).toBe("alert"); + expect(alert?.label).toContain("blocked"); + }); + + it("counts tickets needing attention", () => { + expect( + countNeedsAttention( + [ + { status: "waiting_on_user", updatedAt: minutesAgo(45) }, + { status: "running", updatedAt: minutesAgo(45) }, + { status: "blocked", updatedAt: minutesAgo(200) }, + ], + NOW, + ), + ).toBe(2); + }); +}); diff --git a/apps/web/src/workflow/agingFormat.ts b/apps/web/src/workflow/agingFormat.ts new file mode 100644 index 00000000000..20e68367037 --- /dev/null +++ b/apps/web/src/workflow/agingFormat.ts @@ -0,0 +1,43 @@ +import { formatDuration } from "~/session-logic"; + +const WARN_AFTER_MS = 30 * 60 * 1000; +const ALERT_AFTER_MS = 2 * 60 * 60 * 1000; + +export interface TicketAging { + readonly level: "warn" | "alert"; + readonly label: string; +} + +/** + * "The board nags you": tickets stuck waiting on a human (or blocked) for + * long enough get a visible age. Warn after 30 minutes, alert after 2 hours. + */ +export const ticketAging = ( + ticket: { readonly status: string; readonly updatedAt?: string | undefined }, + nowMs: number, +): TicketAging | null => { + if (ticket.status !== "waiting_on_user" && ticket.status !== "blocked") { + return null; + } + if (ticket.updatedAt === undefined) { + return null; + } + const since = Date.parse(ticket.updatedAt); + if (!Number.isFinite(since)) { + return null; + } + const ageMs = nowMs - since; + if (ageMs < WARN_AFTER_MS) { + return null; + } + const verb = ticket.status === "blocked" ? "blocked" : "needs you"; + return { + level: ageMs >= ALERT_AFTER_MS ? "alert" : "warn", + label: `${verb} · ${formatDuration(ageMs)}`, + }; +}; + +export const countNeedsAttention = ( + tickets: ReadonlyArray<{ readonly status: string; readonly updatedAt?: string | undefined }>, + nowMs: number, +): number => tickets.filter((ticket) => ticketAging(ticket, nowMs) !== null).length; diff --git a/apps/web/src/workflow/boardListState.test.ts b/apps/web/src/workflow/boardListState.test.ts new file mode 100644 index 00000000000..65610d9269a --- /dev/null +++ b/apps/web/src/workflow/boardListState.test.ts @@ -0,0 +1,54 @@ +import { scopeProjectRef } from "@t3tools/client-runtime"; +import { BoardId, EnvironmentId, ProjectId, type BoardListEntry } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { applyBoardList, selectBoardsForProject, type AppState } from "../store"; + +const projectId = ProjectId.make("project-board-list"); +const otherProjectId = ProjectId.make("project-other"); +const localEnvironmentId = EnvironmentId.make("environment-local"); +const remoteEnvironmentId = EnvironmentId.make("environment-remote"); +const localProjectRef = scopeProjectRef(localEnvironmentId, projectId); +const remoteProjectRef = scopeProjectRef(remoteEnvironmentId, projectId); +const otherProjectRef = scopeProjectRef(localEnvironmentId, otherProjectId); + +const entry = (slug: string, name: string): BoardListEntry => ({ + boardId: BoardId.make(`${projectId}__${slug}`), + name, + filePath: `.t3/boards/${slug}.json`, + error: null, +}); + +const makeState = (): AppState => ({ + activeEnvironmentId: null, + environmentStateById: {}, + boardStateById: {}, + boardsByScopedProjectKey: {}, +}); + +describe("board list store slice", () => { + it("stores, selects, and replaces project board entries", () => { + const first = [entry("delivery", "Delivery")]; + const second = [entry("triage", "Triage")]; + const empty = makeState(); + + expect(selectBoardsForProject(empty, localProjectRef)).toEqual([]); + + const withBoards = applyBoardList(empty, localProjectRef, first); + expect(selectBoardsForProject(withBoards, localProjectRef)).toEqual(first); + expect(selectBoardsForProject(withBoards, otherProjectRef)).toEqual([]); + + const replaced = applyBoardList(withBoards, localProjectRef, second); + expect(selectBoardsForProject(replaced, localProjectRef)).toEqual(second); + }); + + it("keeps board lists isolated for environments sharing a project id", () => { + const localBoards = [entry("local", "Local")]; + const remoteBoards = [entry("remote", "Remote")]; + const withLocalBoards = applyBoardList(makeState(), localProjectRef, localBoards); + const withBothBoards = applyBoardList(withLocalBoards, remoteProjectRef, remoteBoards); + + expect(selectBoardsForProject(withBothBoards, localProjectRef)).toEqual(localBoards); + expect(selectBoardsForProject(withBothBoards, remoteProjectRef)).toEqual(remoteBoards); + }); +}); diff --git a/apps/web/src/workflow/boardRpc.test.ts b/apps/web/src/workflow/boardRpc.test.ts new file mode 100644 index 00000000000..e9db466c0e3 --- /dev/null +++ b/apps/web/src/workflow/boardRpc.test.ts @@ -0,0 +1,91 @@ +import { + BoardId, + type AgentSelection, + type BoardListEntry, + type BoardSnapshot, + type EnvironmentApi, + type ProjectId, + StepRunId, + TicketId, +} from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + answerTicketStep, + createBoard, + deleteBoard, + editTicket, + listBoards, + renameBoard, +} from "./boardRpc"; + +describe("boardRpc", () => { + it("delegates listBoards and createBoard through the workflow EnvironmentApi", async () => { + const projectId = "project-web" as ProjectId; + const boardId = BoardId.make("project-web__delivery"); + const agent = { instance: "codex_main", model: "gpt-5.5" } satisfies AgentSelection; + const entries = [ + { + boardId, + name: "Delivery", + filePath: ".t3/boards/delivery.json", + error: null, + }, + ] satisfies BoardListEntry[]; + const snapshot = { + projectId, + board: { boardId, name: "Delivery", lanes: [] }, + tickets: [], + } satisfies BoardSnapshot; + const api = { + workflow: { + listBoards: vi.fn(async () => entries), + createBoard: vi.fn(async () => ({ + boardId, + snapshot, + })), + deleteBoard: vi.fn(async () => undefined), + renameBoard: vi.fn(async () => undefined), + answerTicketStep: vi.fn(async () => undefined), + editTicket: vi.fn(async () => undefined), + }, + } as unknown as EnvironmentApi; + + await expect(listBoards(api, projectId)).resolves.toBe(entries); + await expect(createBoard(api, { projectId, name: "Delivery", agent })).resolves.toEqual({ + boardId, + snapshot, + }); + await expect(deleteBoard(api, boardId)).resolves.toBeUndefined(); + await expect(renameBoard(api, boardId, "Renamed Delivery")).resolves.toBeUndefined(); + await expect( + answerTicketStep(api, { + stepRunId: StepRunId.make("step-1"), + text: "Use sandbox.", + attachments: [], + }), + ).resolves.toBeUndefined(); + await expect( + editTicket(api, { + ticketId: TicketId.make("ticket-1"), + title: "Updated", + description: "", + }), + ).resolves.toBeUndefined(); + + expect(api.workflow.listBoards).toHaveBeenCalledWith({ projectId }); + expect(api.workflow.createBoard).toHaveBeenCalledWith({ projectId, name: "Delivery", agent }); + expect(api.workflow.deleteBoard).toHaveBeenCalledWith({ boardId }); + expect(api.workflow.renameBoard).toHaveBeenCalledWith({ boardId, name: "Renamed Delivery" }); + expect(api.workflow.answerTicketStep).toHaveBeenCalledWith({ + stepRunId: StepRunId.make("step-1"), + text: "Use sandbox.", + attachments: [], + }); + expect(api.workflow.editTicket).toHaveBeenCalledWith({ + ticketId: TicketId.make("ticket-1"), + title: "Updated", + description: "", + }); + }); +}); diff --git a/apps/web/src/workflow/boardRpc.ts b/apps/web/src/workflow/boardRpc.ts new file mode 100644 index 00000000000..797f0e21927 --- /dev/null +++ b/apps/web/src/workflow/boardRpc.ts @@ -0,0 +1,125 @@ +import type { + BoardId, + BoardSnapshot, + BoardStreamItem, + BoardTicketView, + EnvironmentApi, + EnvironmentId, + LaneKey, + ProjectId, + StepRunId, + TicketId, + WorkflowImportBoardInput, + WorkflowCreateWorkflowBoardInput, + WorkflowGenerateWorkflowDraftInput, + WorkflowProposeBoardImprovementInput, + WorkflowResolveBoardProposalInput, +} from "@t3tools/contracts"; + +import { useStore } from "../store"; + +interface SubscriptionOptions { + readonly onResubscribe?: () => void; + readonly onSnapshot?: (snapshot: BoardSnapshot) => void; + readonly onTicketUpdate?: (ticket: BoardTicketView) => void; +} + +export const subscribeBoard = ( + api: EnvironmentApi, + environmentId: EnvironmentId, + boardId: BoardId, + options?: SubscriptionOptions, +): (() => void) => + api.workflow.subscribeBoard( + { boardId }, + (item: BoardStreamItem) => { + useStore.getState().applyBoardStreamItem(environmentId, boardId, item); + if (item.kind === "snapshot") { + options?.onSnapshot?.(item.snapshot); + } + if (item.kind === "ticket") { + options?.onTicketUpdate?.(item.ticket); + } + }, + options, + ); + +export const createTicket = ( + api: EnvironmentApi, + input: Parameters<EnvironmentApi["workflow"]["createTicket"]>[0], +) => api.workflow.createTicket(input); + +export const listBoards = (api: EnvironmentApi, projectId: ProjectId) => + api.workflow.listBoards({ projectId }); + +export const createBoard = ( + api: EnvironmentApi, + input: Parameters<EnvironmentApi["workflow"]["createBoard"]>[0], +) => api.workflow.createBoard(input); + +export const importBoard = (api: EnvironmentApi, input: WorkflowImportBoardInput) => + api.workflow.importBoard(input); + +export const createWorkflowBoard = (api: EnvironmentApi, input: WorkflowCreateWorkflowBoardInput) => + api.workflow.createWorkflowBoard(input); + +export const generateWorkflowDraft = ( + api: EnvironmentApi, + input: WorkflowGenerateWorkflowDraftInput, +) => api.workflow.generateWorkflowDraft(input); + +export const listBoardTemplates = (api: EnvironmentApi) => api.workflow.listBoardTemplates({}); + +export const deleteBoard = (api: EnvironmentApi, boardId: BoardId) => + api.workflow.deleteBoard({ boardId }); + +export const renameBoard = (api: EnvironmentApi, boardId: BoardId, name: string) => + api.workflow.renameBoard({ boardId, name }); + +export const editTicket = ( + api: EnvironmentApi, + input: Parameters<EnvironmentApi["workflow"]["editTicket"]>[0], +) => api.workflow.editTicket(input); + +export const moveTicket = (api: EnvironmentApi, ticketId: TicketId, toLane: LaneKey) => + api.workflow.moveTicket({ ticketId, toLane }); + +export const resolveApproval = (api: EnvironmentApi, stepRunId: StepRunId, approved: boolean) => + api.workflow.resolveApproval({ stepRunId, approved }); + +export const postTicketMessage = ( + api: EnvironmentApi, + input: Parameters<EnvironmentApi["workflow"]["postTicketMessage"]>[0], +) => api.workflow.postTicketMessage(input); + +export const editTicketMessage = ( + api: EnvironmentApi, + input: Parameters<EnvironmentApi["workflow"]["editTicketMessage"]>[0], +) => api.workflow.editTicketMessage(input); + +export const answerTicketStep = ( + api: EnvironmentApi, + input: Parameters<EnvironmentApi["workflow"]["answerTicketStep"]>[0], +) => api.workflow.answerTicketStep(input); + +export const getTicketDiff = (api: EnvironmentApi, ticketId: TicketId) => + api.workflow.getTicketDiff({ ticketId }); + +export const proposeBoardImprovement = ( + api: EnvironmentApi, + input: WorkflowProposeBoardImprovementInput, +) => api.workflow.proposeBoardImprovement(input); + +export const listBoardProposals = (api: EnvironmentApi, boardId: BoardId) => + api.workflow.listBoardProposals({ boardId }); + +export const getBoardProposal = (api: EnvironmentApi, proposalId: string) => + api.workflow.getBoardProposal({ proposalId }); + +export const resolveBoardProposal = ( + api: EnvironmentApi, + input: WorkflowResolveBoardProposalInput, +) => api.workflow.resolveBoardProposal(input); + +export const revertBoardProposal = (api: EnvironmentApi, proposalId: string) => + api.workflow.revertBoardProposal({ proposalId }); diff --git a/apps/web/src/workflow/boardState.test.ts b/apps/web/src/workflow/boardState.test.ts new file mode 100644 index 00000000000..fdefecd7031 --- /dev/null +++ b/apps/web/src/workflow/boardState.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { applyBoardStreamItem, emptyBoardState } from "./boardState.ts"; + +describe("boardState", () => { + it("applies a snapshot then a ticket delta", () => { + let state = applyBoardStreamItem(emptyBoardState, { + kind: "snapshot", + snapshot: { + projectId: "project-1", + board: { + boardId: "b-1", + name: "Delivery", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual", pipelineStepCount: 0, wipLimit: 1 }, + { key: "done", name: "Done", entry: "manual", pipelineStepCount: 0, terminal: true }, + ], + }, + tickets: [ + { + ticketId: "t-1", + boardId: "b-1", + title: "X", + description: "Snapshot description", + currentLaneKey: "backlog", + status: "idle", + }, + { + ticketId: "t-queued", + boardId: "b-1", + title: "Queued", + currentLaneKey: "backlog", + queuedAt: "2026-06-07T00:00:00.000Z", + status: "queued", + }, + ], + }, + } as never); + expect(state.projectId).toBe("project-1"); + expect(state.ticketIds).toEqual(["t-1", "t-queued"]); + expect(state.lanes[0]?.wipLimit).toBe(1); + expect(state.lanes[0]?.admittedTicketIds).toEqual(["t-1"]); + expect(state.lanes[0]?.queuedTicketIds).toEqual(["t-queued"]); + expect(state.ticketById["t-1"]?.description).toBe("Snapshot description"); + expect(state.ticketById["t-queued"]?.queuedAt).toBe("2026-06-07T00:00:00.000Z"); + + state = applyBoardStreamItem(state, { + kind: "ticket", + ticket: { + ticketId: "t-queued", + boardId: "b-1", + title: "Queued", + description: "", + currentLaneKey: "done", + status: "done", + }, + } as never); + expect(state.ticketById["t-queued"]?.currentLaneKey).toBe("done"); + expect(state.ticketById["t-queued"]?.description).toBe(""); + expect(state.ticketById["t-queued"]?.queuedAt).toBeUndefined(); + expect(state.lanes[0]?.queuedTicketIds).toEqual([]); + expect(state.lanes[1]?.admittedTicketIds).toEqual(["t-queued"]); + }); +}); diff --git a/apps/web/src/workflow/boardState.ts b/apps/web/src/workflow/boardState.ts new file mode 100644 index 00000000000..87fa6852b54 --- /dev/null +++ b/apps/web/src/workflow/boardState.ts @@ -0,0 +1,162 @@ +import type { BoardStreamItem } from "@t3tools/contracts"; + +export interface BoardState { + readonly projectId: string | null; + readonly boardId: string | null; + readonly boardName: string; + readonly lanes: ReadonlyArray<{ + readonly key: string; + readonly name: string; + readonly entry: string; + readonly pipelineStepCount: number; + readonly wipLimit?: number | undefined; + readonly terminal?: boolean | undefined; + readonly actions?: + | ReadonlyArray<{ + readonly label: string; + readonly to: string; + readonly hint?: string | undefined; + }> + | undefined; + readonly admittedTicketIds: ReadonlyArray<string>; + readonly queuedTicketIds: ReadonlyArray<string>; + }>; + readonly ticketIds: ReadonlyArray<string>; + readonly ticketById: Record< + string, + { + readonly ticketId: string; + readonly title: string; + readonly description?: string | undefined; + readonly currentLaneKey: string; + readonly status: string; + readonly queuedAt?: string | undefined; + readonly totalTokens?: number | undefined; + readonly unresolvedDependencyCount?: number | undefined; + readonly tokenBudget?: number | undefined; + readonly updatedAt?: string | undefined; + readonly totalDurationMs?: number | undefined; + readonly pr?: + | { + readonly number: number; + readonly url: string; + readonly state: "open" | "merged" | "closed"; + readonly ciState?: "pending" | "success" | "failure" | undefined; + } + | undefined; + } + >; +} + +export const emptyBoardState: BoardState = { + projectId: null, + boardId: null, + boardName: "", + lanes: [], + ticketIds: [], + ticketById: {}, +}; + +const isQueuedTicket = (ticket: BoardState["ticketById"][string]): boolean => + ticket.status === "queued" || ticket.queuedAt !== undefined; + +const buildLaneGroups = ( + lanes: BoardState["lanes"], + ticketIds: ReadonlyArray<string>, + ticketById: BoardState["ticketById"], +): BoardState["lanes"] => + lanes.map((lane) => { + const admittedTicketIds: string[] = []; + const queuedTicketIds: string[] = []; + for (const ticketId of ticketIds) { + const ticket = ticketById[ticketId]; + if (!ticket || ticket.currentLaneKey !== lane.key) { + continue; + } + if (isQueuedTicket(ticket)) { + queuedTicketIds.push(ticketId); + } else { + admittedTicketIds.push(ticketId); + } + } + + return { + ...lane, + admittedTicketIds, + queuedTicketIds, + }; + }); + +export const applyBoardStreamItem = (state: BoardState, item: BoardStreamItem): BoardState => { + if (item.kind === "snapshot") { + const ticketById: BoardState["ticketById"] = {}; + for (const ticket of item.snapshot.tickets) { + ticketById[ticket.ticketId] = { + ticketId: ticket.ticketId, + title: ticket.title, + ...(ticket.description === undefined ? {} : { description: ticket.description }), + currentLaneKey: ticket.currentLaneKey, + status: ticket.status, + ...(ticket.queuedAt === undefined ? {} : { queuedAt: ticket.queuedAt }), + ...(ticket.totalTokens === undefined ? {} : { totalTokens: ticket.totalTokens }), + ...(ticket.unresolvedDependencyCount === undefined + ? {} + : { unresolvedDependencyCount: ticket.unresolvedDependencyCount }), + ...(ticket.tokenBudget === undefined ? {} : { tokenBudget: ticket.tokenBudget }), + ...(ticket.updatedAt === undefined ? {} : { updatedAt: ticket.updatedAt }), + ...(ticket.totalDurationMs === undefined + ? {} + : { totalDurationMs: ticket.totalDurationMs }), + ...(ticket.pr === undefined ? {} : { pr: ticket.pr }), + }; + } + const ticketIds = item.snapshot.tickets.map((ticket) => ticket.ticketId); + const lanes = buildLaneGroups( + item.snapshot.board.lanes.map((lane) => ({ + ...lane, + admittedTicketIds: [], + queuedTicketIds: [], + })), + ticketIds, + ticketById, + ); + + return { + projectId: item.snapshot.projectId, + boardId: item.snapshot.board.boardId, + boardName: item.snapshot.board.name, + lanes, + ticketIds, + ticketById, + }; + } + + const ticket = item.ticket; + const exists = state.ticketById[ticket.ticketId] !== undefined; + const ticketIds = exists ? state.ticketIds : [...state.ticketIds, ticket.ticketId]; + const ticketById = { + ...state.ticketById, + [ticket.ticketId]: { + ticketId: ticket.ticketId, + title: ticket.title, + ...(ticket.description === undefined ? {} : { description: ticket.description }), + currentLaneKey: ticket.currentLaneKey, + status: ticket.status, + ...(ticket.queuedAt === undefined ? {} : { queuedAt: ticket.queuedAt }), + ...(ticket.totalTokens === undefined ? {} : { totalTokens: ticket.totalTokens }), + ...(ticket.unresolvedDependencyCount === undefined + ? {} + : { unresolvedDependencyCount: ticket.unresolvedDependencyCount }), + ...(ticket.tokenBudget === undefined ? {} : { tokenBudget: ticket.tokenBudget }), + ...(ticket.updatedAt === undefined ? {} : { updatedAt: ticket.updatedAt }), + ...(ticket.totalDurationMs === undefined ? {} : { totalDurationMs: ticket.totalDurationMs }), + ...(ticket.pr === undefined ? {} : { pr: ticket.pr }), + }, + }; + return { + ...state, + lanes: buildLaneGroups(state.lanes, ticketIds, ticketById), + ticketIds, + ticketById, + }; +}; diff --git a/apps/web/src/workflow/downloadJson.ts b/apps/web/src/workflow/downloadJson.ts new file mode 100644 index 00000000000..c0b5d537156 --- /dev/null +++ b/apps/web/src/workflow/downloadJson.ts @@ -0,0 +1,14 @@ +export function downloadJson(filename: string, value: unknown): void { + const blob = new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + try { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + } finally { + URL.revokeObjectURL(url); + } +} diff --git a/apps/web/src/workflow/dryRunFormat.test.ts b/apps/web/src/workflow/dryRunFormat.test.ts new file mode 100644 index 00000000000..1e96ec75c65 --- /dev/null +++ b/apps/web/src/workflow/dryRunFormat.test.ts @@ -0,0 +1,69 @@ +import type { WorkflowDryRunResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { describeDryRunEnd, describeDryRunHop } from "./dryRunFormat"; + +const laneName = (key: string) => (key === "work" ? "Work" : key === "done" ? "Done" : key); + +describe("dryRunFormat", () => { + it("describes hops by source", () => { + expect( + describeDryRunHop( + { + fromLane: "work", + toLane: "done", + source: "step_on", + viaStepKey: "code", + result: "success", + } as never, + laneName, + ), + ).toBe('Work → Done — step "code" success route'); + expect( + describeDryRunHop( + { + fromLane: "work", + toLane: "done", + source: "lane_transition", + matchedTransitionIndex: 1, + result: "success", + } as never, + laneName, + ), + ).toBe("Work → Done — transition #2 matched"); + expect( + describeDryRunHop( + { fromLane: "work", toLane: "done", source: "lane_on", result: "failure" } as never, + laneName, + ), + ).toBe("Work → Done — lane failure fallback"); + }); + + it("describes end states", () => { + const base = { startLane: "work", scenario: "success", hops: [], notes: [] }; + expect( + describeDryRunEnd( + { ...base, end: "terminal", endLane: "done" } as unknown as WorkflowDryRunResult, + laneName, + ), + ).toBe('Reached terminal lane "Done".'); + expect( + describeDryRunEnd( + { ...base, end: "no_route", endLane: "work" } as unknown as WorkflowDryRunResult, + laneName, + ), + ).toContain("no route matched"); + expect( + describeDryRunEnd( + { ...base, end: "manual", endLane: "work" } as unknown as WorkflowDryRunResult, + laneName, + ), + ).toContain("manual lane"); + expect( + describeDryRunEnd( + { ...base, end: "cycle_cap", endLane: "work" } as unknown as WorkflowDryRunResult, + laneName, + ), + ).toContain("unbounded cycle"); + }); +}); diff --git a/apps/web/src/workflow/dryRunFormat.ts b/apps/web/src/workflow/dryRunFormat.ts new file mode 100644 index 00000000000..a9b335f797a --- /dev/null +++ b/apps/web/src/workflow/dryRunFormat.ts @@ -0,0 +1,33 @@ +import type { WorkflowDryRunHop, WorkflowDryRunResult } from "@t3tools/contracts"; + +/** One simulated hop as a sentence fragment for the dry-run result list. */ +export const describeDryRunHop = ( + hop: WorkflowDryRunHop, + laneName: (key: string) => string, +): string => { + const route = `${laneName(hop.fromLane as string)} → ${laneName(hop.toLane as string)}`; + if (hop.source === "step_on") { + return `${route} — step "${hop.viaStepKey ?? "?"}" ${hop.result} route`; + } + if (hop.source === "lane_transition") { + return `${route} — transition #${(hop.matchedTransitionIndex ?? 0) + 1} matched`; + } + return `${route} — lane ${hop.result} fallback`; +}; + +export const describeDryRunEnd = ( + run: WorkflowDryRunResult, + laneName: (key: string) => string, +): string => { + const lane = laneName(run.endLane as string); + switch (run.end) { + case "terminal": + return `Reached terminal lane "${lane}".`; + case "manual": + return `Waiting in "${lane}" for a human (manual lane).`; + case "no_route": + return `Stuck in "${lane}" — no route matched. Add a transition or fallback.`; + case "cycle_cap": + return `Still looping after ${run.hops.length} hops (ended in "${lane}") — likely an unbounded cycle.`; + } +}; diff --git a/apps/web/src/workflow/editorModel.test.ts b/apps/web/src/workflow/editorModel.test.ts new file mode 100644 index 00000000000..6bd6dce289a --- /dev/null +++ b/apps/web/src/workflow/editorModel.test.ts @@ -0,0 +1,516 @@ +import { + LaneKey, + WorkflowDefinition, + WorkflowDefinitionEncoded, + type WorkflowDefinitionEncoded as WorkflowDefinitionEncodedType, + type WorkflowLintError, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "@effect/vitest"; + +import { + addLane, + addLaneAction, + addLaneEvent, + addStep, + addTransition, + adjustSelectionAfterTransitionRemoval, + canonicalizeDefinitionJson, + createWorkflowEditorModel, + discardWorkflowChanges, + loadRevertedDefinition, + markWorkflowSaved, + normalizeSelection, + removeLane, + removeLaneAction, + removeLaneEvent, + removeStep, + removeTransition, + renameLane, + reorderStep, + setLaneColor, + setLaneEntry, + updateLaneAction, + updateLaneEvent, + setLaneOn, + setLaneTerminal, + setLaneWipLimit, + setWorkflowLintErrors, + updateStep, + updateTransition, + type WorkflowEditorSelection, +} from "./editorModel"; + +const decodeEditorDefinition = Schema.decodeUnknownEffect(WorkflowDefinitionEncoded); +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); + +const baseDefinition = { + name: "Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { + key: "run", + name: "Run", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "codex_main", model: "gpt-5.5" }, + instruction: "Review the diff.", + captureOutput: true, + on: { success: "done" }, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +} satisfies WorkflowDefinitionEncodedType; + +const expectDecodable = (definition: WorkflowDefinitionEncodedType) => + Effect.gen(function* () { + const encoded = yield* decodeEditorDefinition(definition); + yield* decodeWorkflowDefinition(encoded); + }); + +describe("workflow editor model", () => { + it.effect("tracks dirty state, lint errors, saved baselines, and discards", () => + Effect.gen(function* () { + const model = createWorkflowEditorModel(baseDefinition); + expect(model.dirty).toBe(false); + expect(model.lintErrors).toEqual([]); + + const renamed = renameLane(model, "queue", "Intake"); + expect(renamed).not.toBe(model); + expect(renamed.definition.lanes[0]?.name).toBe("Intake"); + expect(model.definition.lanes[0]?.name).toBe("Queue"); + expect(renamed.dirty).toBe(true); + + const lintErrors = [ + { code: "invalid_wip_limit", message: "Bad WIP", laneKey: LaneKey.make("queue") }, + ] satisfies WorkflowLintError[]; + const withErrors = setWorkflowLintErrors(renamed, lintErrors); + expect(withErrors.lintErrors).toEqual(lintErrors); + + const saved = markWorkflowSaved(withErrors, withErrors.definition); + expect(saved.dirty).toBe(false); + expect(saved.lintErrors).toEqual([]); + expect(saved.baselineDefinition.lanes[0]?.name).toBe("Intake"); + + const changedAgain = setLaneColor(saved, "queue", "#0ea5e9"); + const discarded = discardWorkflowChanges(changedAgain); + expect(discarded.dirty).toBe(false); + expect(discarded.definition).toEqual(saved.baselineDefinition); + yield* expectDecodable(discarded.definition); + }), + ); + + it("loads reverted definitions as dirty changes without adopting the old version as baseline", () => { + const model = createWorkflowEditorModel(baseDefinition); + const oldVersionDefinition = { + name: "Delivery v1", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + } satisfies WorkflowDefinitionEncodedType; + + const reverted = loadRevertedDefinition(model, oldVersionDefinition); + + expect(reverted.definition).toEqual(oldVersionDefinition); + expect(reverted.definition).not.toBe(oldVersionDefinition); + expect(reverted.baselineDefinition).toEqual(model.baselineDefinition); + expect(reverted.dirty).toBe(true); + expect(reverted.lintErrors).toEqual([]); + expect(reverted.pendingSaveSource).toBe("revert"); + + const discarded = discardWorkflowChanges(reverted); + expect(discarded.definition).toEqual(model.baselineDefinition); + expect(discarded.pendingSaveSource).toBeUndefined(); + + const saved = markWorkflowSaved(reverted, oldVersionDefinition); + expect(saved.pendingSaveSource).toBeUndefined(); + }); + + it("canonicalizes encoded workflow definitions with stable key order", () => { + const left = { + lanes: [ + { + name: "Run", + pipeline: [{ run: "pnpm test", type: "script", timeout: "5 minutes", key: "smoke" }], + entry: "auto", + key: "run", + }, + { terminal: true, entry: "manual", name: "Done", key: "done" }, + ], + name: "Canonical", + } satisfies WorkflowDefinitionEncodedType; + const right = { + name: "Canonical", + lanes: [ + { + key: "run", + entry: "auto", + pipeline: [{ key: "smoke", timeout: "5 minutes", type: "script", run: "pnpm test" }], + name: "Run", + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + } satisfies WorkflowDefinitionEncodedType; + + const canonical = canonicalizeDefinitionJson(left); + + expect(canonical).toBe(canonicalizeDefinitionJson(right)); + expect(canonical.split("\n").slice(0, 3)).toEqual(["{", ' "lanes": [', " {"]); + }); + + it.effect("mutates lanes immutably with unique keys and decodable encoded output", () => + Effect.gen(function* () { + let model = createWorkflowEditorModel(baseDefinition); + model = addLane(model); + model = addLane(model); + const addedKeys = model.definition.lanes.slice(-2).map((lane) => lane.key); + expect(addedKeys).toEqual(["new-lane", "new-lane-2"]); + + model = renameLane(model, "new-lane", "QA"); + model = setLaneEntry(model, "new-lane", "auto"); + model = setLaneWipLimit(model, "new-lane", 3); + model = setLaneTerminal(model, "new-lane", false); + model = setLaneColor(model, "new-lane", "#22c55e"); + + const lane = model.definition.lanes.find((candidate) => candidate.key === "new-lane"); + expect(lane).toEqual({ + key: "new-lane", + name: "QA", + entry: "auto", + wipLimit: 3, + terminal: false, + color: "#22c55e", + }); + + model = setLaneWipLimit(model, "new-lane", undefined); + model = setLaneColor(model, "new-lane", undefined); + model = removeLane(model, "new-lane-2"); + expect(model.definition.lanes.some((candidate) => candidate.key === "new-lane-2")).toBe( + false, + ); + yield* expectDecodable(model.definition); + }), + ); + + it.effect("adds, updates, reorders, and removes steps with decodable defaults", () => + Effect.gen(function* () { + let model = createWorkflowEditorModel(baseDefinition); + model = addStep(model, "queue", "agent"); + model = addStep(model, "queue", "script"); + model = addStep(model, "queue", "approval"); + + const steps = model.definition.lanes[0]?.pipeline ?? []; + expect(steps.map((step) => step.key)).toEqual(["agent", "script", "approval"]); + const agent = steps[0]; + expect(agent?.type).toBe("agent"); + if (agent?.type === "agent") { + expect(agent.agent).toEqual({ instance: "codex_main", model: "gpt-5.5" }); + expect(agent.instruction).toBe(""); + } + const script = steps[1]; + expect(script?.type).toBe("script"); + if (script?.type === "script") { + expect(script.run).toBe("true"); + } + + model = updateStep(model, "queue", "script", { + run: "pnpm test", + timeout: "5 minutes", + on: { failure: "run" }, + }); + const updatedScript = model.definition.lanes[0]?.pipeline?.find( + (step) => step.key === "script", + ); + expect(updatedScript?.type).toBe("script"); + if (updatedScript?.type === "script") { + expect(updatedScript.timeout).toBe("5 minutes"); + expect(updatedScript.on?.failure).toBe("run"); + } + + model = reorderStep(model, "queue", 2, 0); + expect(model.definition.lanes[0]?.pipeline?.map((step) => step.key)).toEqual([ + "approval", + "agent", + "script", + ]); + model = removeStep(model, "queue", "agent"); + expect(model.definition.lanes[0]?.pipeline?.map((step) => step.key)).toEqual([ + "approval", + "script", + ]); + yield* expectDecodable(model.definition); + }), + ); + + it.effect("mutates lane routing and transitions with parsed transition predicates", () => + Effect.gen(function* () { + let model = createWorkflowEditorModel(baseDefinition); + model = setLaneOn(model, "queue", "success", "run"); + model = setLaneOn(model, "queue", "failure", "done"); + model = setLaneOn(model, "queue", "failure", undefined); + expect(model.definition.lanes[0]?.on).toEqual({ success: "run" }); + + model = addTransition(model, "run"); + model = updateTransition(model, "run", 0, { + when: { "==": [{ var: "steps.review.output.verdict" }, "pass"] }, + to: "done", + }); + expect(model.definition.lanes[1]?.transitions?.[0]).toEqual({ + when: { "==": [{ var: "steps.review.output.verdict" }, "pass"] }, + to: "done", + }); + + model = removeTransition(model, "run", 0); + expect(model.definition.lanes[1]?.transitions).toBeUndefined(); + yield* expectDecodable(model.definition); + }), + ); + + it.effect("mutates lane external-event matchers", () => + Effect.gen(function* () { + let model = createWorkflowEditorModel(baseDefinition); + model = addLaneEvent(model, "run"); + expect(model.definition.lanes[1]?.onEvent?.[0]?.name).toBe("ci.passed"); + + model = updateLaneEvent(model, "run", 0, { + name: "ci.finished", + when: { "==": [{ var: "event.payload.status" }, "green"] }, + to: "done", + }); + expect(model.definition.lanes[1]?.onEvent?.[0]).toEqual({ + name: "ci.finished", + when: { "==": [{ var: "event.payload.status" }, "green"] }, + to: "done", + }); + + // when: null clears the predicate, undefined keeps it. + model = updateLaneEvent(model, "run", 0, { when: null }); + expect(model.definition.lanes[1]?.onEvent?.[0]).toEqual({ name: "ci.finished", to: "done" }); + yield* expectDecodable(model.definition); + + model = removeLaneEvent(model, "run", 0); + expect(model.definition.lanes[1]?.onEvent).toBeUndefined(); + yield* expectDecodable(model.definition); + }), + ); + + it.effect("drops lane action and event targets referencing a removed lane", () => + Effect.gen(function* () { + let model = createWorkflowEditorModel(baseDefinition); + model = addLaneAction(model, "queue"); + model = updateLaneAction(model, "queue", 0, { to: "run" }); + model = addLaneAction(model, "queue"); + model = updateLaneAction(model, "queue", 1, { to: "done" }); + model = addLaneEvent(model, "queue"); + model = updateLaneEvent(model, "queue", 0, { to: "run" }); + model = addLaneEvent(model, "queue"); + model = updateLaneEvent(model, "queue", 1, { to: "done" }); + + model = removeLane(model, "run"); + + expect(model.definition.lanes[0]?.actions).toEqual([{ label: "New action", to: "done" }]); + expect(model.definition.lanes[0]?.onEvent).toEqual([{ name: "ci.passed", to: "done" }]); + + model = removeLane(model, "done"); + expect(model.definition.lanes[0]?.actions).toBeUndefined(); + expect(model.definition.lanes[0]?.onEvent).toBeUndefined(); + yield* expectDecodable(model.definition); + }), + ); + + it("does not throw when transition patches contain invalid JSON text", () => { + let model = createWorkflowEditorModel(baseDefinition); + model = addTransition(model, "run"); + + expect(() => + updateTransition(model, "run", 0, { + when: "{", + }), + ).not.toThrow(); + }); + + it("normalizes stale lane, step, and transition selections after model mutations", () => { + let model = createWorkflowEditorModel(baseDefinition); + model = addTransition(model, "run"); + + const laneSelection = { + kind: "lane", + laneKey: "run", + } satisfies WorkflowEditorSelection; + const stepSelection = { + kind: "step", + laneKey: "run", + stepKey: "review", + } satisfies WorkflowEditorSelection; + const transitionSelection = { + kind: "transition", + laneKey: "run", + index: 0, + } satisfies WorkflowEditorSelection; + + expect(normalizeSelection(model, laneSelection)).toEqual(laneSelection); + expect(normalizeSelection(model, stepSelection)).toEqual(stepSelection); + expect(normalizeSelection(model, transitionSelection)).toEqual(transitionSelection); + + const withoutStep = removeStep(model, "run", "review"); + expect(normalizeSelection(withoutStep, stepSelection)).toEqual({ + kind: "lane", + laneKey: "run", + }); + + const withoutTransition = removeTransition(model, "run", 0); + expect(normalizeSelection(withoutTransition, transitionSelection)).toEqual({ + kind: "lane", + laneKey: "run", + }); + + const withoutLane = removeLane(model, "run"); + expect(normalizeSelection(withoutLane, laneSelection)).toBeNull(); + }); + + it("adjusts transition selections from the removed index before normalizing", () => { + const selectedTransition = { + kind: "transition", + laneKey: "run", + index: 1, + } satisfies WorkflowEditorSelection; + + expect(adjustSelectionAfterTransitionRemoval(selectedTransition, "run", 0)).toEqual({ + kind: "transition", + laneKey: "run", + index: 0, + }); + expect(adjustSelectionAfterTransitionRemoval(selectedTransition, "run", 1)).toEqual({ + kind: "lane", + laneKey: "run", + }); + expect(adjustSelectionAfterTransitionRemoval(selectedTransition, "run", 2)).toEqual( + selectedTransition, + ); + expect(adjustSelectionAfterTransitionRemoval(selectedTransition, "queue", 0)).toEqual( + selectedTransition, + ); + }); + + it("falls back to the lane when the selected transition is removed from a multi-transition lane", () => { + const model = createWorkflowEditorModel({ + ...baseDefinition, + lanes: baseDefinition.lanes.map((lane) => + lane.key === "run" + ? { + ...lane, + transitions: [ + { when: { "==": [{ var: "ticket.status" }, "queued"] }, to: "queue" }, + { when: { "==": [{ var: "ticket.status" }, "done"] }, to: "done" }, + { when: { "==": [{ var: "ticket.status" }, "retry"] }, to: "queue" }, + ], + } + : lane, + ), + }); + const selection = { + kind: "transition", + laneKey: "run", + index: 1, + } as const; + + const withoutSelectedTransition = removeTransition(model, "run", 1); + const adjustedSelection = adjustSelectionAfterTransitionRemoval(selection, "run", 1); + + expect(normalizeSelection(withoutSelectedTransition, adjustedSelection)).toEqual({ + kind: "lane", + laneKey: "run", + }); + }); + + it("keeps the same selected transition when an earlier transition is removed", () => { + const model = createWorkflowEditorModel({ + ...baseDefinition, + lanes: baseDefinition.lanes.map((lane) => + lane.key === "run" + ? { + ...lane, + transitions: [ + { when: { "==": [{ var: "ticket.status" }, "queued"] }, to: "queue" }, + { when: { "==": [{ var: "ticket.status" }, "done"] }, to: "done" }, + { when: { "==": [{ var: "ticket.status" }, "retry"] }, to: "queue" }, + ], + } + : lane, + ), + }); + const selection = { + kind: "transition", + laneKey: "run", + index: 1, + } as const; + + const withoutEarlierTransition = removeTransition(model, "run", 0); + const adjustedSelection = adjustSelectionAfterTransitionRemoval(selection, "run", 0); + + expect(normalizeSelection(withoutEarlierTransition, adjustedSelection)).toEqual({ + kind: "transition", + laneKey: "run", + index: 0, + }); + }); +}); + +describe("lane actions", () => { + const base = { + name: "Action board", + lanes: [ + { key: "review", name: "Review", entry: "manual" }, + { key: "land", name: "Land", entry: "manual" }, + ], + } as never; + + it("adds, edits, and removes lane actions", () => { + let model = createWorkflowEditorModel(base); + model = addLaneAction(model, "review"); + expect(model.definition.lanes[0]?.actions).toEqual([{ label: "New action", to: "land" }]); + + model = updateLaneAction(model, "review", 0, { + label: "Approve & land" as never, + hint: "Merge it.", + }); + expect(model.definition.lanes[0]?.actions?.[0]).toEqual({ + label: "Approve & land", + to: "land", + hint: "Merge it.", + }); + + model = updateLaneAction(model, "review", 0, { hint: "" }); + expect(model.definition.lanes[0]?.actions?.[0]).toEqual({ + label: "Approve & land", + to: "land", + }); + + model = removeLaneAction(model, "review", 0); + expect(model.definition.lanes[0]?.actions).toBeUndefined(); + expect(model.dirty).toBe(true); + }); + + it("updateLaneAction is a no-op on out-of-range index", () => { + let model = createWorkflowEditorModel(base); + // No actions yet — out-of-range index leaves lane.actions undefined + model = updateLaneAction(model, "review", 0, { to: "land" }); + expect(model.definition.lanes[0]?.actions).toBeUndefined(); + + // With one action — negative and high index leave it unchanged + model = addLaneAction(model, "review"); + const snapshot = model.definition.lanes[0]?.actions; + model = updateLaneAction(model, "review", -1, { to: "land" }); + expect(model.definition.lanes[0]?.actions).toEqual(snapshot); + model = updateLaneAction(model, "review", 5, { to: "land" }); + expect(model.definition.lanes[0]?.actions).toEqual(snapshot); + }); +}); diff --git a/apps/web/src/workflow/editorModel.ts b/apps/web/src/workflow/editorModel.ts new file mode 100644 index 00000000000..2d7044d4b31 --- /dev/null +++ b/apps/web/src/workflow/editorModel.ts @@ -0,0 +1,586 @@ +import { LaneKey, StepKey, WorkflowDefinition } from "@t3tools/contracts"; +import type { + WorkflowDefinitionEncoded, + WorkflowLaneTransition, + WorkflowLintError, +} from "@t3tools/contracts"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +type WorkflowLaneEncoded = WorkflowDefinitionEncoded["lanes"][number]; +type WorkflowStepEncoded = NonNullable<WorkflowLaneEncoded["pipeline"]>[number]; +type WorkflowStepType = WorkflowStepEncoded["type"]; +type LaneRoutingKind = "success" | "failure" | "blocked"; +type WorkflowEditorPendingSaveSource = "revert"; +type Mutable<T> = + T extends ReadonlyArray<infer U> + ? Array<Mutable<U>> + : T extends object + ? { -readonly [K in keyof T]: Mutable<T[K]> } + : T; +type MutableWorkflowDefinition = Mutable<WorkflowDefinitionEncoded>; +type MutableWorkflowLane = Mutable<WorkflowLaneEncoded>; +type MutableWorkflowStep = Mutable<WorkflowStepEncoded>; + +export interface WorkflowEditorModel { + readonly definition: WorkflowDefinitionEncoded; + readonly baselineDefinition: WorkflowDefinitionEncoded; + readonly dirty: boolean; + readonly lintErrors: ReadonlyArray<WorkflowLintError>; + readonly pendingSaveSource?: WorkflowEditorPendingSaveSource | undefined; +} + +export type WorkflowEditorSelection = + | { readonly kind: "lane"; readonly laneKey: string } + | { readonly kind: "step"; readonly laneKey: string; readonly stepKey: string } + | { readonly kind: "transition"; readonly laneKey: string; readonly index: number }; + +const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T; +const decodeWorkflowDefinition = Schema.decodeUnknownExit(WorkflowDefinition); +const encodeWorkflowDefinition = Schema.encodeSync(WorkflowDefinition); + +const uniqueKey = (existing: ReadonlySet<string>, base: string): string => { + if (!existing.has(base)) { + return base; + } + + let suffix = 2; + while (existing.has(`${base}-${suffix}`)) { + suffix += 1; + } + return `${base}-${suffix}`; +}; + +const allStepKeys = (definition: WorkflowDefinitionEncoded): ReadonlySet<string> => + new Set( + definition.lanes.flatMap((lane) => (lane.pipeline ?? []).map((step) => step.key as string)), + ); + +const compactOn = (on: MutableWorkflowLane["on"] | MutableWorkflowStep["on"] | undefined) => { + if (!on) { + return undefined; + } + const next = { ...on }; + if (next.success === undefined) { + delete next.success; + } + if (next.failure === undefined) { + delete next.failure; + } + if (next.blocked === undefined) { + delete next.blocked; + } + return Object.keys(next).length === 0 ? undefined : next; +}; + +const mutateDefinition = ( + model: WorkflowEditorModel, + mutate: (definition: MutableWorkflowDefinition) => void, +): WorkflowEditorModel => { + const definition = cloneJson(model.definition) as MutableWorkflowDefinition; + mutate(definition); + return { + ...model, + definition: definition as WorkflowDefinitionEncoded, + dirty: true, + lintErrors: [], + }; +}; + +const updateLane = ( + model: WorkflowEditorModel, + laneKey: string, + update: (lane: MutableWorkflowLane, definition: MutableWorkflowDefinition) => void, +): WorkflowEditorModel => + mutateDefinition(model, (definition) => { + const lane = definition.lanes.find((candidate) => candidate.key === laneKey); + if (lane) { + update(lane, definition); + } + }); + +export const createWorkflowEditorModel = ( + definition: WorkflowDefinitionEncoded, +): WorkflowEditorModel => ({ + definition: cloneJson(definition), + baselineDefinition: cloneJson(definition), + dirty: false, + lintErrors: [], +}); + +export const normalizeSelection = ( + model: WorkflowEditorModel, + selection: WorkflowEditorSelection | null, +): WorkflowEditorSelection | null => { + if (!selection) { + return null; + } + + const lane = model.definition.lanes.find( + (candidate) => String(candidate.key) === selection.laneKey, + ); + if (!lane) { + return null; + } + + if (selection.kind === "lane") { + return selection; + } + + if (selection.kind === "step") { + return (lane.pipeline ?? []).some((step) => String(step.key) === selection.stepKey) + ? selection + : { kind: "lane", laneKey: selection.laneKey }; + } + + const transitions = lane.transitions ?? []; + const transition = selection.index >= 0 ? transitions[selection.index] : undefined; + return transition ? selection : { kind: "lane", laneKey: selection.laneKey }; +}; + +export const adjustSelectionAfterTransitionRemoval = ( + selection: WorkflowEditorSelection | null, + laneKey: string, + removedIndex: number, +): WorkflowEditorSelection | null => { + if (selection?.kind !== "transition" || selection.laneKey !== laneKey) { + return selection; + } + if (removedIndex < selection.index) { + return { ...selection, index: selection.index - 1 }; + } + if (removedIndex === selection.index) { + return { kind: "lane", laneKey: selection.laneKey }; + } + return selection; +}; + +export const setWorkflowLintErrors = ( + model: WorkflowEditorModel, + lintErrors: ReadonlyArray<WorkflowLintError>, +): WorkflowEditorModel => ({ ...model, lintErrors: [...lintErrors] }); + +export const lintErrorKey = (lintError: WorkflowLintError): string => + [ + lintError.code, + lintError.message, + lintError.laneKey, + lintError.stepKey, + lintError.transitionIndex, + ] + .filter((part) => part !== undefined) + .join(":"); + +export const formatVersionTime = (value: string): string => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +}; + +export const markWorkflowSaved = ( + model: WorkflowEditorModel, + definition: WorkflowDefinitionEncoded, +): WorkflowEditorModel => ({ + ...model, + definition: cloneJson(definition), + baselineDefinition: cloneJson(definition), + dirty: false, + lintErrors: [], + pendingSaveSource: undefined, +}); + +export const markWorkflowSavedIfUnchanged = ( + model: WorkflowEditorModel, + submittedDefinition: WorkflowDefinitionEncoded, + savedDefinition: WorkflowDefinitionEncoded, +): WorkflowEditorModel => { + if (JSON.stringify(model.definition) === JSON.stringify(submittedDefinition)) { + return markWorkflowSaved(model, savedDefinition); + } + + return { + ...model, + baselineDefinition: cloneJson(savedDefinition), + dirty: true, + lintErrors: [], + pendingSaveSource: undefined, + }; +}; + +export const discardWorkflowChanges = (model: WorkflowEditorModel): WorkflowEditorModel => ({ + ...model, + definition: cloneJson(model.baselineDefinition), + dirty: false, + lintErrors: [], + pendingSaveSource: undefined, +}); + +export const loadRevertedDefinition = ( + model: WorkflowEditorModel, + versionDefinition: WorkflowDefinitionEncoded, +): WorkflowEditorModel => ({ + ...model, + definition: cloneJson(versionDefinition), + dirty: true, + lintErrors: [], + pendingSaveSource: "revert", +}); + +const sortJsonValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(sortJsonValue); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, child]) => [key, sortJsonValue(child)]), + ); + } + return value; +}; + +export const canonicalizeDefinitionJson = (definition: WorkflowDefinitionEncoded): string => { + const decoded = decodeWorkflowDefinition(definition); + const canonicalValue = Exit.isSuccess(decoded) + ? encodeWorkflowDefinition(decoded.value) + : definition; + return `${JSON.stringify(sortJsonValue(canonicalValue), null, 2)}\n`; +}; + +export const addLane = (model: WorkflowEditorModel): WorkflowEditorModel => + mutateDefinition(model, (definition) => { + const key = uniqueKey(new Set(definition.lanes.map((lane) => lane.key as string)), "new-lane"); + definition.lanes.push({ key: LaneKey.make(key), name: "New lane", entry: "manual" }); + }); + +export const removeLane = (model: WorkflowEditorModel, laneKey: string): WorkflowEditorModel => + mutateDefinition(model, (definition) => { + definition.lanes = definition.lanes.filter((lane) => lane.key !== laneKey); + for (const lane of definition.lanes) { + lane.on = compactOn({ + success: lane.on?.success === laneKey ? undefined : lane.on?.success, + failure: lane.on?.failure === laneKey ? undefined : lane.on?.failure, + blocked: lane.on?.blocked === laneKey ? undefined : lane.on?.blocked, + }); + lane.transitions = lane.transitions?.filter((transition) => transition.to !== laneKey); + if (lane.transitions?.length === 0) { + delete lane.transitions; + } + lane.actions = lane.actions?.filter((action) => action.to !== laneKey); + if (lane.actions?.length === 0) { + delete lane.actions; + } + lane.onEvent = lane.onEvent?.filter((event) => event.to !== laneKey); + if (lane.onEvent?.length === 0) { + delete lane.onEvent; + } + for (const step of lane.pipeline ?? []) { + step.on = compactOn({ + success: step.on?.success === laneKey ? undefined : step.on?.success, + failure: step.on?.failure === laneKey ? undefined : step.on?.failure, + blocked: step.on?.blocked === laneKey ? undefined : step.on?.blocked, + }); + } + } + }); + +export const renameLane = ( + model: WorkflowEditorModel, + laneKey: string, + name: string, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + lane.name = name; + }); + +export const setLaneEntry = ( + model: WorkflowEditorModel, + laneKey: string, + entry: WorkflowLaneEncoded["entry"], +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + lane.entry = entry; + }); + +export const setLaneWipLimit = ( + model: WorkflowEditorModel, + laneKey: string, + wipLimit: number | undefined, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + if (wipLimit === undefined) { + delete lane.wipLimit; + } else { + lane.wipLimit = wipLimit; + } + }); + +export const setLaneTerminal = ( + model: WorkflowEditorModel, + laneKey: string, + terminal: boolean | undefined, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + if (terminal === undefined) { + delete lane.terminal; + } else { + lane.terminal = terminal; + } + }); + +export type LaneActionEncoded = NonNullable<WorkflowLaneEncoded["actions"]>[number]; + +export const addLaneAction = (model: WorkflowEditorModel, laneKey: string): WorkflowEditorModel => + updateLane(model, laneKey, (lane, definition) => { + const to = definition.lanes.find((candidate) => candidate.key !== laneKey)?.key ?? lane.key; + lane.actions = [...(lane.actions ?? []), { label: "New action", to } as LaneActionEncoded]; + }); + +export const updateLaneAction = ( + model: WorkflowEditorModel, + laneKey: string, + index: number, + patch: Partial<LaneActionEncoded>, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + if (index < 0 || index >= (lane.actions?.length ?? 0)) { + return; + } + lane.actions = (lane.actions ?? []).map((action, candidateIndex) => { + if (candidateIndex !== index) { + return action; + } + const next = { ...action, ...patch }; + if (next.hint !== undefined && next.hint.length === 0) { + delete next.hint; + } + return next; + }); + }); + +export const removeLaneAction = ( + model: WorkflowEditorModel, + laneKey: string, + index: number, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + const next = (lane.actions ?? []).filter((_, candidateIndex) => candidateIndex !== index); + if (next.length === 0) { + delete lane.actions; + } else { + lane.actions = next; + } + }); + +export const setLaneColor = ( + model: WorkflowEditorModel, + laneKey: string, + color: string | undefined, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + if (color === undefined) { + delete lane.color; + } else { + lane.color = color; + } + }); + +type MutableAgentSelection = Extract<MutableWorkflowStep, { type: "agent" }>["agent"]; + +const defaultAgent = (definition: WorkflowDefinitionEncoded): MutableAgentSelection => { + for (const lane of definition.lanes) { + for (const step of lane.pipeline ?? []) { + if (step.type === "agent") { + return cloneJson(step.agent) as MutableAgentSelection; + } + } + } + return { instance: "missing-provider", model: "missing-model" }; +}; + +const newStep = ( + definition: WorkflowDefinitionEncoded, + type: WorkflowStepType, +): MutableWorkflowStep => { + const key = uniqueKey(allStepKeys(definition), type); + if (type === "agent") { + return { + key: StepKey.make(key), + type, + agent: defaultAgent(definition), + instruction: "", + }; + } + if (type === "script") { + return { key: StepKey.make(key), type, run: "true" }; + } + if (type === "pullRequest") { + return { key: StepKey.make(key), type, action: "open" }; + } + return { key: StepKey.make(key), type }; +}; + +export const addStep = ( + model: WorkflowEditorModel, + laneKey: string, + type: WorkflowStepType, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane, definition) => { + lane.pipeline = [...(lane.pipeline ?? []), newStep(definition, type)]; + }); + +export const removeStep = ( + model: WorkflowEditorModel, + laneKey: string, + stepKey: string, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + lane.pipeline = lane.pipeline?.filter((step) => step.key !== stepKey); + if (lane.pipeline?.length === 0) { + delete lane.pipeline; + } + }); + +export const reorderStep = ( + model: WorkflowEditorModel, + laneKey: string, + from: number, + to: number, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + const pipeline = [...(lane.pipeline ?? [])]; + if (from < 0 || from >= pipeline.length || to < 0 || to >= pipeline.length) { + return; + } + const [step] = pipeline.splice(from, 1); + if (!step) { + return; + } + pipeline.splice(to, 0, step); + lane.pipeline = pipeline; + }); + +const applyPatch = <T extends Record<string, unknown>>(target: T, patch: Partial<T>): T => { + const next = { ...target }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + delete next[key]; + } else { + next[key as keyof T] = value as T[keyof T]; + } + } + return next; +}; + +export const updateStep = ( + model: WorkflowEditorModel, + laneKey: string, + stepKey: string, + patch: Partial<WorkflowStepEncoded>, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + lane.pipeline = lane.pipeline?.map((step) => + step.key === stepKey + ? (applyPatch(step as Record<string, unknown>, patch) as MutableWorkflowStep) + : step, + ); + }); + +export const setLaneOn = ( + model: WorkflowEditorModel, + laneKey: string, + kind: LaneRoutingKind, + targetLaneKey: string | undefined, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + lane.on = compactOn({ + ...lane.on, + [kind]: targetLaneKey === undefined ? undefined : LaneKey.make(targetLaneKey), + }); + }); + +export const addTransition = (model: WorkflowEditorModel, laneKey: string): WorkflowEditorModel => + updateLane(model, laneKey, (lane, definition) => { + const to = + definition.lanes.find((candidate) => candidate.key !== laneKey)?.key ?? LaneKey.make(laneKey); + lane.transitions = [...(lane.transitions ?? []), { when: { var: "pipeline.result" }, to }]; + }); + +export const updateTransition = ( + model: WorkflowEditorModel, + laneKey: string, + index: number, + patch: { readonly when?: unknown; readonly to?: string }, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + if (!lane.transitions?.[index]) { + return; + } + const current = lane.transitions[index]; + const next: WorkflowLaneTransition = { + when: patch.when === undefined ? current.when : patch.when, + to: patch.to === undefined ? LaneKey.make(current.to as string) : LaneKey.make(patch.to), + }; + lane.transitions = lane.transitions.map((transition, transitionIndex) => + transitionIndex === index ? next : transition, + ); + }); + +export const removeTransition = ( + model: WorkflowEditorModel, + laneKey: string, + index: number, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + lane.transitions = lane.transitions?.filter((_, transitionIndex) => transitionIndex !== index); + if (lane.transitions?.length === 0) { + delete lane.transitions; + } + }); + +export const addLaneEvent = (model: WorkflowEditorModel, laneKey: string): WorkflowEditorModel => + updateLane(model, laneKey, (lane, definition) => { + const to = + definition.lanes.find((candidate) => candidate.key !== laneKey)?.key ?? LaneKey.make(laneKey); + lane.onEvent = [...(lane.onEvent ?? []), { name: "ci.passed", to }]; + }); + +export const updateLaneEvent = ( + model: WorkflowEditorModel, + laneKey: string, + index: number, + patch: { readonly name?: string; readonly when?: unknown | null; readonly to?: string }, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + if (!lane.onEvent?.[index]) { + return; + } + const current = lane.onEvent[index]; + // when: null clears the predicate; undefined keeps it. + const when = + patch.when === undefined ? current.when : patch.when === null ? undefined : patch.when; + const next = { + name: patch.name === undefined ? current.name : patch.name, + ...(when === undefined ? {} : { when }), + to: patch.to === undefined ? LaneKey.make(current.to as string) : LaneKey.make(patch.to), + }; + lane.onEvent = lane.onEvent.map((event, eventIndex) => (eventIndex === index ? next : event)); + }); + +export const removeLaneEvent = ( + model: WorkflowEditorModel, + laneKey: string, + index: number, +): WorkflowEditorModel => + updateLane(model, laneKey, (lane) => { + lane.onEvent = lane.onEvent?.filter((_, eventIndex) => eventIndex !== index); + if (lane.onEvent?.length === 0) { + delete lane.onEvent; + } + }); diff --git a/apps/web/src/workflow/importPicker.test.ts b/apps/web/src/workflow/importPicker.test.ts new file mode 100644 index 00000000000..96b13deaabc --- /dev/null +++ b/apps/web/src/workflow/importPicker.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + applyPickerFilters, + defaultChecked, + groupSelectedBySource, + isUrl, + selectionKey, + type FilterState, +} from "./importPicker.ts"; + +const row = (over: Partial<Parameters<typeof applyPickerFilters>[0][number]> = {}) => ({ + provider: "github" as const, + sourceId: "s1", + externalId: "1", + displayRef: "#1", + title: "Fix bug", + container: "a/b", + url: "https://github.com/a/b/issues/1", + assignees: ["alice"], + lifecycle: "open" as const, + mappedTicketId: null, + mappedLane: null, + ...over, +}); + +describe("applyPickerFilters", () => { + const base: FilterState = { search: "", assignedToMe: false, hideTasked: false }; + + it("hide tasked drops mapped rows", () => { + const out = applyPickerFilters( + [row({ mappedTicketId: "t1" as any }), row({ externalId: "2" })], + { ...base, hideTasked: true }, + {}, + ); + expect(out.map((r) => r.externalId)).toEqual(["2"]); + }); + + it("assigned-to-me uses the row's source viewer aliases", () => { + const out = applyPickerFilters( + [row({ assignees: ["alice"] }), row({ externalId: "2", assignees: ["bob"] })], + { ...base, assignedToMe: true }, + { s1: { id: "alice", aliases: ["alice"] } }, + ); + expect(out.map((r) => r.externalId)).toEqual(["1"]); + }); + + it("assigned-to-me disabled for a source with null viewer (drops those rows)", () => { + const out = applyPickerFilters([row({})], { ...base, assignedToMe: true }, { s1: null }); + expect(out.map((r) => r.externalId)).toEqual([]); + }); + + it("search matches title and displayRef, case-insensitive", () => { + const out = applyPickerFilters( + [row({ title: "Fix bug" }), row({ externalId: "2", title: "Other" })], + { ...base, search: "fix" }, + {}, + ); + expect(out.map((r) => r.externalId)).toEqual(["1"]); + }); + + it("a pasted URL filters to the row whose url matches", () => { + const out = applyPickerFilters( + [ + row({ url: "https://github.com/a/b/issues/1" }), + row({ externalId: "2", url: "https://github.com/a/b/issues/2" }), + ], + { ...base, search: "https://github.com/a/b/issues/2" }, + {}, + ); + expect(out.map((r) => r.externalId)).toEqual(["2"]); + }); + + it("a pasted URL matches exactly, not as a prefix (issues/1 not issues/10)", () => { + const out = applyPickerFilters( + [ + row({ url: "https://github.com/a/b/issues/1" }), + row({ externalId: "10", url: "https://github.com/a/b/issues/10" }), + ], + { ...base, search: "https://github.com/a/b/issues/1" }, + {}, + ); + expect(out.map((r) => r.externalId)).toEqual(["1"]); + }); + + it("combines hideTasked and search", () => { + const out = applyPickerFilters( + [ + row({ externalId: "1", title: "Fix bug", mappedTicketId: "t1" as any }), + row({ externalId: "2", title: "Fix bug" }), + row({ externalId: "3", title: "Other" }), + ], + { ...base, hideTasked: true, search: "fix" }, + {}, + ); + expect(out.map((r) => r.externalId)).toEqual(["2"]); + }); + + it("returns [] for empty input", () => { + expect(applyPickerFilters([], base, {})).toEqual([]); + }); +}); + +describe("selection + grouping", () => { + it("defaultChecked: closed/mapped unchecked, open+unmapped checked", () => { + expect(defaultChecked(row({ lifecycle: "closed" }))).toBe(false); + expect(defaultChecked(row({ mappedTicketId: "t" as any }))).toBe(false); + expect(defaultChecked(row({}))).toBe(true); + }); + + it("selectionKey + groupSelectedBySource bucket externalIds per source", () => { + const keys = new Set([ + selectionKey(row({})), + selectionKey(row({ sourceId: "s2", externalId: "9" })), + ]); + const groups = groupSelectedBySource(keys); + expect(groups).toEqual({ s1: ["1"], s2: ["9"] }); + }); + + it("isUrl detects http(s) urls", () => { + expect(isUrl("https://x/y")).toBe(true); + expect(isUrl("fix bug")).toBe(false); + }); +}); diff --git a/apps/web/src/workflow/importPicker.ts b/apps/web/src/workflow/importPicker.ts new file mode 100644 index 00000000000..c545b2949c5 --- /dev/null +++ b/apps/web/src/workflow/importPicker.ts @@ -0,0 +1,54 @@ +import type { ImportableWorkItemView } from "@t3tools/contracts/workSource"; + +export interface FilterState { + readonly search: string; + readonly assignedToMe: boolean; + readonly hideTasked: boolean; +} + +type ViewerMap = Record<string, { id: string; aliases: ReadonlyArray<string> } | null>; + +export const isUrl = (s: string): boolean => /^https?:\/\//i.test(s.trim()); + +export const selectionKey = (r: Pick<ImportableWorkItemView, "sourceId" | "externalId">): string => + `${r.sourceId}:${r.externalId}`; + +export const defaultChecked = (r: ImportableWorkItemView): boolean => + r.mappedTicketId === null && r.lifecycle === "open"; + +export const applyPickerFilters = ( + rows: ReadonlyArray<ImportableWorkItemView>, + f: FilterState, + viewer: ViewerMap, +): ReadonlyArray<ImportableWorkItemView> => { + const raw = f.search.trim(); + const url = isUrl(raw) ? raw.toLowerCase() : null; + const q = url ? null : raw.toLowerCase(); + return rows.filter((r) => { + if (f.hideTasked && r.mappedTicketId !== null) return false; + if (f.assignedToMe) { + const v = viewer[r.sourceId]; + if (v === null || v === undefined) return false; + if (!r.assignees.some((a) => v.aliases.includes(a))) return false; + } + if (url !== null) return r.url.trim().toLowerCase() === url; // url is already trimmed+lowercased + if (q !== null && q.length > 0 && !`${r.title} ${r.displayRef}`.toLowerCase().includes(q)) + return false; + return true; + }); +}; + +// keys: `${sourceId}:${externalId}` -> { [sourceId]: externalId[] } +// Uses indexOf(":") (first colon) to split, so externalIds containing colons still work +// (sourceId is a UUID with no colon). +export const groupSelectedBySource = (keys: ReadonlySet<string>): Record<string, string[]> => { + const out: Record<string, string[]> = {}; + for (const key of keys) { + const idx = key.indexOf(":"); + if (idx === -1) continue; + const sourceId = key.slice(0, idx); + const externalId = key.slice(idx + 1); + (out[sourceId] ??= []).push(externalId); + } + return out; +}; diff --git a/apps/web/src/workflow/intakeState.test.ts b/apps/web/src/workflow/intakeState.test.ts new file mode 100644 index 00000000000..5c165d5b21f --- /dev/null +++ b/apps/web/src/workflow/intakeState.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { approvedIntakeTickets, toIntakeDrafts, updateIntakeDraft } from "./intakeState.ts"; + +describe("intakeState", () => { + it("converts proposals to included drafts with backward-only dependencies", () => { + const drafts = toIntakeDrafts([ + { title: "Fix login", description: "Sessions drop" }, + { title: "Add rate limiting", dependsOn: [0, 1, 5] }, + ]); + expect(drafts).toEqual([ + { title: "Fix login", description: "Sessions drop", include: true, dependsOn: [] }, + { title: "Add rate limiting", description: "", include: true, dependsOn: [0] }, + ]); + }); + + it("updates a single draft immutably", () => { + const drafts = toIntakeDrafts([{ title: "A" }, { title: "B" }]); + const updated = updateIntakeDraft(drafts, 1, { include: false, title: "B2" }); + expect(updated[0]).toEqual(drafts[0]); + expect(updated[1]).toEqual({ title: "B2", description: "", include: false, dependsOn: [] }); + }); + + it("returns only approved, non-blank tickets with trimmed fields", () => { + const drafts = [ + { title: " Fix login ", description: " Sessions drop ", include: true, dependsOn: [] }, + { title: "Skipped", description: "", include: false, dependsOn: [] }, + { title: " ", description: "blank title", include: true, dependsOn: [] }, + { title: "No description", description: " ", include: true, dependsOn: [] }, + ]; + expect(approvedIntakeTickets(drafts)).toEqual([ + { title: "Fix login", description: "Sessions drop", dependsOnIndices: [] }, + { title: "No description", dependsOnIndices: [] }, + ]); + }); + + it("remaps dependency edges onto the approved list and drops excluded targets", () => { + const drafts = toIntakeDrafts([ + { title: "API" }, + { title: "Skipped" }, + { title: "UI", dependsOn: [0, 1] }, + { title: "Docs", dependsOn: [2] }, + ]); + const withExclusion = updateIntakeDraft(drafts, 1, { include: false }); + + const approved = approvedIntakeTickets(withExclusion); + expect(approved.map((ticket) => ticket.title)).toEqual(["API", "UI", "Docs"]); + // UI depended on API (kept, index 0) and Skipped (dropped). + expect(approved[1]?.dependsOnIndices).toEqual([0]); + // Docs depended on UI, which is now approved index 1. + expect(approved[2]?.dependsOnIndices).toEqual([1]); + }); +}); diff --git a/apps/web/src/workflow/intakeState.ts b/apps/web/src/workflow/intakeState.ts new file mode 100644 index 00000000000..dcd38a15f64 --- /dev/null +++ b/apps/web/src/workflow/intakeState.ts @@ -0,0 +1,73 @@ +export interface IntakeProposalDraft { + readonly title: string; + readonly description: string; + readonly include: boolean; + // Indices into the ORIGINAL proposal list this one depends on. + readonly dependsOn: ReadonlyArray<number>; +} + +export interface IntakeTicketInput { + readonly title: string; + readonly description?: string | undefined; + readonly dependsOn?: ReadonlyArray<number> | undefined; +} + +export interface ApprovedIntakeTicket { + readonly title: string; + readonly description?: string | undefined; + // Indices into the APPROVED ticket list (the array this entry lives in), + // remapped from original proposal indices; edges to excluded rows drop. + readonly dependsOnIndices: ReadonlyArray<number>; +} + +export const toIntakeDrafts = ( + proposals: ReadonlyArray<{ + readonly title: string; + readonly description?: string | undefined; + readonly dependsOn?: ReadonlyArray<number> | undefined; + }>, +): ReadonlyArray<IntakeProposalDraft> => + proposals.map((proposal, index) => ({ + title: proposal.title, + description: proposal.description ?? "", + include: true, + dependsOn: (proposal.dependsOn ?? []).filter( + (dependency) => Number.isInteger(dependency) && dependency >= 0 && dependency < index, + ), + })); + +export const updateIntakeDraft = ( + drafts: ReadonlyArray<IntakeProposalDraft>, + index: number, + patch: Partial<IntakeProposalDraft>, +): ReadonlyArray<IntakeProposalDraft> => + drafts.map((draft, draftIndex) => (draftIndex === index ? { ...draft, ...patch } : draft)); + +/** + * The tickets the user actually approved — trimmed, excluded/blank rows + * dropped, and dependency edges remapped onto the approved list (edges to + * excluded rows simply disappear). + */ +export const approvedIntakeTickets = ( + drafts: ReadonlyArray<IntakeProposalDraft>, +): ReadonlyArray<ApprovedIntakeTicket> => { + const approvedIndexByOriginal = new Map<number, number>(); + const approved: ApprovedIntakeTicket[] = []; + for (const [originalIndex, draft] of drafts.entries()) { + const title = draft.title.trim(); + if (!draft.include || title === "") { + continue; + } + const description = draft.description.trim(); + const dependsOnIndices = draft.dependsOn + .map((dependency) => approvedIndexByOriginal.get(dependency)) + .filter((mapped): mapped is number => mapped !== undefined); + approvedIndexByOriginal.set(originalIndex, approved.length); + approved.push({ + title, + ...(description === "" ? {} : { description }), + dependsOnIndices, + }); + } + return approved; +}; diff --git a/apps/web/src/workflow/jiraConnectionForm.test.ts b/apps/web/src/workflow/jiraConnectionForm.test.ts new file mode 100644 index 00000000000..bcebfd3c952 --- /dev/null +++ b/apps/web/src/workflow/jiraConnectionForm.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { buildConnectionInput, isConnectionFormValid, type ConnectionFormState } from "./jiraConnectionForm.ts"; + +const base: ConnectionFormState = { + provider: "github", + displayName: "X", + token: "t", + jiraDeployment: "cloud", + baseUrl: "", + email: "", +}; + +describe("jiraConnectionForm", () => { + it("github needs only displayName + token", () => { + expect(isConnectionFormValid(base)).toBe(true); + const input = buildConnectionInput(base); + expect(input).toEqual({ provider: "github", displayName: "X", token: "t" }); + }); + + it("jira cloud requires base url + email and maps to basic auth", () => { + const cloud: ConnectionFormState = { + ...base, + provider: "jira", + jiraDeployment: "cloud", + baseUrl: "https://acme.atlassian.net", + email: "me@acme.test", + }; + expect(isConnectionFormValid(cloud)).toBe(true); + expect(buildConnectionInput(cloud)).toEqual({ + provider: "jira", + displayName: "X", + token: "t", + authMode: "basic", + baseUrl: "https://acme.atlassian.net", + email: "me@acme.test", + }); + }); + + it("jira cloud is invalid without an email", () => { + expect( + isConnectionFormValid({ + ...base, + provider: "jira", + jiraDeployment: "cloud", + baseUrl: "https://acme.atlassian.net", + email: "", + }), + ).toBe(false); + }); + + it("jira server requires base url, no email, maps to bearer auth", () => { + const server: ConnectionFormState = { + ...base, + provider: "jira", + jiraDeployment: "server", + baseUrl: "https://jira.corp", + email: "", + }; + expect(isConnectionFormValid(server)).toBe(true); + expect(buildConnectionInput(server)).toEqual({ + provider: "jira", + displayName: "X", + token: "t", + authMode: "bearer", + baseUrl: "https://jira.corp", + }); + }); + + it("jira server is invalid with a non-http base url", () => { + expect( + isConnectionFormValid({ + ...base, + provider: "jira", + jiraDeployment: "server", + baseUrl: "jira.corp", // missing protocol + email: "", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/workflow/jiraConnectionForm.ts b/apps/web/src/workflow/jiraConnectionForm.ts new file mode 100644 index 00000000000..3003a5e13f1 --- /dev/null +++ b/apps/web/src/workflow/jiraConnectionForm.ts @@ -0,0 +1,66 @@ +import type { WorkSourceProviderName } from "@t3tools/contracts/workSource"; + +export type ConnectionProvider = WorkSourceProviderName; +export type JiraDeployment = "cloud" | "server"; + +export interface ConnectionFormState { + readonly provider: ConnectionProvider; + readonly displayName: string; + readonly token: string; + readonly jiraDeployment: JiraDeployment; + readonly baseUrl: string; + readonly email: string; +} + +export interface CreateConnectionInput { + readonly provider: ConnectionProvider; + readonly displayName: string; + readonly token: string; + // Jira only: "basic" (Cloud) or "bearer" (Server/DC). Non-Jira connections + // omit authMode and the server defaults them to "pat". + readonly authMode?: "basic" | "bearer"; + readonly baseUrl?: string; + readonly email?: string; +} + +const isHttpUrl = (value: string): boolean => { + try { + const u = new URL(value.trim()); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +}; + +export function isConnectionFormValid(state: ConnectionFormState): boolean { + if (!state.displayName.trim() || !state.token.trim()) return false; + if (state.provider !== "jira") return true; + if (!isHttpUrl(state.baseUrl)) return false; + if (state.jiraDeployment === "cloud" && !state.email.trim()) return false; + return true; +} + +export function buildConnectionInput(state: ConnectionFormState): CreateConnectionInput { + const displayName = state.displayName.trim(); + const token = state.token.trim(); + if (state.provider !== "jira") { + return { provider: state.provider, displayName, token }; + } + if (state.jiraDeployment === "cloud") { + return { + provider: "jira", + displayName, + token, + authMode: "basic", + baseUrl: state.baseUrl.trim(), + email: state.email.trim(), + }; + } + return { + provider: "jira", + displayName, + token, + authMode: "bearer", + baseUrl: state.baseUrl.trim(), + }; +} diff --git a/apps/web/src/workflow/resolveRecentAgent.test.ts b/apps/web/src/workflow/resolveRecentAgent.test.ts new file mode 100644 index 00000000000..53ed02677ee --- /dev/null +++ b/apps/web/src/workflow/resolveRecentAgent.test.ts @@ -0,0 +1,236 @@ +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ServerConfig, + type ServerProvider, +} from "@t3tools/contracts"; +import { resetAppAtomRegistryForTests } from "../rpc/atomRegistry"; +import { setServerConfigSnapshot } from "../rpc/serverState"; +import { type EnvironmentState, useStore } from "../store"; +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ThreadShell } from "../types"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { pickRecentAgent, resolveRecentAgent } from "./resolveRecentAgent"; + +const environmentId = EnvironmentId.make("environment-recent-agent"); +const projectId = ProjectId.make("project-recent-agent"); + +const makeProvider = (input: { + readonly instanceId: string; + readonly driver?: string; + readonly enabled?: boolean; + readonly installed?: boolean; + readonly availability?: "available" | "unavailable"; + readonly models?: ReadonlyArray<{ readonly slug: string; readonly isCustom?: boolean }>; +}): ServerProvider => ({ + instanceId: ProviderInstanceId.make(input.instanceId), + driver: ProviderDriverKind.make(input.driver ?? "codex"), + enabled: input.enabled ?? true, + installed: input.installed ?? true, + version: "0.0.0-test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-07T00:00:00.000Z", + availability: input.availability, + models: (input.models ?? []).map((model) => ({ + slug: model.slug, + name: model.slug, + isCustom: model.isCustom ?? false, + capabilities: {}, + })), + slashCommands: [], + skills: [], +}); + +const makeServerConfig = (providers: ReadonlyArray<ServerProvider>): ServerConfig => ({ + environment: { + environmentId, + label: "Recent agent test", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-access-token"], + sessionCookieName: "t3_session", + }, + cwd: "/tmp/recent-agent", + keybindingsConfigPath: "/tmp/recent-agent/keybindings.json", + keybindings: [], + issues: [], + providers, + availableEditors: [], + observability: { + logsDirectoryPath: "/tmp/recent-agent/logs", + localTracingEnabled: false, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + }, + settings: DEFAULT_SERVER_SETTINGS, +}); + +const makeShell = (input: { + readonly id: string; + readonly instanceId: string; + readonly model: string; + readonly createdAt: string; + readonly updatedAt?: string; +}): ThreadShell => ({ + id: ThreadId.make(input.id), + environmentId, + codexThreadId: null, + projectId, + title: input.id, + modelSelection: { + instanceId: ProviderInstanceId.make(input.instanceId), + model: input.model, + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + error: null, + createdAt: input.createdAt, + archivedAt: null, + updatedAt: input.updatedAt, + branch: null, + worktreePath: null, +}); + +const makeEnvironmentState = (shells: ReadonlyArray<ThreadShell>): EnvironmentState => ({ + projectIds: [projectId], + projectById: { + [projectId]: { + id: projectId, + environmentId, + name: "Recent agent project", + cwd: "/tmp/recent-agent", + defaultModelSelection: null, + createdAt: "2026-06-07T00:00:00.000Z", + updatedAt: "2026-06-07T00:00:00.000Z", + scripts: [], + }, + }, + threadIds: shells.map((shell) => shell.id), + threadIdsByProjectId: { [projectId]: shells.map((shell) => shell.id) }, + threadShellById: Object.fromEntries(shells.map((shell) => [shell.id, shell])), + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, + bootstrapComplete: true, +}); + +beforeEach(() => { + resetAppAtomRegistryForTests(); + useStore.setState({ + activeEnvironmentId: null, + environmentStateById: {}, + boardStateById: {}, + boardsByScopedProjectKey: {}, + }); + useComposerDraftStore.setState({ + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, + }); +}); + +describe("pickRecentAgent", () => { + const avail = (id: string) => id === "codex" || id === "claude_main"; + + it("prefers sticky when available", () => { + expect( + pickRecentAgent({ + sticky: { instance: "codex", model: "gpt-5.4" }, + recentThread: { instance: "claude_main", model: "sonnet" }, + defaultChoice: { instance: "claude_main", model: "sonnet" }, + isAvailable: avail, + }), + ).toEqual({ instance: "codex", model: "gpt-5.4" }); + }); + + it("falls through unavailable sticky to recent thread", () => { + expect( + pickRecentAgent({ + sticky: { instance: "ghost", model: "x" }, + recentThread: { instance: "claude_main", model: "sonnet" }, + defaultChoice: null, + isAvailable: avail, + }), + ).toEqual({ instance: "claude_main", model: "sonnet" }); + }); + + it("returns null when nothing is available", () => { + expect( + pickRecentAgent({ + sticky: null, + recentThread: null, + defaultChoice: null, + isAvailable: avail, + }), + ).toBeNull(); + }); +}); + +describe("resolveRecentAgent", () => { + it("uses thread shells for the most recent available agent before defaulting", () => { + setServerConfigSnapshot( + makeServerConfig([ + makeProvider({ + instanceId: "ghost", + enabled: true, + installed: true, + availability: "unavailable", + models: [{ slug: "ghost-model" }], + }), + makeProvider({ + instanceId: "claude_main", + driver: "claudeAgent", + models: [{ slug: "sonnet" }, { slug: "custom-claude", isCustom: true }], + }), + ]), + ); + useComposerDraftStore.setState({ + stickyActiveProvider: ProviderInstanceId.make("ghost"), + stickyModelSelectionByProvider: { + [ProviderInstanceId.make("ghost")]: { + instanceId: ProviderInstanceId.make("ghost"), + model: "ghost-model", + }, + }, + }); + const olderShell = makeShell({ + id: "thread-old", + instanceId: "claude_main", + model: "old-sonnet", + createdAt: "2026-06-06T00:00:00.000Z", + }); + const newerShell = makeShell({ + id: "thread-new", + instanceId: "claude_main", + model: "sonnet", + createdAt: "2026-06-06T00:00:00.000Z", + updatedAt: "2026-06-07T00:00:00.000Z", + }); + useStore.setState({ + activeEnvironmentId: environmentId, + environmentStateById: { + [environmentId]: makeEnvironmentState([olderShell, newerShell]), + }, + }); + + expect(resolveRecentAgent()).toEqual({ instance: "claude_main", model: "sonnet" }); + }); +}); diff --git a/apps/web/src/workflow/resolveRecentAgent.ts b/apps/web/src/workflow/resolveRecentAgent.ts new file mode 100644 index 00000000000..02af8fa4e8f --- /dev/null +++ b/apps/web/src/workflow/resolveRecentAgent.ts @@ -0,0 +1,92 @@ +import { + DEFAULT_MODEL_BY_PROVIDER, + type AgentSelection, + type ModelSelection, + type ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { deriveProviderInstanceEntries } from "../providerInstances"; +import { getServerConfig } from "../rpc/serverState"; +import { selectThreadShellsAcrossEnvironments, useStore } from "../store"; + +type AgentChoice = AgentSelection | null; + +export interface RecentAgentSources { + readonly sticky: AgentChoice; + readonly recentThread: AgentChoice; + readonly defaultChoice: AgentChoice; + readonly isAvailable: (instance: string) => boolean; +} + +export function pickRecentAgent(sources: RecentAgentSources): AgentSelection | null { + for (const candidate of [sources.sticky, sources.recentThread, sources.defaultChoice]) { + if (candidate && sources.isAvailable(candidate.instance)) { + return candidate; + } + } + return null; +} + +const fromModelSelection = (selection: ModelSelection | null | undefined): AgentChoice => + selection + ? { + instance: selection.instanceId, + model: selection.model, + } + : null; + +function resolveStickyAgent(): AgentChoice { + const composerState = useComposerDraftStore.getState(); + const activeProvider = composerState.stickyActiveProvider; + return activeProvider + ? fromModelSelection(composerState.stickyModelSelectionByProvider[activeProvider]) + : null; +} + +function resolveRecentThreadAgent(): AgentChoice { + const [latestShell] = selectThreadShellsAcrossEnvironments(useStore.getState()).sort( + (left, right) => + (right.updatedAt ?? right.createdAt).localeCompare(left.updatedAt ?? left.createdAt), + ); + return fromModelSelection(latestShell?.modelSelection); +} + +function resolveDefaultAgent(input: { + readonly entries: ReturnType<typeof deriveProviderInstanceEntries>; +}): AgentChoice { + const entry = input.entries[0]; + if (!entry) { + return null; + } + const model = + entry.models.find((candidate) => !candidate.isCustom)?.slug ?? + entry.models[0]?.slug ?? + DEFAULT_MODEL_BY_PROVIDER[entry.driverKind]; + if (!model) { + return null; + } + return { + instance: entry.instanceId, + model, + }; +} + +export function resolveRecentAgent( + providers?: ReadonlyArray<ServerProvider>, +): AgentSelection | null { + const availableEntries = deriveProviderInstanceEntries( + providers ?? getServerConfig()?.providers ?? [], + ).filter((entry) => entry.enabled && entry.installed && entry.isAvailable); + const availableInstances = new Set<ProviderInstanceId>( + availableEntries.map((entry) => entry.instanceId), + ); + + return pickRecentAgent({ + sticky: resolveStickyAgent(), + recentThread: resolveRecentThreadAgent(), + defaultChoice: resolveDefaultAgent({ entries: availableEntries }), + isAvailable: (instance) => availableInstances.has(instance as ProviderInstanceId), + }); +} diff --git a/apps/web/src/workflow/routeDecision.test.ts b/apps/web/src/workflow/routeDecision.test.ts new file mode 100644 index 00000000000..97c4b594f66 --- /dev/null +++ b/apps/web/src/workflow/routeDecision.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { describeRouteDecision, extractVerdict, type RouteDecisionView } from "./routeDecision.ts"; + +const laneName = (key: string): string => + ({ implement: "Implementation", review: "Review", stuck: "Stuck" })[key] ?? key; + +const decision = (overrides: Partial<RouteDecisionView>): RouteDecisionView => ({ + occurredAt: "2026-06-09T10:00:00.000Z", + toLane: "review", + source: "lane_transition", + ...overrides, +}); + +describe("extractVerdict", () => { + it("reads a string verdict from captured output", () => { + expect(extractVerdict({ verdict: "approve" })).toBe("approve"); + expect(extractVerdict({ verdict: "revise", notes: "x" })).toBe("revise"); + }); + + it("returns null for everything else", () => { + expect(extractVerdict(null)).toBeNull(); + expect(extractVerdict("approve")).toBeNull(); + expect(extractVerdict({ verdict: 3 })).toBeNull(); + expect(extractVerdict(undefined)).toBeNull(); + }); +}); + +describe("describeRouteDecision", () => { + it("describes a matched transition with verdict and run count", () => { + const described = describeRouteDecision( + decision({ + fromLane: "implement", + matchedTransitionIndex: 1, + pipelineResult: "success", + laneRunCount: 2, + steps: { + verdict: { status: "completed", exitCode: 0, verdict: "approve" }, + }, + }), + laneName, + ); + + expect(described.title).toBe("Implementation → Review"); + expect(described.details).toContain("Matched transition #2"); + expect(described.details).toContain("Pipeline succeeded"); + expect(described.details).toContain("Run 2 in this lane"); + expect(described.details).toContain("verdict: approve"); + }); + + it("describes manual moves", () => { + const described = describeRouteDecision(decision({ source: "manual" }), laneName); + expect(described.title).toBe("Moved to Review"); + expect(described.details).toEqual(["Moved manually"]); + }); + + it("describes default lane routing after a failure with exit codes", () => { + const described = describeRouteDecision( + decision({ + fromLane: "implement", + toLane: "stuck", + source: "lane_on", + pipelineResult: "failure", + steps: { gate: { status: "failed", exitCode: 1 } }, + }), + laneName, + ); + + expect(described.title).toBe("Implementation → Stuck"); + expect(described.details).toContain("Default route"); + expect(described.details).toContain("Pipeline failed"); + expect(described.details).toContain("gate: exit 1"); + }); + + it("describes step-driven routing", () => { + const described = describeRouteDecision( + decision({ fromLane: "implement", source: "step_on" }), + laneName, + ); + expect(described.details).toContain("Routed by a step outcome"); + }); + + it("describes work-source syncs without mislabeling them as step outcomes", () => { + const described = describeRouteDecision(decision({ source: "work_source" }), laneName); + expect(described.details).toContain("Synced from a work source"); + expect(described.details).not.toContain("Routed by a step outcome"); + }); + + it("describes external events with their name", () => { + const described = describeRouteDecision( + decision({ fromLane: "implement", source: "external_event", eventName: "ci.passed" }), + laneName, + ); + expect(described.details).toContain('External event "ci.passed"'); + }); + + it("keeps the closing quote when an external event name is truncated", () => { + const described = describeRouteDecision( + decision({ + fromLane: "implement", + source: "external_event", + eventName: "ci.deploy.completed.production.us-east-1.cluster", + }), + laneName, + ); + const eventDetail = described.details.find((detail) => detail.startsWith("External event")); + expect(eventDetail).toBeDefined(); + // Truncating the name (not the wrapper) must preserve the balanced quotes. + expect(eventDetail?.startsWith('External event "')).toBe(true); + expect(eventDetail?.endsWith('"')).toBe(true); + expect(eventDetail).toContain("…"); + }); + + it("truncates runaway verdict strings", () => { + const described = describeRouteDecision( + decision({ + fromLane: "implement", + steps: { review: { status: "completed", verdict: "x".repeat(500) } }, + }), + laneName, + ); + const verdictDetail = described.details.find((detail) => detail.startsWith("review:")); + expect(verdictDetail?.length).toBeLessThanOrEqual(48); + expect(verdictDetail?.endsWith("…")).toBe(true); + }); +}); diff --git a/apps/web/src/workflow/routeDecision.ts b/apps/web/src/workflow/routeDecision.ts new file mode 100644 index 00000000000..67ac497002b --- /dev/null +++ b/apps/web/src/workflow/routeDecision.ts @@ -0,0 +1,103 @@ +export interface RouteDecisionStepView { + readonly status: string; + readonly exitCode?: number | undefined; + readonly verdict?: string | undefined; +} + +export interface RouteDecisionView { + readonly occurredAt: string; + readonly fromLane?: string | undefined; + readonly toLane: string; + readonly source: + | "step_on" + | "lane_transition" + | "lane_on" + | "manual" + | "external_event" + | "work_source"; + readonly matchedTransitionIndex?: number | undefined; + readonly eventName?: string | undefined; + readonly pipelineResult?: "success" | "failure" | "blocked" | undefined; + readonly laneRunCount?: number | undefined; + readonly steps?: Readonly<Record<string, RouteDecisionStepView>> | undefined; +} + +export interface DescribedRouteDecision { + readonly title: string; + readonly details: ReadonlyArray<string>; +} + +/** A captured review output's verdict field, when it has the common shape. */ +export const extractVerdict = (output: unknown): string | null => { + if (typeof output !== "object" || output === null || Array.isArray(output)) { + return null; + } + const verdict = (output as Record<string, unknown>)["verdict"]; + return typeof verdict === "string" ? verdict : null; +}; + +/** Agent-produced labels can be arbitrarily long — bound them for badges. */ +export const truncateLabel = (value: string, maxLength = 48): string => + value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`; + +const PIPELINE_RESULT_LABELS: Record<string, string> = { + success: "Pipeline succeeded", + failure: "Pipeline failed", + blocked: "Pipeline blocked", +}; + +/** + * Human-readable explanation of one routing decision for the ticket drawer. + * `laneName` resolves lane keys to display names (falls back to the key). + */ +export const describeRouteDecision = ( + decision: RouteDecisionView, + laneName: (key: string) => string, +): DescribedRouteDecision => { + const to = laneName(decision.toLane); + const title = + decision.fromLane === undefined ? `Moved to ${to}` : `${laneName(decision.fromLane)} → ${to}`; + + if (decision.source === "manual") { + return { title, details: ["Moved manually"] }; + } + + const details: string[] = []; + if (decision.source === "lane_transition" && decision.matchedTransitionIndex !== undefined) { + details.push(`Matched transition #${decision.matchedTransitionIndex + 1}`); + } else if (decision.source === "lane_transition") { + details.push("Matched a lane transition"); + } else if (decision.source === "lane_on") { + details.push("Default route"); + } else if (decision.source === "external_event") { + details.push( + decision.eventName === undefined + ? "External event" + : // Truncate the name only, then wrap in quotes, so a long name never + // drops the closing quote (which would be the truncated character). + `External event "${truncateLabel(decision.eventName, 30)}"`, + ); + } else if (decision.source === "work_source") { + details.push("Synced from a work source"); + } else { + details.push("Routed by a step outcome"); + } + const resultLabel = + decision.pipelineResult === undefined + ? undefined + : PIPELINE_RESULT_LABELS[decision.pipelineResult]; + if (resultLabel !== undefined) { + details.push(resultLabel); + } + if (decision.laneRunCount !== undefined) { + details.push(`Run ${decision.laneRunCount} in this lane`); + } + for (const [stepKey, step] of Object.entries(decision.steps ?? {})) { + if (step.verdict !== undefined) { + details.push(truncateLabel(`${stepKey}: ${step.verdict}`)); + } else if (step.exitCode !== undefined) { + details.push(`${stepKey}: exit ${step.exitCode}`); + } + } + return { title, details }; +}; diff --git a/apps/web/src/workflow/usageFormat.ts b/apps/web/src/workflow/usageFormat.ts new file mode 100644 index 00000000000..17112bff042 --- /dev/null +++ b/apps/web/src/workflow/usageFormat.ts @@ -0,0 +1,49 @@ +import { formatDuration } from "~/session-logic"; + +export function formatTokenCount(tokens: number): string { + if (tokens < 1_000) { + return `${tokens} tok`; + } + if (tokens < 1_000_000) { + const thousands = tokens / 1_000; + return `${thousands < 10 ? thousands.toFixed(1) : Math.round(thousands)}k tok`; + } + return `${(tokens / 1_000_000).toFixed(1)}M tok`; +} + +export function ticketUsageSummary(ticket: { + readonly totalTokens?: number | undefined; + readonly totalDurationMs?: number | undefined; + readonly tokenBudget?: number | undefined; +}): string | null { + const parts: string[] = []; + if (ticket.tokenBudget !== undefined && ticket.tokenBudget > 0) { + parts.push( + `${formatTokenCount(ticket.totalTokens ?? 0)} / ${formatTokenCount(ticket.tokenBudget)}`, + ); + } else if (ticket.totalTokens !== undefined && ticket.totalTokens > 0) { + parts.push(formatTokenCount(ticket.totalTokens)); + } + if (ticket.totalDurationMs !== undefined && ticket.totalDurationMs > 0) { + parts.push(formatDuration(ticket.totalDurationMs)); + } + return parts.length > 0 ? parts.join(" · ") : null; +} + +export function stepUsageSummary(step: { + readonly startedAt?: string | undefined; + readonly finishedAt?: string | undefined; + readonly usage?: { readonly totalTokens?: number | undefined } | undefined; +}): string | null { + const parts: string[] = []; + if (step.startedAt !== undefined && step.finishedAt !== undefined) { + const durationMs = Date.parse(step.finishedAt) - Date.parse(step.startedAt); + if (Number.isFinite(durationMs) && durationMs >= 0) { + parts.push(formatDuration(durationMs)); + } + } + if (step.usage?.totalTokens !== undefined && step.usage.totalTokens > 0) { + parts.push(formatTokenCount(step.usage.totalTokens)); + } + return parts.length > 0 ? parts.join(" · ") : null; +} diff --git a/apps/web/src/workflow/useNowTick.ts b/apps/web/src/workflow/useNowTick.ts new file mode 100644 index 00000000000..a6aa789bf77 --- /dev/null +++ b/apps/web/src/workflow/useNowTick.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +/** + * The current time, refreshed on an interval — so time-derived UI (aging + * badges, attention counts) crosses its thresholds without waiting for an + * unrelated re-render. + */ +export const useNowTick = (intervalMs: number): number => { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return now; +}; diff --git a/docs/workflow-boards/github-flow-example.json b/docs/workflow-boards/github-flow-example.json new file mode 100644 index 00000000000..543a6d04f13 --- /dev/null +++ b/docs/workflow-boards/github-flow-example.json @@ -0,0 +1,127 @@ +{ + "name": "GitHub flow", + "settings": { + "maxConcurrentTickets": 3 + }, + "lanes": [ + { + "key": "backlog", + "name": "Backlog", + "entry": "manual" + }, + { + "key": "implement", + "name": "Implement", + "entry": "auto", + "pipeline": [ + { + "key": "code", + "type": "agent", + "agent": { + "instance": "codex", + "model": "gpt-5.5", + "options": [ + { + "id": "reasoningEffort", + "value": "xhigh" + } + ] + }, + "instruction": "Implement the requested ticket in this worktree. Keep the change focused, run the relevant checks, and report the verification evidence.", + "captureOutput": true + }, + { + "key": "review", + "type": "agent", + "agent": { + "instance": "codex", + "model": "gpt-5.5", + "options": [ + { + "id": "reasoningEffort", + "value": "medium" + } + ] + }, + "instruction": "Review the accumulated diff. Reply LGTM if no blocking issues, or list what must be fixed before opening a PR.", + "captureOutput": true + } + ], + "on": { + "success": "open_pr", + "failure": "needs_attention", + "blocked": "needs_attention" + } + }, + { + "key": "open_pr", + "name": "Open PR", + "entry": "auto", + "pipeline": [ + { + "key": "pr_open", + "type": "pullRequest", + "action": "open", + "draft": false + } + ], + "on": { + "success": "in_review", + "failure": "needs_attention" + } + }, + { + "key": "in_review", + "name": "In Review", + "entry": "manual", + "onEvent": [ + { + "name": "ci.failed", + "to": "implement" + }, + { + "name": "pr.changes_requested", + "to": "implement" + }, + { + "name": "ci.passed", + "when": { "==": [{ "var": "pr.reviewDecision" }, "approved"] }, + "to": "land" + }, + { + "name": "pr.approved", + "when": { "==": [{ "var": "pr.ciState" }, "success"] }, + "to": "land" + } + ] + }, + { + "key": "land", + "name": "Land", + "entry": "auto", + "pipeline": [ + { + "key": "pr_land", + "type": "pullRequest", + "action": "land", + "strategy": "squash" + } + ], + "on": { + "success": "done", + "failure": "needs_attention" + } + }, + { + "key": "needs_attention", + "name": "Needs Attention", + "entry": "manual" + }, + { + "key": "done", + "name": "Done", + "entry": "manual", + "terminal": true + } + ] +} diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index 1d327aa945b..4814aaf2869 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -137,4 +137,73 @@ describe("ApnsClient", () => { }); }).pipe(Effect.provide(TestLayer)), ); + + it.effect( + "REGRESSION: thread-shaped payload serializes exactly as before (threadId+deepLink, no board/ticket keys)", + () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makePushNotificationRequest({ + token: "push-token", + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/threads/env/thread", + }, + }); + + // payload must carry threadId and deepLink, and must NOT carry boardId or ticketId + expect(request.payload).toEqual({ + aps: { + alert: { + title: "Thread", + body: "Input: Project", + }, + sound: "default", + }, + environmentId: "env", + threadId: "thread", + deepLink: "/threads/env/thread", + }); + expect(request.payload).not.toHaveProperty("boardId"); + expect(request.payload).not.toHaveProperty("ticketId"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("builds a ticket-shaped APNs payload with boardId+ticketId, no threadId", () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makePushNotificationRequest({ + token: "push-token", + notification: { + title: "Ticket ready", + body: "Review: My Board", + environmentId: "env", + boardId: "board-1", + ticketId: "ticket-1", + deepLink: "/tickets/env/board-1/ticket-1", + }, + }); + + expect(request.priority).toBe("10"); + // must carry boardId, ticketId, and deepLink — and NO threadId key + expect(request.payload).toEqual({ + aps: { + alert: { + title: "Ticket ready", + body: "Review: My Board", + }, + sound: "default", + }, + environmentId: "env", + boardId: "board-1", + ticketId: "ticket-1", + deepLink: "/tickets/env/board-1/ticket-1", + }); + // must NOT carry threadId at all (toEqual above makes this redundant, kept for clarity) + expect(request.payload).not.toHaveProperty("threadId"); + }).pipe(Effect.provide(TestLayer)), + ); }); diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index a779085118d..b727ff5b4f7 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -215,7 +215,13 @@ function makePushNotificationRequest(input: { sound: "default", }, environmentId: input.notification.environmentId, - threadId: input.notification.threadId, + ...(input.notification.threadId !== undefined + ? { threadId: input.notification.threadId } + : {}), + ...(input.notification.boardId !== undefined ? { boardId: input.notification.boardId } : {}), + ...(input.notification.ticketId !== undefined + ? { ticketId: input.notification.ticketId } + : {}), deepLink: input.notification.deepLink, }, }; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index c1dba1467fa..89b2847314f 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -563,7 +563,7 @@ const make = Effect.gen(function* () { const notification = sanitizeApnsNotificationPayload(input.notification); yield* Effect.annotateCurrentSpan({ "relay.environment_id": notification.environmentId, - "relay.thread_id": notification.threadId, + ...(notification.threadId !== undefined ? { "relay.thread_id": notification.threadId } : {}), }); const request = apns.makePushNotificationRequest({ token: input.token, @@ -573,7 +573,7 @@ const make = Effect.gen(function* () { const claim = yield* attempts.claimSourceJob({ userId: input.target.user_id, environmentId: notification.environmentId, - threadId: notification.threadId, + threadId: notification.threadId ?? null, deviceId: input.target.device_id, kind: "push_notification", sourceJobId: input.sourceJobId, @@ -637,7 +637,7 @@ const make = Effect.gen(function* () { yield* attempts.record({ userId: input.target.user_id, environmentId: notification.environmentId, - threadId: notification.threadId, + threadId: notification.threadId ?? null, deviceId: input.target.device_id, kind: "push_notification", token: input.token, diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index 3582e236b4d..44271ba4ed8 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -55,6 +55,7 @@ export interface ApnsDeliveryQueueShape { readonly deviceId: string; readonly token: string; readonly notification: NonNullable<ApnsDeliveryJobPayload["notification"]>; + readonly jobId?: string; }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>; } @@ -109,12 +110,18 @@ const make = Effect.gen(function* () { "relay.mobile.device_id": input.deviceId, "relay.delivery.kind": "push_notification", "relay.environment_id": input.notification.environmentId, - "relay.thread_id": input.notification.threadId, + // threadId is optional (ticket pushes carry boardId+ticketId, no + // threadId) — only annotate when present, matching ApnsDeliveries.ts. + ...(input.notification.threadId !== undefined + ? { "relay.thread_id": input.notification.threadId } + : {}), }); const now = yield* DateTime.now; - const jobId = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), - ); + const jobId = + input.jobId ?? + (yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), + )); yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId }); const payload = makeApnsDeliveryJobPayload({ kind: "push_notification", diff --git a/infra/relay/src/agentActivity/BoardTicketPublisher.test.ts b/infra/relay/src/agentActivity/BoardTicketPublisher.test.ts new file mode 100644 index 00000000000..1b4f44bb9de --- /dev/null +++ b/infra/relay/src/agentActivity/BoardTicketPublisher.test.ts @@ -0,0 +1,472 @@ +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; +import type { RelayBoardTicketState, RelayDeliveryResult } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import * as BoardTicketPublisher from "./BoardTicketPublisher.ts"; +import * as LiveActivities from "./LiveActivities.ts"; +import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; + +function boardTicketState(overrides: Partial<RelayBoardTicketState> = {}): RelayBoardTicketState { + return { + environmentId: "env" as RelayBoardTicketState["environmentId"], + boardId: "board" as RelayBoardTicketState["boardId"], + ticketId: "ticket" as RelayBoardTicketState["ticketId"], + attentionKind: "blocked", + title: "Ticket blocked", + body: "A dependency is blocking this ticket", + deepLink: "/boards/env/board/ticket", + transitionId: "transition" as RelayBoardTicketState["transitionId"], + ...overrides, + }; +} + +function target(overrides: Partial<LiveActivities.TargetRow> = {}): LiveActivities.TargetRow { + return { + user_id: "dev:julius", + device_id: "device-1", + platform: "ios", + ios_major_version: 18, + app_version: "1.0.0", + push_token: "push-token", + push_to_start_token: null, + preferences_json: "{}", + activity_push_token: null, + remote_start_queued_at: null, + remote_started_at: null, + ended_at: null, + last_aggregate_json: null, + last_live_activity_delivery_at: null, + ...overrides, + }; +} + +function preferences(overrides: Record<string, unknown> = {}): string { + return JSON.stringify({ + liveActivitiesEnabled: false, + notificationsEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + ...overrides, + }); +} + +const deliveryResult: RelayDeliveryResult = { + deviceId: "device-1", + kind: "push_notification", + ok: true, + queued: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, +}; + +function makeEnvironmentLinks(): EnvironmentLinks.EnvironmentLinksShape { + return { + upsert: () => Effect.void, + listUsersForEnvironment: () => Effect.succeed(["dev:julius"]), + listDeliveryUsersForEnvironment: () => + Effect.succeed([ + { userId: "dev:julius", notificationsEnabled: true, liveActivitiesEnabled: true }, + ]), + listPublicKeysForEnvironment: () => Effect.succeed([]), + listForUser: () => Effect.succeed([]), + getForUser: () => Effect.succeed(null), + revokeForUser: () => Effect.succeed(false), + }; +} + +function makeLiveActivities( + overrides: Partial<LiveActivities.LiveActivitiesShape> = {}, +): LiveActivities.LiveActivitiesShape { + return { + register: () => Effect.void, + listTargets: () => Effect.succeed([]), + markDelivery: () => Effect.void, + markStartQueued: () => Effect.void, + clearStartQueued: () => Effect.void, + invalidateDeliveryToken: () => Effect.void, + ...overrides, + }; +} + +type EnqueueArgs = Parameters< + ApnsDeliveryQueue.ApnsDeliveryQueueShape["enqueuePushNotification"] +>[0]; + +function makeApnsDeliveryQueue( + capture: Array<EnqueueArgs>, +): ApnsDeliveryQueue.ApnsDeliveryQueueShape { + return { + enqueueLiveActivity: () => Effect.die("unexpected enqueueLiveActivity"), + enqueuePushNotification: (input) => + Effect.sync(() => { + capture.push(input); + return deliveryResult; + }), + }; +} + +function provide(input: { + readonly targets: ReadonlyArray<LiveActivities.TargetRow>; + readonly capture: Array<EnqueueArgs>; +}) { + return BoardTicketPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ listTargets: () => Effect.succeed(input.targets) }), + ), + Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueue, makeApnsDeliveryQueue(input.capture)), + NodeCryptoLayer.layer, + ), + ), + ); +} + +// Flexible harness for multi-user / failure-isolation tests: route targets per +// userId and allow overriding the live-activities + queue services directly. +function provideCustom(input: { + readonly deliveryUsers: ReadonlyArray<EnvironmentLinks.AgentAwarenessDeliveryUserRecord>; + readonly liveActivities: LiveActivities.LiveActivitiesShape; + readonly queue: ApnsDeliveryQueue.ApnsDeliveryQueueShape; +}) { + return BoardTicketPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(EnvironmentLinks.EnvironmentLinks, { + ...makeEnvironmentLinks(), + listDeliveryUsersForEnvironment: () => Effect.succeed(input.deliveryUsers), + }), + Layer.succeed(LiveActivities.LiveActivities, input.liveActivities), + Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueue, input.queue), + NodeCryptoLayer.layer, + ), + ), + ); +} + +describe("BoardTicketPublisher", () => { + it.effect( + "enqueues a single push with a bounded, stable jobId for a blocked state when notifyOnBlocked is absent", + () => { + // Realistic-length component ids: the raw composite would be 150-400 chars, + // overflowing the varchar(64) source_job_id column. The digest must keep it + // within 64 chars while staying deterministic. + const longDeviceId = "device-" + "d".repeat(40); + const state = boardTicketState({ + environmentId: ("env-" + "a".repeat(40)) as RelayBoardTicketState["environmentId"], + boardId: ("project-" + + "b".repeat(36) + + "__some-board-slug") as RelayBoardTicketState["boardId"], + ticketId: ("ticket-" + "c".repeat(36)) as RelayBoardTicketState["ticketId"], + transitionId: ("transition-" + "e".repeat(36)) as RelayBoardTicketState["transitionId"], + }); + const capture: Array<EnqueueArgs> = []; + return Effect.gen(function* () { + const publisher = yield* BoardTicketPublisher.BoardTicketPublisher; + const response = yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "public-key", + boardId: state.boardId, + ticketId: state.ticketId, + state, + }); + expect(response.deliveries).toHaveLength(1); + expect(capture).toHaveLength(1); + const jobId = capture[0]?.jobId ?? ""; + expect(jobId.startsWith("board:")).toBe(true); + // source_job_id is varchar(64): the persisted dedup key must fit. + expect(jobId.length).toBeLessThanOrEqual(64); + expect(capture[0]?.notification).toEqual({ + title: "Ticket blocked", + body: "A dependency is blocking this ticket", + environmentId: state.environmentId, + boardId: state.boardId, + ticketId: state.ticketId, + deepLink: "/boards/env/board/ticket", + }); + expect(capture[0]?.notification).not.toHaveProperty("threadId"); + + // Determinism: republishing the same state for the same device yields the + // same jobId, so the queue consumer dedups instead of double-delivering. + yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "public-key", + boardId: state.boardId, + ticketId: state.ticketId, + state, + }); + expect(capture).toHaveLength(2); + expect(capture[1]?.jobId).toBe(jobId); + }).pipe( + Effect.provide( + provide({ + targets: [target({ device_id: longDeviceId, preferences_json: preferences() })], + capture, + }), + ), + ); + }, + ); + + it.effect( + "does not enqueue when notifyOnApproval is false for a waiting_for_approval state", + () => { + const capture: Array<EnqueueArgs> = []; + const state = boardTicketState({ attentionKind: "waiting_for_approval" }); + return Effect.gen(function* () { + const publisher = yield* BoardTicketPublisher.BoardTicketPublisher; + const response = yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "public-key", + boardId: state.boardId, + ticketId: state.ticketId, + state, + }); + expect(response.deliveries).toHaveLength(0); + expect(capture).toHaveLength(0); + }).pipe( + Effect.provide( + provide({ + targets: [target({ preferences_json: preferences({ notifyOnApproval: false }) })], + capture, + }), + ), + ); + }, + ); + + it.effect("does not enqueue when notificationsEnabled is false", () => { + const capture: Array<EnqueueArgs> = []; + const state = boardTicketState(); + return Effect.gen(function* () { + const publisher = yield* BoardTicketPublisher.BoardTicketPublisher; + const response = yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "public-key", + boardId: state.boardId, + ticketId: state.ticketId, + state, + }); + expect(response.deliveries).toHaveLength(0); + expect(capture).toHaveLength(0); + }).pipe( + Effect.provide( + provide({ + targets: [target({ preferences_json: preferences({ notificationsEnabled: false }) })], + capture, + }), + ), + ); + }); + + it.effect("returns no deliveries and enqueues nothing for a cleared (null) state", () => { + const capture: Array<EnqueueArgs> = []; + return Effect.gen(function* () { + const publisher = yield* BoardTicketPublisher.BoardTicketPublisher; + const response = yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "public-key", + boardId: "", + ticketId: "ticket", + state: null, + }); + expect(response.deliveries).toHaveLength(0); + expect(capture).toHaveLength(0); + }).pipe(Effect.provide(provide({ targets: [target()], capture }))); + }); + + // Fix A: honor the env-level notification opt-out, per-user (not global). + it.effect("skips a user with env notifications off but delivers to a user with them on", () => { + const capture: Array<EnqueueArgs> = []; + const state = boardTicketState(); + return Effect.gen(function* () { + const publisher = yield* BoardTicketPublisher.BoardTicketPublisher; + const response = yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "public-key", + boardId: state.boardId, + ticketId: state.ticketId, + state, + }); + // Only the opted-in user's device should enqueue. + expect(capture).toHaveLength(1); + expect(capture[0]?.userId).toBe("user:on"); + expect(response.deliveries).toHaveLength(1); + }).pipe( + Effect.provide( + provideCustom({ + deliveryUsers: [ + // Env notifications OFF but live activities ON — must NOT receive a push, + // even though the device's own preferences_json allows it. + { userId: "user:off", notificationsEnabled: false, liveActivitiesEnabled: true }, + { userId: "user:on", notificationsEnabled: true, liveActivitiesEnabled: true }, + ], + liveActivities: makeLiveActivities({ + listTargets: ({ userId }) => + Effect.succeed([ + target({ + user_id: userId, + device_id: `${userId}:device`, + preferences_json: preferences(), + }), + ]), + }), + queue: makeApnsDeliveryQueue(capture), + }), + ), + ); + }); + + // Fix B: distinct tuples whose plain colon-join is IDENTICAL must not collide. + it.effect("produces distinct bounded jobIds for tuples differing only by colon placement", () => { + const capture: Array<EnqueueArgs> = []; + // Under a naive `${env}:${board}:${ticket}:...` join both tuples flatten to the + // same string `env:a:b:c:device-1`, so they would share a hash and the UNIQUE + // source_job_id would suppress a genuinely-distinct notification. stableStringify + // keeps the field boundaries, so the jobIds must differ. + const stateOne = boardTicketState({ + boardId: "a:b" as RelayBoardTicketState["boardId"], + ticketId: "c" as RelayBoardTicketState["ticketId"], + }); + const stateTwo = boardTicketState({ + boardId: "a" as RelayBoardTicketState["boardId"], + ticketId: "b:c" as RelayBoardTicketState["ticketId"], + }); + return Effect.gen(function* () { + const publisher = yield* BoardTicketPublisher.BoardTicketPublisher; + yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "public-key", + boardId: stateOne.boardId, + ticketId: stateOne.ticketId, + state: stateOne, + }); + yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "public-key", + boardId: stateTwo.boardId, + ticketId: stateTwo.ticketId, + state: stateTwo, + }); + expect(capture).toHaveLength(2); + const [first, second] = capture; + for (const arg of capture) { + expect(arg.jobId?.startsWith("board:")).toBe(true); + expect((arg.jobId ?? "").length).toBeLessThanOrEqual(64); + } + expect(first?.jobId).not.toBe(second?.jobId); + }).pipe( + // device_id contains a colon — must still produce a valid bounded key. + Effect.provide( + provide({ + targets: [target({ device_id: "device:with:colons", preferences_json: preferences() })], + capture, + }), + ), + ); + }); + + // Fix C(1): one user's listTargets failure does not block another user's delivery. + it.effect("isolates a per-user listTargets failure and still delivers to other users", () => { + const capture: Array<EnqueueArgs> = []; + const state = boardTicketState(); + return Effect.gen(function* () { + const publisher = yield* BoardTicketPublisher.BoardTicketPublisher; + // publish must NOT reject despite the failing user. + const response = yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "public-key", + boardId: state.boardId, + ticketId: state.ticketId, + state, + }); + expect(capture).toHaveLength(1); + expect(capture[0]?.userId).toBe("user:ok"); + expect(response.ok).toBe(true); + expect(response.deliveries).toHaveLength(1); + }).pipe( + Effect.provide( + provideCustom({ + deliveryUsers: [ + { userId: "user:bad", notificationsEnabled: true, liveActivitiesEnabled: true }, + { userId: "user:ok", notificationsEnabled: true, liveActivitiesEnabled: true }, + ], + liveActivities: makeLiveActivities({ + listTargets: ({ userId }) => + userId === "user:bad" + ? Effect.fail( + new LiveActivities.LiveActivityTargetListPersistenceError({ + cause: new Error("boom"), + }), + ) + : Effect.succeed([ + target({ + user_id: userId, + device_id: `${userId}:device`, + preferences_json: preferences(), + }), + ]), + }), + queue: makeApnsDeliveryQueue(capture), + }), + ), + ); + }); + + // Fix C(2): one device's enqueue failure does not block sibling devices. + it.effect("isolates a per-device enqueue failure and still delivers to other devices", () => { + const capture: Array<EnqueueArgs> = []; + const state = boardTicketState(); + return Effect.gen(function* () { + const publisher = yield* BoardTicketPublisher.BoardTicketPublisher; + const response = yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "public-key", + boardId: state.boardId, + ticketId: state.ticketId, + state, + }); + // The good device still enqueues; publish resolves ok. + expect(response.ok).toBe(true); + expect(capture.map((arg) => arg.deviceId)).toContain("device-good"); + expect(response.deliveries).toHaveLength(1); + }).pipe( + Effect.provide( + provideCustom({ + deliveryUsers: [ + { userId: "dev:julius", notificationsEnabled: true, liveActivitiesEnabled: true }, + ], + liveActivities: makeLiveActivities({ + listTargets: () => + Effect.succeed([ + target({ device_id: "device-bad", preferences_json: preferences() }), + target({ device_id: "device-good", preferences_json: preferences() }), + ]), + }), + queue: { + enqueueLiveActivity: () => Effect.die("unexpected enqueueLiveActivity"), + enqueuePushNotification: (input) => + input.deviceId === "device-bad" + ? Effect.fail( + new ApnsDeliveryQueue.ApnsDeliveryQueueSendError({ + cause: new Error("queue down"), + }), + ) + : Effect.sync(() => { + capture.push(input); + return { ...deliveryResult, deviceId: input.deviceId }; + }), + }, + }), + ), + ); + }); +}); diff --git a/infra/relay/src/agentActivity/BoardTicketPublisher.ts b/infra/relay/src/agentActivity/BoardTicketPublisher.ts new file mode 100644 index 00000000000..6974a9652e5 --- /dev/null +++ b/infra/relay/src/agentActivity/BoardTicketPublisher.ts @@ -0,0 +1,248 @@ +import type { + RelayBoardTicketState, + RelayDeliveryResult, + RelayPublishResponse, +} from "@t3tools/contracts/relay"; +import { RelayAgentAwarenessPreferences as RelayAgentAwarenessPreferencesSchema } from "@t3tools/contracts/relay"; +import { stableStringify } from "@t3tools/shared/relaySigning"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import * as LiveActivities from "./LiveActivities.ts"; +import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; + +// Board pushes are best-effort: the needs-you inbox/RPC is the reliable browsing +// surface, so a per-user or per-device failure is logged and skipped rather than +// failing the whole publish (which would make the server retry the outbox row and +// could permanently strand every other device behind one persistent failure). +// Two errors propagate instead of being swallowed: the pre-fanout delivery-user +// lookup, and ApnsDeliveryQueueSendError (the delivery-queue transport is down for +// the whole batch — a retryable outage that is NOT a per-device opt-out). Letting +// the latter propagate makes the server's notification outbox retry the row rather +// than recording {ok:true,deliveries:[]} as terminal success and dropping every +// attention push issued during the outage. +export type BoardTicketPublishError = + | EnvironmentLinks.EnvironmentLinkUserListPersistenceError + | ApnsDeliveryQueue.ApnsDeliveryQueueSendError; + +export interface BoardTicketPublisherShape { + readonly publish: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly boardId: string; + readonly ticketId: string; + readonly state: RelayBoardTicketState | null; + }) => Effect.Effect<RelayPublishResponse, BoardTicketPublishError>; +} + +export class BoardTicketPublisher extends Context.Service< + BoardTicketPublisher, + BoardTicketPublisherShape +>()("t3code-relay/agentActivity/BoardTicketPublisher") {} + +const decodeRelayAgentAwarenessPreferencesJson = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayAgentAwarenessPreferencesSchema), +); + +// Per-target fan-out result: the enqueued delivery (or null when skipped/failed) +// plus whether the failure was a RETRYABLE infra error — a queue-transport send +// error OR a per-user target-lookup failure. Used after the fan-out to detect a +// batch-wide outage worth retrying via the server's notification outbox. +interface TargetOutcome { + readonly delivery: RelayDeliveryResult | null; + readonly transportFailed: boolean; +} + +function isApnsDeliveryQueueSendError(cause: unknown): boolean { + return ( + typeof cause === "object" && + cause !== null && + "_tag" in cause && + (cause as { readonly _tag: unknown })._tag === "ApnsDeliveryQueueSendError" + ); +} + +function notificationAllowedForState(input: { + readonly preferencesJson: string; + readonly attentionKind: RelayBoardTicketState["attentionKind"]; +}): boolean { + const preferences = Option.getOrNull( + decodeRelayAgentAwarenessPreferencesJson(input.preferencesJson), + ); + if (!preferences?.notificationsEnabled) { + return false; + } + switch (input.attentionKind) { + case "waiting_for_approval": + return preferences.notifyOnApproval; + case "waiting_for_input": + return preferences.notifyOnInput; + case "blocked": + return preferences.notifyOnBlocked ?? true; + // A future WorkflowTicketAttentionKind value (the relay copy in relay.ts is a + // manual "keep in sync" mirror) has no per-kind toggle yet. Default to + // notifying — consistent with `blocked`'s `?? true` and so a new attention + // kind is not silently suppressed before its preference is wired up — instead + // of falling through to an implicit `undefined` (falsy) return. + default: + return true; + } +} + +const make = Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + const liveActivities = yield* LiveActivities.LiveActivities; + const deliveryQueue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; + const crypto = yield* Crypto.Crypto; + + return BoardTicketPublisher.of({ + publish: Effect.fn("relay.board_ticket_publisher.publish")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + "relay.board_id": input.boardId, + "relay.ticket_id": input.ticketId, + "relay.board_ticket.attention_kind": input.state?.attentionKind ?? "cleared", + }); + const state = input.state; + if (state === null) { + return { ok: true, deliveries: [] }; + } + const notification: ApnsNotificationPayload = { + title: state.title, + body: state.body, + environmentId: state.environmentId, + boardId: state.boardId, + ticketId: state.ticketId, + deepLink: state.deepLink, + }; + const deliveryUsers = yield* links.listDeliveryUsersForEnvironment({ + environmentId: input.environmentId, + environmentPublicKey: input.environmentPublicKey, + }); + const deliveriesByUser = yield* Effect.forEach( + deliveryUsers, + (deliveryUser) => + Effect.gen(function* () { + // Honor the user's environment-level notification opt-out. The delivery + // list also includes users who only enabled live activities, so this + // filter must be per-user — not just the per-device preferences below. + if (!deliveryUser.notificationsEnabled) { + return [] as ReadonlyArray<TargetOutcome>; + } + const targets = yield* liveActivities.listTargets({ userId: deliveryUser.userId }); + const perTarget = yield* Effect.forEach( + targets, + (target) => + Effect.gen(function* () { + if (!target.push_token) { + return { delivery: null, transportFailed: false } satisfies TargetOutcome; + } + if ( + !notificationAllowedForState({ + preferencesJson: target.preferences_json, + attentionKind: state.attentionKind, + }) + ) { + return { delivery: null, transportFailed: false } satisfies TargetOutcome; + } + // Bound the persisted dedup key: source_job_id is varchar(64). Real + // component ids make the raw composite 150-400 chars, which Postgres + // rejects (no truncation), silently dropping every notification. Hash + // an unambiguous serialization (colons are legal in the ids, so a + // plain colon-join could collide distinct tuples) into a fixed-width, + // still-stable key so dedup survives. + const composite = stableStringify({ + environmentId: state.environmentId, + boardId: state.boardId, + ticketId: state.ticketId, + transitionId: state.transitionId, + deviceId: target.device_id, + }); + const digest = yield* crypto.digest( + "SHA-256", + new TextEncoder().encode(composite), + ); + const jobId = `board:${Encoding.encodeBase64Url(digest)}`; + const delivery = yield* deliveryQueue.enqueuePushNotification({ + userId: target.user_id, + deviceId: target.device_id, + token: target.push_token, + notification, + jobId, + }); + return { delivery, transportFailed: false } satisfies TargetOutcome; + }).pipe( + // Isolate per-device failures (digest or enqueue): log and skip so + // one stranded device does not block its siblings. We still record + // whether the failure was a queue-transport error (vs a per-device + // issue) so a batch-wide outage — where every attempted enqueue + // fails and nothing gets through — can be detected after the + // fan-out and propagated for an outbox retry, rather than reported + // as a false {ok:true,deliveries:[]} terminal success. + Effect.catch((cause) => + Effect.as( + Effect.logWarning("board ticket push enqueue failed", { + userId: deliveryUser.userId, + deviceId: target.device_id, + cause, + }), + { + delivery: null, + transportFailed: isApnsDeliveryQueueSendError(cause), + } satisfies TargetOutcome, + ), + ), + ), + { concurrency: 2 }, + ); + return perTarget; + }).pipe( + // Isolate per-user failures (e.g. a transient listTargets/persistence + // error) — but mark this user's whole fan-out as a retryable failure + // (transportFailed) rather than swallowing it to an empty []. Without + // this, a target-lookup failure looked identical to "user has no + // devices", so a batch-wide lookup outage produced a false + // {ok:true,deliveries:[]} terminal success and the server outbox never + // retried. A single user's lookup failure when others succeed still + // stays best-effort (the outage check below only retries when NOTHING + // was delivered). + Effect.catch((cause) => + Effect.as( + Effect.logWarning("board ticket push fan-out failed for user", { + userId: deliveryUser.userId, + cause, + }), + [{ delivery: null, transportFailed: true }] as ReadonlyArray<TargetOutcome>, + ), + ), + ), + { concurrency: 4 }, + ); + const outcomes = deliveriesByUser.flat(); + const deliveries = outcomes + .map((outcome) => outcome.delivery) + .filter((delivery): delivery is RelayDeliveryResult => delivery !== null); + // A batch-wide queue-transport outage is the one mechanical failure worth a + // retry: every attempted enqueue failed with a transport error AND nothing + // got through. Distinguish it from the benign all-opted-out case (no transport + // failures) and from a single stranded device (some delivery succeeded) so we + // only fail — and trigger the server's outbox retry — when the whole publish + // was lost to the outage. A partial success stays best-effort, as before. + if (deliveries.length === 0 && outcomes.some((outcome) => outcome.transportFailed)) { + return yield* new ApnsDeliveryQueue.ApnsDeliveryQueueSendError({ + cause: new Error("board ticket push fan-out failed: delivery queue unavailable"), + }); + } + return { ok: true, deliveries }; + }), + }); +}); + +export const layer = Layer.effect(BoardTicketPublisher, make); diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts index 7a3b227703f..0d342747e84 100644 --- a/infra/relay/src/agentActivity/Devices.test.ts +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -208,6 +208,7 @@ describe("Devices", () => { notifyOnInput: true, notifyOnCompletion: true, notifyOnFailure: true, + notifyOnBlocked: true, }, liveActivities: { enabled: true, @@ -217,4 +218,86 @@ describe("Devices", () => { ]); }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); }); + + it.effect( + "defaults notifyOnBlocked to true when the stored preferences JSON omits the field", + () => { + const preferencesWithoutBlocked = { + notificationsEnabled: true, + liveActivitiesEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + // notifyOnBlocked intentionally absent + }; + + const fakeDb = { + select: () => ({ + from: (_table: unknown) => ({ + where: (_condition: SQL) => + Effect.succeed([ + { + deviceId: "device-2", + label: "Old iPhone", + platform: "ios" as const, + iosMajorVersion: 18, + appVersion: "1.0.0", + preferences: preferencesWithoutBlocked, + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ]), + }), + }), + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + const listed = yield* devices.listForUser({ userId: "user-3" }); + + expect(listed[0]?.notifications.notifyOnBlocked).toBe(true); + }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }, + ); + + it.effect( + "preserves notifyOnBlocked: false when the stored preferences JSON sets it to false", + () => { + const preferencesWithBlockedFalse = { + notificationsEnabled: true, + liveActivitiesEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + notifyOnBlocked: false, + }; + + const fakeDb = { + select: () => ({ + from: (_table: unknown) => ({ + where: (_condition: SQL) => + Effect.succeed([ + { + deviceId: "device-3", + label: "User iPhone", + platform: "ios" as const, + iosMajorVersion: 18, + appVersion: "1.0.0", + preferences: preferencesWithBlockedFalse, + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ]), + }), + }), + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + const listed = yield* devices.listForUser({ userId: "user-4" }); + + expect(listed[0]?.notifications.notifyOnBlocked).toBe(false); + }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }, + ); }); diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 86c338b0912..ed47591df04 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -177,6 +177,7 @@ const make = Effect.gen(function* () { notifyOnInput: row.preferences.notifyOnInput, notifyOnCompletion: row.preferences.notifyOnCompletion, notifyOnFailure: row.preferences.notifyOnFailure, + notifyOnBlocked: row.preferences.notifyOnBlocked ?? true, }, liveActivities: { enabled: row.preferences.liveActivitiesEnabled, diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index d509baa9168..fd893b34d9e 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -21,7 +21,11 @@ export const ApnsNotificationPayload = Schema.Struct({ title: Schema.String, body: Schema.String, environmentId: Schema.String, - threadId: Schema.String, + // Thread notifications carry threadId; ticket notifications carry boardId + ticketId. + // deepLink is the routing key used by the mobile app for both notification kinds. + threadId: Schema.optional(Schema.String), + boardId: Schema.optional(Schema.String), + ticketId: Schema.optional(Schema.String), deepLink: Schema.String, }); export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index a74ce670cfb..72729658525 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -4,7 +4,11 @@ import type { RelayAgentActivityPublishProofPayload, RelayAgentActivityPublishRequest, RelayAgentActivityState, + RelayBoardTicketPublishProofPayload, + RelayBoardTicketPublishRequest, + RelayBoardTicketState, } from "@t3tools/contracts/relay"; +import { RELAY_BOARD_TICKET_PUBLISH_TYP } from "@t3tools/contracts/relay"; import { RELAY_ACTIVITY_PUBLISH_TYP } from "@t3tools/shared/relayJwt"; import { stableStringify } from "@t3tools/shared/relaySigning"; import { describe, expect, it } from "@effect/vitest"; @@ -80,6 +84,46 @@ const freshRequest = Effect.gen(function* () { } satisfies RelayAgentActivityPublishRequest; }); +const boardTicketState: RelayBoardTicketState = { + environmentId: "env" as RelayBoardTicketState["environmentId"], + boardId: "board" as RelayBoardTicketState["boardId"], + ticketId: "ticket" as RelayBoardTicketState["ticketId"], + attentionKind: "waiting_for_approval", + title: "Needs approval", + body: "Approve the deploy step", + deepLink: "/boards/env/board/ticket", + transitionId: "transition" as RelayBoardTicketState["transitionId"], +}; + +function signBoardTicketJwt(payload: object, privateKey: string): string { + const header = Buffer.from( + JSON.stringify({ alg: "EdDSA", typ: RELAY_BOARD_TICKET_PUBLISH_TYP }), + ).toString("base64url"); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signingInput = `${header}.${encodedPayload}`; + return `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), privateKey).toString("base64url")}`; +} + +const freshBoardTicketRequest = Effect.gen(function* () { + const now = yield* DateTime.now; + const payload = { + iss: "t3-env:env", + aud: "https://relay.example.test", + sub: "env", + jti: "board-publish-jti", + iat: Math.floor(now.epochMilliseconds / 1_000), + exp: Math.floor(DateTime.add(now, { minutes: 5 }).epochMilliseconds / 1_000), + environmentId: boardTicketState.environmentId, + boardId: boardTicketState.boardId, + ticketId: boardTicketState.ticketId, + state: boardTicketState, + } satisfies RelayBoardTicketPublishProofPayload; + return { + state: boardTicketState, + proof: signBoardTicketJwt(payload, keyPair.privateKey), + } satisfies RelayBoardTicketPublishRequest; +}); + function layer(replay?: Partial<DpopProofs.DpopProofReplayShape>) { return EnvironmentPublishSignatures.layer.pipe( Layer.provide( @@ -163,4 +207,49 @@ describe("EnvironmentPublishSignatures", () => { expect(Result.isFailure(result)).toBe(true); }).pipe(Effect.provide(layer({ consume: () => Effect.succeed(false) }))), ); + + it.effect("verifies a validly-signed board-ticket proof", () => + Effect.gen(function* () { + const request = yield* freshBoardTicketRequest; + const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + yield* signatures.verifyBoardTicket({ + environmentId: boardTicketState.environmentId, + environmentPublicKey: keyPair.publicKey, + ticketId: boardTicketState.ticketId, + request, + }); + }).pipe(Effect.provide(layer())), + ); + + it.effect("rejects a board-ticket proof whose ticketId differs from the path ticketId", () => + Effect.gen(function* () { + const request = yield* freshBoardTicketRequest; + const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + const result = yield* Effect.result( + signatures.verifyBoardTicket({ + environmentId: boardTicketState.environmentId, + environmentPublicKey: keyPair.publicKey, + ticketId: "other-ticket", + request, + }), + ); + expect(Result.isFailure(result)).toBe(true); + }).pipe(Effect.provide(layer())), + ); + + it.effect("rejects board-ticket state tampering", () => + Effect.gen(function* () { + const request = yield* freshBoardTicketRequest; + const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + const result = yield* Effect.result( + signatures.verifyBoardTicket({ + environmentId: boardTicketState.environmentId, + environmentPublicKey: keyPair.publicKey, + ticketId: boardTicketState.ticketId, + request: { ...request, state: { ...boardTicketState, title: "Tampered" } }, + }), + ); + expect(Result.isFailure(result)).toBe(true); + }).pipe(Effect.provide(layer())), + ); }); diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.ts index 4d2d316b228..da41e979ba0 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.ts @@ -1,6 +1,9 @@ import { RelayAgentActivityPublishProofPayload, type RelayAgentActivityPublishRequest, + RelayBoardTicketPublishProofPayload, + type RelayBoardTicketPublishRequest, + RELAY_BOARD_TICKET_PUBLISH_TYP, } from "@t3tools/contracts/relay"; import { decodeRelayJwt, @@ -66,6 +69,12 @@ export interface EnvironmentPublishSignaturesShape { readonly threadId: string; readonly request: RelayAgentActivityPublishRequest; }) => Effect.Effect<void, EnvironmentPublishSignatureError>; + readonly verifyBoardTicket: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly ticketId: string; + readonly request: RelayBoardTicketPublishRequest; + }) => Effect.Effect<void, EnvironmentPublishSignatureError>; } export class EnvironmentPublishSignatures extends Context.Service< @@ -74,6 +83,7 @@ export class EnvironmentPublishSignatures extends Context.Service< >()("t3code-relay/environments/EnvironmentPublishSignatures") {} const decodeProof = Schema.decodeUnknownEffect(RelayAgentActivityPublishProofPayload); +const decodeBoardTicketProof = Schema.decodeUnknownEffect(RelayBoardTicketPublishProofPayload); function environmentPublishReplayThumbprintData(input: { readonly environmentId: string; @@ -172,6 +182,86 @@ const make = Effect.gen(function* () { }); } }), + verifyBoardTicket: Effect.fn("relay.environment_publish_signatures.verify_board_ticket")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + "relay.ticket_id": input.ticketId, + }); + const now = yield* DateTime.now; + const decoded = yield* Effect.try({ + try: () => decodeRelayJwt(input.request.proof), + catch: () => + new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + }); + if ( + typeof decoded.exp === "number" && + decoded.exp <= Math.floor(now.epochMilliseconds / 1_000) + ) { + return yield* new EnvironmentPublishSignatureExpired({ + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(decoded.exp * 1_000)), + }); + } + const proof = yield* verifyRelayJwt({ + publicKey: input.environmentPublicKey, + token: input.request.proof, + typ: RELAY_BOARD_TICKET_PUBLISH_TYP, + issuer: `t3-env:${input.environmentId}`, + audience: normalizeRelayIssuer(config.relayIssuer), + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + }).pipe( + Effect.flatMap(decodeBoardTicketProof), + Effect.mapError( + () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + ), + ); + if ( + proof.environmentId !== input.environmentId || + proof.ticketId !== input.ticketId || + proof.sub !== input.environmentId || + stableStringify(proof.state) !== stableStringify(input.request.state) || + (input.request.state !== null && + (input.request.state.environmentId !== input.environmentId || + input.request.state.ticketId !== input.ticketId || + proof.boardId !== input.request.state.boardId)) + ) { + return yield* new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + }); + } + const expiresAt = DateTime.make(proof.exp * 1_000); + if (expiresAt._tag === "None") { + return yield* new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + }); + } + const thumbprint = yield* crypto + .digest( + "SHA-256", + environmentPublishReplayThumbprintData({ + environmentId: input.environmentId, + environmentPublicKey: input.environmentPublicKey, + }), + ) + .pipe( + Effect.map(formatEnvironmentPublishReplayThumbprint), + Effect.mapError( + () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + ), + ); + const consumedNonce = yield* proofReplay.consume({ + thumbprint, + jti: proof.jti, + iat: proof.iat, + expiresAt: expiresAt.value, + }); + if (!consumedNonce) { + return yield* new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + }); + } + }, + ), }); }); diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index fa2a2fec686..9c94770f412 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -59,6 +59,7 @@ import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as LiveActivities from "../agentActivity/LiveActivities.ts"; import * as RelayConfiguration from "../Config.ts"; import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts"; +import * as BoardTicketPublisher from "../agentActivity/BoardTicketPublisher.ts"; import * as EnvironmentConnector from "../environments/EnvironmentConnector.ts"; import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts"; import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; @@ -731,81 +732,144 @@ export const serverApi = HttpApiBuilder.group( "server", Effect.fnUntraced(function* (handlers) { const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + const boardTicketPublisher = yield* BoardTicketPublisher.BoardTicketPublisher; const publishSignatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; - return handlers.handle( - "publishAgentActivity", - Effect.fn("relay.api.server.publishAgentActivity")( - function* (args) { - const { params, payload } = args; - const principal = yield* RelayEnvironmentPrincipal; - if (principal.environmentId !== params.environmentId) { - return yield* new HttpApiError.Unauthorized({}); - } - yield* publishSignatures.verify({ - environmentId: params.environmentId, - environmentPublicKey: principal.environmentPublicKey, - threadId: params.threadId, - request: payload, - }); - return yield* publisher.publish({ - environmentId: params.environmentId, - environmentPublicKey: principal.environmentPublicKey, - threadId: params.threadId, - state: payload.state, - }); - }, - mapErrorTags({ - EnvironmentPublishPublicKeyMissing: (_error, traceId) => - new RelayAuthInvalidError({ - code: "auth_invalid", - reason: "not_authorized", - traceId, - }), - EnvironmentPublishSignatureExpired: (_error, traceId) => - new RelayAgentActivityPublishProofExpiredError({ - code: "agent_activity_publish_proof_expired", - traceId, - }), - EnvironmentPublishSignatureInvalid: (_error, traceId) => - new RelayAgentActivityPublishProofInvalidError({ - code: "agent_activity_publish_proof_invalid", - reason: "invalid_signature_or_payload", - traceId, - }), - DpopProofReplayPersistenceError: (_error, traceId) => - new RelayInternalError({ - code: "internal_error", - reason: "persistence_failed", - traceId, - }), - ApnsDeliveryJobInvalid: (_error, traceId) => - new RelayInternalError({ - code: "internal_error", - reason: "internal_error", - traceId, - }), - ApnsDeliveryJobExpired: (_error, traceId) => - new RelayInternalError({ - code: "internal_error", - reason: "internal_error", - traceId, - }), - ApnsDeliveryJobClaimInFlight: (_error, traceId) => - new RelayInternalError({ - code: "internal_error", - reason: "internal_error", - traceId, - }), - ApnsDeliveryQueueSendError: (_error, traceId) => - new RelayInternalError({ - code: "internal_error", - reason: "upstream_unavailable", - traceId, - }), - }), - mapRelayCommonApiErrors("not_authorized"), - ), - ); + return handlers + .handle( + "publishAgentActivity", + Effect.fn("relay.api.server.publishAgentActivity")( + function* (args) { + const { params, payload } = args; + const principal = yield* RelayEnvironmentPrincipal; + if (principal.environmentId !== params.environmentId) { + return yield* new HttpApiError.Unauthorized({}); + } + yield* publishSignatures.verify({ + environmentId: params.environmentId, + environmentPublicKey: principal.environmentPublicKey, + threadId: params.threadId, + request: payload, + }); + return yield* publisher.publish({ + environmentId: params.environmentId, + environmentPublicKey: principal.environmentPublicKey, + threadId: params.threadId, + state: payload.state, + }); + }, + mapErrorTags({ + EnvironmentPublishPublicKeyMissing: (_error, traceId) => + new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "not_authorized", + traceId, + }), + EnvironmentPublishSignatureExpired: (_error, traceId) => + new RelayAgentActivityPublishProofExpiredError({ + code: "agent_activity_publish_proof_expired", + traceId, + }), + EnvironmentPublishSignatureInvalid: (_error, traceId) => + new RelayAgentActivityPublishProofInvalidError({ + code: "agent_activity_publish_proof_invalid", + reason: "invalid_signature_or_payload", + traceId, + }), + DpopProofReplayPersistenceError: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "persistence_failed", + traceId, + }), + ApnsDeliveryJobInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobExpired: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobClaimInFlight: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryQueueSendError: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "upstream_unavailable", + traceId, + }), + }), + mapRelayCommonApiErrors("not_authorized"), + ), + ) + .handle( + "publishBoardTicket", + Effect.fn("relay.api.server.publishBoardTicket")( + function* (args) { + const { params, payload } = args; + const principal = yield* RelayEnvironmentPrincipal; + if (principal.environmentId !== params.environmentId) { + return yield* new HttpApiError.Unauthorized({}); + } + yield* publishSignatures.verifyBoardTicket({ + environmentId: params.environmentId, + environmentPublicKey: principal.environmentPublicKey, + ticketId: params.ticketId, + request: payload, + }); + return yield* boardTicketPublisher.publish({ + environmentId: params.environmentId, + environmentPublicKey: principal.environmentPublicKey, + boardId: payload.state?.boardId ?? "", + ticketId: params.ticketId, + state: payload.state, + }); + }, + mapErrorTags({ + EnvironmentPublishPublicKeyMissing: (_error, traceId) => + new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "not_authorized", + traceId, + }), + EnvironmentPublishSignatureExpired: (_error, traceId) => + new RelayAgentActivityPublishProofExpiredError({ + code: "agent_activity_publish_proof_expired", + traceId, + }), + EnvironmentPublishSignatureInvalid: (_error, traceId) => + new RelayAgentActivityPublishProofInvalidError({ + code: "agent_activity_publish_proof_invalid", + reason: "invalid_signature_or_payload", + traceId, + }), + DpopProofReplayPersistenceError: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "persistence_failed", + traceId, + }), + // A delivery-queue transport outage now propagates out of publish() + // (see BoardTicketPublisher) so the server's notification outbox retries + // rather than recording a false terminal success. Surface it as a + // retryable upstream error, matching publishAgentActivity above. + ApnsDeliveryQueueSendError: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "upstream_unavailable", + traceId, + }), + }), + mapRelayCommonApiErrors("not_authorized"), + ), + ); }), ); diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 2c11d5066ec..6b3a0079615 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -46,6 +46,7 @@ import { RelayDb, RelayHyperdrive } from "./db.ts"; import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queues.ts"; import * as RelayConfiguration from "./Config.ts"; import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; +import * as BoardTicketPublisher from "./agentActivity/BoardTicketPublisher.ts"; import * as ApnsClient from "./agentActivity/ApnsClient.ts"; import * as ApnsDeliveryQueue from "./agentActivity/ApnsDeliveryQueue.ts"; import * as ApnsDeliveries from "./agentActivity/ApnsDeliveries.ts"; @@ -183,7 +184,7 @@ export default class Api extends Cloudflare.Worker<Api>()( const runtimeLayer = Layer.empty.pipe( Layer.provideMerge(MobileRegistrations.layer), - Layer.provideMerge(AgentActivityPublisher.layer), + Layer.provideMerge(Layer.mergeAll(AgentActivityPublisher.layer, BoardTicketPublisher.layer)), Layer.provideMerge(EnvironmentConnector.layer), Layer.provideMerge(EnvironmentLinker.layer), Layer.provideMerge(EnvironmentPublishSignatures.layer), diff --git a/packages/client-runtime/src/managedRelay.test.ts b/packages/client-runtime/src/managedRelay.test.ts index e340f12f620..59dbf121f3e 100644 --- a/packages/client-runtime/src/managedRelay.test.ts +++ b/packages/client-runtime/src/managedRelay.test.ts @@ -157,6 +157,7 @@ describe("ManagedRelayClient", () => { notifyOnInput: true, notifyOnCompletion: true, notifyOnFailure: true, + notifyOnBlocked: true, }, liveActivities: { enabled: true, diff --git a/packages/client-runtime/src/managedRelayState.test.ts b/packages/client-runtime/src/managedRelayState.test.ts index ce58241e796..7a1b4c98af5 100644 --- a/packages/client-runtime/src/managedRelayState.test.ts +++ b/packages/client-runtime/src/managedRelayState.test.ts @@ -44,6 +44,7 @@ const device = { notifyOnInput: true, notifyOnCompletion: true, notifyOnFailure: true, + notifyOnBlocked: true, }, liveActivities: { enabled: true, diff --git a/packages/client-runtime/src/wsRpcClient.test.ts b/packages/client-runtime/src/wsRpcClient.test.ts index 584fb958fba..1f5437223f0 100644 --- a/packages/client-runtime/src/wsRpcClient.test.ts +++ b/packages/client-runtime/src/wsRpcClient.test.ts @@ -3,7 +3,17 @@ import type { VcsStatusRemoteResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; -import { ORCHESTRATION_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts"; +import { + BoardId, + LaneKey, + MessageId, + ORCHESTRATION_WS_METHODS, + StepRunId, + TicketId, + ThreadId, + WORKFLOW_WS_METHODS, + WS_METHODS, +} from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; vi.mock("./wsTransport.ts", () => ({ @@ -168,6 +178,20 @@ describe("createWsRpcClient", () => { const client = createWsRpcClient(transport as unknown as WsTransport); const listener = vi.fn(); + const terminalAttachHistory = ( + client.terminal as unknown as { + readonly attachHistory: ( + input: { readonly threadId: string; readonly terminalId: string }, + listener: (event: unknown) => void, + ) => () => void; + } + ).attachHistory; + expect(typeof terminalAttachHistory).toBe("function"); + + terminalAttachHistory( + { threadId: "script-thread-1", terminalId: "script-terminal-1" }, + listener, + ); client.terminal.onMetadata(listener); client.vcs.onStatus({ cwd: "/repo" }, listener); client.server.subscribeConfig(listener); @@ -177,10 +201,183 @@ describe("createWsRpcClient", () => { readonly [unknown, unknown, { readonly tag?: string }?] >; expect(subscribeCalls.map((call) => call[2]?.tag)).toEqual([ + "terminal.attachHistory", WS_METHODS.subscribeTerminalMetadata, WS_METHODS.subscribeVcsStatus, WS_METHODS.subscribeServerConfig, ORCHESTRATION_WS_METHODS.subscribeThread, ]); }); + + it("maps workflow board version methods to websocket RPC names", async () => { + const boardId = BoardId.make("board-versions"); + const rpcInvocations: Array<readonly [string, unknown]> = []; + const rpcClient = { + [WORKFLOW_WS_METHODS.listBoardVersions]: (input: unknown) => { + rpcInvocations.push([WORKFLOW_WS_METHODS.listBoardVersions, input]); + return []; + }, + [WORKFLOW_WS_METHODS.deleteBoard]: (input: unknown) => { + rpcInvocations.push([WORKFLOW_WS_METHODS.deleteBoard, input]); + }, + [WORKFLOW_WS_METHODS.renameBoard]: (input: unknown) => { + rpcInvocations.push([WORKFLOW_WS_METHODS.renameBoard, input]); + }, + [WORKFLOW_WS_METHODS.getBoardVersion]: (input: unknown) => { + rpcInvocations.push([WORKFLOW_WS_METHODS.getBoardVersion, input]); + return { + versionId: 7, + definition: { name: "Delivery", lanes: [] }, + versionHash: "hash-7", + source: "save", + createdAt: "2026-06-08T12:00:00.000Z", + }; + }, + [WORKFLOW_WS_METHODS.editTicket]: (input: unknown) => { + rpcInvocations.push([WORKFLOW_WS_METHODS.editTicket, input]); + }, + [WORKFLOW_WS_METHODS.answerTicketStep]: (input: unknown) => { + rpcInvocations.push([WORKFLOW_WS_METHODS.answerTicketStep, input]); + }, + }; + const request = vi.fn(async (connect: (client: typeof rpcClient) => unknown) => + connect(rpcClient), + ) as unknown as WsTransport["request"]; + const transport = { + dispose: vi.fn(async () => undefined), + reconnect: vi.fn(async () => undefined), + isHeartbeatFresh: vi.fn(() => true), + request, + requestStream: vi.fn(), + subscribe: vi.fn(() => () => undefined), + } satisfies Pick< + WsTransport, + "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" + >; + + const client = createWsRpcClient(transport as unknown as WsTransport); + + await client.workflow.listBoardVersions({ boardId }); + await client.workflow.deleteBoard({ boardId }); + await client.workflow.renameBoard({ boardId, name: "Renamed delivery" }); + await client.workflow.getBoardVersion({ boardId, versionId: 7 }); + await client.workflow.editTicket({ + ticketId: TicketId.make("ticket-1"), + title: "Updated", + description: "Notes", + }); + await client.workflow.answerTicketStep({ + stepRunId: StepRunId.make("step-1"), + text: "Use the compatibility guard.", + attachments: [], + }); + + expect(rpcInvocations).toEqual([ + [WORKFLOW_WS_METHODS.listBoardVersions, { boardId }], + [WORKFLOW_WS_METHODS.deleteBoard, { boardId }], + [WORKFLOW_WS_METHODS.renameBoard, { boardId, name: "Renamed delivery" }], + [WORKFLOW_WS_METHODS.getBoardVersion, { boardId, versionId: 7 }], + [ + WORKFLOW_WS_METHODS.editTicket, + { ticketId: TicketId.make("ticket-1"), title: "Updated", description: "Notes" }, + ], + [ + WORKFLOW_WS_METHODS.answerTicketStep, + { + stepRunId: StepRunId.make("step-1"), + text: "Use the compatibility guard.", + attachments: [], + }, + ], + ]); + }); + + it("maps import work-item methods to the correct websocket RPC names", async () => { + const boardId = BoardId.make("board-import"); + const rpcInvocations: Array<readonly [string, unknown]> = []; + const rpcClient = { + [WORKFLOW_WS_METHODS.listImportableWorkItems]: (input: unknown) => { + rpcInvocations.push([WORKFLOW_WS_METHODS.listImportableWorkItems, input]); + return { items: [], sources: [], viewer: {}, truncated: {}, sourceErrors: {} }; + }, + [WORKFLOW_WS_METHODS.importWorkItems]: (input: unknown) => { + rpcInvocations.push([WORKFLOW_WS_METHODS.importWorkItems, input]); + return { imported: [], skipped: [] }; + }, + }; + const request = vi.fn(async (connect: (client: typeof rpcClient) => unknown) => + connect(rpcClient), + ) as unknown as WsTransport["request"]; + const transport = { + dispose: vi.fn(async () => undefined), + reconnect: vi.fn(async () => undefined), + isHeartbeatFresh: vi.fn(() => true), + request, + requestStream: vi.fn(), + subscribe: vi.fn(() => () => undefined), + } satisfies Pick< + WsTransport, + "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" + >; + + const client = createWsRpcClient(transport as unknown as WsTransport); + + await client.workflow.listImportableWorkItems({ boardId }); + await client.workflow.importWorkItems({ + boardId, + sourceId: "gh-123", + externalIds: ["issue-1", "issue-2"], + destinationLane: "todo" as LaneKey, + }); + + expect(rpcInvocations).toEqual([ + [WORKFLOW_WS_METHODS.listImportableWorkItems, { boardId }], + [ + WORKFLOW_WS_METHODS.importWorkItems, + { boardId, sourceId: "gh-123", externalIds: ["issue-1", "issue-2"], destinationLane: "todo" }, + ], + ]); + }); + + it("maps editTicketMessage to the correct websocket RPC name", async () => { + const rpcInvocations: Array<readonly [string, unknown]> = []; + const rpcClient = { + [WORKFLOW_WS_METHODS.editTicketMessage]: (input: unknown) => { + rpcInvocations.push([WORKFLOW_WS_METHODS.editTicketMessage, input]); + }, + }; + const request = vi.fn(async (connect: (client: typeof rpcClient) => unknown) => + connect(rpcClient), + ) as unknown as WsTransport["request"]; + const transport = { + dispose: vi.fn(async () => undefined), + reconnect: vi.fn(async () => undefined), + isHeartbeatFresh: vi.fn(() => true), + request, + requestStream: vi.fn(), + subscribe: vi.fn(() => () => undefined), + } satisfies Pick< + WsTransport, + "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" + >; + + const client = createWsRpcClient(transport as unknown as WsTransport); + + await client.workflow.editTicketMessage({ + ticketId: TicketId.make("ticket-1"), + messageId: MessageId.make("message-1"), + body: "Updated comment body", + }); + + expect(rpcInvocations).toEqual([ + [ + WORKFLOW_WS_METHODS.editTicketMessage, + { + ticketId: TicketId.make("ticket-1"), + messageId: MessageId.make("message-1"), + body: "Updated comment body", + }, + ], + ]); + }); }); diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index 18a6559f315..b20c4ee2fdc 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -9,8 +9,18 @@ import { type ServerSettingsPatch, type VcsStatusResult, type VcsStatusStreamEvent, + WORKFLOW_WS_METHODS, WS_METHODS, + type BoardId, + type LaneKey, } from "@t3tools/contracts"; +import type { + WorkSourceConnectionView, + WorkSourceProviderName, + ListImportableWorkItemsResult, + ImportWorkItemsResult, +} from "@t3tools/contracts/workSource"; +import type { OutboundConnectionView, CreateOutboundConnectionInput } from "@t3tools/contracts"; import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; @@ -71,6 +81,7 @@ export interface WsRpcClient { readonly terminal: { readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>; readonly attach: RpcInputStreamMethod<typeof WS_METHODS.terminalAttach>; + readonly attachHistory: RpcInputStreamMethod<typeof WS_METHODS.terminalAttachHistory>; readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>; readonly resize: RpcUnaryMethod<typeof WS_METHODS.terminalResize>; readonly clear: RpcUnaryMethod<typeof WS_METHODS.terminalClear>; @@ -189,6 +200,81 @@ export interface WsRpcClient { readonly subscribeShell: RpcStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeShell>; readonly subscribeThread: RpcInputStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeThread>; }; + readonly workflow: { + readonly listBoards: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.listBoards>; + readonly createBoard: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.createBoard>; + readonly importBoard: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.importBoard>; + readonly createWorkflowBoard: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.createWorkflowBoard>; + readonly generateWorkflowDraft: RpcUnaryMethod< + typeof WORKFLOW_WS_METHODS.generateWorkflowDraft + >; + readonly listBoardTemplates: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.listBoardTemplates>; + readonly deleteBoard: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.deleteBoard>; + readonly renameBoard: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.renameBoard>; + readonly getBoard: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.getBoard>; + readonly getBoardDefinition: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.getBoardDefinition>; + readonly saveBoardDefinition: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.saveBoardDefinition>; + readonly listBoardVersions: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.listBoardVersions>; + readonly getBoardVersion: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.getBoardVersion>; + readonly subscribeBoard: RpcInputStreamMethod<typeof WORKFLOW_WS_METHODS.subscribeBoard>; + readonly createTicket: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.createTicket>; + readonly editTicket: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.editTicket>; + readonly moveTicket: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.moveTicket>; + readonly runLane: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.runLane>; + readonly resolveApproval: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.resolveApproval>; + readonly answerTicketStep: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.answerTicketStep>; + readonly postTicketMessage: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.postTicketMessage>; + readonly editTicketMessage: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.editTicketMessage>; + readonly setProjectScriptTrust: RpcUnaryMethod< + typeof WORKFLOW_WS_METHODS.setProjectScriptTrust + >; + readonly cancelStep: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.cancelStep>; + readonly getTicketDetail: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.getTicketDetail>; + readonly listNeedsAttentionTickets: RpcUnaryMethod< + typeof WORKFLOW_WS_METHODS.listNeedsAttentionTickets + >; + readonly getTicketDiff: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.getTicketDiff>; + readonly intakeTickets: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.intakeTickets>; + readonly listTicketArtifacts: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.listTicketArtifacts>; + readonly getWebhookConfig: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.getWebhookConfig>; + readonly getBoardDigest: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.getBoardDigest>; + readonly getBoardMetrics: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.getBoardMetrics>; + readonly dryRunBoard: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.dryRunBoard>; + readonly listWorkSourceConnections: ( + input: Record<string, never>, + ) => Promise<ReadonlyArray<WorkSourceConnectionView>>; + readonly createWorkSourceConnection: (input: { + readonly provider: WorkSourceProviderName; + readonly displayName: string; + readonly token: string; + }) => Promise<WorkSourceConnectionView>; + readonly deleteWorkSourceConnection: (input: { + readonly connectionRef: string; + }) => Promise<void>; + readonly listOutboundConnections: ( + input: Record<string, never>, + ) => Promise<{ readonly connections: ReadonlyArray<OutboundConnectionView> }>; + readonly createOutboundConnection: ( + input: CreateOutboundConnectionInput, + ) => Promise<{ readonly connection: OutboundConnectionView }>; + readonly deleteOutboundConnection: (input: { readonly connectionRef: string }) => Promise<void>; + readonly proposeBoardImprovement: RpcUnaryMethod< + typeof WORKFLOW_WS_METHODS.proposeBoardImprovement + >; + readonly listBoardProposals: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.listBoardProposals>; + readonly getBoardProposal: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.getBoardProposal>; + readonly resolveBoardProposal: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.resolveBoardProposal>; + readonly revertBoardProposal: RpcUnaryMethod<typeof WORKFLOW_WS_METHODS.revertBoardProposal>; + readonly listImportableWorkItems: (input: { + readonly boardId: BoardId; + }) => Promise<ListImportableWorkItemsResult>; + readonly importWorkItems: (input: { + readonly boardId: BoardId; + readonly sourceId: string; + readonly externalIds: ReadonlyArray<string>; + readonly destinationLane?: LaneKey; + }) => Promise<ImportWorkItemsResult>; + }; } export interface CreateWsRpcClientOptions { @@ -215,6 +301,12 @@ export function createWsRpcClient( listener, subscriptionOptions(options, WS_METHODS.terminalAttach), ), + attachHistory: (input, listener, options) => + transport.subscribe( + (client) => client[WS_METHODS.terminalAttachHistory](input), + listener, + subscriptionOptions(options, WS_METHODS.terminalAttachHistory), + ), write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)), clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)), @@ -436,5 +528,106 @@ export function createWsRpcClient( subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeThread), ), }, + workflow: { + listBoards: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.listBoards](input)), + createBoard: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.createBoard](input)), + importBoard: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.importBoard](input)), + createWorkflowBoard: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.createWorkflowBoard](input)), + generateWorkflowDraft: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.generateWorkflowDraft](input)), + listBoardTemplates: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.listBoardTemplates](input)), + deleteBoard: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.deleteBoard](input)), + renameBoard: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.renameBoard](input)), + getBoard: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.getBoard](input)), + getBoardDefinition: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.getBoardDefinition](input)), + saveBoardDefinition: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.saveBoardDefinition](input)), + listBoardVersions: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.listBoardVersions](input)), + getBoardVersion: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.getBoardVersion](input)), + subscribeBoard: (input, listener, options) => + transport.subscribe( + (client) => client[WORKFLOW_WS_METHODS.subscribeBoard](input), + listener, + subscriptionOptions(options, WORKFLOW_WS_METHODS.subscribeBoard), + ), + createTicket: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.createTicket](input)), + editTicket: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.editTicket](input)), + moveTicket: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.moveTicket](input)), + runLane: (input) => transport.request((client) => client[WORKFLOW_WS_METHODS.runLane](input)), + resolveApproval: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.resolveApproval](input)), + answerTicketStep: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.answerTicketStep](input)), + postTicketMessage: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.postTicketMessage](input)), + editTicketMessage: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.editTicketMessage](input)), + setProjectScriptTrust: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.setProjectScriptTrust](input)), + cancelStep: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.cancelStep](input)), + getTicketDetail: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.getTicketDetail](input)), + listNeedsAttentionTickets: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.listNeedsAttentionTickets](input)), + getTicketDiff: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.getTicketDiff](input)), + intakeTickets: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.intakeTickets](input)), + listTicketArtifacts: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.listTicketArtifacts](input)), + getWebhookConfig: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.getWebhookConfig](input)), + getBoardDigest: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.getBoardDigest](input)), + getBoardMetrics: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.getBoardMetrics](input)), + dryRunBoard: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.dryRunBoard](input)), + listWorkSourceConnections: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.listWorkSourceConnections](input)), + createWorkSourceConnection: (input) => + transport.request((client) => + client[WORKFLOW_WS_METHODS.createWorkSourceConnection](input), + ), + deleteWorkSourceConnection: (input) => + transport.request((client) => + client[WORKFLOW_WS_METHODS.deleteWorkSourceConnection](input), + ), + listOutboundConnections: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.listOutboundConnections](input)), + createOutboundConnection: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.createOutboundConnection](input)), + deleteOutboundConnection: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.deleteOutboundConnection](input)), + proposeBoardImprovement: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.proposeBoardImprovement](input)), + listBoardProposals: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.listBoardProposals](input)), + getBoardProposal: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.getBoardProposal](input)), + resolveBoardProposal: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.resolveBoardProposal](input)), + revertBoardProposal: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.revertBoardProposal](input)), + listImportableWorkItems: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.listImportableWorkItems](input)), + importWorkItems: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.importWorkItems](input)), + }, }; } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 35fe8085b89..dc1428b2c13 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -18,6 +18,10 @@ "./relay": { "types": "./src/relay.ts", "import": "./src/relay.ts" + }, + "./workSource": { + "types": "./src/workSource.ts", + "import": "./src/workSource.ts" } }, "scripts": { diff --git a/packages/contracts/src/auth.test.ts b/packages/contracts/src/auth.test.ts new file mode 100644 index 00000000000..362725bbd42 --- /dev/null +++ b/packages/contracts/src/auth.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "./auth.ts"; + +describe("auth scope contracts", () => { + it("pins the standard client scope list", () => { + expect(AuthStandardClientScopes).toEqual([ + "orchestration:read", + "orchestration:operate", + "workflow:read", + "workflow:operate", + "terminal:operate", + "review:write", + "relay:read", + ]); + }); + + it("pins the administrative scope list", () => { + expect(AuthAdministrativeScopes).toEqual([ + ...AuthStandardClientScopes, + "access:read", + "access:write", + "relay:write", + ]); + }); +}); diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 70b2899757d..8902700e145 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -75,6 +75,8 @@ export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; export const AuthOrchestrationReadScope = "orchestration:read" as const; export const AuthOrchestrationOperateScope = "orchestration:operate" as const; +export const AuthWorkflowReadScope = "workflow:read" as const; +export const AuthWorkflowOperateScope = "workflow:operate" as const; export const AuthTerminalOperateScope = "terminal:operate" as const; export const AuthReviewWriteScope = "review:write" as const; export const AuthAccessReadScope = "access:read" as const; @@ -84,6 +86,8 @@ export const AuthRelayWriteScope = "relay:write" as const; export const AuthEnvironmentScope = Schema.Literals([ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, + AuthWorkflowReadScope, + AuthWorkflowOperateScope, AuthTerminalOperateScope, AuthReviewWriteScope, AuthAccessReadScope, @@ -98,6 +102,8 @@ export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type; export const AuthStandardClientScopes = [ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, + AuthWorkflowReadScope, + AuthWorkflowOperateScope, AuthTerminalOperateScope, AuthReviewWriteScope, AuthRelayReadScope, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 43270efdec7..c084eb0b514 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -26,3 +26,5 @@ export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./rpc.ts"; +export * from "./workflow.ts"; +export * from "./outbound.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index dce7c0709d3..d148e834bd6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -48,6 +48,8 @@ import type { import type { TerminalAttachInput, TerminalAttachStreamEvent, + TerminalHistoryAttachInput, + TerminalHistoryAttachStreamEvent, TerminalClearInput, TerminalCloseInput, TerminalMetadataStreamEvent, @@ -95,7 +97,7 @@ import type { OrchestrationSubscribeThreadInput, OrchestrationThreadStreamItem, } from "./orchestration.ts"; -import { EnvironmentId } from "./baseSchemas.ts"; +import { EnvironmentId, type MessageId, type ProjectId } from "./baseSchemas.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; @@ -110,6 +112,55 @@ import type { SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; +import type { + BoardId, + BoardListEntry, + BoardSnapshot, + BoardStreamItem, + LaneKey, + StepRunId, + TicketAttachment, + TicketDiff, + TicketId, + WorkflowBoardVersionSummary, + WorkflowCreateBoardInput, + WorkflowImportBoardInput, + WorkflowImportBoardResult, + WorkflowCreateWorkflowBoardInput, + WorkflowCreateWorkflowBoardResult, + WorkflowGenerateWorkflowDraftInput, + WorkflowGenerateWorkflowDraftResult, + WorkflowListBoardTemplatesResult, + WorkflowGetBoardDefinitionResult, + WorkflowGetBoardVersionResult, + WorkflowRenameBoardInput, + WorkflowSaveBoardDefinitionInput, + WorkflowSaveBoardDefinitionResult, + WorkflowIntakeResult, + WorkflowTicketArtifactsResult, + WorkflowWebhookConfig, + WorkflowBoardDigest, + WorkflowBoardMetrics, + WorkflowDefinitionEncoded, + WorkflowDryRunResult, + WorkflowDryRunScenario, + WorkflowTicketDetailView, + AgentSelection, + WorkSourceProviderName, + WorkflowProposeBoardImprovementInput, + WorkflowProposeBoardImprovementResult, + WorkflowListBoardProposalsResult, + WorkflowGetBoardProposalResult, + WorkflowResolveBoardProposalInput, + WorkflowResolveBoardProposalResult, + WorkflowRevertBoardProposalResult, +} from "./workflow.ts"; +import type { + WorkSourceConnectionView, + ListImportableWorkItemsResult, + ImportWorkItemsResult, +} from "./workSource.ts"; +import type { OutboundConnectionView, CreateOutboundConnectionInput } from "./outbound.ts"; export interface ContextMenuItem<T extends string = string> { id: T; @@ -1101,6 +1152,13 @@ export interface EnvironmentApi { onResubscribe?: () => void; }, ) => () => void; + attachHistory: ( + input: typeof TerminalHistoryAttachInput.Encoded, + callback: (event: TerminalHistoryAttachStreamEvent) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>; resize: (input: typeof TerminalResizeInput.Encoded) => Promise<void>; clear: (input: typeof TerminalClearInput.Encoded) => Promise<void>; @@ -1209,4 +1267,154 @@ export interface EnvironmentApi { options?: { onResubscribe?: () => void }, ) => () => void; }; + workflow: { + listBoards: (input: { + readonly projectId: ProjectId; + }) => Promise<ReadonlyArray<BoardListEntry>>; + createBoard: ( + input: WorkflowCreateBoardInput, + ) => Promise<{ readonly boardId: BoardId; readonly snapshot: BoardSnapshot }>; + importBoard: (input: WorkflowImportBoardInput) => Promise<WorkflowImportBoardResult>; + createWorkflowBoard: ( + input: WorkflowCreateWorkflowBoardInput, + ) => Promise<WorkflowCreateWorkflowBoardResult>; + generateWorkflowDraft: ( + input: WorkflowGenerateWorkflowDraftInput, + ) => Promise<WorkflowGenerateWorkflowDraftResult>; + listBoardTemplates: (input: {}) => Promise<WorkflowListBoardTemplatesResult>; + deleteBoard: (input: { readonly boardId: BoardId }) => Promise<void>; + renameBoard: (input: WorkflowRenameBoardInput) => Promise<void>; + getBoard: (input: { readonly boardId: BoardId }) => Promise<BoardSnapshot>; + getBoardDefinition: (input: { + readonly boardId: BoardId; + }) => Promise<WorkflowGetBoardDefinitionResult>; + saveBoardDefinition: ( + input: WorkflowSaveBoardDefinitionInput, + ) => Promise<WorkflowSaveBoardDefinitionResult>; + listBoardVersions: (input: { + readonly boardId: BoardId; + }) => Promise<ReadonlyArray<WorkflowBoardVersionSummary>>; + getBoardVersion: (input: { + readonly boardId: BoardId; + readonly versionId: number; + }) => Promise<WorkflowGetBoardVersionResult>; + subscribeBoard: ( + input: { readonly boardId: BoardId }, + callback: (event: BoardStreamItem) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; + createTicket: (input: { + readonly boardId: BoardId; + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: LaneKey; + readonly dependsOn?: ReadonlyArray<TicketId> | undefined; + readonly tokenBudget?: number | undefined; + }) => Promise<{ readonly ticketId: TicketId }>; + editTicket: (input: { + readonly ticketId: TicketId; + readonly title?: string | undefined; + readonly description?: string | undefined; + readonly dependsOn?: ReadonlyArray<TicketId> | undefined; + readonly tokenBudget?: number | null | undefined; + }) => Promise<void>; + moveTicket: (input: { readonly ticketId: TicketId; readonly toLane: LaneKey }) => Promise<void>; + runLane: (input: { readonly ticketId: TicketId }) => Promise<void>; + resolveApproval: (input: { + readonly stepRunId: StepRunId; + readonly approved: boolean; + }) => Promise<void>; + answerTicketStep: (input: { + readonly stepRunId: StepRunId; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray<TicketAttachment> | undefined; + }) => Promise<void>; + postTicketMessage: (input: { + readonly ticketId: TicketId; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray<TicketAttachment> | undefined; + }) => Promise<void>; + editTicketMessage: (input: { + readonly ticketId: TicketId; + readonly messageId: MessageId; + readonly body: string; + }) => Promise<void>; + setProjectScriptTrust: (input: { + readonly projectId: ProjectId; + readonly trusted: boolean; + }) => Promise<void>; + cancelStep: (input: { readonly stepRunId: StepRunId }) => Promise<void>; + getTicketDetail: (input: { readonly ticketId: TicketId }) => Promise<WorkflowTicketDetailView>; + getTicketDiff: (input: { readonly ticketId: TicketId }) => Promise<TicketDiff>; + intakeTickets: (input: { + readonly boardId: BoardId; + readonly braindump: string; + readonly agent: AgentSelection; + }) => Promise<WorkflowIntakeResult>; + listTicketArtifacts: (input: { + readonly ticketId: TicketId; + }) => Promise<WorkflowTicketArtifactsResult>; + getWebhookConfig: (input: { + readonly boardId: BoardId; + readonly rotate?: boolean | undefined; + }) => Promise<WorkflowWebhookConfig>; + getBoardDigest: (input: { + readonly boardId: BoardId; + readonly windowHours?: number | undefined; + }) => Promise<WorkflowBoardDigest>; + getBoardMetrics: (input: { + readonly boardId: BoardId; + readonly windowDays?: number | undefined; + }) => Promise<WorkflowBoardMetrics>; + dryRunBoard: (input: { + readonly definition: WorkflowDefinitionEncoded; + readonly startLane: LaneKey; + readonly scenario: WorkflowDryRunScenario; + }) => Promise<WorkflowDryRunResult>; + listWorkSourceConnections: ( + input: Record<string, never>, + ) => Promise<ReadonlyArray<WorkSourceConnectionView>>; + createWorkSourceConnection: (input: { + readonly provider: WorkSourceProviderName; + readonly displayName: string; + readonly token: string; + readonly authMode?: "pat" | "basic" | "bearer"; + readonly baseUrl?: string; + readonly email?: string; + }) => Promise<WorkSourceConnectionView>; + deleteWorkSourceConnection: (input: { readonly connectionRef: string }) => Promise<void>; + listOutboundConnections: ( + input: Record<string, never>, + ) => Promise<{ readonly connections: ReadonlyArray<OutboundConnectionView> }>; + createOutboundConnection: ( + input: CreateOutboundConnectionInput, + ) => Promise<{ readonly connection: OutboundConnectionView }>; + deleteOutboundConnection: (input: { readonly connectionRef: string }) => Promise<void>; + proposeBoardImprovement: ( + input: WorkflowProposeBoardImprovementInput, + ) => Promise<WorkflowProposeBoardImprovementResult>; + listBoardProposals: (input: { + readonly boardId: BoardId; + }) => Promise<WorkflowListBoardProposalsResult>; + getBoardProposal: (input: { + readonly proposalId: string; + }) => Promise<WorkflowGetBoardProposalResult>; + resolveBoardProposal: ( + input: WorkflowResolveBoardProposalInput, + ) => Promise<WorkflowResolveBoardProposalResult>; + revertBoardProposal: (input: { + readonly proposalId: string; + }) => Promise<WorkflowRevertBoardProposalResult>; + listImportableWorkItems: (input: { + readonly boardId: BoardId; + }) => Promise<ListImportableWorkItemsResult>; + importWorkItems: (input: { + readonly boardId: BoardId; + readonly sourceId: string; + readonly externalIds: ReadonlyArray<string>; + readonly destinationLane?: LaneKey; + }) => Promise<ImportWorkItemsResult>; + }; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 46d51da371f..ba3be84ac41 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -504,6 +504,9 @@ const ThreadCreateCommand = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, + // Internal threads (workflow step/intake dispatches) carry projections but + // never appear in user-facing thread lists. + hidden: Schema.optional(Schema.Boolean), }); const ThreadDeleteCommand = Schema.Struct({ @@ -848,6 +851,7 @@ export const ThreadCreatedPayload = Schema.Struct({ worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, updatedAt: IsoDateTime, + hidden: Schema.optional(Schema.Boolean), }); export const ThreadDeletedPayload = Schema.Struct({ diff --git a/packages/contracts/src/outbound.test.ts b/packages/contracts/src/outbound.test.ts new file mode 100644 index 00000000000..35ea86e262c --- /dev/null +++ b/packages/contracts/src/outbound.test.ts @@ -0,0 +1,50 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; +import { OutboundConnectionView, OutboundEventContext } from "./outbound.ts"; +import { WorkflowLintCode } from "./workflow.ts"; + +describe("outbound contracts", () => { + it("WorkflowLintCode includes the outbound codes", () => { + expect(() => Schema.decodeUnknownSync(WorkflowLintCode)("invalid_outbound")).not.toThrow(); + expect(() => Schema.decodeUnknownSync(WorkflowLintCode)("duplicate_outbound_id")).not.toThrow(); + }); + it("OutboundConnectionView decodes", () => { + const v = Schema.decodeUnknownSync(OutboundConnectionView)({ + connectionRef: "conn-1", + kind: "slack", + displayName: "Eng alerts", + createdAt: "2026-06-14T00:00:00.000Z", + }); + expect(v.kind).toBe("slack"); + }); + it("OutboundEventContext decodes a blocked event", () => { + const c = Schema.decodeUnknownSync(OutboundEventContext)({ + trigger: "blocked", + ticketId: "t1", + boardId: "b1", + title: "Fix login", + status: "blocked", + fromLane: "in-progress", + toLane: "in-progress", + isTerminal: false, + reason: "3 retries failed", + occurredAt: "2026-06-14T00:00:00.000Z", + }); + expect(c.trigger).toBe("blocked"); + }); + it("OutboundEventContext allows null fromLane/toLane and absent reason", () => { + const c = Schema.decodeUnknownSync(OutboundEventContext)({ + trigger: "lane_entered", + ticketId: "t1", + boardId: "b1", + title: "x", + status: "queued", + fromLane: null, + toLane: "todo", + isTerminal: false, + occurredAt: "2026-06-14T00:00:00.000Z", + }); + expect(c.fromLane).toBeNull(); + expect(c.reason).toBeUndefined(); + }); +}); diff --git a/packages/contracts/src/outbound.ts b/packages/contracts/src/outbound.ts new file mode 100644 index 00000000000..39da5945249 --- /dev/null +++ b/packages/contracts/src/outbound.ts @@ -0,0 +1,37 @@ +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { OutboundTrigger } from "./workflow.ts"; + +export const OutboundConnectionKind = Schema.Literals(["webhook", "slack"]); +export type OutboundConnectionKind = typeof OutboundConnectionKind.Type; + +export const OutboundConnectionView = Schema.Struct({ + connectionRef: Schema.String, + kind: OutboundConnectionKind, + displayName: Schema.String, + createdAt: Schema.String, +}); +export type OutboundConnectionView = typeof OutboundConnectionView.Type; + +export const CreateOutboundConnectionInput = Schema.Struct({ + kind: OutboundConnectionKind, + displayName: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, // validated server-side (SSRF); never echoed back +}); +export type CreateOutboundConnectionInput = typeof CreateOutboundConnectionInput.Type; + +// The normalized object `when` predicates evaluate against and formatters render from. +export const OutboundEventContext = Schema.Struct({ + trigger: OutboundTrigger, + ticketId: Schema.String, + boardId: Schema.String, + title: Schema.String, + status: Schema.String, + fromLane: Schema.NullOr(Schema.String), + toLane: Schema.NullOr(Schema.String), + isTerminal: Schema.Boolean, + reason: Schema.optional(Schema.String), + occurredAt: Schema.String, +}); +export type OutboundEventContext = typeof OutboundEventContext.Type; diff --git a/packages/contracts/src/relay.test.ts b/packages/contracts/src/relay.test.ts index 4ad600953b9..01f4c208a88 100644 --- a/packages/contracts/src/relay.test.ts +++ b/packages/contracts/src/relay.test.ts @@ -1,7 +1,15 @@ -import { describe, expect, it } from "vite-plus/test"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as OpenApi from "effect/unstable/httpapi/OpenApi"; -import { RelayApi } from "./relay.ts"; +import { + RelayAgentAwarenessPreferences, + RelayApi, + RelayBoardTicketPublishProofPayload, + RelayBoardTicketState, + WorkflowTicketAttentionKind, +} from "./relay.ts"; describe("RelayApi security", () => { it("describes DPoP access tokens using the HTTP DPoP authorization scheme", () => { @@ -14,3 +22,125 @@ describe("RelayApi security", () => { }); }); }); + +describe("WorkflowTicketAttentionKind", () => { + const decode = Schema.decodeUnknownEffect(WorkflowTicketAttentionKind); + + it.effect("decodes blocked", () => + Effect.gen(function* () { + const kind = yield* decode("blocked"); + assert.equal(kind, "blocked"); + }), + ); + + it.effect("rejects an invalid kind", () => + Effect.gen(function* () { + const result = yield* Effect.exit(decode("bogus")); + assert.strictEqual(result._tag, "Failure"); + }), + ); +}); + +describe("RelayBoardTicketState", () => { + const decode = Schema.decodeUnknownEffect(RelayBoardTicketState); + + it.effect("decodes a valid board ticket state with attentionKind blocked", () => + Effect.gen(function* () { + const state = yield* decode({ + environmentId: "env-1", + boardId: "b1", + ticketId: "t1", + attentionKind: "blocked", + title: "Fix login", + body: "Merge conflict in auth.ts", + deepLink: "/tickets/env-1/b1/t1", + transitionId: "42", + }); + assert.equal(state.attentionKind, "blocked"); + assert.equal(state.ticketId, "t1"); + }), + ); +}); + +describe("RelayBoardTicketPublishProofPayload", () => { + const decode = Schema.decodeUnknownEffect(RelayBoardTicketPublishProofPayload); + + it.effect("decodes a proof payload wrapping a board ticket state", () => + Effect.gen(function* () { + const payload = yield* decode({ + iss: "relay.t3.dev", + aud: "env-1", + sub: "t1", + jti: "nonce-1", + iat: 1_700_000_000, + exp: 1_700_003_600, + environmentId: "env-1", + boardId: "b1", + ticketId: "t1", + state: { + environmentId: "env-1", + boardId: "b1", + ticketId: "t1", + attentionKind: "blocked", + title: "Fix login", + body: "Merge conflict in auth.ts", + deepLink: "/tickets/env-1/b1/t1", + transitionId: "42", + }, + }); + assert.equal(payload.ticketId, "t1"); + assert.equal(payload.state?.attentionKind, "blocked"); + }), + ); + + it.effect("decodes a proof payload with null state", () => + Effect.gen(function* () { + const payload = yield* decode({ + iss: "relay.t3.dev", + aud: "env-1", + sub: "t1", + jti: "nonce-2", + iat: 1_700_000_000, + exp: 1_700_003_600, + environmentId: "env-1", + boardId: "b1", + ticketId: "t1", + state: null, + }); + assert.equal(payload.state, null); + }), + ); +}); + +describe("RelayAgentAwarenessPreferences notifyOnBlocked", () => { + const decode = Schema.decodeUnknownEffect(RelayAgentAwarenessPreferences); + + it.effect("decodes preferences WITHOUT notifyOnBlocked — field is undefined", () => + Effect.gen(function* () { + const prefs = yield* decode({ + liveActivitiesEnabled: true, + notificationsEnabled: true, + notifyOnApproval: false, + notifyOnInput: false, + notifyOnCompletion: true, + notifyOnFailure: false, + }); + assert.equal(prefs.notifyOnBlocked, undefined); + }), + ); + + it.effect("decodes preferences WITH notifyOnBlocked:false", () => + Effect.gen(function* () { + const prefs = yield* decode({ + liveActivitiesEnabled: true, + notificationsEnabled: true, + notifyOnApproval: false, + notifyOnInput: false, + notifyOnCompletion: true, + notifyOnFailure: false, + notifyOnBlocked: false, + }); + assert.equal(prefs.notifyOnBlocked, false); + }), + ); +}); diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 5c8cc1ad001..b96dd0a7a5b 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -25,6 +25,14 @@ export const RelayAgentAwarenessPhase = Schema.Literals([ ]); export type RelayAgentAwarenessPhase = typeof RelayAgentAwarenessPhase.Type; +// Intentional copy — keep in sync with WorkflowTicketAttentionKind in workflow.ts. +export const WorkflowTicketAttentionKind = Schema.Literals([ + "waiting_for_approval", + "waiting_for_input", + "blocked", +]); +export type WorkflowTicketAttentionKind = typeof WorkflowTicketAttentionKind.Type; + export const RelayAgentAwarenessPreferences = Schema.Struct({ liveActivitiesEnabled: Schema.Boolean, notificationsEnabled: Schema.Boolean, @@ -32,6 +40,7 @@ export const RelayAgentAwarenessPreferences = Schema.Struct({ notifyOnInput: Schema.Boolean, notifyOnCompletion: Schema.Boolean, notifyOnFailure: Schema.Boolean, + notifyOnBlocked: Schema.optional(Schema.Boolean), }); export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type; @@ -59,6 +68,7 @@ export const RelayClientDeviceRecord = Schema.Struct({ notifyOnInput: Schema.Boolean, notifyOnCompletion: Schema.Boolean, notifyOnFailure: Schema.Boolean, + notifyOnBlocked: Schema.Boolean, }), liveActivities: Schema.Struct({ enabled: Schema.Boolean, @@ -197,6 +207,35 @@ export const RelayAgentActivityPublishRequest = Schema.Struct({ }).annotate({ description: "Publishes a signed agent-awareness update from an environment." }); export type RelayAgentActivityPublishRequest = typeof RelayAgentActivityPublishRequest.Type; +export const RELAY_BOARD_TICKET_PUBLISH_TYP = "t3-relay-board-ticket-publish+jwt" as const; + +export const RelayBoardTicketState = Schema.Struct({ + environmentId: EnvironmentId, + boardId: TrimmedNonEmptyString, + ticketId: TrimmedNonEmptyString, + attentionKind: WorkflowTicketAttentionKind, + title: TrimmedNonEmptyString, + body: TrimmedNonEmptyString, + deepLink: TrimmedNonEmptyString, + transitionId: TrimmedNonEmptyString, +}); +export type RelayBoardTicketState = typeof RelayBoardTicketState.Type; + +export const RelayBoardTicketPublishProofPayload = Schema.Struct({ + ...RelaySignedJwtRegisteredClaims, + environmentId: EnvironmentId, + boardId: TrimmedNonEmptyString, + ticketId: TrimmedNonEmptyString, + state: Schema.NullOr(RelayBoardTicketState), +}); +export type RelayBoardTicketPublishProofPayload = typeof RelayBoardTicketPublishProofPayload.Type; + +export const RelayBoardTicketPublishRequest = Schema.Struct({ + state: Schema.NullOr(RelayBoardTicketState), + proof: TrimmedNonEmptyString, +}); +export type RelayBoardTicketPublishRequest = typeof RelayBoardTicketPublishRequest.Type; + export const RelayEnvironmentLinkScope = Schema.Literals([ "agent_activity_notifications", "managed_tunnels", @@ -992,6 +1031,19 @@ export const RelayServerGroup = HttpApiGroup.make("server") error: RelayAgentActivityPublishErrors, }, ).annotate(OpenApi.Summary, "Publish agent activity"), + HttpApiEndpoint.post( + "publishBoardTicket", + "/v1/environments/:environmentId/tickets/:ticketId/board-activity", + { + params: Schema.Struct({ + environmentId: EnvironmentId, + ticketId: TrimmedNonEmptyString, + }), + payload: RelayBoardTicketPublishRequest, + success: RelayPublishResponse, + error: RelayAgentActivityPublishErrors, + }, + ).annotate(OpenApi.Summary, "Publish board ticket attention"), ) .annotate(OpenApi.Description, "Environment-authenticated activity publication.") .middleware(RelayEnvironmentAuth); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 87c5a49c73b..38f79b70bec 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,12 +2,21 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { + WorkSourceConnectionView, + WorkSourceProviderName, + ListImportableWorkItemsResult, + ImportWorkItemsResult, +} from "./workSource.ts"; +import { OutboundConnectionView, CreateOutboundConnectionInput } from "./outbound.ts"; + import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; import { AuthAccessStreamError, AuthAccessStreamEvent, EnvironmentAuthorizationError, } from "./auth.ts"; +import { MessageId, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { FilesystemBrowseInput, FilesystemBrowseResult, @@ -81,6 +90,8 @@ import { import { TerminalAttachInput, TerminalAttachStreamEvent, + TerminalHistoryAttachInput, + TerminalHistoryAttachStreamEvent, TerminalClearInput, TerminalCloseInput, TerminalError, @@ -141,6 +152,55 @@ import { SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; +import { + AgentSelection, + BoardId, + BoardListEntry, + BoardSnapshot, + BoardStreamItem, + LaneKey, + StepRunId, + TicketDiff, + TicketId, + WorkflowBoardVersionSummary, + WorkflowGetBoardDefinitionResult, + WorkflowGetBoardVersionResult, + WorkflowImportBoardInput, + WorkflowImportBoardResult, + WorkflowCreateWorkflowBoardInput, + WorkflowCreateWorkflowBoardResult, + WorkflowGenerateWorkflowDraftInput, + WorkflowGenerateWorkflowDraftResult, + WorkflowListBoardTemplatesResult, + WorkflowNeedsAttentionTicketView, + WorkflowBoardName, + WorkflowRenameBoardInput, + WorkflowSaveBoardDefinitionInput, + WorkflowSaveBoardDefinitionResult, + WorkflowRpcError, + TicketAttachment, + WorkflowIntakeBraindump, + WorkflowIntakeResult, + WorkflowTicketArtifactsResult, + WorkflowWebhookConfig, + WorkflowBoardDigest, + WorkflowBoardMetrics, + WorkflowDefinitionEncoded, + WorkflowDryRunResult, + WorkflowDryRunScenario, + WorkflowTicketDetailView, + WorkflowProposeBoardImprovementInput, + WorkflowProposeBoardImprovementResult, + WorkflowListBoardProposalsInput, + WorkflowListBoardProposalsResult, + WorkflowGetBoardProposalInput, + WorkflowGetBoardProposalResult, + WorkflowResolveBoardProposalInput, + WorkflowResolveBoardProposalResult, + WorkflowRevertBoardProposalInput, + WorkflowRevertBoardProposalResult, + WORKFLOW_WS_METHODS, +} from "./workflow.ts"; export const WS_METHODS = { // Project registry methods @@ -180,6 +240,7 @@ export const WS_METHODS = { // Terminal methods terminalOpen: "terminal.open", terminalAttach: "terminal.attach", + terminalAttachHistory: "terminal.attachHistory", terminalWrite: "terminal.write", terminalResize: "terminal.resize", terminalClear: "terminal.clear", @@ -489,6 +550,13 @@ export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, { stream: true, }); +export const WsTerminalAttachHistoryRpc = Rpc.make(WS_METHODS.terminalAttachHistory, { + payload: TerminalHistoryAttachInput, + success: TerminalHistoryAttachStreamEvent, + error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), + stream: true, +}); + export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { payload: TerminalWriteInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), @@ -643,6 +711,383 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make( }, ); +export const WsWorkflowListBoardsRpc = Rpc.make(WORKFLOW_WS_METHODS.listBoards, { + payload: Schema.Struct({ projectId: ProjectId }), + success: Schema.Array(BoardListEntry), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowCreateBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.createBoard, { + payload: Schema.Struct({ + projectId: ProjectId, + // Align with WorkflowCreateBoardInput/renameBoard: enforce the branded name + // bounds at the wire boundary instead of accepting any string. + name: WorkflowBoardName, + agent: AgentSelection, + }), + success: Schema.Struct({ boardId: BoardId, snapshot: BoardSnapshot }), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowDeleteBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.deleteBoard, { + payload: Schema.Struct({ boardId: BoardId }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowRenameBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.renameBoard, { + payload: WorkflowRenameBoardInput, + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowGetBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.getBoard, { + payload: Schema.Struct({ boardId: BoardId }), + success: BoardSnapshot, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowGetBoardDefinitionRpc = Rpc.make(WORKFLOW_WS_METHODS.getBoardDefinition, { + payload: Schema.Struct({ boardId: BoardId }), + success: WorkflowGetBoardDefinitionResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowSaveBoardDefinitionRpc = Rpc.make(WORKFLOW_WS_METHODS.saveBoardDefinition, { + payload: WorkflowSaveBoardDefinitionInput, + success: WorkflowSaveBoardDefinitionResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowImportBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.importBoard, { + payload: WorkflowImportBoardInput, + success: WorkflowImportBoardResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowCreateWorkflowBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.createWorkflowBoard, { + payload: WorkflowCreateWorkflowBoardInput, + success: WorkflowCreateWorkflowBoardResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowGenerateWorkflowDraftRpc = Rpc.make( + WORKFLOW_WS_METHODS.generateWorkflowDraft, + { + payload: WorkflowGenerateWorkflowDraftInput, + success: WorkflowGenerateWorkflowDraftResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowListBoardTemplatesRpc = Rpc.make(WORKFLOW_WS_METHODS.listBoardTemplates, { + payload: Schema.Struct({}), + success: WorkflowListBoardTemplatesResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowListBoardVersionsRpc = Rpc.make(WORKFLOW_WS_METHODS.listBoardVersions, { + payload: Schema.Struct({ boardId: BoardId }), + success: Schema.Array(WorkflowBoardVersionSummary), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowGetBoardVersionRpc = Rpc.make(WORKFLOW_WS_METHODS.getBoardVersion, { + payload: Schema.Struct({ boardId: BoardId, versionId: Schema.Int }), + success: WorkflowGetBoardVersionResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowSubscribeBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.subscribeBoard, { + payload: Schema.Struct({ boardId: BoardId }), + success: BoardStreamItem, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + stream: true, +}); + +// Bound ticket title/description at decode so an unbounded value can never +// bloat SQLite or the agent instruction templates the engine persists them +// into. Caps mirror WorkflowTicketDetail (title ≤200) and the other 4000-char +// description caps in workflow.ts; title is non-empty to match the engine's +// TicketCreated event schema. +const WorkflowTicketTitleInput = TrimmedNonEmptyString.check(Schema.isMaxLength(200)); +const WorkflowTicketDescriptionInput = Schema.String.check(Schema.isMaxLength(4000)); + +export const WsWorkflowCreateTicketRpc = Rpc.make(WORKFLOW_WS_METHODS.createTicket, { + payload: Schema.Struct({ + boardId: BoardId, + title: WorkflowTicketTitleInput, + description: Schema.optional(WorkflowTicketDescriptionInput), + initialLane: LaneKey, + dependsOn: Schema.optional(Schema.Array(TicketId)), + tokenBudget: Schema.optional(Schema.Int), + }), + success: Schema.Struct({ ticketId: TicketId }), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowEditTicketRpc = Rpc.make(WORKFLOW_WS_METHODS.editTicket, { + payload: Schema.Struct({ + ticketId: TicketId, + // Same caps as create (title ≤200 non-empty, description ≤4000) so an edit + // can't reintroduce an unbounded value the create path now rejects. + title: Schema.optional(WorkflowTicketTitleInput), + description: Schema.optional(WorkflowTicketDescriptionInput), + dependsOn: Schema.optional(Schema.Array(TicketId)), + tokenBudget: Schema.optional(Schema.NullOr(Schema.Int)), + }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowMoveTicketRpc = Rpc.make(WORKFLOW_WS_METHODS.moveTicket, { + payload: Schema.Struct({ ticketId: TicketId, toLane: LaneKey }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowRunLaneRpc = Rpc.make(WORKFLOW_WS_METHODS.runLane, { + payload: Schema.Struct({ ticketId: TicketId }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowResolveApprovalRpc = Rpc.make(WORKFLOW_WS_METHODS.resolveApproval, { + payload: Schema.Struct({ stepRunId: StepRunId, approved: Schema.Boolean }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowAnswerTicketStepRpc = Rpc.make(WORKFLOW_WS_METHODS.answerTicketStep, { + payload: Schema.Struct({ + stepRunId: StepRunId, + text: Schema.optional(Schema.String), + attachments: Schema.optional(Schema.Array(TicketAttachment)), + }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowPostTicketMessageRpc = Rpc.make(WORKFLOW_WS_METHODS.postTicketMessage, { + payload: Schema.Struct({ + ticketId: TicketId, + text: Schema.optional(Schema.String), + attachments: Schema.optional(Schema.Array(TicketAttachment)), + }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowEditTicketMessageRpc = Rpc.make(WORKFLOW_WS_METHODS.editTicketMessage, { + payload: Schema.Struct({ + ticketId: TicketId, + messageId: MessageId, + body: Schema.String, + }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowSetProjectScriptTrustRpc = Rpc.make( + WORKFLOW_WS_METHODS.setProjectScriptTrust, + { + payload: Schema.Struct({ projectId: ProjectId, trusted: Schema.Boolean }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowCancelStepRpc = Rpc.make(WORKFLOW_WS_METHODS.cancelStep, { + payload: Schema.Struct({ stepRunId: StepRunId }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowGetTicketDetailRpc = Rpc.make(WORKFLOW_WS_METHODS.getTicketDetail, { + payload: Schema.Struct({ ticketId: TicketId }), + success: WorkflowTicketDetailView, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowIntakeTicketsRpc = Rpc.make(WORKFLOW_WS_METHODS.intakeTickets, { + payload: Schema.Struct({ + boardId: BoardId, + braindump: WorkflowIntakeBraindump, + agent: AgentSelection, + }), + success: WorkflowIntakeResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowListTicketArtifactsRpc = Rpc.make(WORKFLOW_WS_METHODS.listTicketArtifacts, { + payload: Schema.Struct({ ticketId: TicketId }), + success: WorkflowTicketArtifactsResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowGetWebhookConfigRpc = Rpc.make(WORKFLOW_WS_METHODS.getWebhookConfig, { + payload: Schema.Struct({ boardId: BoardId, rotate: Schema.optional(Schema.Boolean) }), + success: WorkflowWebhookConfig, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowGetBoardDigestRpc = Rpc.make(WORKFLOW_WS_METHODS.getBoardDigest, { + payload: Schema.Struct({ boardId: BoardId, windowHours: Schema.optional(Schema.Int) }), + success: WorkflowBoardDigest, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowGetBoardMetricsRpc = Rpc.make(WORKFLOW_WS_METHODS.getBoardMetrics, { + payload: Schema.Struct({ boardId: BoardId, windowDays: Schema.optional(Schema.Int) }), + success: WorkflowBoardMetrics, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowDryRunBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.dryRunBoard, { + payload: Schema.Struct({ + definition: WorkflowDefinitionEncoded, + startLane: LaneKey, + scenario: WorkflowDryRunScenario, + }), + success: WorkflowDryRunResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowGetTicketDiffRpc = Rpc.make(WORKFLOW_WS_METHODS.getTicketDiff, { + payload: Schema.Struct({ ticketId: TicketId }), + success: TicketDiff, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowListNeedsAttentionTicketsRpc = Rpc.make( + WORKFLOW_WS_METHODS.listNeedsAttentionTickets, + { + payload: Schema.Struct({}), + success: Schema.Array(WorkflowNeedsAttentionTicketView), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowListWorkSourceConnectionsRpc = Rpc.make( + WORKFLOW_WS_METHODS.listWorkSourceConnections, + { + payload: Schema.Struct({}), + success: Schema.Array(WorkSourceConnectionView), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowCreateWorkSourceConnectionRpc = Rpc.make( + WORKFLOW_WS_METHODS.createWorkSourceConnection, + { + payload: Schema.Struct({ + provider: WorkSourceProviderName, + displayName: TrimmedNonEmptyString, + token: TrimmedNonEmptyString, + authMode: Schema.optional(Schema.Literals(["pat", "basic", "bearer"])), + baseUrl: Schema.optional(Schema.String), + email: Schema.optional(Schema.String), + }), + success: WorkSourceConnectionView, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowDeleteWorkSourceConnectionRpc = Rpc.make( + WORKFLOW_WS_METHODS.deleteWorkSourceConnection, + { + payload: Schema.Struct({ connectionRef: TrimmedNonEmptyString }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowListOutboundConnectionsRpc = Rpc.make( + WORKFLOW_WS_METHODS.listOutboundConnections, + { + payload: Schema.Struct({}), + success: Schema.Struct({ connections: Schema.Array(OutboundConnectionView) }), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowCreateOutboundConnectionRpc = Rpc.make( + WORKFLOW_WS_METHODS.createOutboundConnection, + { + payload: CreateOutboundConnectionInput, + success: Schema.Struct({ connection: OutboundConnectionView }), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowDeleteOutboundConnectionRpc = Rpc.make( + WORKFLOW_WS_METHODS.deleteOutboundConnection, + { + payload: Schema.Struct({ connectionRef: TrimmedNonEmptyString }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowProposeBoardImprovementRpc = Rpc.make( + WORKFLOW_WS_METHODS.proposeBoardImprovement, + { + payload: WorkflowProposeBoardImprovementInput, + success: WorkflowProposeBoardImprovementResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowListBoardProposalsRpc = Rpc.make(WORKFLOW_WS_METHODS.listBoardProposals, { + payload: WorkflowListBoardProposalsInput, + success: WorkflowListBoardProposalsResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowGetBoardProposalRpc = Rpc.make(WORKFLOW_WS_METHODS.getBoardProposal, { + payload: WorkflowGetBoardProposalInput, + success: WorkflowGetBoardProposalResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowResolveBoardProposalRpc = Rpc.make( + WORKFLOW_WS_METHODS.resolveBoardProposal, + { + payload: WorkflowResolveBoardProposalInput, + success: WorkflowResolveBoardProposalResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowRevertBoardProposalRpc = Rpc.make(WORKFLOW_WS_METHODS.revertBoardProposal, { + payload: WorkflowRevertBoardProposalInput, + success: WorkflowRevertBoardProposalResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowListImportableWorkItemsRpc = Rpc.make( + WORKFLOW_WS_METHODS.listImportableWorkItems, + { + payload: Schema.Struct({ boardId: BoardId }), + success: ListImportableWorkItemsResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowImportWorkItemsRpc = Rpc.make( + WORKFLOW_WS_METHODS.importWorkItems, + { + payload: Schema.Struct({ + boardId: BoardId, + sourceId: Schema.String, + externalIds: Schema.Array(Schema.String), + destinationLane: Schema.optional(LaneKey), + }), + success: ImportWorkItemsResult, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { payload: Schema.Struct({}), success: TerminalEvent, @@ -718,6 +1163,7 @@ export const WsRpcGroup = RpcGroup.make( WsReviewGetDiffPreviewRpc, WsTerminalOpenRpc, WsTerminalAttachRpc, + WsTerminalAttachHistoryRpc, WsTerminalWriteRpc, WsTerminalResizeRpc, WsTerminalClearRpc, @@ -747,4 +1193,50 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationGetArchivedShellSnapshotRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, + WsWorkflowListBoardsRpc, + WsWorkflowCreateBoardRpc, + WsWorkflowDeleteBoardRpc, + WsWorkflowRenameBoardRpc, + WsWorkflowGetBoardRpc, + WsWorkflowGetBoardDefinitionRpc, + WsWorkflowSaveBoardDefinitionRpc, + WsWorkflowImportBoardRpc, + WsWorkflowCreateWorkflowBoardRpc, + WsWorkflowGenerateWorkflowDraftRpc, + WsWorkflowListBoardTemplatesRpc, + WsWorkflowListBoardVersionsRpc, + WsWorkflowGetBoardVersionRpc, + WsWorkflowSubscribeBoardRpc, + WsWorkflowCreateTicketRpc, + WsWorkflowEditTicketRpc, + WsWorkflowMoveTicketRpc, + WsWorkflowRunLaneRpc, + WsWorkflowResolveApprovalRpc, + WsWorkflowAnswerTicketStepRpc, + WsWorkflowPostTicketMessageRpc, + WsWorkflowEditTicketMessageRpc, + WsWorkflowSetProjectScriptTrustRpc, + WsWorkflowCancelStepRpc, + WsWorkflowGetTicketDetailRpc, + WsWorkflowGetTicketDiffRpc, + WsWorkflowIntakeTicketsRpc, + WsWorkflowListTicketArtifactsRpc, + WsWorkflowGetWebhookConfigRpc, + WsWorkflowGetBoardDigestRpc, + WsWorkflowGetBoardMetricsRpc, + WsWorkflowDryRunBoardRpc, + WsWorkflowListNeedsAttentionTicketsRpc, + WsWorkflowListWorkSourceConnectionsRpc, + WsWorkflowCreateWorkSourceConnectionRpc, + WsWorkflowDeleteWorkSourceConnectionRpc, + WsWorkflowListOutboundConnectionsRpc, + WsWorkflowCreateOutboundConnectionRpc, + WsWorkflowDeleteOutboundConnectionRpc, + WsWorkflowProposeBoardImprovementRpc, + WsWorkflowListBoardProposalsRpc, + WsWorkflowGetBoardProposalRpc, + WsWorkflowResolveBoardProposalRpc, + WsWorkflowRevertBoardProposalRpc, + WsWorkflowListImportableWorkItemsRpc, + WsWorkflowImportWorkItemsRpc, ); diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index a08ed492388..c979dbf6b38 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -13,6 +13,8 @@ import { TerminalThreadInput, TerminalWriteInput, } from "./terminal.ts"; +import * as TerminalContracts from "./terminal.ts"; +import { WS_METHODS } from "./rpc.ts"; function decodeSync<S extends Schema.Top>(schema: S, input: unknown): Schema.Schema.Type<S> { return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>; @@ -123,6 +125,31 @@ describe("TerminalAttachInput", () => { }); }); +describe("TerminalHistoryAttachInput", () => { + it("accepts terminal identity without a cwd", () => { + const schema = (TerminalContracts as unknown as Record<string, Schema.Top | undefined>) + .TerminalHistoryAttachInput; + expect(schema).toBeDefined(); + if (!schema) return; + + const parsed = decodeSync(schema, { + threadId: "script-thread-1", + terminalId: "script-terminal-1", + }) as { readonly threadId: string; readonly terminalId: string }; + + expect(parsed).toEqual({ + threadId: "script-thread-1", + terminalId: "script-terminal-1", + }); + }); + + it("defines a history-only terminal RPC method", () => { + expect((WS_METHODS as Record<string, string>).terminalAttachHistory).toBe( + "terminal.attachHistory", + ); + }); +}); + describe("TerminalWriteInput", () => { it("accepts non-empty data", () => { expect( diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index a3c8e37e7f9..a6eb460d679 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -57,6 +57,9 @@ export const TerminalAttachInput = Schema.Struct({ }); export type TerminalAttachInput = Schema.Codec.Encoded<typeof TerminalAttachInput>; +export const TerminalHistoryAttachInput = TerminalSessionInput; +export type TerminalHistoryAttachInput = Schema.Codec.Encoded<typeof TerminalHistoryAttachInput>; + export const TerminalWriteInput = Schema.Struct({ ...TerminalSessionInput.fields, data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)), @@ -232,6 +235,33 @@ export const TerminalAttachStreamEvent = Schema.Union([ ]); export type TerminalAttachStreamEvent = typeof TerminalAttachStreamEvent.Type; +const TerminalHistoryAttachSnapshot = Schema.Struct({ + threadId: Schema.String.check(Schema.isNonEmpty()), + terminalId: Schema.String.check(Schema.isNonEmpty()), + history: Schema.String, + status: Schema.NullOr(TerminalSessionStatus), + exitCode: Schema.NullOr(Schema.Int), + exitSignal: Schema.NullOr(Schema.Int), + sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), +}); +export type TerminalHistoryAttachSnapshot = typeof TerminalHistoryAttachSnapshot.Type; + +const TerminalHistoryAttachSnapshotEvent = Schema.Struct({ + type: Schema.Literal("snapshot"), + snapshot: TerminalHistoryAttachSnapshot, +}); + +export const TerminalHistoryAttachStreamEvent = Schema.Union([ + TerminalHistoryAttachSnapshotEvent, + TerminalOutputEvent, + TerminalExitedEvent, + TerminalClosedEvent, + TerminalErrorEvent, + TerminalClearedEvent, + TerminalActivityEvent, +]); +export type TerminalHistoryAttachStreamEvent = typeof TerminalHistoryAttachStreamEvent.Type; + export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()( "TerminalCwdError", { diff --git a/packages/contracts/src/workSource.test.ts b/packages/contracts/src/workSource.test.ts new file mode 100644 index 00000000000..54b9f0bac9f --- /dev/null +++ b/packages/contracts/src/workSource.test.ts @@ -0,0 +1,182 @@ +import { assert, describe, expect, it } from "vite-plus/test"; +import { Schema } from "effect"; +import { + GithubSelector, + AsanaSelector, + JiraSelector, + WorkflowSourceConfig, + SourceId, + ImportableWorkItemView, + ListImportableWorkItemsResult, + ImportWorkItemsResult, + ALWAYS_RULE, + compileAutoPullRule, + decodeAutoPullRule, + effectiveAutoPullRule, + type AutoPullCriteria, +} from "./workSource.ts"; +import { WsWorkflowCreateWorkSourceConnectionRpc } from "./rpc.ts"; + +describe("workSource contracts", () => { + it("defaults github selector state to 'all'", () => { + const sel = Schema.decodeUnknownSync(GithubSelector)({ owner: "o", repo: "r" }); + expect(sel.state).toBe("all"); + }); + it("defaults asana includeCompleted to true", () => { + const sel = Schema.decodeUnknownSync(AsanaSelector)({ projectGid: "123" }); + expect(sel.includeCompleted).toBe(true); + }); + it("decodes a github source config", () => { + const cfg = Schema.decodeUnknownSync(WorkflowSourceConfig)({ + id: "src-1", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "o", repo: "r" }, + destinationLane: "inbox", + closedLane: "done", + enabled: true, + }); + expect(cfg.provider).toBe("github"); + expect(SourceId.is(cfg.id)).toBe(true); + }); + it("rejects an unknown provider", () => { + expect(() => + Schema.decodeUnknownSync(WorkflowSourceConfig)({ + id: "s", + provider: "linear", + connectionRef: "c", + selector: {}, + destinationLane: "a", + closedLane: "b", + enabled: true, + }), + ).toThrow(); + }); + + it("accepts jira as a valid provider", () => { + const cfg = Schema.decodeUnknownSync(WorkflowSourceConfig)({ + id: "src-2", + provider: "jira", + connectionRef: "conn-2", + selector: { projectKey: "ENG" }, + destinationLane: "inbox", + closedLane: "done", + enabled: true, + }); + expect(cfg.provider).toBe("jira"); + }); + + it("ImportableWorkItemView decodes a minimal Asana row (no displayRef, null mapping)", () => { + const row = { + provider: "asana", sourceId: "s", externalId: "task-gid-1", displayRef: "", title: "t", + container: "111", url: "https://app.asana.com/0/111/task-gid-1", assignees: ["Jo"], + lifecycle: "open", mappedTicketId: null, mappedLane: null, + }; + const decoded = Schema.decodeUnknownSync(ImportableWorkItemView)(row); + expect(decoded.externalId).toBe("task-gid-1"); + expect(decoded.mappedTicketId).toBe(null); + }); + + it("ListImportableWorkItemsResult decodes an empty result", () => { + const decoded = Schema.decodeUnknownSync(ListImportableWorkItemsResult)({ + items: [], sources: [], viewer: {}, truncated: {}, sourceErrors: {}, + }); + expect(decoded.items).toEqual([]); + }); + + it("ImportWorkItemsResult decodes an empty result", () => { + const decoded = Schema.decodeUnknownSync(ImportWorkItemsResult)({ imported: [], skipped: [] }); + expect(decoded.skipped).toEqual([]); + }); +}); + +describe("compileAutoPullRule", () => { + it("labels any-of → or-of-in (single → bare in)", () => { + assert.deepEqual(compileAutoPullRule({ labels: { mode: "any", values: ["XS", "S"] } }), + { or: [ { in: ["XS", { var: "labels" }] }, { in: ["S", { var: "labels" }] } ] }); + assert.deepEqual(compileAutoPullRule({ labels: { mode: "any", values: ["XS"] } }), + { in: ["XS", { var: "labels" }] }); + }); + it("labels all-of + state → and", () => { + assert.deepEqual(compileAutoPullRule({ labels: { mode: "all", values: ["A", "B"] }, state: "open" }), + { and: [ { and: [ { in: ["A", { var: "labels" }] }, { in: ["B", { var: "labels" }] } ] }, + { "==": [ { var: "state" }, "open" ] } ] }); + }); + it("assigned-to-anyone → bare var; specific → in; empty → ALWAYS", () => { + assert.deepEqual(compileAutoPullRule({ assignee: { kind: "anyone" } }), { var: "assignees" }); + assert.deepEqual(compileAutoPullRule({ assignee: { kind: "login", value: "octocat" } }), + { in: ["octocat", { var: "assignees" }] }); + assert.deepEqual(compileAutoPullRule({}), ALWAYS_RULE); + }); +}); + +describe("decodeAutoPullRule round-trips compiled criteria", () => { + for (const c of [ + { labels: { mode: "any", values: ["XS"] } }, { labels: { mode: "all", values: ["A", "B"] } }, + { assignee: { kind: "anyone" } }, { assignee: { kind: "login", value: "x" } }, + { state: "open" }, { labels: { mode: "any", values: ["XS", "S"] }, state: "closed" }, {}, + ] as AutoPullCriteria[]) { + it(`round-trips ${JSON.stringify(c)}`, () => assert.deepEqual(decodeAutoPullRule(compileAutoPullRule(c)), c)); + } + it("returns null for an undecodable (raw/advanced) rule", () => + assert.equal(decodeAutoPullRule({ ">": [{ var: "title" }, 5] }), null)); +}); + +describe("effectiveAutoPullRule", () => { + const base = { id: "s", provider: "github", connectionRef: "c", selector: {}, destinationLane: "a", closedLane: "b" } as const; + it("autoPull present → its rule; legacy enabled:true → ALWAYS; else null", () => { + assert.deepEqual(effectiveAutoPullRule({ ...base, autoPull: { rule: { var: "assignees" } } }), { var: "assignees" }); + assert.deepEqual(effectiveAutoPullRule({ ...base, enabled: true }), ALWAYS_RULE); + assert.equal(effectiveAutoPullRule({ ...base, enabled: false }), null); + assert.equal(effectiveAutoPullRule({ ...base }), null); + }); +}); + +describe("JiraSelector", () => { + it("decodes projectKey + optional jql", () => { + const a = Schema.decodeUnknownSync(JiraSelector)({ projectKey: "ENG" }); + expect(a.projectKey).toBe("ENG"); + expect(a.jql).toBeUndefined(); + + const b = Schema.decodeUnknownSync(JiraSelector)({ + projectKey: "ENG", + jql: "labels = backend", + }); + expect(b.jql).toBe("labels = backend"); + }); + + it("rejects an empty projectKey", () => { + expect(() => Schema.decodeUnknownSync(JiraSelector)({ projectKey: "" })).toThrow(); + }); +}); + +describe("createWorkSourceConnection RPC payload", () => { + // `provider: "github"` is a stand-in here: Task 3 adds "jira" to + // WorkSourceProviderName, after which a Jira connection exercises the same + // optional auth fields. These tests only need to prove the new optional + // fields (authMode/baseUrl/email) decode, which any valid provider exercises. + it("decodes optional auth fields (authMode/baseUrl/email)", () => { + const decoded = Schema.decodeUnknownSync(WsWorkflowCreateWorkSourceConnectionRpc.payloadSchema)({ + provider: "github", + displayName: "My Connection", + token: "tok", + authMode: "basic", + baseUrl: "https://acme.atlassian.net", + email: "me@acme.test", + }); + expect(decoded.authMode).toBe("basic"); + expect(decoded.baseUrl).toBe("https://acme.atlassian.net"); + expect(decoded.email).toBe("me@acme.test"); + }); + + it("rejects an invalid authMode", () => { + expect(() => + Schema.decodeUnknownSync(WsWorkflowCreateWorkSourceConnectionRpc.payloadSchema)({ + provider: "github", + displayName: "X", + token: "t", + authMode: "oauth", + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/workSource.ts b/packages/contracts/src/workSource.ts new file mode 100644 index 00000000000..9e4afcd26fc --- /dev/null +++ b/packages/contracts/src/workSource.ts @@ -0,0 +1,287 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { LaneKey, TicketId, WorkSourceProviderName } from "./workflow.ts"; +import type { WorkflowSourceConfig } from "./workflow.ts"; + +// SourceId and WorkSourceProviderName are defined in workflow.ts (to avoid an +// import cycle: workSource.ts imports LaneKey from workflow.ts, so workflow.ts +// cannot import from workSource.ts). They are re-exported here so callers +// can import everything source-related from @t3tools/contracts/workSource. +export { + LaneKey, + SourceId, + TicketId, + WorkSourceProviderName, + WorkflowSourceConfig, + WorkSourceAutoPull, +} from "./workflow.ts"; + +// ── Auto-pull rule helpers ──────────────────────────────────────────────────── + +export const ALWAYS_RULE = true as const; + +export interface AutoPullCriteria { + readonly labels?: { readonly mode: "any" | "all"; readonly values: ReadonlyArray<string> }; + readonly assignee?: + | { readonly kind: "anyone" } + | { readonly kind: "login"; readonly value: string }; + readonly state?: "open" | "closed"; +} + +const labelIn = (label: string) => ({ in: [label, { var: "labels" }] }); + +export const compileAutoPullRule = (c: AutoPullCriteria): unknown => { + const clauses: Array<unknown> = []; + if (c.labels && c.labels.values.length > 0) { + const ins = c.labels.values.map(labelIn); + clauses.push(ins.length === 1 ? ins[0] : { [c.labels.mode === "any" ? "or" : "and"]: ins }); + } + if (c.assignee) + clauses.push( + c.assignee.kind === "anyone" + ? { var: "assignees" } + : { in: [c.assignee.value, { var: "assignees" }] }, + ); + if (c.state) clauses.push({ "==": [{ var: "state" }, c.state] }); + if (clauses.length === 0) return ALWAYS_RULE; + return clauses.length === 1 ? clauses[0] : { and: clauses }; +}; + +type DecodedClause = + | { kind: "labels"; value: AutoPullCriteria["labels"] } + | { kind: "assignee"; value: AutoPullCriteria["assignee"] } + | { kind: "state"; value: AutoPullCriteria["state"] }; + +// Try to decode a single jsonLogic sub-expression into a typed clause. +const decodeClause = (clause: unknown): DecodedClause | null => { + if (typeof clause !== "object" || clause === null) return null; + const obj = clause as Record<string, unknown>; + + // { var: "assignees" } → assignee: anyone + if ("var" in obj && obj.var === "assignees") { + return { kind: "assignee", value: { kind: "anyone" } }; + } + + // { "==": [{ var: "state" }, "open"|"closed"] } + if ("==" in obj && Array.isArray(obj["=="])) { + const [left, right] = obj["=="] as unknown[]; + if ( + typeof left === "object" && + left !== null && + (left as Record<string, unknown>).var === "state" && + (right === "open" || right === "closed") + ) { + return { kind: "state", value: right }; + } + } + + // { in: [...] } + if ("in" in obj && Array.isArray(obj.in)) { + const [val, varExpr] = obj.in as unknown[]; + if (typeof val === "string" && typeof varExpr === "object" && varExpr !== null) { + const varName = (varExpr as Record<string, unknown>).var; + // { in: [label, { var: "labels" }] } → labels any single + if (varName === "labels") { + return { kind: "labels", value: { mode: "any", values: [val] } }; + } + // { in: [login, { var: "assignees" }] } → assignee: login + if (varName === "assignees") { + return { kind: "assignee", value: { kind: "login", value: val } }; + } + } + } + + // { or: [{in:[v,{var:"labels"}]}, ...] } → labels any multi + if ("or" in obj && Array.isArray(obj.or)) { + const values = decodeLabelList(obj.or as unknown[]); + if (values !== null) return { kind: "labels", value: { mode: "any", values } }; + } + + // { and: [{in:[v,{var:"labels"}]}, ...] } → labels all multi + if ("and" in obj && Array.isArray(obj.and)) { + const values = decodeLabelList(obj.and as unknown[]); + if (values !== null) return { kind: "labels", value: { mode: "all", values } }; + } + + return null; +}; + +const decodeLabelList = (items: unknown[]): string[] | null => { + const values: string[] = []; + for (const item of items) { + if (typeof item !== "object" || item === null) return null; + const obj = item as Record<string, unknown>; + if (!("in" in obj) || !Array.isArray(obj.in)) return null; + const [val, varExpr] = obj.in as unknown[]; + if (typeof val !== "string" || typeof varExpr !== "object" || varExpr === null) return null; + if ((varExpr as Record<string, unknown>).var !== "labels") return null; + values.push(val); + } + return values.length > 0 ? values : null; +}; + +// Build an AutoPullCriteria from an array of decoded clauses (null if duplicate kinds). +const clausesToCriteria = (clauses: DecodedClause[]): AutoPullCriteria | null => { + let labels: AutoPullCriteria["labels"] | undefined; + let assignee: AutoPullCriteria["assignee"] | undefined; + let state: AutoPullCriteria["state"] | undefined; + for (const c of clauses) { + if (c.kind === "labels") { + if (labels !== undefined) return null; + labels = c.value; + } else if (c.kind === "assignee") { + if (assignee !== undefined) return null; + assignee = c.value; + } else { + if (state !== undefined) return null; + state = c.value; + } + } + // Build result without undefined properties (exactOptionalPropertyTypes) + return { + ...(labels !== undefined ? { labels } : {}), + ...(assignee !== undefined ? { assignee } : {}), + ...(state !== undefined ? { state } : {}), + }; +}; + +/** Best-effort inverse for the editor. Returns null for any shape compileAutoPullRule + * does not emit (→ the editor shows a read-only "advanced rule"). */ +export const decodeAutoPullRule = (rule: unknown): AutoPullCriteria | null => { + // bare true → empty criteria + if (rule === true) return {}; + + if (typeof rule !== "object" || rule === null) return null; + const obj = rule as Record<string, unknown>; + + // Top-level `{ and: [...] }` — split and decode each element + if ("and" in obj && Array.isArray(obj.and)) { + // First check if the whole `and` is a label-list (all-of labels single clause) + const labelValues = decodeLabelList(obj.and as unknown[]); + if (labelValues !== null) { + return { labels: { mode: "all", values: labelValues } }; + } + // Otherwise decode as a compound: each element is a standalone clause + const clauses: DecodedClause[] = []; + for (const clause of obj.and as unknown[]) { + const decoded = decodeClause(clause); + if (decoded === null) return null; + clauses.push(decoded); + } + return clausesToCriteria(clauses); + } + + // Single clause + const decoded = decodeClause(rule); + if (decoded === null) return null; + return clausesToCriteria([decoded]); +}; + +export const summarizeAutoPull = (c: AutoPullCriteria | null): string => { + if (c === null) return "Manual only"; + const parts: string[] = []; + if (c.labels && c.labels.values.length > 0) { + const joined = c.labels.values.join(c.labels.mode === "any" ? " or " : " and "); + parts.push(`labeled ${joined}`); + } + if (c.assignee) { + parts.push( + c.assignee.kind === "anyone" ? "assigned to anyone" : `assigned to @${c.assignee.value}`, + ); + } + if (c.state) parts.push(c.state); + return parts.length > 0 ? `Issues ${parts.join(", ")}` : "All issues"; +}; + +// NOTE: `provider` is included in the Pick even though it is not read in the +// body. A Pick of all-optional properties is a "weak type" under TS's +// exactOptionalPropertyTypes, and TypeScript raises TS2559. Adding the +// required `provider` field makes the type non-weak and suppresses the error. +// Do NOT remove it. +export const effectiveAutoPullRule = ( + source: Pick<WorkflowSourceConfig, "autoPull" | "enabled" | "provider">, +): unknown | null => + source.autoPull !== undefined + ? source.autoPull.rule + : source.enabled === true + ? ALWAYS_RULE + : null; + +// PURE selector schemas — used by synchronous lint AND the providers AND the UI. +export const GithubSelector = Schema.Struct({ + owner: TrimmedNonEmptyString, + repo: TrimmedNonEmptyString, + labels: Schema.optional(Schema.Array(Schema.String)), + assignee: Schema.optional(Schema.String), + state: Schema.Literals(["all", "open"]).pipe( + Schema.withDecodingDefault(Effect.succeed("all" as const)), + ), +}); +export type GithubSelector = typeof GithubSelector.Type; + +export const AsanaSelector = Schema.Struct({ + projectGid: TrimmedNonEmptyString, + sectionGid: Schema.optional(Schema.String), + tagGid: Schema.optional(Schema.String), + includeCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), +}); +export type AsanaSelector = typeof AsanaSelector.Type; + +export const JiraSelector = Schema.Struct({ + projectKey: TrimmedNonEmptyString, + jql: Schema.optional(Schema.String), +}); +export type JiraSelector = typeof JiraSelector.Type; + +export const WorkSourceConnectionView = Schema.Struct({ + connectionRef: TrimmedNonEmptyString, + provider: WorkSourceProviderName, + displayName: TrimmedNonEmptyString, + authMode: Schema.Literals(["pat", "basic", "bearer"]), + baseUrl: Schema.NullOr(Schema.String), +}); +export type WorkSourceConnectionView = typeof WorkSourceConnectionView.Type; + +// ── Import picker schemas ───────────────────────────────────────────────────── + +export const ImportableWorkItemView = Schema.Struct({ + provider: WorkSourceProviderName, + sourceId: Schema.String, + externalId: Schema.String, + displayRef: Schema.String, + title: Schema.String, + container: Schema.String, + url: Schema.String, + assignees: Schema.Array(Schema.String), + lifecycle: Schema.Literals(["open", "closed", "deleted"]), + mappedTicketId: Schema.NullOr(TicketId), + mappedLane: Schema.NullOr(LaneKey), +}); +export type ImportableWorkItemView = typeof ImportableWorkItemView.Type; + +export const ImportableSourceSummary = Schema.Struct({ + sourceId: Schema.String, + provider: WorkSourceProviderName, + container: Schema.String, + destinationLane: LaneKey, +}); +export type ImportableSourceSummary = typeof ImportableSourceSummary.Type; + +export const ListImportableWorkItemsResult = Schema.Struct({ + items: Schema.Array(ImportableWorkItemView), + sources: Schema.Array(ImportableSourceSummary), + viewer: Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ id: Schema.String, aliases: Schema.Array(Schema.String) })), + ), + truncated: Schema.Record(Schema.String, Schema.Boolean), + sourceErrors: Schema.Record(Schema.String, Schema.String), +}); +export type ListImportableWorkItemsResult = typeof ListImportableWorkItemsResult.Type; + +export const ImportWorkItemsResult = Schema.Struct({ + imported: Schema.Array(Schema.Struct({ externalId: Schema.String, ticketId: TicketId })), + skipped: Schema.Array(Schema.Struct({ externalId: Schema.String, reason: Schema.String })), +}); +export type ImportWorkItemsResult = typeof ImportWorkItemsResult.Type; diff --git a/packages/contracts/src/workflow.test.ts b/packages/contracts/src/workflow.test.ts new file mode 100644 index 00000000000..f425ca50f31 --- /dev/null +++ b/packages/contracts/src/workflow.test.ts @@ -0,0 +1,2164 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { MessageId } from "./baseSchemas.ts"; +import { + BoardListEntry, + BoardId, + BoardSnapshot, + BoardTicketView, + LaneEntryToken, + AgentStep, + StepOutcome, + StepRunStatus, + TicketAttachment, + TicketStatus, + TicketId, + WorkflowStep, + WorkflowTicketDetailView, + WorkflowStepRunView, + WorkflowCreateBoardInput, + WorkflowDefinition, + WorkflowDefinitionEncoded, + WorkflowEvent, + WorkflowEventId, + WorkflowBoardVersionSummary, + WorkflowGetBoardVersionResult, + WorkflowGetBoardDefinitionResult, + WorkflowImportBoardInput, + WorkflowImportBoardResult, + WorkflowLintCode, + WorkflowLintError, + WorkflowNeedsAttentionTicketView, + WorkflowOutboundRule, + WorkflowRenameBoardInput, + WorkflowSaveBoardDefinitionResult, + WorkflowBoardMetrics, + WorkflowBoardVersionSource, + WorkflowBoardProposalView, + WorkflowProposeBoardImprovementInput, + WorkflowListBoardProposalsInput, + WorkflowGenerateWorkflowDraftInput, + WorkflowGetBoardProposalInput, + WorkflowResolveBoardProposalInput, + WorkflowRevertBoardProposalInput, + WorkflowProposeBoardImprovementResult, + WorkflowListBoardProposalsResult, + WorkflowGetBoardProposalResult, + WorkflowResolveBoardProposalResult, + WorkflowRevertBoardProposalResult, + WorkflowTicketMessageView, + WORKFLOW_WS_METHODS, + WorkflowSourceConfig, + WorkSourceAutoPull, +} from "./workflow.ts"; + +const decodeTicketId = Schema.decodeUnknownEffect(TicketId); +const decodeStepOutcome = Schema.decodeUnknownEffect(StepOutcome); +const decodeStepRunStatus = Schema.decodeUnknownEffect(StepRunStatus); +const decodeTicketAttachment = Schema.decodeUnknownEffect(TicketAttachment); +const decodeTicketStatus = Schema.decodeUnknownEffect(TicketStatus); +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); +const decodeWorkflowDefinitionEncoded = Schema.decodeUnknownEffect(WorkflowDefinitionEncoded); +const decodeWorkflowEvent = Schema.decodeUnknownEffect(WorkflowEvent); +const decodeWorkflowTicketMessageView = Schema.decodeUnknownEffect(WorkflowTicketMessageView); +const decodeWorkflowTicketDetailView = Schema.decodeUnknownEffect(WorkflowTicketDetailView); +const decodeWorkflowStepRunView = Schema.decodeUnknownEffect(WorkflowStepRunView); +const decodeBoardSnapshot = Schema.decodeUnknownEffect(BoardSnapshot); +const decodeWorkflowLintCode = Schema.decodeUnknownEffect(WorkflowLintCode); +const decodeWorkflowLintError = Schema.decodeUnknownEffect(WorkflowLintError); +const decodeWorkflowGetBoardDefinitionResult = Schema.decodeUnknownEffect( + WorkflowGetBoardDefinitionResult, +); +const decodeWorkflowBoardVersionSummary = Schema.decodeUnknownEffect(WorkflowBoardVersionSummary); +const decodeWorkflowGetBoardVersionResult = Schema.decodeUnknownEffect( + WorkflowGetBoardVersionResult, +); +const decodeWorkflowSaveBoardDefinitionResult = Schema.decodeUnknownEffect( + WorkflowSaveBoardDefinitionResult, +); + +describe("workflow ids", () => { + it("brands a board id from a non-empty string", () => { + const id = BoardId.make("board-123"); + assert.equal(id, "board-123"); + }); + + it.effect("rejects an empty ticket id", () => + Effect.gen(function* () { + const result = yield* Effect.exit(decodeTicketId("")); + assert.strictEqual(result._tag, "Failure"); + }), + ); + + it("brands lane-entry tokens and event ids", () => { + assert.equal(LaneEntryToken.make("tok-1"), "tok-1"); + assert.equal(WorkflowEventId.make("evt-1"), "evt-1"); + }); +}); + +describe("WorkflowDefinition", () => { + const example = { + name: "Standard delivery", + settings: { maxConcurrentTickets: 3 }, + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: { file: "prompts/implement.md" }, + }, + { + key: "review", + type: "agent", + agent: { instance: "codex_main", model: "gpt-5.4" }, + instruction: "Review the diff.", + }, + ], + on: { success: "owner_review", failure: "needs_attention" }, + }, + { key: "owner_review", name: "Owner Review", entry: "manual" }, + { key: "needs_attention", name: "Needs Attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }; + + it.effect("decodes a valid workflow file", () => + Effect.gen(function* () { + const decoded = yield* decodeWorkflowDefinition(example); + assert.equal(decoded.lanes.length, 5); + assert.equal(decoded.lanes[1]?.pipeline?.length, 2); + }), + ); + + it.effect("rejects workflow definition names longer than 128 characters", () => + Effect.gen(function* () { + const overlong = yield* Effect.exit( + decodeWorkflowDefinition({ + name: "A".repeat(129), + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }), + ); + + assert.strictEqual(overlong._tag, "Failure"); + }), + ); + + it.effect("decodes captureOutput on agent steps", () => + Effect.gen(function* () { + const decoded = yield* decodeWorkflowDefinition({ + name: "x", + lanes: [ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Return a verdict.", + captureOutput: true, + }, + ], + }, + ], + }); + + const step = decoded.lanes[0]?.pipeline?.[0]; + assert.equal(step?.type, "agent"); + assert.equal((step as any)?.captureOutput, true); + }), + ); + + it.effect("decodes canonical effort options on agent steps", () => + Effect.gen(function* () { + const decoded = yield* decodeWorkflowDefinition({ + name: "x", + lanes: [ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { + instance: "claude_main", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "high" }, + { id: "thinking", value: true }, + ], + }, + instruction: "Do the thing.", + }, + ], + }, + ], + }); + + const step = decoded.lanes[0]?.pipeline?.[0]; + assert.equal(step?.type, "agent"); + assert.deepEqual((step as any)?.agent?.options, [ + { id: "effort", value: "high" }, + { id: "thinking", value: true }, + ]); + }), + ); + + it.effect("rejects the legacy object form of agent options (canonical array only)", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeWorkflowDefinition({ + name: "x", + lanes: [ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { + instance: "codex_main", + model: "gpt-5.5", + options: { reasoningEffort: "high", fastMode: true }, + }, + instruction: "Do the thing.", + }, + ], + }, + ], + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), + ); + + it.effect("omits agent options when absent", () => + Effect.gen(function* () { + const decoded = yield* decodeWorkflowDefinition({ + name: "x", + lanes: [ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "Do the thing.", + }, + ], + }, + ], + }); + + const step = decoded.lanes[0]?.pipeline?.[0]; + assert.equal((step as any)?.agent?.options, undefined); + }), + ); + + it.effect("decodes step routing and lane transitions", () => + Effect.gen(function* () { + const decoded = yield* decodeWorkflowDefinition({ + name: "smart routing", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "tests", + type: "script", + run: "pnpm test", + on: { failure: "needs_attention", blocked: "blocked" }, + }, + { + key: "review", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Review the diff.", + captureOutput: true, + on: { success: "done" }, + }, + { + key: "owner", + type: "approval", + prompt: "Ship?", + on: { success: "done", failure: "needs_attention" }, + }, + ], + transitions: [ + { + when: { "==": [{ var: "steps.review.output.verdict" }, "block"] }, + to: "needs_attention", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs Attention", entry: "manual" }, + { key: "blocked", name: "Blocked", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + + const lane = decoded.lanes[0]; + assert.equal(lane?.transitions?.[0]?.to, "needs_attention"); + assert.deepEqual(lane?.transitions?.[0]?.when, { + "==": [{ var: "steps.review.output.verdict" }, "block"], + }); + assert.deepEqual((lane?.pipeline?.[0] as any)?.on, { + failure: "needs_attention", + blocked: "blocked", + }); + assert.deepEqual((lane?.pipeline?.[1] as any)?.on, { success: "done" }); + assert.deepEqual((lane?.pipeline?.[2] as any)?.on, { + success: "done", + failure: "needs_attention", + }); + }), + ); + + it.effect("decodes a script step with a duration timeout", () => + Effect.gen(function* () { + const decoded = yield* decodeWorkflowDefinition({ + name: "x", + lanes: [ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "script", + run: "pnpm test && pnpm lint", + timeout: "10 minutes", + cwd: ".", + allowFailure: true, + }, + ], + }, + ], + }); + + const step = decoded.lanes[0]?.pipeline?.[0]; + assert.equal(step?.type, "script"); + if (step?.type === "script") { + assert.equal(step.run, "pnpm test && pnpm lint"); + } + }), + ); + + it.effect("decodes terminal lane retention with duration-string encoding", () => + Effect.gen(function* () { + const decoded = yield* decodeWorkflowDefinition({ + name: "retained terminal lanes", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true, retention: "7 days" }, + ], + }); + + assert.isDefined(decoded.lanes[1]?.retention); + }), + ); + + it.effect("exposes an encoded workflow definition schema for editor JSON", () => + Effect.gen(function* () { + const encoded = yield* decodeWorkflowDefinitionEncoded({ + name: "Editor JSON", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + wipLimit: 2, + pipeline: [ + { + key: "tests", + type: "script", + run: "pnpm test", + timeout: "5 minutes", + }, + { + key: "review", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Review the diff.", + captureOutput: true, + }, + ], + transitions: [ + { + when: { "==": [{ var: "steps.review.output.verdict" }, "pass"] }, + to: "done", + }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true, retention: "7 days" }, + ], + }); + + const scriptStep = (encoded as any).lanes[0].pipeline[0]; + assert.equal(scriptStep.timeout, "5 minutes"); + assert.deepEqual((encoded as any).lanes[0].transitions[0].when, { + "==": [{ var: "steps.review.output.verdict" }, "pass"], + }); + assert.equal((encoded as any).lanes[0].wipLimit, 2); + assert.equal((encoded as any).lanes[0].pipeline[1].captureOutput, true); + assert.equal((encoded as any).lanes[1].retention, "7 days"); + }), + ); + + it.effect("rejects a script step with an invalid timeout", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeWorkflowDefinition({ + name: "x", + lanes: [ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [{ key: "s", type: "script", run: "echo hi", timeout: "soonish" }], + }, + ], + }), + ); + assert.strictEqual(result._tag, "Failure"); + }), + ); + + it.effect("rejects an unknown step type", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeWorkflowDefinition({ + name: "x", + lanes: [ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [{ key: "s", type: "unknown", run: "echo hi" }], + }, + ], + }), + ); + assert.strictEqual(result._tag, "Failure"); + }), + ); +}); + +describe("pullRequest step and TicketPrOpened event", () => { + const decodeBoardTicketView = Schema.decodeUnknownEffect(BoardTicketView); + const decodeWorkflowStep = Schema.decodeUnknownEffect(WorkflowStep); + + it.effect("decodes a pullRequest step (open and land)", () => + Effect.gen(function* () { + const open = yield* decodeWorkflowStep({ + key: "pr-open", + type: "pullRequest", + action: "open", + base: "main", + draft: true, + titleTemplate: "{{ticket.title}}", + bodyTemplate: "Closes internal ticket.", + }); + assert.ok(open.type === "pullRequest"); + assert.equal(open.action, "open"); + assert.equal(open.base, "main"); + assert.equal(open.draft, true); + + const land = yield* decodeWorkflowStep({ + key: "pr-land", + type: "pullRequest", + action: "land", + strategy: "squash", + deleteBranch: false, + }); + assert.ok(land.type === "pullRequest"); + assert.equal(land.action, "land"); + assert.equal(land.strategy, "squash"); + assert.equal(land.deleteBranch, false); + + const bogusAction = yield* Effect.exit( + decodeWorkflowStep({ key: "x", type: "pullRequest", action: "bogus" }), + ); + assert.strictEqual(bogusAction._tag, "Failure"); + + const missingAction = yield* Effect.exit( + decodeWorkflowStep({ key: "x", type: "pullRequest" }), + ); + assert.strictEqual(missingAction._tag, "Failure"); + }), + ); + + it.effect("decodes TicketPrOpened events and ticket pr views", () => + Effect.gen(function* () { + const event = yield* decodeWorkflowEvent({ + type: "TicketPrOpened", + eventId: "e1", + ticketId: "t1", + streamVersion: 0, + occurredAt: "2026-06-12T00:00:00.000Z", + payload: { + stepRunId: "s1", + prNumber: 7, + url: "https://github.com/o/r/pull/7", + branch: "workflow/t1", + remoteName: "origin", + repo: "o/r", + }, + }); + assert.ok(event.type === "TicketPrOpened"); + assert.equal(event.payload.prNumber, 7); + assert.equal(event.payload.repo, "o/r"); + + const view = yield* decodeBoardTicketView({ + ticketId: "t1", + boardId: "b1", + title: "T", + currentLaneKey: "lane", + status: "idle", + pr: { number: 7, url: "https://github.com/o/r/pull/7", state: "open", ciState: "pending" }, + }); + assert.equal(view.pr?.number, 7); + assert.equal(view.pr?.ciState, "pending"); + + const withoutCi = yield* decodeBoardTicketView({ + ticketId: "t1", + boardId: "b1", + title: "T", + currentLaneKey: "lane", + status: "idle", + pr: { number: 8, url: "https://github.com/o/r/pull/8", state: "merged" }, + }); + assert.equal(withoutCi.pr?.number, 8); + assert.equal(withoutCi.pr?.state, "merged"); + assert.equal(withoutCi.pr?.ciState, undefined); + }), + ); + + it.effect("decodes a StepStarted event with stepType pullRequest", () => + Effect.gen(function* () { + const event = yield* decodeWorkflowEvent({ + eventId: "evt-1", + ticketId: "ticket-1", + streamVersion: 1, + occurredAt: "2026-06-12T00:00:00.000Z", + type: "StepStarted", + payload: { + pipelineRunId: "pipe-1", + stepRunId: "step-1", + stepKey: "pr-open", + stepType: "pullRequest", + }, + }); + assert.ok(event.type === "StepStarted"); + assert.equal(event.payload.stepType, "pullRequest"); + }), + ); +}); + +describe("AgentStep continueSession", () => { + const decodeAgentStep = Schema.decodeUnknownEffect(AgentStep); + + it.effect("decodes an agent step with and without continueSession", () => + Effect.gen(function* () { + const base = { + key: "implement", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "do work", + }; + + const without = yield* decodeAgentStep(base); + assert.equal(without.type, "agent"); + assert.equal(without.continueSession, undefined); + + const withTrue = yield* decodeAgentStep({ ...base, continueSession: true }); + assert.equal(withTrue.continueSession, true); + + const withFalse = yield* decodeAgentStep({ ...base, continueSession: false }); + assert.equal(withFalse.continueSession, false); + + const bogus = yield* Effect.exit(decodeAgentStep({ ...base, continueSession: "yes" })); + assert.strictEqual(bogus._tag, "Failure"); + }), + ); +}); + +describe("WorkflowLintCode collaboration codes", () => { + it.effect("accepts invalid_continue_session and invalid_handoff_reference", () => + Effect.gen(function* () { + assert.equal( + yield* decodeWorkflowLintCode("invalid_continue_session"), + "invalid_continue_session", + ); + assert.equal( + yield* decodeWorkflowLintCode("invalid_handoff_reference"), + "invalid_handoff_reference", + ); + }), + ); +}); + +describe("WorkflowEvent", () => { + const ticketCreated = { + type: "TicketCreated", + eventId: "evt-1", + ticketId: "t-1", + streamVersion: 0, + occurredAt: "2026-06-07T00:00:00.000Z", + payload: { boardId: "b-1", title: "Add export", laneKey: "backlog" }, + }; + + it.effect("decodes a TicketCreated event", () => + Effect.gen(function* () { + const event = yield* decodeWorkflowEvent(ticketCreated); + assert.equal(event.type, "TicketCreated"); + }), + ); + + it.effect("decodes ticket collaboration message and edit events", () => + Effect.gen(function* () { + const message = yield* decodeWorkflowEvent({ + type: "TicketMessagePosted", + eventId: "evt-message-posted", + ticketId: "t-1", + streamVersion: 2, + occurredAt: "2026-06-08T00:00:02.000Z", + payload: { + messageId: "msg-ticket-1", + stepRunId: "sr-1", + author: "user", + body: "Use the experimental endpoint.", + attachments: [ + { + kind: "image", + id: "img-1", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 1200, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + createdAt: "2026-06-08T00:00:01.000Z", + }, + }); + const edit = yield* decodeWorkflowEvent({ + type: "TicketEdited", + eventId: "evt-ticket-edited", + ticketId: "t-1", + streamVersion: 3, + occurredAt: "2026-06-08T00:00:03.000Z", + payload: { + title: "Updated title", + description: "", + }, + }); + + assert.equal(message.type, "TicketMessagePosted"); + if (message.type !== "TicketMessagePosted") { + assert.fail("expected TicketMessagePosted"); + } + assert.equal(message.payload.messageId, MessageId.make("msg-ticket-1")); + assert.equal(message.payload.attachments[0]?.kind, "image"); + assert.equal(edit.type, "TicketEdited"); + }), + ); + + it.effect("decodes TicketMessageEdited + editedAt view", () => + Effect.gen(function* () { + const event = yield* decodeWorkflowEvent({ + type: "TicketMessageEdited", + eventId: "e1", + ticketId: "t1", + streamVersion: 1, + occurredAt: "2026-06-17T00:00:00.000Z", + payload: { + messageId: "m1", + body: "x", + editedAt: "2026-06-17T00:00:00.000Z", + }, + }); + assert.equal(event.type, "TicketMessageEdited"); + + const view = yield* decodeWorkflowTicketMessageView({ + messageId: "m1", + ticketId: "t1", + author: "user", + body: "b", + attachments: [], + createdAt: "2026-06-17T00:00:00.000Z", + editedAt: "2026-06-17T00:00:00.000Z", + }); + assert.isDefined(view.editedAt); + }), + ); + + it.effect("accepts reserved ticket attachment variants", () => + Effect.gen(function* () { + const video = yield* decodeTicketAttachment({ + kind: "video", + id: "video-1", + name: "clip.mp4", + mimeType: "video/mp4", + sizeBytes: 42, + ref: "ticket-media/video-1", + }); + const file = yield* decodeTicketAttachment({ + kind: "file", + id: "file-1", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 42, + ref: "ticket-media/file-1", + }); + + assert.equal(video.kind, "video"); + assert.equal(file.kind, "file"); + }), + ); + + it.effect("rejects SVG image data URLs for ticket attachments", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeTicketAttachment({ + kind: "image", + id: "svg-1", + name: "payload.svg", + mimeType: "image/svg+xml", + sizeBytes: 1200, + dataUrl: "data:image/svg+xml;base64,PHN2Zy8+", + }), + ); + assert.isTrue(result._tag === "Failure"); + }), + ); + + it.effect("decodes a TicketMovedToLane event", () => + Effect.gen(function* () { + const event = yield* decodeWorkflowEvent({ + type: "TicketMovedToLane", + eventId: "evt-2", + ticketId: "t-1", + streamVersion: 1, + occurredAt: "2026-06-07T00:00:01.000Z", + payload: { toLane: "implement", laneEntryToken: "tok-1", reason: "manual" }, + }); + assert.equal(event.type, "TicketMovedToLane"); + }), + ); + + it.effect("decodes queue and admission events", () => + Effect.gen(function* () { + const queued = yield* decodeWorkflowEvent({ + type: "TicketQueued", + eventId: "evt-queued", + ticketId: "t-1", + streamVersion: 2, + occurredAt: "2026-06-07T00:00:02.000Z", + payload: { lane: "implement" }, + }); + const admitted = yield* decodeWorkflowEvent({ + type: "TicketAdmitted", + eventId: "evt-admitted", + ticketId: "t-1", + streamVersion: 3, + occurredAt: "2026-06-07T00:00:03.000Z", + payload: { lane: "implement", laneEntryToken: "tok-2" }, + }); + + assert.equal(queued.type, "TicketQueued"); + assert.equal(admitted.type, "TicketAdmitted"); + }), + ); + + it.effect("decodes queued ticket status", () => + Effect.gen(function* () { + const status = yield* decodeTicketStatus("queued"); + assert.equal(status, "queued"); + }), + ); + + it.effect("decodes provider question ids on awaiting-user step metadata", () => + Effect.gen(function* () { + const outcome = yield* decodeStepOutcome({ + _tag: "awaiting_user", + waitingReason: "Which API should I use?", + providerThreadId: "thread-ticket-answer", + providerRequestId: "request-ticket-answer", + providerResponseKind: "user-input", + providerQuestionId: "question-api-choice", + }); + assert.equal(outcome._tag, "awaiting_user"); + assert.equal((outcome as any).providerQuestionId, "question-api-choice"); + + const event = yield* decodeWorkflowEvent({ + type: "StepAwaitingUser", + eventId: "evt-awaiting-question", + ticketId: "t-1", + streamVersion: 2, + occurredAt: "2026-06-07T00:00:02.000Z", + payload: { + stepRunId: "sr-1", + waitingReason: "Which API should I use?", + providerThreadId: "thread-ticket-answer", + providerRequestId: "request-ticket-answer", + providerResponseKind: "user-input", + providerQuestionId: "question-api-choice", + }, + }); + assert.equal(event.type, "StepAwaitingUser"); + assert.equal((event.payload as any).providerQuestionId, "question-api-choice"); + }), + ); + + it.effect("decodes blocked step terminal semantics", () => + Effect.gen(function* () { + const status = yield* decodeStepRunStatus("blocked"); + assert.equal(status, "blocked"); + + const outcome = yield* decodeStepOutcome({ + _tag: "blocked", + reason: "Project not trusted to run scripts", + }); + assert.equal(outcome._tag, "blocked"); + + const event = yield* decodeWorkflowEvent({ + type: "StepBlocked", + eventId: "evt-blocked", + ticketId: "t-1", + streamVersion: 2, + occurredAt: "2026-06-07T00:00:02.000Z", + payload: { + stepRunId: "sr-1", + reason: "Project not trusted to run scripts", + }, + }); + assert.equal(event.type, "StepBlocked"); + + const view = yield* decodeWorkflowStepRunView({ + stepRunId: "sr-1", + stepKey: "tests", + stepType: "script", + status: "blocked", + waitingReason: null, + blockedReason: "Project not trusted to run scripts", + scriptThreadId: null, + terminalId: null, + scriptStatus: null, + exitCode: null, + signal: null, + }); + assert.equal(view.blockedReason, "Project not trusted to run scripts"); + }), + ); + + it.effect("carries structured output on completed steps and step run views", () => + Effect.gen(function* () { + const outcome = yield* decodeStepOutcome({ + _tag: "completed", + output: { verdict: "pass", score: 0.98 }, + }); + assert.equal(outcome._tag, "completed"); + assert.deepEqual((outcome as any).output, { verdict: "pass", score: 0.98 }); + + const event = yield* decodeWorkflowEvent({ + type: "StepCompleted", + eventId: "evt-completed-output", + ticketId: "t-1", + streamVersion: 3, + occurredAt: "2026-06-07T00:00:03.000Z", + payload: { + stepRunId: "sr-output", + output: { verdict: "pass", score: 0.98 }, + }, + }); + assert.equal(event.type, "StepCompleted"); + assert.deepEqual((event.payload as any).output, { verdict: "pass", score: 0.98 }); + + const view = yield* decodeWorkflowStepRunView({ + stepRunId: "sr-output", + stepKey: "review", + stepType: "agent", + status: "completed", + waitingReason: null, + blockedReason: null, + scriptThreadId: null, + terminalId: null, + scriptStatus: null, + exitCode: null, + signal: null, + output: { verdict: "pass", score: 0.98 }, + }); + assert.deepEqual((view as any).output, { verdict: "pass", score: 0.98 }); + }), + ); + + it.effect("decodes provider response kind on step run views", () => + Effect.gen(function* () { + const view = yield* decodeWorkflowStepRunView({ + stepRunId: "sr-awaiting", + stepKey: "review", + stepType: "agent", + status: "awaiting_user", + waitingReason: "Approve this command?", + blockedReason: null, + providerResponseKind: "request", + scriptThreadId: null, + terminalId: null, + scriptStatus: null, + exitCode: null, + signal: null, + }); + + assert.equal((view as any).providerResponseKind, "request"); + }), + ); + + it.effect("decodes script step start and exit events", () => + Effect.gen(function* () { + const started = yield* decodeWorkflowEvent({ + type: "ScriptStepStarted", + eventId: "evt-script-started", + ticketId: "t-1", + streamVersion: 3, + occurredAt: "2026-06-07T00:00:03.000Z", + payload: { + scriptRunId: "script-run-1", + stepRunId: "sr-1", + scriptThreadId: "script-thread-1", + terminalId: "script-terminal-1", + }, + }); + assert.equal(started.type, "ScriptStepStarted"); + + const exited = yield* decodeWorkflowEvent({ + type: "ScriptStepExited", + eventId: "evt-script-exited", + ticketId: "t-1", + streamVersion: 4, + occurredAt: "2026-06-07T00:00:04.000Z", + payload: { + scriptRunId: "script-run-1", + exitCode: 1, + signal: null, + outcome: "exited", + }, + }); + assert.equal(exited.type, "ScriptStepExited"); + }), + ); + + it.effect("decodes script terminal metadata on a step run view", () => + Effect.gen(function* () { + const view = yield* decodeWorkflowStepRunView({ + stepRunId: "sr-script", + stepKey: "tests", + stepType: "script", + status: "completed", + waitingReason: null, + blockedReason: null, + scriptThreadId: "workflow-script:script-run", + terminalId: "script-script-run", + scriptStatus: "exited", + exitCode: 0, + signal: null, + }); + + assert.equal((view as any).scriptThreadId, "workflow-script:script-run"); + assert.equal((view as any).terminalId, "script-script-run"); + assert.equal((view as any).scriptStatus, "exited"); + assert.equal((view as any).exitCode, 0); + assert.equal((view as any).signal, null); + }), + ); + + it.effect("decodes a TicketRouteDecided audit event", () => + Effect.gen(function* () { + const event = yield* decodeWorkflowEvent({ + type: "TicketRouteDecided", + eventId: "evt-route-decided", + ticketId: "t-1", + streamVersion: 5, + occurredAt: "2026-06-07T00:00:05.000Z", + payload: { + pipelineRunId: "pr-1", + fromLane: "implement", + toLane: "needs_attention", + source: "lane_transition", + matchedTransitionIndex: 0, + contextSnapshot: { + pipeline: { result: "failure" }, + status: "running", + steps: { + tests: { exitCode: 1, status: "completed", output: null }, + review: { + exitCode: null, + status: "completed", + output: { verdict: "block" }, + }, + }, + }, + }, + }); + + assert.equal(event.type, "TicketRouteDecided"); + if (event.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(event.payload.source, "lane_transition"); + assert.equal(event.payload.matchedTransitionIndex, 0); + assert.deepEqual((event.payload.contextSnapshot as any).steps.review.output, { + verdict: "block", + }); + }), + ); +}); + +describe("board creation contracts", () => { + const decodeBoardListEntry = Schema.decodeUnknownEffect(BoardListEntry); + + it.effect("decodes a BoardListEntry", () => + Effect.gen(function* () { + const entry = yield* decodeBoardListEntry({ + boardId: "p1__board", + name: "Board", + filePath: ".t3/boards/board.json", + error: null, + }); + assert.equal(entry.error, null); + }), + ); + + it("BoardSnapshot carries projectId", () => { + assert.isTrue(Object.keys(BoardSnapshot.fields).includes("projectId")); + }); + + describe("WorkflowGenerateWorkflowDraftInput.description cap", () => { + const decodeDraftInput = Schema.decodeUnknownEffect(WorkflowGenerateWorkflowDraftInput); + const baseInput = { + projectId: "project-1", + name: "Release Flow", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }; + + it.effect("accepts a 4000-character description", () => + Effect.gen(function* () { + const decoded = yield* decodeDraftInput({ + ...baseInput, + description: "x".repeat(4000), + }); + assert.equal(decoded.description.length, 4000); + }), + ); + + it.effect("rejects a 4001-character description", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + decodeDraftInput({ ...baseInput, description: "x".repeat(4001) }), + ); + assert.isTrue(exit._tag === "Failure"); + }), + ); + }); + + it.effect("decodes lane WIP limits and queued ticket timestamps in board snapshots", () => + Effect.gen(function* () { + const snapshot = yield* decodeBoardSnapshot({ + projectId: "project-1", + board: { + boardId: "board-1", + name: "Board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipelineStepCount: 1, + wipLimit: 2, + }, + ], + }, + tickets: [ + { + ticketId: "ticket-1", + boardId: "board-1", + title: "Queued work", + description: "Carry this context into the drawer", + currentLaneKey: "implement", + status: "queued", + queuedAt: "2026-06-07T00:00:02.000Z", + }, + ], + }); + + assert.equal(snapshot.board.lanes[0]?.wipLimit, 2); + assert.equal(snapshot.tickets[0]?.queuedAt, "2026-06-07T00:00:02.000Z"); + assert.equal(snapshot.tickets[0]?.description, "Carry this context into the drawer"); + }), + ); + + it.effect("decodes ticket detail messages", () => + Effect.gen(function* () { + const detail = yield* decodeWorkflowTicketDetailView({ + ticket: { + ticketId: "ticket-1", + boardId: "board-1", + title: "Queued work", + description: "Ticket context", + currentLaneKey: "implement", + status: "waiting_on_user", + }, + steps: [ + { + stepRunId: "sr-1", + stepKey: "agent", + stepType: "agent", + status: "awaiting_user", + waitingReason: "Need endpoint choice", + blockedReason: null, + scriptThreadId: null, + terminalId: null, + scriptStatus: null, + exitCode: null, + signal: null, + }, + ], + messages: [ + { + messageId: "msg-agent-1", + ticketId: "ticket-1", + stepRunId: "sr-1", + author: "agent", + body: "Which endpoint should I use?", + attachments: [], + createdAt: "2026-06-08T00:00:01.000Z", + }, + ], + }); + + assert.equal(detail.ticket.description, "Ticket context"); + assert.equal(detail.messages[0]?.body, "Which endpoint should I use?"); + }), + ); + + it.effect("decodes ticket detail route history", () => + Effect.gen(function* () { + const detail = yield* decodeWorkflowTicketDetailView({ + ticket: { + ticketId: "ticket-1", + boardId: "board-1", + title: "Queued work", + currentLaneKey: "review", + status: "idle", + }, + steps: [], + messages: [], + routeHistory: [ + { + occurredAt: "2026-06-08T00:00:01.000Z", + fromLane: "implement", + toLane: "review", + source: "lane_transition", + matchedTransitionIndex: 1, + pipelineResult: "success", + laneRunCount: 2, + steps: { + verdict: { status: "completed", exitCode: 0, verdict: "approve" }, + }, + }, + { + occurredAt: "2026-06-08T00:00:02.000Z", + toLane: "implement", + source: "manual", + }, + ], + }); + + const first = detail.routeHistory?.[0]; + assert.equal(first?.source, "lane_transition"); + assert.equal(first?.matchedTransitionIndex, 1); + assert.equal(first?.laneRunCount, 2); + assert.equal(first?.steps?.["verdict"]?.verdict, "approve"); + assert.equal(detail.routeHistory?.[1]?.source, "manual"); + assert.equal(detail.routeHistory?.[1]?.fromLane, undefined); + }), + ); + + it("exposes the new methods", () => { + assert.equal(WORKFLOW_WS_METHODS.listBoards, "workflow.listBoards"); + assert.equal(WORKFLOW_WS_METHODS.createBoard, "workflow.createBoard"); + assert.equal(WORKFLOW_WS_METHODS.deleteBoard, "workflow.deleteBoard"); + assert.equal( + (WORKFLOW_WS_METHODS as Record<string, string>).renameBoard, + "workflow.renameBoard", + ); + assert.equal( + (WORKFLOW_WS_METHODS as Record<string, string>).getBoardDefinition, + "workflow.getBoardDefinition", + ); + assert.equal( + (WORKFLOW_WS_METHODS as Record<string, string>).saveBoardDefinition, + "workflow.saveBoardDefinition", + ); + assert.equal( + (WORKFLOW_WS_METHODS as Record<string, string>).listBoardVersions, + "workflow.listBoardVersions", + ); + assert.equal( + (WORKFLOW_WS_METHODS as Record<string, string>).getBoardVersion, + "workflow.getBoardVersion", + ); + assert.equal( + (WORKFLOW_WS_METHODS as Record<string, string>).setProjectScriptTrust, + "workflow.setProjectScriptTrust", + ); + assert.equal((WORKFLOW_WS_METHODS as Record<string, string>).cancelStep, "workflow.cancelStep"); + assert.equal( + (WORKFLOW_WS_METHODS as Record<string, string>).answerTicketStep, + "workflow.answerTicketStep", + ); + assert.equal((WORKFLOW_WS_METHODS as Record<string, string>).editTicket, "workflow.editTicket"); + }); + + it.effect("decodes workflow editor result contracts", () => + Effect.gen(function* () { + assert.equal(yield* decodeWorkflowLintCode("invalid_wip_limit"), "invalid_wip_limit"); + assert.equal( + yield* decodeWorkflowLintCode("unsafe_instruction_path"), + "unsafe_instruction_path", + ); + const lintError = yield* decodeWorkflowLintError({ + code: "invalid_json_logic", + message: "Lane route is invalid", + laneKey: "implement", + stepKey: "review", + transitionIndex: 1, + }); + assert.equal((lintError as any).transitionIndex, 1); + + const definition = { + name: "Editable", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [{ key: "tests", type: "script", run: "pnpm test", timeout: "5 minutes" }], + transitions: [{ when: { var: "pipeline.result" }, to: "done" }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }; + const snapshot = { + projectId: "project-1", + board: { + boardId: "project-1__editable", + name: "Editable", + lanes: [{ key: "implement", name: "Implement", entry: "auto", pipelineStepCount: 1 }], + }, + tickets: [], + }; + + const getResult = yield* decodeWorkflowGetBoardDefinitionResult({ + definition, + versionHash: "hash-1", + }); + assert.equal((getResult as any).definition.lanes[0].pipeline[0].timeout, "5 minutes"); + + const versionSummary = yield* decodeWorkflowBoardVersionSummary({ + versionId: 42, + versionHash: "hash-42", + source: "revert", + createdAt: "2026-06-08T12:00:00.000Z", + isCurrent: true, + }); + assert.equal((versionSummary as any).versionId, 42); + assert.equal((versionSummary as any).source, "revert"); + assert.equal((versionSummary as any).isCurrent, true); + + const versionResult = yield* decodeWorkflowGetBoardVersionResult({ + versionId: 41, + definition, + versionHash: "hash-41", + source: "import", + createdAt: "2026-06-08T11:00:00.000Z", + }); + assert.equal((versionResult as any).definition.lanes[0].pipeline[0].timeout, "5 minutes"); + assert.equal((versionResult as any).source, "import"); + + const okResult = yield* decodeWorkflowSaveBoardDefinitionResult({ + ok: true, + definition, + versionHash: "hash-2", + snapshot, + }); + assert.equal((okResult as any).ok, true); + assert.equal((okResult as any).definition.lanes[0].pipeline[0].timeout, "5 minutes"); + + const lintResult = yield* decodeWorkflowSaveBoardDefinitionResult({ + ok: false, + lintErrors: [ + { + code: "invalid_json_logic", + message: "Invalid transition", + laneKey: "implement", + transitionIndex: 0, + }, + ], + }); + assert.equal((lintResult as any).ok, false); + assert.equal((lintResult as any).lintErrors[0].transitionIndex, 0); + + const conflictResult = yield* decodeWorkflowSaveBoardDefinitionResult({ + ok: false, + conflict: true, + currentVersionHash: "hash-current", + }); + assert.equal((conflictResult as any).ok, false); + assert.equal((conflictResult as any).conflict, true); + assert.equal((conflictResult as any).currentVersionHash, "hash-current"); + }), + ); + + it.effect("decodes workflow board create input and rejects overlong names", () => + Effect.gen(function* () { + assert.isDefined(WorkflowCreateBoardInput); + const decodeWorkflowCreateBoardInput = Schema.decodeUnknownEffect(WorkflowCreateBoardInput); + const input = yield* decodeWorkflowCreateBoardInput({ + projectId: "project-create", + name: "Created Board", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }); + assert.equal(input.name, "Created Board"); + + const overlong = yield* Effect.exit( + decodeWorkflowCreateBoardInput({ + projectId: "project-create", + name: "A".repeat(129), + agent: { instance: "codex_main", model: "gpt-5.5" }, + }), + ); + assert.strictEqual(overlong._tag, "Failure"); + }), + ); + + it.effect("decodes workflow board rename input and rejects blank and overlong names", () => + Effect.gen(function* () { + assert.isDefined(WorkflowRenameBoardInput); + const decodeWorkflowRenameBoardInput = Schema.decodeUnknownEffect(WorkflowRenameBoardInput); + const input = yield* decodeWorkflowRenameBoardInput({ + boardId: "board-rename", + name: "Renamed Board", + }); + assert.equal(input.name, "Renamed Board"); + + const blank = yield* Effect.exit( + decodeWorkflowRenameBoardInput({ + boardId: "board-rename", + name: " ", + }), + ); + assert.strictEqual(blank._tag, "Failure"); + + const overlong = yield* Effect.exit( + decodeWorkflowRenameBoardInput({ + boardId: "board-rename", + name: "A".repeat(129), + }), + ); + assert.strictEqual(overlong._tag, "Failure"); + }), + ); +}); + +describe("StepRetryPolicy", () => { + const lanesWith = (pipeline: ReadonlyArray<unknown>) => ({ + name: "Retry board", + lanes: [ + { key: "work", name: "Work", entry: "auto", pipeline }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + + it.effect("decodes retry with escalation on agent steps", () => + Effect.gen(function* () { + const decoded = yield* decodeWorkflowDefinition( + lanesWith([ + { + key: "implement", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "Do the work.", + retry: { + maxAttempts: 3, + escalate: { + model: "opus", + options: [{ id: "effort", value: "high" }], + }, + }, + }, + ]), + ); + const step = decoded.lanes[0]?.pipeline?.[0]; + assert.ok(step?.type === "agent"); + assert.equal(step.retry?.maxAttempts, 3); + assert.equal(step.retry?.escalate?.model, "opus"); + assert.equal(step.retry?.escalate?.options?.[0]?.id, "effort"); + }), + ); + + it.effect("decodes retry on script steps", () => + Effect.gen(function* () { + const decoded = yield* decodeWorkflowDefinition( + lanesWith([ + { + key: "test", + type: "script", + run: "pnpm test", + retry: { maxAttempts: 2 }, + }, + ]), + ); + const step = decoded.lanes[0]?.pipeline?.[0]; + assert.ok(step?.type === "script"); + assert.equal(step.retry?.maxAttempts, 2); + }), + ); + + it.effect("rejects non-integer maxAttempts", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeWorkflowDefinition( + lanesWith([ + { + key: "test", + type: "script", + run: "pnpm test", + retry: { maxAttempts: 2.5 }, + }, + ]), + ), + ); + assert.strictEqual(result._tag, "Failure"); + }), + ); + + it.effect("decodes the new lint codes", () => + Effect.gen(function* () { + assert.equal(yield* decodeWorkflowLintCode("invalid_retry"), "invalid_retry"); + assert.equal( + yield* decodeWorkflowLintCode("unknown_template_placeholder"), + "unknown_template_placeholder", + ); + }), + ); +}); + +describe("StepStarted attempt", () => { + it.effect("decodes StepStarted with and without attempt", () => + Effect.gen(function* () { + const base = { + eventId: "evt-1", + ticketId: "ticket-1", + streamVersion: 1, + occurredAt: "2026-06-09T00:00:00.000Z", + type: "StepStarted", + payload: { + pipelineRunId: "pipe-1", + stepRunId: "step-1", + stepKey: "implement", + stepType: "agent", + }, + }; + const legacy = yield* decodeWorkflowEvent(base); + assert.ok(legacy.type === "StepStarted"); + assert.equal(legacy.payload.attempt, undefined); + + const retried = yield* decodeWorkflowEvent({ + ...base, + payload: { ...base.payload, attempt: 2 }, + }); + assert.ok(retried.type === "StepStarted"); + assert.equal(retried.payload.attempt, 2); + }), + ); + + it.effect("decodes step run views with attempt", () => + Effect.gen(function* () { + const view = yield* decodeWorkflowStepRunView({ + stepRunId: "step-1", + stepKey: "implement", + stepType: "agent", + attempt: 2, + status: "failed", + waitingReason: null, + blockedReason: null, + scriptThreadId: null, + terminalId: null, + scriptStatus: null, + exitCode: null, + signal: null, + }); + assert.equal(view.attempt, 2); + }), + ); +}); + +describe("MergeStep", () => { + it.effect("decodes merge steps and the merge step type", () => + Effect.gen(function* () { + const decoded = yield* decodeWorkflowDefinition({ + name: "Merge board", + lanes: [ + { + key: "land", + name: "Land", + entry: "auto", + pipeline: [ + { + key: "merge", + type: "merge", + target: "main", + commitMessage: "Land ticket work", + on: { success: "done", blocked: "needs" }, + }, + ], + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const step = decoded.lanes[0]?.pipeline?.[0]; + assert.ok(step?.type === "merge"); + assert.equal(step.target, "main"); + assert.equal(step.commitMessage, "Land ticket work"); + }), + ); + + it.effect("decodes non-retryable failed step outcomes", () => + Effect.gen(function* () { + const outcome = yield* decodeStepOutcome({ + _tag: "failed", + error: "script cancelled", + retryable: false, + }); + assert.ok(outcome._tag === "failed"); + assert.equal(outcome.retryable, false); + + const legacy = yield* decodeStepOutcome({ _tag: "failed", error: "boom" }); + assert.ok(legacy._tag === "failed"); + assert.equal(legacy.retryable, undefined); + }), + ); +}); + +describe("WorkflowLaneAction", () => { + it.effect("decodes lane actions and snapshot lanes carrying them", () => + Effect.gen(function* () { + const decoded = yield* decodeWorkflowDefinition({ + name: "Action board", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + actions: [ + { label: "Approve & land", to: "land", hint: "Merges the ticket branch." }, + { label: "Send back", to: "impl" }, + ], + }, + { key: "impl", name: "Impl", entry: "auto" }, + { key: "land", name: "Land", entry: "manual" }, + ], + }); + const review = decoded.lanes[0]; + assert.equal(review?.actions?.length, 2); + assert.equal(review?.actions?.[0]?.label, "Approve & land"); + assert.equal(review?.actions?.[1]?.hint, undefined); + + const snapshot = yield* decodeBoardSnapshot({ + projectId: "project-1", + board: { + boardId: "board-1", + name: "Action board", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + pipelineStepCount: 0, + actions: [{ label: "Approve & land", to: "land" }], + }, + ], + }, + tickets: [], + }); + assert.equal(snapshot.board.lanes[0]?.actions?.[0]?.to, "land"); + }), + ); + + it.effect("rejects overlong action labels", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeWorkflowDefinition({ + name: "Action board", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + actions: [{ label: "A".repeat(49), to: "review" }], + }, + ], + }), + ); + assert.strictEqual(result._tag, "Failure"); + }), + ); +}); + +describe("WorkflowOutboundRule", () => { + const decodeRule = Schema.decodeUnknownSync(WorkflowOutboundRule); + + it("decodes a full rule", () => { + const rule = decodeRule({ + id: "notify-blocked", + on: "blocked", + when: { "==": [{ var: "toLane" }, "needs-attention"] }, + to: "conn-abc", + as: "slack", + enabled: true, + }); + assert.equal(rule.id, "notify-blocked"); + assert.equal(rule.on, "blocked"); + assert.equal(rule.as, "slack"); + }); + + it("decodes a rule without when (optional)", () => { + const rule = decodeRule({ id: "x", on: "done", to: "conn", as: "generic", enabled: true }); + assert.strictEqual(rule.when, undefined); + }); + + it("rejects an unknown trigger", () => { + assert.throws(() => + decodeRule({ id: "x", on: "pr_merged", to: "c", as: "generic", enabled: true }), + ); + }); + + it("rejects an empty `to`", () => { + assert.throws(() => + decodeRule({ id: "x", on: "blocked", to: "", as: "generic", enabled: true }), + ); + }); + + it.effect("definition accepts an optional outbound array and defaults to undefined", () => + Effect.gen(function* () { + const def = yield* decodeWorkflowDefinition({ + name: "Standard delivery", + settings: { maxConcurrentTickets: 3 }, + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + assert.equal(def.outbound, undefined); + }), + ); +}); + +describe("WorkflowNeedsAttentionTicketView", () => { + const decode = Schema.decodeUnknownEffect(WorkflowNeedsAttentionTicketView); + + it.effect("decodes a needs-attention ticket view with attentionKind blocked", () => + Effect.gen(function* () { + const view = yield* decode({ + ticketId: "t1", + boardId: "b1", + boardName: "Delivery", + title: "Fix login", + status: "blocked", + currentLaneKey: "needs_attention", + attentionKind: "blocked", + attentionReason: "Merge conflict", + updatedAt: "2026-06-13T00:00:00.000Z", + }); + assert.equal(view.attentionKind, "blocked"); + assert.equal(view.ticketId, "t1"); + }), + ); + + it.effect("decodes a needs-attention ticket view with null attentionKind", () => + Effect.gen(function* () { + const view = yield* decode({ + ticketId: "t2", + boardId: "b1", + boardName: "Delivery", + title: "Review PR", + status: "waiting_on_user", + currentLaneKey: "review", + attentionKind: null, + attentionReason: null, + updatedAt: "2026-06-13T00:00:00.000Z", + }); + assert.equal(view.attentionKind, null); + }), + ); +}); + +describe("WorkflowBoardMetrics", () => { + const decode = Schema.decodeUnknownEffect(WorkflowBoardMetrics); + + it("getBoardMetrics method name is correct", () => { + assert.equal(WORKFLOW_WS_METHODS.getBoardMetrics, "workflow.getBoardMetrics"); + }); + + it.effect("decodes a full WorkflowBoardMetrics object", () => + Effect.gen(function* () { + const metrics = yield* decode({ + windowDays: 7, + generatedAt: "2026-06-14T00:00:00.000Z", + throughput: { created: 12, shipped: 8 }, + cycleTime: { count: 8, p50Ms: 3600000, p90Ms: 7200000, avgMs: 4000000 }, + wipByLane: [ + { laneKey: "implement", admitted: 3, queued: 1 }, + { laneKey: "review", admitted: 1, queued: 0 }, + ], + statusBreakdown: { idle: 4, running: 2, done: 6, blocked: 1 }, + attention: { + blocked: 1, + waitingOnUser: 2, + oldest: [ + { ticketId: "t1", title: "Old ticket", laneKey: "review", ageMs: 86400000 }, + { ticketId: "t2", title: "Another old ticket", laneKey: null, ageMs: 172800000 }, + ], + }, + routeOutcomes: [ + { + fromLane: "implement", + toLane: "review", + source: "lane_on", + result: "success", + count: 5, + }, + { + fromLane: null, + toLane: "implement", + source: "work_source", + result: "success", + count: 3, + }, + ], + manualMoveCount: 2, + stepStats: [ + { + laneKey: "implement", + stepKey: "code", + stepType: "agent", + succeeded: 7, + failed: 1, + retries: 2, + totalTokens: 50000, + avgDurationMs: 120000, + }, + ], + }); + + assert.equal(metrics.throughput.created, 12); + assert.equal(metrics.cycleTime.p50Ms, 3600000); + assert.equal(metrics.wipByLane[0]?.laneKey, "implement"); + assert.equal((metrics.statusBreakdown as Record<string, number>)["idle"], 4); + assert.equal(metrics.routeOutcomes[0]?.result, "success"); + assert.equal(metrics.stepStats[0]?.retries, 2); + assert.equal(metrics.attention.oldest[1]?.laneKey, null); + }), + ); +}); + +describe("importBoard contracts", () => { + it("exposes importBoard WS method", () => { + assert.equal( + (WORKFLOW_WS_METHODS as Record<string, string>).importBoard, + "workflow.importBoard", + ); + }); + + it.effect("decodes WorkflowImportBoardInput", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowImportBoardInput); + const input = yield* decode({ + projectId: "project-import", + definition: { + name: "X", + lanes: [{ key: "todo", name: "To do", entry: "manual" }], + }, + }); + assert.equal((input as any).projectId, "project-import"); + assert.equal((input as any).definition.name, "X"); + }), + ); + + it.effect("decodes WorkflowImportBoardResult ok variant", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowImportBoardResult); + const result = yield* decode({ ok: true, boardId: "p__b", warnings: [] }); + assert.equal((result as any).ok, true); + assert.equal((result as any).boardId, "p__b"); + assert.deepEqual((result as any).warnings, []); + }), + ); + + it.effect("decodes WorkflowImportBoardResult lintErrors variant", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowImportBoardResult); + const result = yield* decode({ + ok: false, + lintErrors: [ + { + code: "invalid_json_logic", + message: "Invalid transition", + laneKey: "todo", + transitionIndex: 0, + }, + ], + }); + assert.equal((result as any).ok, false); + assert.equal((result as any).lintErrors[0].code, "invalid_json_logic"); + }), + ); +}); + +describe("WorkflowBoardVersionSource", () => { + const decode = Schema.decodeUnknownEffect(WorkflowBoardVersionSource); + + it.effect("decodes existing sources", () => + Effect.gen(function* () { + assert.equal(yield* decode("create"), "create"); + assert.equal(yield* decode("save"), "save"); + assert.equal(yield* decode("revert"), "revert"); + assert.equal(yield* decode("import"), "import"); + assert.equal(yield* decode("rename"), "rename"); + }), + ); + + it.effect("decodes self-improve", () => + Effect.gen(function* () { + assert.equal(yield* decode("self-improve"), "self-improve"); + }), + ); + + it.effect("decodes self-improve-revert", () => + Effect.gen(function* () { + assert.equal(yield* decode("self-improve-revert"), "self-improve-revert"); + }), + ); +}); + +describe("WorkflowBoardProposalView", () => { + const decode = Schema.decodeUnknownEffect(WorkflowBoardProposalView); + + const exampleProposal = { + proposalId: "prop-1", + boardId: "board-abc", + status: "pending", + rationale: "Improve the backlog lane throughput.", + validation: { + preservationOk: true, + lintOk: true, + dryRunOk: true, + laneDiffCount: 1, + lintErrors: [], + dryRunRegressions: [], + messages: ["No issues found."], + }, + baseVersionHash: "abc123", + appliedVersionHash: null, + outdated: false, + agent: { instance: "claude_main", model: "claude-sonnet-4-6" }, + createdAt: "2026-06-14T00:00:00.000Z", + resolvedAt: null, + }; + + it.effect("decodes a full proposal view", () => + Effect.gen(function* () { + const result = yield* decode(exampleProposal); + assert.equal(result.proposalId, "prop-1"); + assert.equal(result.status, "pending"); + assert.equal(result.validation.lintOk, true); + assert.equal(result.appliedVersionHash, null); + assert.equal(result.resolvedAt, null); + }), + ); + + it.effect("decodes proposal with appliedVersionHash set", () => + Effect.gen(function* () { + const result = yield* decode({ + ...exampleProposal, + status: "approved", + appliedVersionHash: "def456", + resolvedAt: "2026-06-14T01:00:00.000Z", + }); + assert.equal(result.status, "approved"); + assert.equal(result.appliedVersionHash, "def456"); + assert.equal(result.resolvedAt, "2026-06-14T01:00:00.000Z"); + }), + ); + + it.effect("decodes validation with lint errors", () => + Effect.gen(function* () { + const result = yield* decode({ + ...exampleProposal, + status: "invalid", + validation: { + ...exampleProposal.validation, + lintOk: false, + lintErrors: [{ code: "duplicate_lane_key", message: "Duplicate lane key 'backlog'" }], + }, + }); + assert.equal(result.validation.lintOk, false); + assert.equal(result.validation.lintErrors[0]?.code, "duplicate_lane_key"); + }), + ); +}); + +describe("self-improve RPC shapes", () => { + const minimalDef = { + name: "Board", + lanes: [{ key: "todo", name: "Todo", entry: "manual" as const }], + }; + const encodedDef = Schema.encodeUnknownSync(WorkflowDefinitionEncoded)(minimalDef); + + const exampleProposal = { + proposalId: "prop-1", + boardId: "board-abc", + status: "pending", + rationale: "Improve throughput.", + validation: { + preservationOk: true, + lintOk: true, + dryRunOk: true, + laneDiffCount: 0, + lintErrors: [], + dryRunRegressions: [], + messages: [], + }, + baseVersionHash: "abc123", + appliedVersionHash: null, + outdated: false, + agent: { instance: "claude_main", model: "claude-sonnet-4-6" }, + createdAt: "2026-06-14T00:00:00.000Z", + resolvedAt: null, + }; + + it.effect("decodes WorkflowProposeBoardImprovementInput", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowProposeBoardImprovementInput); + const result = yield* decode({ + boardId: "board-abc", + agent: { instance: "claude_main", model: "sonnet" }, + }); + assert.equal(result.boardId, "board-abc"); + assert.equal(result.agent.instance, "claude_main"); + }), + ); + + it.effect("decodes WorkflowListBoardProposalsInput", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowListBoardProposalsInput); + const result = yield* decode({ boardId: "board-abc" }); + assert.equal(result.boardId, "board-abc"); + }), + ); + + it.effect("decodes WorkflowGetBoardProposalInput", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowGetBoardProposalInput); + const result = yield* decode({ proposalId: "prop-1" }); + assert.equal(result.proposalId, "prop-1"); + }), + ); + + it.effect("decodes WorkflowResolveBoardProposalInput with approve", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowResolveBoardProposalInput); + const result = yield* decode({ proposalId: "prop-1", action: "approve" }); + assert.equal(result.proposalId, "prop-1"); + assert.equal(result.action, "approve"); + }), + ); + + it.effect("decodes WorkflowResolveBoardProposalInput with reject", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowResolveBoardProposalInput); + const result = yield* decode({ proposalId: "prop-1", action: "reject" }); + assert.equal(result.action, "reject"); + }), + ); + + it.effect("decodes WorkflowRevertBoardProposalInput", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowRevertBoardProposalInput); + const result = yield* decode({ proposalId: "prop-1" }); + assert.equal(result.proposalId, "prop-1"); + }), + ); + + it.effect("decodes WorkflowProposeBoardImprovementResult", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowProposeBoardImprovementResult); + const result = yield* decode({ proposal: exampleProposal }); + assert.equal((result as any).proposal.proposalId, "prop-1"); + }), + ); + + it.effect("decodes WorkflowListBoardProposalsResult", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowListBoardProposalsResult); + const result = yield* decode({ proposals: [exampleProposal] }); + assert.equal((result as any).proposals.length, 1); + assert.equal((result as any).proposals[0].status, "pending"); + }), + ); + + it.effect("decodes WorkflowGetBoardProposalResult", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowGetBoardProposalResult); + const result = yield* decode({ + proposal: exampleProposal, + proposedDefinition: encodedDef, + baseDefinition: encodedDef, + }); + assert.equal((result as any).proposal.proposalId, "prop-1"); + }), + ); + + it.effect("decodes WorkflowResolveBoardProposalResult ok variant", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowResolveBoardProposalResult); + const result = yield* decode({ ok: true, proposal: exampleProposal }); + assert.equal((result as any).ok, true); + assert.equal((result as any).proposal.proposalId, "prop-1"); + }), + ); + + it.effect("decodes WorkflowResolveBoardProposalResult false/conflict variant", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowResolveBoardProposalResult); + const result = yield* decode({ + ok: false, + reason: "conflict", + message: "Conflict detected.", + }); + assert.equal((result as any).ok, false); + assert.equal((result as any).reason, "conflict"); + }), + ); + + it.effect("decodes WorkflowResolveBoardProposalResult false/lint variant", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowResolveBoardProposalResult); + const result = yield* decode({ + ok: false, + reason: "lint", + message: "Lint failed.", + lintErrors: [{ code: "duplicate_step_key", message: "Duplicate step key 'code'" }], + }); + assert.equal((result as any).ok, false); + assert.equal((result as any).reason, "lint"); + assert.equal((result as any).lintErrors[0].code, "duplicate_step_key"); + }), + ); + + it.effect("decodes WorkflowRevertBoardProposalResult ok variant", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowRevertBoardProposalResult); + const result = yield* decode({ + ok: true, + proposal: { ...exampleProposal, status: "reverted" }, + }); + assert.equal((result as any).ok, true); + assert.equal((result as any).proposal.status, "reverted"); + }), + ); + + it.effect("decodes WorkflowRevertBoardProposalResult false variant", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowRevertBoardProposalResult); + const result = yield* decode({ ok: false, reason: "invalid", message: "Already reverted." }); + assert.equal((result as any).ok, false); + assert.equal((result as any).reason, "invalid"); + }), + ); +}); + +describe("WORKFLOW_WS_METHODS self-improve entries", () => { + it("has proposeBoardImprovement method", () => { + assert.equal(WORKFLOW_WS_METHODS.proposeBoardImprovement, "workflow.proposeBoardImprovement"); + }); + + it("has listBoardProposals method", () => { + assert.equal(WORKFLOW_WS_METHODS.listBoardProposals, "workflow.listBoardProposals"); + }); + + it("has getBoardProposal method", () => { + assert.equal(WORKFLOW_WS_METHODS.getBoardProposal, "workflow.getBoardProposal"); + }); + + it("has resolveBoardProposal method", () => { + assert.equal(WORKFLOW_WS_METHODS.resolveBoardProposal, "workflow.resolveBoardProposal"); + }); + + it("has revertBoardProposal method", () => { + assert.equal(WORKFLOW_WS_METHODS.revertBoardProposal, "workflow.revertBoardProposal"); + }); +}); + +describe("WorkflowSourceConfig autoPull", () => { + it("decodes with autoPull present and enabled omitted", () => { + const s = Schema.decodeUnknownSync(WorkflowSourceConfig)({ + id: "s1", + provider: "github", + connectionRef: "c", + selector: { owner: "a", repo: "b", state: "all" }, + destinationLane: "inbox", + closedLane: "done", + autoPull: { rule: true }, + }); + assert.deepEqual(s.autoPull, { rule: true }); + assert.equal(s.enabled, undefined); + }); + + it("still decodes a legacy source with enabled and no autoPull", () => { + const s = Schema.decodeUnknownSync(WorkflowSourceConfig)({ + id: "s1", + provider: "github", + connectionRef: "c", + selector: { owner: "a", repo: "b", state: "all" }, + destinationLane: "inbox", + closedLane: "done", + enabled: true, + }); + assert.equal(s.enabled, true); + assert.equal(s.autoPull, undefined); + }); +}); diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts new file mode 100644 index 00000000000..51bdc1ae65c --- /dev/null +++ b/packages/contracts/src/workflow.ts @@ -0,0 +1,1404 @@ +import * as Schema from "effect/Schema"; + +import { + ApprovalRequestId, + IsoDateTime, + MessageId, + NonNegativeInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +import { ProviderOptionSelection } from "./model.ts"; + +export const WORKFLOW_WS_METHODS = { + listBoards: "workflow.listBoards", + createBoard: "workflow.createBoard", + deleteBoard: "workflow.deleteBoard", + renameBoard: "workflow.renameBoard", + getBoard: "workflow.getBoard", + getBoardDefinition: "workflow.getBoardDefinition", + saveBoardDefinition: "workflow.saveBoardDefinition", + listBoardVersions: "workflow.listBoardVersions", + getBoardVersion: "workflow.getBoardVersion", + subscribeBoard: "workflow.subscribeBoard", + createTicket: "workflow.createTicket", + editTicket: "workflow.editTicket", + moveTicket: "workflow.moveTicket", + runLane: "workflow.runLane", + resolveApproval: "workflow.resolveApproval", + answerTicketStep: "workflow.answerTicketStep", + postTicketMessage: "workflow.postTicketMessage", + editTicketMessage: "workflow.editTicketMessage", + setProjectScriptTrust: "workflow.setProjectScriptTrust", + cancelStep: "workflow.cancelStep", + getTicketDetail: "workflow.getTicketDetail", + getTicketDiff: "workflow.getTicketDiff", + intakeTickets: "workflow.intakeTickets", + listTicketArtifacts: "workflow.listTicketArtifacts", + getWebhookConfig: "workflow.getWebhookConfig", + getBoardDigest: "workflow.getBoardDigest", + dryRunBoard: "workflow.dryRunBoard", + listNeedsAttentionTickets: "workflow.listNeedsAttentionTickets", + listWorkSourceConnections: "workflow.listWorkSourceConnections", + createWorkSourceConnection: "workflow.createWorkSourceConnection", + deleteWorkSourceConnection: "workflow.deleteWorkSourceConnection", + listOutboundConnections: "workflow.listOutboundConnections", + createOutboundConnection: "workflow.createOutboundConnection", + deleteOutboundConnection: "workflow.deleteOutboundConnection", + getBoardMetrics: "workflow.getBoardMetrics", + importBoard: "workflow.importBoard", + proposeBoardImprovement: "workflow.proposeBoardImprovement", + listBoardProposals: "workflow.listBoardProposals", + getBoardProposal: "workflow.getBoardProposal", + resolveBoardProposal: "workflow.resolveBoardProposal", + revertBoardProposal: "workflow.revertBoardProposal", + createWorkflowBoard: "workflow.createWorkflowBoard", + generateWorkflowDraft: "workflow.generateWorkflowDraft", + listBoardTemplates: "workflow.listBoardTemplates", + listImportableWorkItems: "workflow.listImportableWorkItems", + importWorkItems: "workflow.importWorkItems", +} as const; + +const makeId = <Brand extends string>(brand: Brand) => + TrimmedNonEmptyString.pipe(Schema.brand(brand)); + +export const BoardId = makeId("BoardId"); +export type BoardId = typeof BoardId.Type; + +export const TicketId = makeId("TicketId"); +export type TicketId = typeof TicketId.Type; + +export const PipelineRunId = makeId("PipelineRunId"); +export type PipelineRunId = typeof PipelineRunId.Type; + +export const StepRunId = makeId("StepRunId"); +export type StepRunId = typeof StepRunId.Type; + +export const SetupRunId = makeId("SetupRunId"); +export type SetupRunId = typeof SetupRunId.Type; + +export const ScriptRunId = makeId("ScriptRunId"); +export type ScriptRunId = typeof ScriptRunId.Type; + +export const DispatchId = makeId("DispatchId"); +export type DispatchId = typeof DispatchId.Type; + +export const LaneEntryToken = makeId("LaneEntryToken"); +export type LaneEntryToken = typeof LaneEntryToken.Type; + +export const WorkflowEventId = makeId("WorkflowEventId"); +export type WorkflowEventId = typeof WorkflowEventId.Type; + +export const LaneKey = TrimmedNonEmptyString.pipe(Schema.brand("LaneKey")); +export type LaneKey = typeof LaneKey.Type; + +export const StepKey = TrimmedNonEmptyString.pipe(Schema.brand("StepKey")); +export type StepKey = typeof StepKey.Type; + +export const WorkflowBoardName = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export type WorkflowBoardName = typeof WorkflowBoardName.Type; + +export const StepInstruction = Schema.Union([ + Schema.String, + Schema.Struct({ file: TrimmedNonEmptyString }), +]); +export type StepInstruction = typeof StepInstruction.Type; + +export const AgentSelection = Schema.Struct({ + instance: TrimmedNonEmptyString, + model: TrimmedNonEmptyString, + // Reasoning effort / provider option selections (the same shape the chat + // composer dispatches), applied when this agent step runs. Canonical array + // form only — this is a new field, so there is no legacy object form to + // tolerate. + options: Schema.optional(Schema.Array(ProviderOptionSelection)), +}); +export type AgentSelection = typeof AgentSelection.Type; + +export const StepRetryEscalation = Schema.Struct({ + instance: Schema.optional(TrimmedNonEmptyString), + model: Schema.optional(TrimmedNonEmptyString), + options: Schema.optional(Schema.Array(ProviderOptionSelection)), +}); +export type StepRetryEscalation = typeof StepRetryEscalation.Type; + +export const StepRetryPolicy = Schema.Struct({ + // Total attempts including the first run. Lint enforces 2..5; the engine + // additionally clamps so a hand-edited file cannot retry unboundedly. + maxAttempts: Schema.Int, + escalate: Schema.optional(StepRetryEscalation), +}); +export type StepRetryPolicy = typeof StepRetryPolicy.Type; + +export const WorkflowStepType = Schema.Union([ + Schema.Literal("agent"), + Schema.Literal("approval"), + Schema.Literal("script"), + Schema.Literal("merge"), + Schema.Literal("pullRequest"), +]); +export type WorkflowStepType = typeof WorkflowStepType.Type; + +export const StepRouting = Schema.Struct({ + success: Schema.optional(LaneKey), + failure: Schema.optional(LaneKey), + blocked: Schema.optional(LaneKey), +}); +export type StepRouting = typeof StepRouting.Type; + +export const AgentStep = Schema.Struct({ + key: StepKey, + type: Schema.Literal("agent"), + agent: AgentSelection, + instruction: StepInstruction, + captureOutput: Schema.optional(Schema.Boolean), + // Resume this agent's own provider session across steps/loops within the + // lane, reusing a stable workflow threadId per (ticket, lane, agentKey). + // Capability-gated to resumable providers; incompatible with a panel. + continueSession: Schema.optional(Schema.Boolean), + // Reviewer panel: run this many independent turns of the same step and + // take the majority verdict from their captured outputs. Requires + // captureOutput; lint enforces 2..5. + panel: Schema.optional(Schema.Int), + retry: Schema.optional(StepRetryPolicy), + on: Schema.optional(StepRouting), +}); + +export const ApprovalStep = Schema.Struct({ + key: StepKey, + type: Schema.Literal("approval"), + prompt: Schema.optional(Schema.String), + on: Schema.optional(StepRouting), +}); + +export const ScriptStep = Schema.Struct({ + key: StepKey, + type: Schema.Literal("script"), + run: TrimmedNonEmptyString, + timeout: Schema.optional(Schema.DurationFromString), + cwd: Schema.optional(Schema.String), + allowFailure: Schema.optional(Schema.Boolean), + retry: Schema.optional(StepRetryPolicy), + on: Schema.optional(StepRouting), +}); +export type ScriptStep = typeof ScriptStep.Type; + +export const MergeStep = Schema.Struct({ + key: StepKey, + type: Schema.Literal("merge"), + // Branch that must be checked out at the repo root for the merge to run; + // when unset the merge lands on whatever branch is currently checked out. + // The engine never switches the user's branch. + target: Schema.optional(TrimmedNonEmptyString), + commitMessage: Schema.optional(Schema.String), + // Repo-relative working files (e.g. PLAN.md / REVIEW.md) removed from the + // worktree before the snapshot commit so they never land in the target + // branch. + cleanupPaths: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + on: Schema.optional(StepRouting), +}); +export type MergeStep = typeof MergeStep.Type; + +export const PullRequestStep = Schema.Struct({ + key: StepKey, + type: Schema.Literal("pullRequest"), + action: Schema.Literals(["open", "land"]), + // action: "open" — base defaults to the repo's default branch (resolved + // via gh at run time); templates use the standard {{ticket.*}} placeholders. + base: Schema.optional(TrimmedNonEmptyString), + draft: Schema.optional(Schema.Boolean), + titleTemplate: Schema.optional(TrimmedNonEmptyString), + bodyTemplate: Schema.optional(Schema.String), + // action: "land" + strategy: Schema.optional(Schema.Literals(["squash", "merge", "rebase"])), + deleteBranch: Schema.optional(Schema.Boolean), + on: Schema.optional(StepRouting), +}); +export type PullRequestStep = typeof PullRequestStep.Type; + +export const WorkflowStep = Schema.Union([ + AgentStep, + ApprovalStep, + ScriptStep, + MergeStep, + PullRequestStep, +]); +export type WorkflowStep = typeof WorkflowStep.Type; + +export const LaneEntry = Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]); +export type LaneEntry = typeof LaneEntry.Type; + +export const LaneRouting = Schema.Struct({ + success: Schema.optional(LaneKey), + failure: Schema.optional(LaneKey), + blocked: Schema.optional(LaneKey), +}); +export type LaneRouting = typeof LaneRouting.Type; + +export const JsonLogicRule = Schema.Unknown; +export type JsonLogicRule = typeof JsonLogicRule.Type; + +export const WorkflowLaneTransition = Schema.Struct({ + when: JsonLogicRule, + to: LaneKey, +}); +export type WorkflowLaneTransition = typeof WorkflowLaneTransition.Type; + +// A human-facing transition out of a lane, rendered as a button on tickets +// in that lane ("Approve & land", "Send back", …). Purely declarative sugar +// over moveTicket — the engine treats it like any manual move. +export const WorkflowLaneAction = Schema.Struct({ + label: TrimmedNonEmptyString.check(Schema.isMaxLength(48)), + to: LaneKey, + hint: Schema.optional(Schema.String.check(Schema.isMaxLength(160))), +}); +export type WorkflowLaneAction = typeof WorkflowLaneAction.Type; + +// An external-event matcher: when a webhook event with this name correlates +// to a ticket sitting in this lane (and the optional predicate over +// {event: {name, payload}} passes), the ticket moves to `to`. +export const WorkflowLaneEvent = Schema.Struct({ + name: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + when: Schema.optional(JsonLogicRule), + to: LaneKey, +}); +export type WorkflowLaneEvent = typeof WorkflowLaneEvent.Type; + +export const WorkflowLane = Schema.Struct({ + key: LaneKey, + name: TrimmedNonEmptyString, + entry: LaneEntry, + actions: Schema.optional(Schema.Array(WorkflowLaneAction)), + onEvent: Schema.optional(Schema.Array(WorkflowLaneEvent)), + pipeline: Schema.optional(Schema.Array(WorkflowStep)), + on: Schema.optional(LaneRouting), + transitions: Schema.optional(Schema.Array(WorkflowLaneTransition)), + wipLimit: Schema.optional(Schema.Int), + color: Schema.optional(Schema.String), + terminal: Schema.optional(Schema.Boolean), + retention: Schema.optional(Schema.DurationFromString), +}); +export type WorkflowLane = typeof WorkflowLane.Type; + +export const WorkflowSettings = Schema.Struct({ + maxConcurrentTickets: Schema.optional(Schema.Int), +}); +export type WorkflowSettings = typeof WorkflowSettings.Type; + +// ── Work-source types (defined here to avoid an import cycle: workSource.ts +// imports LaneKey from this file, so this file must not import from +// workSource.ts). workSource.ts re-exports these for convenience. ────────── + +const _SourceId = TrimmedNonEmptyString.pipe(Schema.brand("SourceId")); +/** Branded schema for a work-source identifier. `SourceId.is(v)` is a type guard. */ +export const SourceId = Object.assign(_SourceId, { is: Schema.is(_SourceId) }); +export type SourceId = typeof _SourceId.Type; + +export const WorkSourceProviderName = Schema.Literals(["github", "asana", "jira"]); +export type WorkSourceProviderName = typeof WorkSourceProviderName.Type; + +export const WorkSourceAutoPull = Schema.Struct({ rule: JsonLogicRule }); +export type WorkSourceAutoPull = typeof WorkSourceAutoPull.Type; + +export const WorkflowSourceConfig = Schema.Struct({ + id: SourceId, + provider: WorkSourceProviderName, + connectionRef: TrimmedNonEmptyString, + selector: Schema.Unknown, // validated against the provider's selector schema by lint + destinationLane: LaneKey, + closedLane: LaneKey, + enabled: Schema.optional(Schema.Boolean), + syncIntervalSec: Schema.optional(Schema.Int), + autoPull: Schema.optional(WorkSourceAutoPull), +}); +export type WorkflowSourceConfig = typeof WorkflowSourceConfig.Type; + +// ───────────────────────────────────────────────────────────────────────────── + +export const OutboundRuleId = makeId("OutboundRuleId"); +export type OutboundRuleId = typeof OutboundRuleId.Type; + +export const OutboundTrigger = Schema.Literals([ + "needs_attention", + "blocked", + "done", + "lane_entered", +]); +export type OutboundTrigger = typeof OutboundTrigger.Type; + +export const OutboundFormatter = Schema.Literals(["generic", "slack"]); +export type OutboundFormatter = typeof OutboundFormatter.Type; + +export const WorkflowOutboundRule = Schema.Struct({ + id: OutboundRuleId, + on: OutboundTrigger, + when: Schema.optional(JsonLogicRule), + to: TrimmedNonEmptyString, + as: OutboundFormatter, + enabled: Schema.Boolean, +}); +export type WorkflowOutboundRule = typeof WorkflowOutboundRule.Type; + +export const WorkflowDefinition = Schema.Struct({ + name: WorkflowBoardName, + settings: Schema.optional(WorkflowSettings), + sources: Schema.optional(Schema.Array(WorkflowSourceConfig)), + outbound: Schema.optional(Schema.Array(WorkflowOutboundRule)), + lanes: Schema.Array(WorkflowLane), +}); +export type WorkflowDefinition = typeof WorkflowDefinition.Type; + +export const WorkflowDefinitionEncoded = Schema.toEncoded(WorkflowDefinition); +export type WorkflowDefinitionEncoded = typeof WorkflowDefinitionEncoded.Type; + +export const WorkflowLintCode = Schema.Union([ + Schema.Literal("duplicate_lane_key"), + Schema.Literal("duplicate_step_key"), + Schema.Literal("missing_lane_ref"), + Schema.Literal("unknown_provider_instance"), + Schema.Literal("missing_instruction_file"), + Schema.Literal("unsafe_instruction_path"), + Schema.Literal("auto_lane_cycle"), + Schema.Literal("unreachable_terminal"), + Schema.Literal("invalid_json_logic"), + Schema.Literal("unknown_predicate_path"), + Schema.Literal("unsafe_step_key"), + Schema.Literal("invalid_wip_limit"), + Schema.Literal("invalid_retention"), + Schema.Literal("invalid_retry"), + Schema.Literal("invalid_panel"), + Schema.Literal("unknown_template_placeholder"), + Schema.Literal("invalid_step"), + Schema.Literal("invalid_source"), + Schema.Literal("duplicate_source_id"), + Schema.Literal("invalid_outbound"), + Schema.Literal("duplicate_outbound_id"), + Schema.Literal("invalid_continue_session"), + Schema.Literal("invalid_handoff_reference"), +]); +export type WorkflowLintCode = typeof WorkflowLintCode.Type; + +export const WorkflowLintError = Schema.Struct({ + code: WorkflowLintCode, + message: Schema.String, + laneKey: Schema.optional(LaneKey), + stepKey: Schema.optional(StepKey), + transitionIndex: Schema.optional(Schema.Int), +}); +export type WorkflowLintError = typeof WorkflowLintError.Type; + +export const WorkflowGetBoardDefinitionResult = Schema.Struct({ + definition: WorkflowDefinitionEncoded, + versionHash: Schema.String, +}); +export type WorkflowGetBoardDefinitionResult = typeof WorkflowGetBoardDefinitionResult.Type; + +export const WorkflowCreateBoardInput = Schema.Struct({ + projectId: ProjectId, + name: WorkflowBoardName, + agent: AgentSelection, +}); +export type WorkflowCreateBoardInput = typeof WorkflowCreateBoardInput.Type; + +export const WorkflowRenameBoardInput = Schema.Struct({ + boardId: BoardId, + name: WorkflowBoardName, +}); +export type WorkflowRenameBoardInput = typeof WorkflowRenameBoardInput.Type; + +export const WorkflowBoardVersionSource = Schema.Literals([ + "create", + "save", + "revert", + "import", + "rename", + "self-improve", + "self-improve-revert", +]); +export type WorkflowBoardVersionSource = typeof WorkflowBoardVersionSource.Type; + +export const WorkflowBoardVersionSummary = Schema.Struct({ + versionId: Schema.Int, + versionHash: Schema.String, + source: WorkflowBoardVersionSource, + createdAt: IsoDateTime, + isCurrent: Schema.Boolean, +}); +export type WorkflowBoardVersionSummary = typeof WorkflowBoardVersionSummary.Type; + +export const WorkflowGetBoardVersionResult = Schema.Struct({ + versionId: Schema.Int, + definition: WorkflowDefinitionEncoded, + versionHash: Schema.String, + source: WorkflowBoardVersionSource, + createdAt: IsoDateTime, +}); +export type WorkflowGetBoardVersionResult = typeof WorkflowGetBoardVersionResult.Type; + +const EventBase = { + eventId: WorkflowEventId, + ticketId: TicketId, + streamVersion: Schema.Int, + occurredAt: IsoDateTime, +}; + +export const TicketStatus = Schema.Union([ + Schema.Literal("idle"), + Schema.Literal("running"), + Schema.Literal("waiting_on_user"), + Schema.Literal("blocked"), + Schema.Literal("queued"), + Schema.Literal("done"), + Schema.Literal("failed"), +]); +export type TicketStatus = typeof TicketStatus.Type; + +// Intentional copy — keep in sync with WorkflowTicketAttentionKind in relay.ts. +export const WorkflowTicketAttentionKind = Schema.Literals([ + "waiting_for_approval", + "waiting_for_input", + "blocked", +]); +export type WorkflowTicketAttentionKind = typeof WorkflowTicketAttentionKind.Type; + +export const StepRunStatus = Schema.Union([ + Schema.Literal("pending"), + Schema.Literal("dispatch_requested"), + Schema.Literal("running"), + Schema.Literal("awaiting_user"), + Schema.Literal("completed"), + Schema.Literal("failed"), + Schema.Literal("blocked"), + Schema.Literal("superseded"), +]); +export type StepRunStatus = typeof StepRunStatus.Type; + +export const ScriptRunStatus = Schema.Union([ + Schema.Literal("running"), + Schema.Literal("exited"), + Schema.Literal("timeout"), + Schema.Literal("cancelled"), +]); +export type ScriptRunStatus = typeof ScriptRunStatus.Type; + +const TicketAttachmentId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +const TicketAttachmentName = TrimmedNonEmptyString.check(Schema.isMaxLength(255)); +const TicketAttachmentMimeType = TrimmedNonEmptyString.check(Schema.isMaxLength(100)); +const TicketRasterImageMimeType = Schema.Literals([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +]); +// Cap raw bytes at ~10 MiB to stay consistent with the dataUrl length cap below +// (14M base64 chars ≈ 10 MiB of raw image data) and with the server's enforced +// MAX_TICKET_ANSWER_ATTACHMENT_BYTES (10 * 1024 * 1024). A higher sizeBytes cap +// would advertise a limit that the dataUrl check and server both reject anyway. +const TicketAttachmentSizeBytes = NonNegativeInt.check( + Schema.isLessThanOrEqualTo(10 * 1024 * 1024), +); +const TicketAttachmentRef = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); +const TicketImageDataUrl = TrimmedNonEmptyString.check( + Schema.isMaxLength(14_000_000), + Schema.isPattern(/^data:image\/(?:png|jpeg|gif|webp);base64,/i), +); + +export const TicketImageAttachment = Schema.Struct({ + kind: Schema.Literal("image"), + id: TicketAttachmentId, + name: TicketAttachmentName, + mimeType: TicketRasterImageMimeType, + sizeBytes: TicketAttachmentSizeBytes, + dataUrl: TicketImageDataUrl, +}); +export type TicketImageAttachment = typeof TicketImageAttachment.Type; + +export const TicketVideoAttachment = Schema.Struct({ + kind: Schema.Literal("video"), + id: TicketAttachmentId, + name: TicketAttachmentName, + mimeType: TicketAttachmentMimeType, + sizeBytes: TicketAttachmentSizeBytes, + ref: TicketAttachmentRef, +}); +export type TicketVideoAttachment = typeof TicketVideoAttachment.Type; + +export const TicketFileAttachment = Schema.Struct({ + kind: Schema.Literal("file"), + id: TicketAttachmentId, + name: TicketAttachmentName, + mimeType: TicketAttachmentMimeType, + sizeBytes: TicketAttachmentSizeBytes, + ref: TicketAttachmentRef, +}); +export type TicketFileAttachment = typeof TicketFileAttachment.Type; + +export const TicketAttachment = Schema.Union([ + TicketImageAttachment, + TicketVideoAttachment, + TicketFileAttachment, +]); +export type TicketAttachment = typeof TicketAttachment.Type; + +export const WorkflowStepUsage = Schema.Struct({ + inputTokens: Schema.optional(NonNegativeInt), + cachedInputTokens: Schema.optional(NonNegativeInt), + outputTokens: Schema.optional(NonNegativeInt), + totalTokens: Schema.optional(NonNegativeInt), +}); +export type WorkflowStepUsage = typeof WorkflowStepUsage.Type; + +export const WorkflowEvent = Schema.Union([ + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketCreated"), + payload: Schema.Struct({ + boardId: BoardId, + title: TrimmedNonEmptyString, + laneKey: LaneKey, + description: Schema.optional(Schema.String), + // Soft cap on provider tokens this ticket may consume; agent steps + // block (not fail) once the roll-up reaches it. + tokenBudget: Schema.optional(NonNegativeInt), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketEdited"), + payload: Schema.Struct({ + title: Schema.optional(TrimmedNonEmptyString), + description: Schema.optional(Schema.String), + // Null clears the budget; absent leaves it unchanged. + tokenBudget: Schema.optional(Schema.NullOr(NonNegativeInt)), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketDependenciesSet"), + // Full-set semantics: replaces the ticket's blocked-by edges. + payload: Schema.Struct({ + dependsOn: Schema.Array(TicketId), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketMessagePosted"), + payload: Schema.Struct({ + messageId: MessageId, + stepRunId: Schema.optional(StepRunId), + author: Schema.Literals(["agent", "user"]), + body: Schema.String, + attachments: Schema.Array(TicketAttachment), + createdAt: IsoDateTime, + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketMessageEdited"), + payload: Schema.Struct({ + messageId: MessageId, + body: Schema.String, + editedAt: IsoDateTime, + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketMovedToLane"), + payload: Schema.Struct({ + toLane: LaneKey, + laneEntryToken: LaneEntryToken, + reason: Schema.Union([ + Schema.Literal("manual"), + Schema.Literal("routed"), + Schema.Literal("initial"), + Schema.Literal("external"), + ]), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketQueued"), + payload: Schema.Struct({ + lane: LaneKey, + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketAdmitted"), + payload: Schema.Struct({ + lane: LaneKey, + laneEntryToken: LaneEntryToken, + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketBlocked"), + payload: Schema.Struct({ reason: Schema.String }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("PipelineStarted"), + payload: Schema.Struct({ + pipelineRunId: PipelineRunId, + laneKey: LaneKey, + laneEntryToken: LaneEntryToken, + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("PipelineCompleted"), + payload: Schema.Struct({ + pipelineRunId: PipelineRunId, + result: Schema.Union([ + Schema.Literal("success"), + Schema.Literal("failure"), + Schema.Literal("blocked"), + Schema.Literal("superseded"), + ]), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepStarted"), + payload: Schema.Struct({ + pipelineRunId: PipelineRunId, + stepRunId: StepRunId, + stepKey: StepKey, + stepType: WorkflowStepType, + attempt: Schema.optional(Schema.Int), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepAwaitingUser"), + payload: Schema.Struct({ + stepRunId: StepRunId, + waitingReason: Schema.String, + providerThreadId: Schema.optional(ThreadId), + providerRequestId: Schema.optional(ApprovalRequestId), + providerResponseKind: Schema.optional(Schema.Literals(["request", "user-input"])), + providerQuestionId: Schema.optional(Schema.String), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepUserResolved"), + payload: Schema.Struct({ stepRunId: StepRunId }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepRefsCaptured"), + payload: Schema.Struct({ + stepRunId: StepRunId, + preRef: Schema.String, + postRef: Schema.String, + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepCompleted"), + payload: Schema.Struct({ + stepRunId: StepRunId, + output: Schema.optional(Schema.Unknown), + usage: Schema.optional(WorkflowStepUsage), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepFailed"), + payload: Schema.Struct({ + stepRunId: StepRunId, + error: Schema.String, + // Persisted so crash recovery cannot auto-retry user-initiated + // rejections/cancellations; absent means retry-eligible. + retryable: Schema.optional(Schema.Boolean), + usage: Schema.optional(WorkflowStepUsage), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepBlocked"), + payload: Schema.Struct({ stepRunId: StepRunId, reason: Schema.String }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("ScriptStepStarted"), + payload: Schema.Struct({ + scriptRunId: ScriptRunId, + stepRunId: StepRunId, + scriptThreadId: ThreadId, + terminalId: TrimmedNonEmptyString, + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("ScriptStepExited"), + payload: Schema.Struct({ + scriptRunId: ScriptRunId, + exitCode: Schema.NullOr(Schema.Int), + signal: Schema.NullOr(Schema.Int), + outcome: Schema.Union([ + Schema.Literal("exited"), + Schema.Literal("timeout"), + Schema.Literal("cancelled"), + ]), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketRouted"), + payload: Schema.Struct({ fromLane: LaneKey, toLane: LaneKey }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketRouteDecided"), + payload: Schema.Struct({ + // Absent for external events — they have no pipeline run. + pipelineRunId: Schema.optional(PipelineRunId), + fromLane: LaneKey, + toLane: LaneKey, + source: Schema.Union([ + Schema.Literal("step_on"), + Schema.Literal("lane_transition"), + Schema.Literal("lane_on"), + Schema.Literal("external_event"), + Schema.Literal("work_source"), + ]), + matchedTransitionIndex: Schema.optional(Schema.Int), + contextSnapshot: Schema.Unknown, + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketPrOpened"), + payload: Schema.Struct({ + stepRunId: StepRunId, + prNumber: Schema.Int, + url: Schema.String, + branch: Schema.String, + remoteName: Schema.String, + repo: Schema.String, // owner/name resolved at open time + }), + }), +]); +export type WorkflowEvent = typeof WorkflowEvent.Type; + +export const StepOutcome = Schema.Union([ + Schema.TaggedStruct("completed", { + output: Schema.optional(Schema.Unknown), + usage: Schema.optional(WorkflowStepUsage), + }), + Schema.TaggedStruct("failed", { + error: Schema.String, + // false marks failures that must never be auto-retried (user-initiated + // cancellations); absent/true failures are eligible for step retry. + retryable: Schema.optional(Schema.Boolean), + usage: Schema.optional(WorkflowStepUsage), + }), + Schema.TaggedStruct("blocked", { reason: Schema.String }), + Schema.TaggedStruct("awaiting_user", { + waitingReason: Schema.String, + providerThreadId: Schema.optional(ThreadId), + providerRequestId: Schema.optional(ApprovalRequestId), + providerResponseKind: Schema.optional(Schema.Literals(["request", "user-input"])), + providerQuestionId: Schema.optional(Schema.String), + }), +]); +export type StepOutcome = typeof StepOutcome.Type; + +export const TicketDiffFile = Schema.Struct({ + path: Schema.String, + additions: Schema.Int, + deletions: Schema.Int, +}); +export type TicketDiffFile = typeof TicketDiffFile.Type; + +export const TicketDiff = Schema.Struct({ + ticketId: TicketId, + baseRef: Schema.String, + patch: Schema.String, + files: Schema.Array(TicketDiffFile), + truncated: Schema.Boolean, +}); +export type TicketDiff = typeof TicketDiff.Type; + +export const TicketPrView = Schema.Struct({ + number: Schema.Int, + url: Schema.String, + state: Schema.Literals(["open", "merged", "closed"]), + ciState: Schema.optional(Schema.Literals(["pending", "success", "failure"])), +}); +export type TicketPrView = typeof TicketPrView.Type; + +export const WorkflowLaneActionView = Schema.Struct({ + label: Schema.String, + to: LaneKey, + hint: Schema.optional(Schema.String), +}); +export type WorkflowLaneActionView = typeof WorkflowLaneActionView.Type; + +export const WorkflowCurrentLaneView = Schema.Struct({ + key: LaneKey, + name: Schema.String, + actions: Schema.Array(WorkflowLaneActionView), +}); +export type WorkflowCurrentLaneView = typeof WorkflowCurrentLaneView.Type; + +export const BoardTicketView = Schema.Struct({ + ticketId: TicketId, + boardId: BoardId, + title: Schema.String, + description: Schema.optional(Schema.String), + currentLaneKey: LaneKey, + status: TicketStatus, + queuedAt: Schema.optional(Schema.String), + totalTokens: Schema.optional(NonNegativeInt), + totalDurationMs: Schema.optional(NonNegativeInt), + dependsOn: Schema.optional(Schema.Array(TicketId)), + // Dependencies whose ticket has not reached a terminal lane yet. Admission + // skips the ticket while this is > 0. + unresolvedDependencyCount: Schema.optional(NonNegativeInt), + tokenBudget: Schema.optional(NonNegativeInt), + // Last projection update — drives "waiting on you for N hours" aging. + updatedAt: Schema.optional(Schema.String), + pr: Schema.optional(TicketPrView), + // Attention fields — present when the ticket needs human attention. + attentionKind: Schema.optional(WorkflowTicketAttentionKind), + attentionReason: Schema.optional(Schema.String), + // Current lane detail — present when the server includes it for attention views. + currentLane: Schema.optional(WorkflowCurrentLaneView), +}); +export type BoardTicketView = typeof BoardTicketView.Type; + +export const WorkflowNeedsAttentionTicketView = Schema.Struct({ + ticketId: TicketId, + boardId: BoardId, + boardName: Schema.String, + title: Schema.String, + status: TicketStatus, + currentLaneKey: LaneKey, + attentionKind: Schema.NullOr(WorkflowTicketAttentionKind), + attentionReason: Schema.NullOr(Schema.String), + updatedAt: Schema.String, +}); +export type WorkflowNeedsAttentionTicketView = typeof WorkflowNeedsAttentionTicketView.Type; + +export const WorkflowTicketMessageView = Schema.Struct({ + messageId: MessageId, + ticketId: TicketId, + stepRunId: Schema.optional(StepRunId), + author: Schema.Literals(["agent", "user"]), + body: Schema.String, + attachments: Schema.Array(TicketAttachment), + createdAt: IsoDateTime, + editedAt: Schema.optional(IsoDateTime), +}); +export type WorkflowTicketMessageView = typeof WorkflowTicketMessageView.Type; + +export const BoardSnapshot = Schema.Struct({ + projectId: ProjectId, + board: Schema.Struct({ + boardId: BoardId, + name: Schema.String, + lanes: Schema.Array( + Schema.Struct({ + key: LaneKey, + name: Schema.String, + entry: LaneEntry, + pipelineStepCount: Schema.Int, + wipLimit: Schema.optional(Schema.Int), + terminal: Schema.optional(Schema.Boolean), + actions: Schema.optional(Schema.Array(WorkflowLaneAction)), + }), + ), + }), + tickets: Schema.Array(BoardTicketView), +}); +export type BoardSnapshot = typeof BoardSnapshot.Type; + +export const WorkflowSaveBoardDefinitionInput = Schema.Struct({ + boardId: BoardId, + definition: WorkflowDefinitionEncoded, + expectedVersionHash: Schema.String, + source: Schema.optional( + Schema.Literals(["save", "revert", "self-improve", "self-improve-revert"]), + ), +}); +export type WorkflowSaveBoardDefinitionInput = typeof WorkflowSaveBoardDefinitionInput.Type; + +// The two ok:false members share the ok:false discriminant and are disambiguated +// only by their distinct required fields: the lint member by the required +// `lintErrors`, the conflict member by the required `conflict: true` + +// `currentVersionHash` (neither object validates the other member). Consumers rely +// on this (WorkflowEditor.tsx discriminates via `"lintErrors" in` / `"conflict" in`). +// INVARIANT: keep `lintErrors` required and `conflict`/`currentVersionHash` +// required — making either optional would let a conflict result decode as an empty +// lint result and silently drop the optimistic-concurrency signal. +export const WorkflowSaveBoardDefinitionResult = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + definition: WorkflowDefinitionEncoded, + versionHash: Schema.String, + snapshot: BoardSnapshot, + }), + Schema.Struct({ + ok: Schema.Literal(false), + lintErrors: Schema.Array(WorkflowLintError), + }), + Schema.Struct({ + ok: Schema.Literal(false), + conflict: Schema.Literal(true), + currentVersionHash: Schema.String, + }), +]); +export type WorkflowSaveBoardDefinitionResult = typeof WorkflowSaveBoardDefinitionResult.Type; + +export const WorkflowImportBoardInput = Schema.Struct({ + projectId: ProjectId, + definition: WorkflowDefinitionEncoded, +}); +export type WorkflowImportBoardInput = typeof WorkflowImportBoardInput.Type; + +export const WorkflowImportBoardResult = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + boardId: BoardId, + warnings: Schema.Array(Schema.String), + }), + Schema.Struct({ ok: Schema.Literal(false), lintErrors: Schema.Array(WorkflowLintError) }), +]); +export type WorkflowImportBoardResult = typeof WorkflowImportBoardResult.Type; + +export const BoardListEntry = Schema.Struct({ + boardId: BoardId, + name: Schema.String, + filePath: Schema.String, + error: Schema.NullOr(Schema.String), +}); +export type BoardListEntry = typeof BoardListEntry.Type; + +export const BoardStreamItem = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("snapshot"), snapshot: BoardSnapshot }), + Schema.Struct({ kind: Schema.Literal("ticket"), ticket: BoardTicketView }), +]); +export type BoardStreamItem = typeof BoardStreamItem.Type; + +export const WorkflowStepRunView = Schema.Struct({ + stepRunId: StepRunId, + stepKey: StepKey, + stepType: WorkflowStepType, + attempt: Schema.optional(Schema.Int), + status: StepRunStatus, + waitingReason: Schema.NullOr(Schema.String), + blockedReason: Schema.NullOr(Schema.String), + providerResponseKind: Schema.optional(Schema.NullOr(Schema.Literals(["request", "user-input"]))), + scriptThreadId: Schema.NullOr(ThreadId), + terminalId: Schema.NullOr(Schema.String), + scriptStatus: Schema.NullOr(ScriptRunStatus), + exitCode: Schema.NullOr(Schema.Int), + signal: Schema.NullOr(Schema.Int), + output: Schema.optional(Schema.Unknown), + startedAt: Schema.optional(IsoDateTime), + finishedAt: Schema.optional(IsoDateTime), + usage: Schema.optional(WorkflowStepUsage), + // Latest dispatch thread for agent steps — lets the UI stream the live + // provider activity for a running step. + providerThreadId: Schema.optional(ThreadId), +}); +export type WorkflowStepRunView = typeof WorkflowStepRunView.Type; + +export const WorkflowRouteStepSnapshotView = Schema.Struct({ + status: Schema.String, + exitCode: Schema.optional(Schema.Int), + // Bounded highlight of the captured output — never the raw payload, which + // can be arbitrarily large and is already visible on the step run itself. + verdict: Schema.optional(Schema.String), +}); +export type WorkflowRouteStepSnapshotView = typeof WorkflowRouteStepSnapshotView.Type; + +/** + * One entry in a ticket's routing history — why the ticket arrived in a lane. + * Automatic routes carry the decision source and the routing-context snapshot + * highlights; manual moves only record the destination. + */ +export const WorkflowRouteDecisionView = Schema.Struct({ + occurredAt: IsoDateTime, + fromLane: Schema.optional(LaneKey), + toLane: LaneKey, + source: Schema.Literals([ + "step_on", + "lane_transition", + "lane_on", + "manual", + "external_event", + "work_source", + ]), + matchedTransitionIndex: Schema.optional(Schema.Int), + // For external_event decisions: the inbound event name. + eventName: Schema.optional(Schema.String), + pipelineResult: Schema.optional(Schema.Literals(["success", "failure", "blocked"])), + laneRunCount: Schema.optional(Schema.Int), + steps: Schema.optional(Schema.Record(Schema.String, WorkflowRouteStepSnapshotView)), +}); +export type WorkflowRouteDecisionView = typeof WorkflowRouteDecisionView.Type; + +// A ticket the intake agent proposes from a braindump; the user reviews and +// approves before anything is created. +export const WorkflowTicketProposal = Schema.Struct({ + title: TrimmedNonEmptyString.check(Schema.isMaxLength(200)), + description: Schema.optional(Schema.String.check(Schema.isMaxLength(4000))), + // Indices of EARLIER proposals in the same intake result this one depends + // on — backward references only, so the proposed set can never contain a + // cycle. The client maps indices to created TicketIds on approval. + dependsOn: Schema.optional(Schema.Array(NonNegativeInt)), +}); +export type WorkflowTicketProposal = typeof WorkflowTicketProposal.Type; + +export const WorkflowIntakeResult = Schema.Struct({ + proposals: Schema.Array(WorkflowTicketProposal), +}); +export type WorkflowIntakeResult = typeof WorkflowIntakeResult.Type; + +export const WorkflowIntakeBraindump = TrimmedNonEmptyString.check(Schema.isMaxLength(20_000)); +export type WorkflowIntakeBraindump = typeof WorkflowIntakeBraindump.Type; + +// A scratch file from .t3/ticket/<id>/ in the ticket's worktree — the +// ticket's case file (PLAN.md, SPEC.md, REVIEW.md, ...). +export const WorkflowTicketArtifact = Schema.Struct({ + name: TrimmedNonEmptyString, + content: Schema.String, + truncated: Schema.optional(Schema.Boolean), +}); +export type WorkflowTicketArtifact = typeof WorkflowTicketArtifact.Type; + +export const WorkflowTicketArtifactsResult = Schema.Struct({ + artifacts: Schema.Array(WorkflowTicketArtifact), +}); +export type WorkflowTicketArtifactsResult = typeof WorkflowTicketArtifactsResult.Type; + +// Webhook ingress config for a board. The plaintext token appears ONLY in +// the response that created/rotated it; thereafter only the prefix. +export const WorkflowWebhookConfig = Schema.Struct({ + path: Schema.String, + hasToken: Schema.Boolean, + tokenPrefix: Schema.optional(Schema.String), + token: Schema.optional(Schema.String), +}); +export type WorkflowWebhookConfig = typeof WorkflowWebhookConfig.Type; + +// ── Per-board metrics dashboard ────────────────────────────────────────────── + +export const BoardMetricsCycleTime = Schema.Struct({ + count: Schema.Number, + p50Ms: Schema.Number, + p90Ms: Schema.Number, + avgMs: Schema.Number, +}); +export type BoardMetricsCycleTime = typeof BoardMetricsCycleTime.Type; + +export const BoardMetricsLaneWip = Schema.Struct({ + laneKey: Schema.String, + admitted: Schema.Number, + queued: Schema.Number, +}); +export type BoardMetricsLaneWip = typeof BoardMetricsLaneWip.Type; + +export const BoardMetricsOldest = Schema.Struct({ + ticketId: Schema.String, + title: Schema.String, + laneKey: Schema.NullOr(Schema.String), + ageMs: Schema.Number, +}); +export type BoardMetricsOldest = typeof BoardMetricsOldest.Type; + +export const BoardMetricsRouteOutcome = Schema.Struct({ + fromLane: Schema.NullOr(Schema.String), + toLane: Schema.NullOr(Schema.String), + source: Schema.String, + result: Schema.String, + count: Schema.Number, +}); +export type BoardMetricsRouteOutcome = typeof BoardMetricsRouteOutcome.Type; + +export const BoardMetricsStep = Schema.Struct({ + laneKey: Schema.String, + stepKey: Schema.String, + stepType: Schema.String, + succeeded: Schema.Number, + failed: Schema.Number, + retries: Schema.Number, + totalTokens: Schema.Number, + avgDurationMs: Schema.Number, +}); +export type BoardMetricsStep = typeof BoardMetricsStep.Type; + +export const WorkflowBoardMetrics = Schema.Struct({ + windowDays: Schema.Number, + generatedAt: Schema.String, + throughput: Schema.Struct({ created: Schema.Number, shipped: Schema.Number }), + cycleTime: BoardMetricsCycleTime, + wipByLane: Schema.Array(BoardMetricsLaneWip), + statusBreakdown: Schema.Record(Schema.String, Schema.Number), + attention: Schema.Struct({ + blocked: Schema.Number, + waitingOnUser: Schema.Number, + oldest: Schema.Array(BoardMetricsOldest), + }), + routeOutcomes: Schema.Array(BoardMetricsRouteOutcome), + manualMoveCount: Schema.Number, + stepStats: Schema.Array(BoardMetricsStep), +}); +export type WorkflowBoardMetrics = typeof WorkflowBoardMetrics.Type; + +// ───────────────────────────────────────────────────────────────────────────── + +// What happened on a board in the last window — the "stand-up" summary. +export const WorkflowBoardDigest = Schema.Struct({ + windowHours: NonNegativeInt, + createdCount: NonNegativeInt, + shippedCount: NonNegativeInt, + totalTokens: NonNegativeInt, + totalDurationMs: NonNegativeInt, + needsAttention: Schema.Array( + Schema.Struct({ + ticketId: TicketId, + title: Schema.String, + status: Schema.String, + laneKey: LaneKey, + sinceMs: NonNegativeInt, + }), + ), +}); +export type WorkflowBoardDigest = typeof WorkflowBoardDigest.Type; + +// Simulated routing for a hypothetical ticket: which lanes it would visit +// under a uniform step-outcome scenario, and why each hop happened. +export const WorkflowDryRunScenario = Schema.Literals(["success", "failure", "blocked"]); +export type WorkflowDryRunScenario = typeof WorkflowDryRunScenario.Type; + +export const WorkflowDryRunHop = Schema.Struct({ + fromLane: LaneKey, + toLane: LaneKey, + source: Schema.Literals(["step_on", "lane_transition", "lane_on"]), + // Which pipeline step's on-route decided the hop (step_on only). + viaStepKey: Schema.optional(StepKey), + // Match the Schema.Int used by every other matchedTransitionIndex field + // (TicketRouteDecided, WorkflowRouteDecisionView) rather than the narrower + // NonNegativeInt, so the constraint is consistent across the conceptual field. + matchedTransitionIndex: Schema.optional(Schema.Int), + result: WorkflowDryRunScenario, +}); +export type WorkflowDryRunHop = typeof WorkflowDryRunHop.Type; + +export const WorkflowDryRunEnd = Schema.Literals([ + // The walk reached a terminal lane. + "terminal", + // The walk reached a manual lane — a human (action/move/event) continues it. + "manual", + // The pipeline finished but nothing routed; the ticket would sit in the lane. + "no_route", + // The walk kept cycling and hit the hop cap — likely an unbounded loop. + "cycle_cap", +]); +export type WorkflowDryRunEnd = typeof WorkflowDryRunEnd.Type; + +export const WorkflowDryRunResult = Schema.Struct({ + startLane: LaneKey, + scenario: WorkflowDryRunScenario, + hops: Schema.Array(WorkflowDryRunHop), + end: WorkflowDryRunEnd, + endLane: LaneKey, + // Transitions whose predicates referenced data a dry run cannot know + // (captured outputs, ticket fields) — evaluated against an empty context. + notes: Schema.Array(Schema.String), +}); +export type WorkflowDryRunResult = typeof WorkflowDryRunResult.Type; + +export const WorkflowTicketDetailView = Schema.Struct({ + ticket: BoardTicketView, + steps: Schema.Array(WorkflowStepRunView), + messages: Schema.Array(WorkflowTicketMessageView), + routeHistory: Schema.optional(Schema.Array(WorkflowRouteDecisionView)), + syncedSource: Schema.optional( + Schema.Struct({ + provider: WorkSourceProviderName, + url: TrimmedNonEmptyString, + assignees: Schema.optional(Schema.Array(Schema.String)), + labels: Schema.optional(Schema.Array(Schema.String)), + }), + ), +}); +export type WorkflowTicketDetailView = typeof WorkflowTicketDetailView.Type; + +// --------------------------------------------------------------------------- +// Self-improve: board proposal schemas +// --------------------------------------------------------------------------- + +export const WorkflowProposalStatus = Schema.Literals([ + "pending", + "approved", + "rejected", + "superseded", + "invalid", + "reverted", +]); +export type WorkflowProposalStatus = typeof WorkflowProposalStatus.Type; + +export const WorkflowProposalValidation = Schema.Struct({ + preservationOk: Schema.Boolean, + lintOk: Schema.Boolean, + dryRunOk: Schema.Boolean, + laneDiffCount: Schema.Number, + lintErrors: Schema.Array(WorkflowLintError), + dryRunRegressions: Schema.Array(Schema.String), + messages: Schema.Array(Schema.String), +}); +export type WorkflowProposalValidation = typeof WorkflowProposalValidation.Type; + +export const WorkflowBoardProposalView = Schema.Struct({ + proposalId: Schema.String, + boardId: BoardId, + status: WorkflowProposalStatus, + rationale: Schema.String, + validation: WorkflowProposalValidation, + baseVersionHash: Schema.String, + appliedVersionHash: Schema.NullOr(Schema.String), + outdated: Schema.Boolean, + agent: AgentSelection, + createdAt: Schema.String, + resolvedAt: Schema.NullOr(Schema.String), +}); +export type WorkflowBoardProposalView = typeof WorkflowBoardProposalView.Type; + +// RPC input shapes + +export const WorkflowProposeBoardImprovementInput = Schema.Struct({ + boardId: BoardId, + agent: AgentSelection, +}); +export type WorkflowProposeBoardImprovementInput = typeof WorkflowProposeBoardImprovementInput.Type; + +export const WorkflowListBoardProposalsInput = Schema.Struct({ boardId: BoardId }); +export type WorkflowListBoardProposalsInput = typeof WorkflowListBoardProposalsInput.Type; + +export const WorkflowGetBoardProposalInput = Schema.Struct({ proposalId: Schema.String }); +export type WorkflowGetBoardProposalInput = typeof WorkflowGetBoardProposalInput.Type; + +export const WorkflowResolveBoardProposalInput = Schema.Struct({ + proposalId: Schema.String, + action: Schema.Literals(["approve", "reject"]), +}); +export type WorkflowResolveBoardProposalInput = typeof WorkflowResolveBoardProposalInput.Type; + +export const WorkflowRevertBoardProposalInput = Schema.Struct({ proposalId: Schema.String }); +export type WorkflowRevertBoardProposalInput = typeof WorkflowRevertBoardProposalInput.Type; + +// RPC result shapes + +export const WorkflowProposeBoardImprovementResult = Schema.Struct({ + proposal: WorkflowBoardProposalView, +}); +export type WorkflowProposeBoardImprovementResult = + typeof WorkflowProposeBoardImprovementResult.Type; + +export const WorkflowListBoardProposalsResult = Schema.Struct({ + proposals: Schema.Array(WorkflowBoardProposalView), +}); +export type WorkflowListBoardProposalsResult = typeof WorkflowListBoardProposalsResult.Type; + +export const WorkflowGetBoardProposalResult = Schema.Struct({ + proposal: WorkflowBoardProposalView, + proposedDefinition: WorkflowDefinitionEncoded, + baseDefinition: WorkflowDefinitionEncoded, +}); +export type WorkflowGetBoardProposalResult = typeof WorkflowGetBoardProposalResult.Type; + +export const WorkflowResolveBoardProposalResult = Schema.Union([ + Schema.Struct({ ok: Schema.Literal(true), proposal: WorkflowBoardProposalView }), + Schema.Struct({ + ok: Schema.Literal(false), + reason: Schema.Literals(["conflict", "live_tickets", "lint", "invalid"]), + message: Schema.String, + lintErrors: Schema.optional(Schema.Array(WorkflowLintError)), + }), +]); +export type WorkflowResolveBoardProposalResult = typeof WorkflowResolveBoardProposalResult.Type; + +export const WorkflowRevertBoardProposalResult = WorkflowResolveBoardProposalResult; +export type WorkflowRevertBoardProposalResult = typeof WorkflowRevertBoardProposalResult.Type; + +// ── Create-workflow wizard ───────────────────────────────────────────────── + +export const BoardTemplateSummary = Schema.Struct({ + id: Schema.String, + name: Schema.String, + description: Schema.String, + requiresAgent: Schema.Boolean, +}); +export type BoardTemplateSummary = typeof BoardTemplateSummary.Type; + +export const WorkflowCreateChoice = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("empty") }), + Schema.Struct({ + kind: Schema.Literal("template"), + templateId: Schema.String, + agent: Schema.optional(AgentSelection), + }), + Schema.Struct({ kind: Schema.Literal("definition"), definition: WorkflowDefinitionEncoded }), +]); +export type WorkflowCreateChoice = typeof WorkflowCreateChoice.Type; + +export const WorkflowCreateWorkflowBoardInput = Schema.Struct({ + projectId: ProjectId, + name: WorkflowBoardName, + choice: WorkflowCreateChoice, +}); +export type WorkflowCreateWorkflowBoardInput = typeof WorkflowCreateWorkflowBoardInput.Type; + +export const WorkflowCreateWorkflowBoardResult = Schema.Union([ + Schema.Struct({ ok: Schema.Literal(true), boardId: BoardId }), + Schema.Struct({ + ok: Schema.Literal(false), + lintErrors: Schema.Array(WorkflowLintError), + message: Schema.optional(Schema.String), + }), +]); +export type WorkflowCreateWorkflowBoardResult = typeof WorkflowCreateWorkflowBoardResult.Type; + +export const WorkflowGenerateWorkflowDraftInput = Schema.Struct({ + projectId: ProjectId, + name: WorkflowBoardName, + // A generous "how you work" description cap — bounds the free text sent to + // the LLM so a runaway client payload can't drive unbounded token cost. + description: TrimmedNonEmptyString.check(Schema.isMaxLength(4000)), + agent: AgentSelection, +}); +export type WorkflowGenerateWorkflowDraftInput = typeof WorkflowGenerateWorkflowDraftInput.Type; + +export const WorkflowGenerateWorkflowDraftResult = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + definition: WorkflowDefinitionEncoded, + rationale: Schema.String, + }), + Schema.Struct({ + ok: Schema.Literal(false), + lintErrors: Schema.optional(Schema.Array(WorkflowLintError)), + message: Schema.String, + }), +]); +export type WorkflowGenerateWorkflowDraftResult = typeof WorkflowGenerateWorkflowDraftResult.Type; + +export const WorkflowListBoardTemplatesResult = Schema.Struct({ + templates: Schema.Array(BoardTemplateSummary), +}); +export type WorkflowListBoardTemplatesResult = typeof WorkflowListBoardTemplatesResult.Type; + +export class WorkflowRpcError extends Schema.TaggedErrorClass<WorkflowRpcError>()( + "WorkflowRpcError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/workflowRpc.test.ts b/packages/contracts/src/workflowRpc.test.ts new file mode 100644 index 00000000000..06d4c911534 --- /dev/null +++ b/packages/contracts/src/workflowRpc.test.ts @@ -0,0 +1,522 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + AuthEnvironmentScope, + AuthStandardClientScopes, + AuthWorkflowOperateScope, + AuthWorkflowReadScope, +} from "./auth.ts"; +import { + BoardStreamItem, + BoardTemplateSummary, + WORKFLOW_WS_METHODS, + WorkflowCreateChoice, + WorkflowCreateWorkflowBoardInput, + WorkflowCreateWorkflowBoardResult, + WorkflowGenerateWorkflowDraftInput, + WorkflowGenerateWorkflowDraftResult, + WorkflowListBoardTemplatesResult, + WorkflowRpcError, + WsWorkflowAnswerTicketStepRpc, + WsWorkflowCreateTicketRpc, + WsWorkflowDeleteBoardRpc, + WsWorkflowEditTicketRpc, + WsWorkflowGetBoardDefinitionRpc, + WsWorkflowGetBoardVersionRpc, + WsWorkflowListBoardVersionsRpc, + WsWorkflowGetTicketDiffRpc, + WsWorkflowRenameBoardRpc, + WsWorkflowSaveBoardDefinitionRpc, + WsWorkflowSubscribeBoardRpc, + WsWorkflowListOutboundConnectionsRpc, + WsWorkflowCreateOutboundConnectionRpc, + WsWorkflowDeleteOutboundConnectionRpc, + WsWorkflowGetBoardMetricsRpc, + WsWorkflowCreateWorkflowBoardRpc, + WsWorkflowGenerateWorkflowDraftRpc, + WsWorkflowListBoardTemplatesRpc, +} from "./index.ts"; + +const decodeAuthScope = Schema.decodeUnknownEffect(AuthEnvironmentScope); +const decodeBoardStreamItem = Schema.decodeUnknownEffect(BoardStreamItem); +const decodeAnswerTicketStepPayload = Schema.decodeUnknownEffect( + WsWorkflowAnswerTicketStepRpc.payloadSchema, +); +const decodeEditTicketPayload = Schema.decodeUnknownEffect(WsWorkflowEditTicketRpc.payloadSchema); +const decodeCreateTicketPayload = Schema.decodeUnknownEffect( + WsWorkflowCreateTicketRpc.payloadSchema, +); +const decodeSaveBoardPayload = Schema.decodeUnknownEffect( + WsWorkflowSaveBoardDefinitionRpc.payloadSchema, +); + +describe("workflow RPC contracts", () => { + it("declares workflow websocket method names", () => { + assert.equal(WORKFLOW_WS_METHODS.createTicket, "workflow.createTicket"); + assert.equal(WORKFLOW_WS_METHODS.deleteBoard, "workflow.deleteBoard"); + assert.equal(WORKFLOW_WS_METHODS.renameBoard, "workflow.renameBoard"); + assert.equal(WORKFLOW_WS_METHODS.getBoardDefinition, "workflow.getBoardDefinition"); + assert.equal(WORKFLOW_WS_METHODS.saveBoardDefinition, "workflow.saveBoardDefinition"); + assert.equal(WORKFLOW_WS_METHODS.listBoardVersions, "workflow.listBoardVersions"); + assert.equal(WORKFLOW_WS_METHODS.getBoardVersion, "workflow.getBoardVersion"); + assert.equal(WORKFLOW_WS_METHODS.subscribeBoard, "workflow.subscribeBoard"); + assert.equal(WORKFLOW_WS_METHODS.getTicketDiff, "workflow.getTicketDiff"); + assert.equal(WORKFLOW_WS_METHODS.answerTicketStep, "workflow.answerTicketStep"); + assert.equal(WORKFLOW_WS_METHODS.editTicket, "workflow.editTicket"); + }); + + it.effect("decodes board snapshots for subscription streams", () => + Effect.gen(function* () { + const item = yield* decodeBoardStreamItem({ + kind: "snapshot", + snapshot: { + projectId: "project-1", + board: { + boardId: "board-1", + name: "Delivery", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual", pipelineStepCount: 0 }], + }, + tickets: [ + { + ticketId: "ticket-1", + boardId: "board-1", + title: "Ship workflow UI", + currentLaneKey: "backlog", + status: "idle", + }, + ], + }, + }); + + assert.equal(item.kind, "snapshot"); + if (item.kind === "snapshot") { + assert.equal(item.snapshot.tickets[0]?.title, "Ship workflow UI"); + } + }), + ); + + it.effect("adds workflow scopes to the environment and standard client grants", () => + Effect.gen(function* () { + assert.equal(yield* decodeAuthScope(AuthWorkflowReadScope), AuthWorkflowReadScope); + assert.equal(yield* decodeAuthScope(AuthWorkflowOperateScope), AuthWorkflowOperateScope); + assert.isTrue(AuthStandardClientScopes.includes(AuthWorkflowReadScope)); + assert.isTrue(AuthStandardClientScopes.includes(AuthWorkflowOperateScope)); + }), + ); + + it("exports workflow RPC definitions and error type", () => { + assert.isDefined(WsWorkflowCreateTicketRpc); + assert.isDefined(WsWorkflowDeleteBoardRpc); + assert.isDefined(WsWorkflowRenameBoardRpc); + assert.isDefined(WsWorkflowGetBoardDefinitionRpc); + assert.isDefined(WsWorkflowSaveBoardDefinitionRpc); + assert.isDefined(WsWorkflowListBoardVersionsRpc); + assert.isDefined(WsWorkflowGetBoardVersionRpc); + assert.isDefined(WsWorkflowSubscribeBoardRpc); + assert.isDefined(WsWorkflowAnswerTicketStepRpc); + assert.isDefined(WsWorkflowEditTicketRpc); + assert.isDefined(WsWorkflowGetTicketDiffRpc); + assert.equal(new WorkflowRpcError({ message: "workflow failed" })._tag, "WorkflowRpcError"); + }); + + it.effect("decodes ticket collaboration RPC payloads", () => + Effect.gen(function* () { + const answer = yield* decodeAnswerTicketStepPayload({ + stepRunId: "sr-1", + text: "Use the sandbox account.", + attachments: [ + { + kind: "image", + id: "img-1", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 1200, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + }); + const edit = yield* decodeEditTicketPayload({ + ticketId: "ticket-1", + title: "Clarify provider routing", + description: "", + }); + + assert.equal(answer.text, "Use the sandbox account."); + assert.equal(answer.attachments?.[0]?.kind, "image"); + assert.equal(edit.description, ""); + }), + ); + + it.effect("bounds createTicket title and description length at decode", () => + Effect.gen(function* () { + // A reasonable ticket decodes fine. + const ok = yield* decodeCreateTicketPayload({ + boardId: "board-1", + title: "Ship the workflow UI", + description: "A normal description.", + initialLane: "backlog", + }); + assert.equal(ok.title, "Ship the workflow UI"); + + // Title over 200 chars is rejected. + const longTitle = yield* Effect.exit( + decodeCreateTicketPayload({ + boardId: "board-1", + title: "x".repeat(201), + initialLane: "backlog", + }), + ); + assert.strictEqual(longTitle._tag, "Failure"); + + // Description over 4000 chars is rejected. + const longDescription = yield* Effect.exit( + decodeCreateTicketPayload({ + boardId: "board-1", + title: "fine", + description: "y".repeat(4001), + initialLane: "backlog", + }), + ); + assert.strictEqual(longDescription._tag, "Failure"); + + // Empty/whitespace title is rejected (mirrors the engine's TicketCreated schema). + const emptyTitle = yield* Effect.exit( + decodeCreateTicketPayload({ + boardId: "board-1", + title: " ", + initialLane: "backlog", + }), + ); + assert.strictEqual(emptyTitle._tag, "Failure"); + + // editTicket title is bounded the same way. + const longEditTitle = yield* Effect.exit( + decodeEditTicketPayload({ ticketId: "ticket-1", title: "z".repeat(201) }), + ); + assert.strictEqual(longEditTitle._tag, "Failure"); + }), + ); + + it.effect("requires the loaded board version when saving board definitions", () => + Effect.gen(function* () { + const payload = yield* decodeSaveBoardPayload({ + boardId: "board-1", + definition: { + name: "Delivery", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }, + expectedVersionHash: "hash-before", + }); + + assert.equal((payload as any).expectedVersionHash, "hash-before"); + + const missingVersion = yield* Effect.exit( + decodeSaveBoardPayload({ + boardId: "board-1", + definition: { + name: "Delivery", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }, + }), + ); + assert.strictEqual(missingVersion._tag, "Failure"); + }), + ); + + it("declares outbound connection websocket method names", () => { + assert.equal(WORKFLOW_WS_METHODS.listOutboundConnections, "workflow.listOutboundConnections"); + assert.equal(WORKFLOW_WS_METHODS.createOutboundConnection, "workflow.createOutboundConnection"); + assert.equal(WORKFLOW_WS_METHODS.deleteOutboundConnection, "workflow.deleteOutboundConnection"); + }); + + it("exports outbound connection RPC definitions", () => { + assert.isDefined(WsWorkflowListOutboundConnectionsRpc); + assert.isDefined(WsWorkflowCreateOutboundConnectionRpc); + assert.isDefined(WsWorkflowDeleteOutboundConnectionRpc); + }); + + it("declares getBoardMetrics websocket method name", () => { + assert.equal(WORKFLOW_WS_METHODS.getBoardMetrics, "workflow.getBoardMetrics"); + }); + + it("exports getBoardMetrics RPC definition", () => { + assert.isDefined(WsWorkflowGetBoardMetricsRpc); + }); + + it.effect("decodes getBoardMetrics request payload and response", () => + Effect.gen(function* () { + const decodePayload = Schema.decodeUnknownEffect(WsWorkflowGetBoardMetricsRpc.payloadSchema); + const decodeSuccess = Schema.decodeUnknownEffect(WsWorkflowGetBoardMetricsRpc.successSchema); + + const payload = yield* decodePayload({ boardId: "board-1", windowDays: 7 }); + assert.equal(payload.boardId, "board-1"); + assert.equal(payload.windowDays, 7); + + const payloadDefault = yield* decodePayload({ boardId: "board-2" }); + assert.equal(payloadDefault.boardId, "board-2"); + assert.isUndefined(payloadDefault.windowDays); + + const success = yield* decodeSuccess({ + windowDays: 7, + generatedAt: "2026-06-14T00:00:00.000Z", + throughput: { created: 3, shipped: 2 }, + cycleTime: { count: 2, p50Ms: 50000, p90Ms: 90000, avgMs: 70000 }, + wipByLane: [], + statusBreakdown: { idle: 5, running: 1 }, + attention: { blocked: 0, waitingOnUser: 1, oldest: [] }, + routeOutcomes: [], + manualMoveCount: 0, + stepStats: [], + }); + assert.equal(success.windowDays, 7); + assert.equal(success.throughput.shipped, 2); + assert.equal(success.cycleTime.p50Ms, 50000); + }), + ); + + it.effect("decodes outbound connection RPC payloads and responses", () => + Effect.gen(function* () { + const decodeCreatePayload = Schema.decodeUnknownEffect( + WsWorkflowCreateOutboundConnectionRpc.payloadSchema, + ); + const decodeListSuccess = Schema.decodeUnknownEffect( + WsWorkflowListOutboundConnectionsRpc.successSchema, + ); + const decodeCreateSuccess = Schema.decodeUnknownEffect( + WsWorkflowCreateOutboundConnectionRpc.successSchema, + ); + const decodeDeletePayload = Schema.decodeUnknownEffect( + WsWorkflowDeleteOutboundConnectionRpc.payloadSchema, + ); + + const createPayload = yield* decodeCreatePayload({ + kind: "webhook", + displayName: "CI Alerts", + url: "https://hooks.example.com/notify", + }); + assert.equal(createPayload.kind, "webhook"); + assert.equal(createPayload.displayName, "CI Alerts"); + + const listSuccess = yield* decodeListSuccess({ + connections: [ + { + connectionRef: "conn-1", + kind: "slack", + displayName: "Eng alerts", + createdAt: "2026-06-14T00:00:00.000Z", + }, + ], + }); + assert.equal(listSuccess.connections[0]?.kind, "slack"); + + const createSuccess = yield* decodeCreateSuccess({ + connection: { + connectionRef: "conn-2", + kind: "webhook", + displayName: "CI Alerts", + createdAt: "2026-06-14T00:00:00.000Z", + }, + }); + assert.equal(createSuccess.connection.connectionRef, "conn-2"); + + const deletePayload = yield* decodeDeletePayload({ connectionRef: "conn-1" }); + assert.equal(deletePayload.connectionRef, "conn-1"); + }), + ); + + it("declares create-workflow wizard websocket method names", () => { + assert.equal(WORKFLOW_WS_METHODS.createWorkflowBoard, "workflow.createWorkflowBoard"); + assert.equal(WORKFLOW_WS_METHODS.generateWorkflowDraft, "workflow.generateWorkflowDraft"); + assert.equal(WORKFLOW_WS_METHODS.listBoardTemplates, "workflow.listBoardTemplates"); + }); + + it.effect("decodes BoardTemplateSummary", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(BoardTemplateSummary); + const result = yield* decode({ + id: "kanban-basic", + name: "Basic Kanban", + description: "A simple kanban board with three lanes.", + requiresAgent: false, + }); + assert.equal(result.id, "kanban-basic"); + assert.equal(result.name, "Basic Kanban"); + assert.isFalse(result.requiresAgent); + }), + ); + + it.effect("decodes all three WorkflowCreateChoice variants", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowCreateChoice); + + const empty = yield* decode({ kind: "empty" }); + assert.equal(empty.kind, "empty"); + + const template = yield* decode({ + kind: "template", + templateId: "kanban-basic", + agent: { instance: "default", model: "claude-opus-4-5" }, + }); + assert.equal(template.kind, "template"); + if (template.kind === "template") { + assert.equal(template.templateId, "kanban-basic"); + assert.equal(template.agent?.instance, "default"); + } + + const templateNoAgent = yield* decode({ kind: "template", templateId: "sprint-review" }); + assert.equal(templateNoAgent.kind, "template"); + + const definition = yield* decode({ + kind: "definition", + definition: { + name: "Custom Board", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }], + }, + }); + assert.equal(definition.kind, "definition"); + }), + ); + + it.effect("decodes WorkflowCreateWorkflowBoardInput and result variants", () => + Effect.gen(function* () { + const decodeInput = Schema.decodeUnknownEffect(WorkflowCreateWorkflowBoardInput); + const decodeResult = Schema.decodeUnknownEffect(WorkflowCreateWorkflowBoardResult); + + const input = yield* decodeInput({ + projectId: "proj-1", + name: "My Board", + choice: { kind: "empty" }, + }); + assert.equal(input.projectId, "proj-1"); + assert.equal(input.name, "My Board"); + assert.equal(input.choice.kind, "empty"); + + const ok = yield* decodeResult({ ok: true, boardId: "board-new" }); + assert.isTrue(ok.ok); + if (ok.ok) assert.equal(ok.boardId, "board-new"); + + const fail = yield* decodeResult({ + ok: false, + lintErrors: [{ code: "duplicate_lane_key", message: "dup" }], + }); + assert.isFalse(fail.ok); + }), + ); + + it.effect("decodes WorkflowGenerateWorkflowDraftInput and result variants", () => + Effect.gen(function* () { + const decodeInput = Schema.decodeUnknownEffect(WorkflowGenerateWorkflowDraftInput); + const decodeResult = Schema.decodeUnknownEffect(WorkflowGenerateWorkflowDraftResult); + + const input = yield* decodeInput({ + projectId: "proj-1", + name: "AI Board", + description: "A board for managing AI coding tasks.", + agent: { instance: "default", model: "claude-opus-4-5" }, + }); + assert.equal(input.name, "AI Board"); + assert.equal(input.description, "A board for managing AI coding tasks."); + assert.equal(input.agent.instance, "default"); + + const ok = yield* decodeResult({ + ok: true, + definition: { + name: "AI Board", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }], + }, + rationale: "Generated a simple kanban layout.", + }); + assert.isTrue(ok.ok); + if (ok.ok) assert.equal(ok.rationale, "Generated a simple kanban layout."); + + const fail = yield* decodeResult({ + ok: false, + message: "Model refused to generate.", + }); + assert.isFalse(fail.ok); + if (!fail.ok) assert.equal(fail.message, "Model refused to generate."); + }), + ); + + it("exports create-workflow wizard RPC definitions", () => { + assert.isDefined(WsWorkflowCreateWorkflowBoardRpc); + assert.isDefined(WsWorkflowGenerateWorkflowDraftRpc); + assert.isDefined(WsWorkflowListBoardTemplatesRpc); + }); + + it.effect( + "decodes create-workflow wizard RPC payloads and results through the group definitions", + () => + Effect.gen(function* () { + const decodeCreatePayload = Schema.decodeUnknownEffect( + WsWorkflowCreateWorkflowBoardRpc.payloadSchema, + ); + const decodeCreateSuccess = Schema.decodeUnknownEffect( + WsWorkflowCreateWorkflowBoardRpc.successSchema, + ); + const decodeDraftPayload = Schema.decodeUnknownEffect( + WsWorkflowGenerateWorkflowDraftRpc.payloadSchema, + ); + const decodeTemplatesSuccess = Schema.decodeUnknownEffect( + WsWorkflowListBoardTemplatesRpc.successSchema, + ); + + const createPayload = yield* decodeCreatePayload({ + projectId: "proj-1", + name: "My Board", + choice: { kind: "empty" }, + }); + assert.equal(createPayload.choice.kind, "empty"); + + const createSuccess = yield* decodeCreateSuccess({ ok: true, boardId: "board-new" }); + assert.isTrue(createSuccess.ok); + + const draftPayload = yield* decodeDraftPayload({ + projectId: "proj-1", + name: "AI Board", + description: "A board for managing AI coding tasks.", + agent: { instance: "default", model: "claude-opus-4-5" }, + }); + assert.equal(draftPayload.name, "AI Board"); + + const templatesSuccess = yield* decodeTemplatesSuccess({ + templates: [ + { + id: "kanban-basic", + name: "Basic Kanban", + description: "Three-lane kanban.", + requiresAgent: false, + }, + ], + }); + assert.equal(templatesSuccess.templates.length, 1); + }), + ); + + it.effect("decodes WorkflowListBoardTemplatesResult", () => + Effect.gen(function* () { + const decode = Schema.decodeUnknownEffect(WorkflowListBoardTemplatesResult); + const result = yield* decode({ + templates: [ + { + id: "kanban-basic", + name: "Basic Kanban", + description: "Three-lane kanban.", + requiresAgent: false, + }, + { + id: "agent-sprint", + name: "Agent Sprint", + description: "Sprint board with AI steps.", + requiresAgent: true, + }, + ], + }); + assert.equal(result.templates.length, 2); + assert.equal(result.templates[0]?.id, "kanban-basic"); + assert.isTrue(result.templates[1]?.requiresAgent); + }), + ); +}); diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index f031b48d19c..ff65d6cb824 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -253,11 +253,19 @@ export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: CodexAppServerClientOptions = {}, ): Layer.Layer<CodexAppServerClient> => - Layer.effect(CodexAppServerClient, makeChildProcessClient(handle, options)); + // The caller owns the handle and may consume stderr itself — draining it + // here would compete for the same chunks and drop diagnostics. + Layer.effect(CodexAppServerClient, makeChildProcessClient(handle, options, false)); const makeChildProcessClient = Effect.fn( "effect-codex-app-server/CodexAppServerClient.makeChildProcessClient", -)(function* (handle: ChildProcessSpawner.ChildProcessHandle, options: CodexAppServerClientOptions) { - yield* Stream.runDrain(handle.stderr).pipe(Effect.ignore, Effect.forkScoped); +)(function* ( + handle: ChildProcessSpawner.ChildProcessHandle, + options: CodexAppServerClientOptions, + drainStderr: boolean, +) { + if (drainStderr) { + yield* Stream.runDrain(handle.stderr).pipe(Effect.ignore, Effect.forkScoped); + } return yield* make(makeChildStdio(handle), options, makeTerminationError(handle)); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad811a254d4..c229e1410f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,10 +177,10 @@ importers: dependencies: '@callstack/liquid-glass': specifier: ^0.7.1 - version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': specifier: ^3.4.1 - version: 3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) @@ -189,10 +189,10 @@ importers: version: 0.4.2 '@expo/ui': specifier: ~56.0.8 - version: 56.0.15(961c4aa6f32829b318e3c87ef20ad401) + version: 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) '@legendapp/list': specifier: 3.0.0-beta.44 - version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -204,7 +204,7 @@ importers: version: 1.3.0-beta.4(patch_hash=0befe84f4202720eeab6fa684d8761a5c0cc7046b58cf2c0b804ad2baa7ce631)(@shikijs/themes@3.23.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@shikijs/core': specifier: 3.23.0 version: 3.23.0 @@ -225,7 +225,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578) + version: file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -246,40 +246,40 @@ importers: version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) expo: specifier: ^56.0.0 - version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-asset: specifier: ~56.0.15 - version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-auth-session: specifier: ~56.0.12 - version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-build-properties: specifier: ~56.0.15 version: 56.0.16(expo@56.0.8) expo-camera: specifier: ~56.0.7 - version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-clipboard: specifier: ~56.0.3 - version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-constants: specifier: ~56.0.16 - version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) expo-dev-client: specifier: ~56.0.16 - version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-file-system: specifier: ~56.0.7 - version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-font: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-glass-effect: specifier: ~56.0.4 - version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: specifier: ~56.0.3 version: 56.0.3(expo@56.0.8) @@ -288,16 +288,16 @@ importers: version: 56.0.15(expo@56.0.8) expo-linking: specifier: ~56.0.12 - version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-notifications: specifier: ~56.0.14 - version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-paste-input: specifier: ^0.1.15 - version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-router: specifier: ~56.2.7 - version: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) + version: 56.2.8(c021de11d02907bd585610408f5252e8) expo-secure-store: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) @@ -306,16 +306,16 @@ importers: version: 56.0.10(expo@56.0.8)(typescript@6.0.3) expo-symbols: specifier: ~56.0.5 - version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-updates: specifier: ~56.0.17 - version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-web-browser: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-widgets: specifier: ~56.0.15 - version: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) + version: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) punycode: specifier: ^2.3.1 version: 2.3.1 @@ -327,40 +327,40 @@ importers: version: 19.2.3(react@19.2.3) react-native: specifier: 0.85.3 - version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-gesture-handler: specifier: ~2.31.1 - version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-image-viewing: specifier: ^0.2.2 - version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.6 - version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 - version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: specifier: ^0.35.4 - version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: specifier: 4.3.1 - version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-safe-area-context: specifier: ~5.7.0 - version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.9 - version: 0.3.10(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.3.10(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-svg: specifier: 15.15.4 - version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-worklets: specifier: 0.8.3 - version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) shiki: specifier: 3.23.0 version: 3.23.0 @@ -369,7 +369,7 @@ importers: version: 3.6.0 uniwind: specifier: ^1.6.2 - version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) + version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 @@ -419,6 +419,9 @@ importers: effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + json-logic-js: + specifier: ^2.0.5 + version: 2.0.5 node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -7065,6 +7068,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-logic-js@2.0.5: + resolution: {integrity: sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==} + json-schema-to-ts@3.1.1: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} @@ -10819,10 +10825,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@capsizecss/unpack@4.0.0': dependencies: @@ -10894,23 +10900,23 @@ snapshots: - react - react-dom - '@clerk/expo@3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/expo@3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: '@clerk/clerk-js': 6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) tslib: 2.8.1 optionalDependencies: - expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: 56.0.4(expo@56.0.8) expo-secure-store: 56.0.4(expo@56.0.8) - expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-dom: 19.2.3(react@19.2.3) '@clerk/react@6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': @@ -11477,7 +11483,7 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.38': {} - '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': + '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 56.0.9(typescript@6.0.3) @@ -11487,7 +11493,7 @@ snapshots: '@expo/image-utils': 0.10.1(typescript@6.0.3) '@expo/inline-modules': 0.0.10(typescript@6.0.3) '@expo/json-file': 10.2.0 - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/metro-file-map': 56.0.3 @@ -11512,7 +11518,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server: 56.0.4 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -11538,8 +11544,8 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -11601,18 +11607,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: chalk: 4.1.2 optionalDependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@expo/env@2.3.0': dependencies: @@ -11673,13 +11679,13 @@ snapshots: - supports-color - typescript - '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 '@expo/metro-config@56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6)': @@ -11709,7 +11715,7 @@ snapshots: postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -11727,14 +11733,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: @@ -11809,14 +11815,14 @@ snapshots: '@expo/router-server@56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 react: 19.2.3 optionalDependencies: - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -11831,18 +11837,18 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@56.0.15(961c4aa6f32829b318e3c87ef20ad401)': + '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: '@babel/core': 7.29.7 react-dom: 19.2.3(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -12103,13 +12109,13 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@legendapp/list@3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: @@ -12932,15 +12938,15 @@ snapshots: prompts: 2.4.2 tinyexec: 1.2.4 - '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@react-native/assets-registry@0.85.3': {} @@ -13000,7 +13006,7 @@ snapshots: tinyglobby: 0.2.17 yargs: 17.7.2 - '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) debug: 4.4.3 @@ -13010,7 +13016,7 @@ snapshots: metro-core: 0.84.4 semver: 7.8.1 optionalDependencies: - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) transitivePeerDependencies: - bufferutil - supports-color @@ -13058,7 +13064,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/metro-config@0.85.3(@babel/core@7.29.7)': dependencies: '@react-native/js-polyfills': 0.85.3 '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) @@ -13066,18 +13072,16 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.85.3': {} - '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: '@types/react': 19.2.16 @@ -13483,15 +13487,15 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984)': dependencies: - expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: 56.0.3(expo@56.0.8) - expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} @@ -14608,8 +14612,8 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-widgets: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) transitivePeerDependencies: - '@babel/core' - supports-color @@ -15482,29 +15486,29 @@ snapshots: expo-application@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: expo-application: 56.0.3(expo@56.0.8) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: 56.0.4(expo@56.0.8) - expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color @@ -15512,119 +15516,119 @@ snapshots: expo-build-properties@56.0.16(expo@56.0.8): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 semver: 7.8.1 - expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@types/emscripten' - expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/env': 2.3.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color expo-crypto@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu-interface: 56.0.1(expo@56.0.8) expo-manifests: 56.0.4(expo@56.0.8) expo-updates-interface: 56.0.2(expo@56.0.8) transitivePeerDependencies: - react-native - expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-manifests: 56.0.4(expo@56.0.8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-dev-menu-interface@56.0.1(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-menu-interface: 56.0.1(expo@56.0.8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-eas-client@56.0.1: {} - expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) fontfaceobserver: 2.3.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-haptics@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-picker@56.0.15(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader: 56.0.3(expo@56.0.8) expo-json-utils@56.0.0: {} expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color expo-manifests@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-json-utils: 56.0.0 expo-modules-autolinking@56.0.14(typescript@6.0.3): @@ -15637,61 +15641,61 @@ snapshots: - supports-color - typescript - expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/expo-modules-macros-plugin': 0.0.9 - expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-application: 56.0.3(expo@56.0.8) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-router@56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310): + expo-router@56.2.8(c021de11d02907bd585610408f5252e8): dependencies: - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) + '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 - expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 @@ -15699,18 +15703,18 @@ snapshots: react: 19.2.3 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(0e9729601f58a7a7ae26c76fe6017455) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -15722,7 +15726,7 @@ snapshots: expo-secure-store@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server@56.0.4: {} @@ -15730,7 +15734,7 @@ snapshots: dependencies: '@expo/config-plugins': 56.0.8(typescript@6.0.3) '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -15738,20 +15742,20 @@ snapshots: expo-structured-headers@56.0.0: {} - expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 expo-updates-interface@56.0.2(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/plist': 0.7.0 @@ -15759,7 +15763,7 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-eas-client: 56.0.1 expo-manifests: 56.0.4(expo@56.0.8) expo-structured-headers: 56.0.0 @@ -15769,25 +15773,25 @@ snapshots: ignore: 5.3.2 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 optionalDependencies: - expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) transitivePeerDependencies: - supports-color - expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-widgets@56.0.16(961c4aa6f32829b318e3c87ef20ad401): + expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584): dependencies: '@expo/plist': 0.7.0 - '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@babel/core' - '@types/react' @@ -15796,35 +15800,35 @@ snapshots: - react-native-reanimated - react-native-worklets - expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): + expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/config': 56.0.9(typescript@6.0.3) '@expo/config-plugins': 56.0.8(typescript@6.0.3) - '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/fingerprint': 0.19.3 '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) '@ungap/structured-clone': 1.3.1 babel-preset-expo: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2) - expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3) expo-modules-autolinking: 56.0.14(typescript@6.0.3) - expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - '@babel/core' @@ -16633,6 +16637,8 @@ snapshots: json-buffer@3.0.1: {} + json-logic-js@2.0.5: {} + json-schema-to-ts@3.1.1: dependencies: '@babel/runtime': 7.29.7 @@ -18223,95 +18229,95 @@ snapshots: transitivePeerDependencies: - supports-color - react-native-drawer-layout@4.2.4(0e9729601f58a7a7ae26c76fe6017455): + react-native-drawer-layout@4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: color: 4.2.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) use-latest-callback: 0.2.6(react@19.2.3) - react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) optionalDependencies: - react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) semver: 7.8.1 - react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-shiki-engine@0.3.10(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-shiki-engine@0.3.10(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@shikijs/types': 3.23.0 '@shikijs/vscode-textmate': 10.0.2 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) whatwg-url-without-unicode: 8.0.0-3 - react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) @@ -18323,23 +18329,23 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) convert-source-map: 2.0.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) semver: 7.8.1 transitivePeerDependencies: - supports-color - react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): + react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): dependencies: '@react-native/assets-registry': 0.85.3 '@react-native/codegen': 0.85.3(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@react-native/gradle-plugin': 0.85.3 '@react-native/js-polyfills': 0.85.3 '@react-native/normalize-colors': 0.85.3 - '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -19372,14 +19378,14 @@ snapshots: universalify@2.0.1: {} - uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): + uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 culori: 4.0.2 lightningcss: 1.30.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) tailwindcss: 4.3.0 unpipe@1.0.0: {}