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