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/.t3/boards/delivery.json b/.t3/boards/delivery.json
new file mode 100644
index 00000000000..4c10b28411b
--- /dev/null
+++ b/.t3/boards/delivery.json
@@ -0,0 +1,73 @@
+{
+ "name": "Standard delivery",
+ "settings": {
+ "maxConcurrentTickets": 3
+ },
+ "lanes": [
+ {
+ "key": "backlog",
+ "name": "Backlog",
+ "entry": "manual"
+ },
+ {
+ "key": "implement",
+ "name": "Implement",
+ "entry": "auto",
+ "pipeline": [
+ {
+ "key": "code",
+ "type": "agent",
+ "agent": {
+ "instance": "codex",
+ "model": "gpt-5.5",
+ "options": [
+ {
+ "id": "reasoningEffort",
+ "value": "xhigh"
+ }
+ ]
+ },
+ "instruction": "Implement the requested ticket in this worktree. Keep the change focused, run the relevant checks, and report the verification evidence.",
+ "captureOutput": true
+ },
+ {
+ "key": "review",
+ "type": "agent",
+ "agent": {
+ "instance": "codex",
+ "model": "gpt-5.5",
+ "options": [
+ {
+ "id": "reasoningEffort",
+ "value": "medium"
+ }
+ ]
+ },
+ "instruction": "Review the accumulated diff for blocking correctness, reliability, or integration issues. List only issues that must be fixed before the ticket can ship.",
+ "captureOutput": true
+ }
+ ],
+ "on": {
+ "success": "owner_review",
+ "failure": "needs_attention",
+ "blocked": "needs_attention"
+ }
+ },
+ {
+ "key": "owner_review",
+ "name": "Owner Review",
+ "entry": "manual"
+ },
+ {
+ "key": "needs_attention",
+ "name": "Needs Attention",
+ "entry": "manual"
+ },
+ {
+ "key": "done",
+ "name": "Done",
+ "entry": "manual",
+ "terminal": true
+ }
+ ]
+}
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 136e141fdcf..d3b31bbd1b7 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -86,6 +86,16 @@ function AppNavigator() {
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..30883feaac6
--- /dev/null
+++ b/apps/mobile/src/features/agent-awareness/notificationPayload.test.ts
@@ -0,0 +1,290 @@
+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..b255f7edd79 100644
--- a/apps/mobile/src/features/agent-awareness/notificationPayload.ts
+++ b/apps/mobile/src/features/agent-awareness/notificationPayload.ts
@@ -69,21 +69,80 @@ 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)}`;
+}
+
+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/NeedsYouInboxScreen.tsx b/apps/mobile/src/features/board/NeedsYouInboxScreen.tsx
new file mode 100644
index 00000000000..10b406c0632
--- /dev/null
+++ b/apps/mobile/src/features/board/NeedsYouInboxScreen.tsx
@@ -0,0 +1,190 @@
+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 { buildTicketRoutePath } from "../../lib/routes";
+import { getEnvironmentClient } from "../../state/environment-session-registry";
+import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
+
+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 [refreshing, setRefreshing] = useState(false);
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ const environmentIds = useMemo(
+ () => Object.keys(environmentStateById).map((id) => EnvironmentId.make(id)),
+ [environmentStateById],
+ );
+
+ const load = useCallback(
+ async (isActive: () => boolean) => {
+ if (isActive()) {
+ setError(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 (!isActive()) {
+ return;
+ }
+
+ aggregated.sort((a, b) => Date.parse(b.ticket.updatedAt) - Date.parse(a.ticket.updatedAt));
+ setRows(aggregated);
+ if (aggregated.length === 0 && failures.length > 0) {
+ setError(failures[0] ?? "Failed to load tickets.");
+ }
+ },
+ [environmentIds],
+ );
+
+ useFocusEffect(
+ useCallback(() => {
+ let cancelled = false;
+ const isActive = () => !cancelled && mountedRef.current;
+ setLoading(true);
+ void load(isActive).finally(() => {
+ if (isActive()) {
+ setLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [load]),
+ );
+
+ const onRefresh = useCallback(() => {
+ setRefreshing(true);
+ void load(() => mountedRef.current).finally(() => {
+ if (mountedRef.current) {
+ setRefreshing(false);
+ }
+ });
+ }, [load]);
+
+ return (
+
+
+ }
+ >
+ Needs you
+
+ {!loading && rows.length === 0 ? (
+
+
+
+ ) : 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)}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/board/TicketActionSheetScreen.tsx b/apps/mobile/src/features/board/TicketActionSheetScreen.tsx
new file mode 100644
index 00000000000..88f934a38db
--- /dev/null
+++ b/apps/mobile/src/features/board/TicketActionSheetScreen.tsx
@@ -0,0 +1,408 @@
+import { Stack, useLocalSearchParams } from "expo-router";
+import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
+import { 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]);
+
+ const runMutation = useCallback(
+ async (mutate: () => Promise) => {
+ setBusy(true);
+ setMutationError(null);
+ try {
+ await mutate();
+ await refetch();
+ } catch (error) {
+ setMutationError(error instanceof Error ? error.message : "Action failed.");
+ } finally {
+ setBusy(false);
+ }
+ },
+ [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/ticketAffordance.test.ts b/apps/mobile/src/features/board/ticketAffordance.test.ts
new file mode 100644
index 00000000000..089bb775dc3
--- /dev/null
+++ b/apps/mobile/src/features/board/ticketAffordance.test.ts
@@ -0,0 +1,268 @@
+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("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..18508b54a05
--- /dev/null
+++ b/apps/mobile/src/features/board/ticketAffordance.ts
@@ -0,0 +1,118 @@
+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");
+ 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 8ef9784ba7f..dcf97ed2b79 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -30,6 +30,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..5e1994bbf68 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";
@@ -351,28 +351,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/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts
index bee861677a5..a02d25c5163 100644
--- a/apps/server/src/git/GitManager.test.ts
+++ b/apps/server/src/git/GitManager.test.ts
@@ -610,6 +610,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..dc4715606a4
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/033_WorkflowSchema.test.ts
@@ -0,0 +1,466 @@
+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_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: "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: "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 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 = cols.map((c) => c.name);
+ assert.isTrue(colNames.includes("attention_kind"), "attention_kind column missing");
+ assert.isTrue(colNames.includes("attention_reason"), "attention_reason column 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", "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 = objects.map((o) => o.name);
+ assert.isTrue(indexNames.includes("idx_work_source_mapping_external"));
+ assert.isTrue(indexNames.includes("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);
+ }),
+ );
+});
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..e6a50bb200c
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/033_WorkflowSchema.ts
@@ -0,0 +1,413 @@
+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,
+ 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)
+ 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
+ )
+ `;
+
+ // 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
+ )
+ `;
+ 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,
+ 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)
+ )
+ `;
+
+ // --- 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_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)
+ `;
+ 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)
+ `;
+ 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)
+ `;
+
+ // --- 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
+ `;
+});
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/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..c4ccf58e458 100644
--- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
@@ -1891,6 +1891,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 38b77c69262..40336b3c3ce 100644
--- a/apps/server/src/provider/Layers/ClaudeAdapter.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts
@@ -770,6 +770,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)) {
@@ -788,7 +789,7 @@ function applyClaudeTaskToolResult(
blockedBy: new Set(readStringArray(task.blockedBy)),
});
}
- return tasks.size > 0;
+ return tasks.size > 0 || hadTasks;
}
if (tool.toolName === "TaskCreate") {
@@ -1925,13 +1926,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(
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 89d7421b232..7262ac476c0 100644
--- a/apps/server/src/provider/Layers/CodexProvider.ts
+++ b/apps/server/src/provider/Layers/CodexProvider.ts
@@ -142,16 +142,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/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
index 3f483d8fd7e..e33e0971ae6 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);
+ });
}
})(),
}),
@@ -460,20 +494,124 @@ 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("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 54444ce586d..31fea152ddd 100644
--- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts
+++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts
@@ -80,6 +80,16 @@ 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.
+ */
+ suppressSettleEventsUntilBusy: boolean;
/**
* One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`.
* The session lifecycle is owned by `sessionScope`; this Ref exists only
@@ -874,6 +884,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,
@@ -896,6 +908,13 @@ 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.
+ break;
+ }
+
if (event.properties.status.type === "idle" && turnId) {
context.activeTurnId = undefined;
yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true });
@@ -915,6 +934,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;
@@ -1124,6 +1149,7 @@ export function makeOpenCodeAdapter(
activeTurnId: undefined,
activeAgent: undefined,
activeVariant: undefined,
+ suppressSettleEventsUntilBusy: false,
stopped: yield* Ref.make(false),
sessionScope: started.sessionScope,
};
@@ -1197,6 +1223,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;
@@ -1235,10 +1267,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;
@@ -1278,11 +1323,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: {
@@ -1290,6 +1342,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/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts
index bbfbd236ad0..e9eb32caf16 100644
--- a/apps/server/src/relay/AgentAwarenessRelay.test.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts
@@ -453,6 +453,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 = {
@@ -619,6 +620,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 960f27e752b..634d80d6c42 100644
--- a/apps/server/src/relay/AgentAwarenessRelay.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.ts
@@ -354,7 +354,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 0bf2f6589f0..a1c68431dc8 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,
@@ -65,6 +66,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";
@@ -107,6 +109,7 @@ import {
ProjectSetupScriptRunnerError,
type ProjectSetupScriptRunnerShape,
} from "./project/Services/ProjectSetupScriptRunner.ts";
+import { ProjectScriptTrust } from "./workflow/Services/ProjectScriptTrust.ts";
import {
RepositoryIdentityResolver,
type RepositoryIdentityResolverShape,
@@ -136,6 +139,19 @@ 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 } from "./workflow/Services/WorkSourceProvider.ts";
import * as Data from "effect/Data";
const defaultProjectId = ProjectId.make("project-default");
@@ -535,6 +551,75 @@ 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 })),
+ create: () => Effect.die("unused work-source connection create"),
+ list: () => Effect.succeed([]),
+ remove: () => Effect.void,
+ }),
+ );
const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, {
disableListenLog: true,
@@ -719,6 +804,7 @@ const buildAppUnderTest = (options?: {
...options?.layers?.checkpointDiffQuery,
}),
),
+ Layer.provide(workflowRouteServicesLayer),
);
const appLayer = servedRoutesLayer.pipe(
@@ -910,9 +996,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 }
@@ -1409,10 +1493,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");
@@ -1430,16 +1511,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 b7331f95e6e..a6387efda41 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 {
attachmentsRouteLayer,
otlpTracesProxyRouteLayer,
@@ -17,6 +18,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";
@@ -76,6 +78,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,
@@ -189,6 +192,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),
@@ -267,13 +279,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(TerminalLayerLive),
Layer.provideMerge(PersistenceLayerLive),
Layer.provideMerge(KeybindingsLive),
@@ -301,6 +327,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
Layer.provideMerge(ProjectFaviconResolverLive),
Layer.provideMerge(RepositoryIdentityResolverLive),
Layer.provideMerge(ServerEnvironmentLive),
+);
+
+const RuntimeCoreDependenciesLive = RuntimeCoreEngineLive.pipe(
Layer.provideMerge(AuthLayerLive),
Layer.provideMerge(ServerSecretStore.layer),
Layer.provideMerge(
@@ -337,6 +366,7 @@ export const makeRoutesLayer = Layer.mergeAll(
attachmentsRouteLayer,
otlpTracesProxyRouteLayer,
projectFaviconRouteLayer,
+ workflowHooksRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
).pipe(Layer.provide(browserApiCorsLayer));
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..b6494892a9b 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,11 @@ 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 { WorkflowGitHubPoller } from "./workflow/Services/WorkflowGitHubPoller.ts";
+import { WorkflowRecovery } from "./workflow/Services/WorkflowRecovery.ts";
+import { WorkflowTerminalRetentionSweeper } from "./workflow/Services/WorkflowTerminalRetentionSweeper.ts";
import {
formatHeadlessServeOutput,
formatHostForUrl,
@@ -286,9 +292,14 @@ export const makeServerRuntimeStartup = Effect.gen(function* () {
const keybindings = yield* Keybindings;
const orchestrationReactor = yield* OrchestrationReactor;
const providerSessionReaper = yield* ProviderSessionReaper;
+ const workflowTerminalRetentionSweeper = yield* WorkflowTerminalRetentionSweeper;
+ 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 crypto = yield* Crypto.Crypto;
const commandGate = yield* makeCommandGate;
@@ -334,9 +345,63 @@ 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));
+ yield* workflowGitHubPoller.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)),
+ ),
+ ),
+ );
+
+ // 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",
+ );
+ }
+
const welcomeBase = yield* resolveWelcomeBase;
const environment = yield* serverEnvironment.getDescriptor;
yield* Effect.logDebug("startup phase: preparing welcome payload");
diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts
index fb765b352c2..82352746333 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,314 @@ 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..93d49c2abe3 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,45 @@ 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 +311,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 +459,147 @@ 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 2ebf8481957..fc47fe0460d 100644
--- a/apps/server/src/terminal/Layers/Manager.test.ts
+++ b/apps/server/src/terminal/Layers/Manager.test.ts
@@ -219,6 +219,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 = {},
@@ -355,6 +376,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 cd490de1e3f..118c691ecb4 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,
@@ -263,6 +264,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,
@@ -2078,6 +2096,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);
@@ -2143,6 +2199,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 => {
@@ -2382,11 +2499,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/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..25c09ee70a0
--- /dev/null
+++ b/apps/server/src/workflow/Layers/ApprovalGate.ts
@@ -0,0 +1,69 @@
+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;
+ });
+
+ 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);
+ 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..c3828e30387
--- /dev/null
+++ b/apps/server/src/workflow/Layers/AsanaProvider.test.ts
@@ -0,0 +1,483 @@
+import { 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),
+ 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("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));
+ });
+ });
+
+ 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));
+ });
+ });
+});
diff --git a/apps/server/src/workflow/Layers/AsanaProvider.ts b/apps/server/src/workflow/Layers/AsanaProvider.ts
new file mode 100644
index 00000000000..844c0bf96ef
--- /dev/null
+++ b/apps/server/src/workflow/Layers/AsanaProvider.ts
@@ -0,0 +1,324 @@
+/**
+ * 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 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;
+
+ if (status === 401) {
+ 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;
+ }
+ if (status === 401) {
+ 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);
+ }),
+ };
+
+ 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..f0ba3283159
--- /dev/null
+++ b/apps/server/src/workflow/Layers/BoardDiscovery.test.ts
@@ -0,0 +1,551 @@
+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"),
+ cancellableProviderTurnsForTicket: () => Effect.die("unused"),
+ supersedeProviderWorkForTicket: () => 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"),
+ 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"),
+ }),
+ ),
+ 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"),
+ }),
+ ),
+ 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"),
+ }),
+ ),
+ 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..360df0a0f54
--- /dev/null
+++ b/apps/server/src/workflow/Layers/BoardDiscovery.ts
@@ -0,0 +1,255 @@
+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 { 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 { WorkflowWebhook } from "../Services/WorkflowWebhook.ts";
+import { WorkflowWorktreeJanitor } from "../Services/WorkflowWorktreeJanitor.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 worktreeJanitor = Context.getOption(
+ (yield* Effect.context()) as Context.Context,
+ WorkflowWorktreeJanitor,
+ );
+ const webhook = Context.getOption(
+ (yield* Effect.context()) as Context.Context,
+ WorkflowWebhook,
+ );
+ const cache = yield* Ref.make