From e967bf6d83336edcf6c8bf6ea1e22edb45f32a16 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 13 Oct 2026 16:10:14 -0700 Subject: [PATCH] feat(at-work): manage workspaces and the settled chat lifecycle Clover wants to settle finished work without deleting its workspace immediately or leaving unused dev servers running. Create managed workspaces from private host configuration through the sidebar and command palette, preserving permissions, drafts, and navigation. Manual Settle closes every chat-owned terminal and browser tab. Warn only when running programs or Browser state will be lost; empty chats and idle shells settle directly. Unknown resource state still warns. Other chats sharing the folder keep their resources. Undo restores the chat, not its commands or page state. Bulk settlement confirms once; cancellation preserves selection. Automatic settlement skips open resources, including a guarded compensation for resources opened while settlement commits. Archive quiet settled chats Saturday at 9 AM in server-local time, catching up after downtime. Keep managed workspaces until archival and release of other references. Archive settled work chats once all linked PRs are verified merged, including settlement after a merge. A new current-head CI failure returns a settled chat to Active and adds a Mergebot notice without starting an agent; settling again acknowledges that failure, while failed reruns can wake it again. Use verified live Mergebot data for work PR decisions. Disconnection does not certify a merge or failure; Saturday archival and inactivity rules remain independent. Guard lifecycle mutations against newer activity or manual intent. Reconcile unused dev frontends at startup, on lifecycle events, and during sweeps, preserving active peers and pending work. Keep Archive all in the Settled header context menu and offer Archive for selected chats. Both include hidden selections and group Undo over actual successes. Guard each settled archive against its current snapshot, skip raced state changes, and continue with later eligible chats. Show the full hidden count and expand it in one action. Skip revived chats and preserve navigation when the reader moves during archival. Use the central inbox grant for offline organization in the settled-chat lifecycle. Skip source resource queries while that machine is disconnected and explain that cleanup waits for reconnection; keep deletion and execution tied to source grants. Assisted-by: gpt-6.1-sol --- .../home/useThreadListActions.test.ts | 236 ++- .../src/features/home/useThreadListActions.ts | 178 ++- .../src/features/threads/ThreadFeed.tsx | 10 +- .../src/features/threads/thread-work-log.tsx | 38 +- apps/mobile/src/lib/threadActivity.test.ts | 34 +- apps/mobile/src/lib/threadActivity.ts | 2 + apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/mcp/toolkits/thread/tools.ts | 2 +- .../src/observability/RpcInstrumentation.ts | 3 + .../Orchestrator.control-reads.test.ts | 273 ++++ .../src/orchestration-v2/Orchestrator.ts | 229 +-- .../src/orchestration-v2/ProjectionStore.ts | 18 +- .../ThreadAutoArchive.integration.test.ts | 279 ++++ .../ThreadSettlementService.test.ts | 1339 ++++++++++++++++- .../ThreadSettlementService.ts | 476 +++++- .../OrchestrationEventStore.sequence.test.ts | 48 + .../persistence/OrchestrationEventStore.ts | 32 + apps/server/src/server.ts | 7 + apps/server/src/terminal/Manager.test.ts | 34 + apps/server/src/terminal/Manager.ts | 9 +- .../ManagedWorkspaces.archive.test.ts | 340 +++++ .../src/workspace/ManagedWorkspaces.test.ts | 173 +++ .../server/src/workspace/ManagedWorkspaces.ts | 223 +++ .../src/workspace/managedWorkspaceArchive.ts | 20 + apps/server/src/ws.ts | 14 +- apps/web/src/components/AppSidebarLayout.tsx | 6 + apps/web/src/components/CommandPalette.tsx | 13 + apps/web/src/components/Sidebar.logic.test.ts | 81 +- apps/web/src/components/Sidebar.logic.ts | 33 +- apps/web/src/components/Sidebar.tsx | 235 ++- .../components/chat/MessagesTimeline.test.tsx | 86 ++ .../src/components/chat/MessagesTimeline.tsx | 40 + .../sidebar/NewWorkWorkspaceButton.tsx | 40 + .../src/components/sidebar/SidebarChrome.tsx | 6 +- .../src/hooks/useNewWorkWorkspace.test.tsx | 202 +++ apps/web/src/hooks/useNewWorkWorkspace.ts | 111 ++ .../useThreadActions.permissions.test.ts | 114 +- apps/web/src/hooks/useThreadActions.ts | 189 ++- .../src/hooks/useThreadActions.undo.test.ts | 273 +++- apps/web/src/state/managedWorkspaces.ts | 25 + docs/user/thread-sidebar.md | 43 +- .../src/operations/commands.test.ts | 20 + .../client-runtime/src/operations/commands.ts | 11 +- .../client-runtime/src/rpc/client.test.ts | 28 + .../src/state/commandPermissions.test.ts | 18 + .../client-runtime/src/state/terminal.test.ts | 108 ++ packages/client-runtime/src/state/terminal.ts | 10 + .../src/state/threadSettled.test.ts | 34 + .../client-runtime/src/state/threadSettled.ts | 17 + .../contracts/src/clientRpcPermissions.ts | 2 + packages/contracts/src/index.ts | 1 + packages/contracts/src/managedWorkspaces.ts | 40 + .../contracts/src/orchestrationV2.test.ts | 17 + packages/contracts/src/orchestrationV2.ts | 12 + packages/contracts/src/pullRequest.ts | 1 + packages/contracts/src/rpc.ts | 38 +- packages/contracts/src/server.ts | 1 + .../src/server/gitHubPullRequestJson.test.ts | 32 + .../src/server/gitHubPullRequestJson.ts | 2 + 59 files changed, 5514 insertions(+), 393 deletions(-) create mode 100644 apps/server/src/orchestration-v2/ThreadAutoArchive.integration.test.ts create mode 100644 apps/server/src/workspace/ManagedWorkspaces.archive.test.ts create mode 100644 apps/server/src/workspace/ManagedWorkspaces.test.ts create mode 100644 apps/server/src/workspace/ManagedWorkspaces.ts create mode 100644 apps/server/src/workspace/managedWorkspaceArchive.ts create mode 100644 apps/web/src/components/sidebar/NewWorkWorkspaceButton.tsx create mode 100644 apps/web/src/hooks/useNewWorkWorkspace.test.tsx create mode 100644 apps/web/src/hooks/useNewWorkWorkspace.ts create mode 100644 apps/web/src/state/managedWorkspaces.ts create mode 100644 packages/client-runtime/src/state/terminal.test.ts create mode 100644 packages/client-runtime/src/state/threadSettled.test.ts create mode 100644 packages/contracts/src/managedWorkspaces.ts diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 4fffe5f36c10df2de2fbd1729b1e1d723b7e91b4..b00909fd9959f16e6f58e8d00997b2cb2bfd4602 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -11,19 +11,55 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +const resourceQueries = vi.hoisted(() => ({ + calls: 0, + terminals: null as { threadId: string; hasRunningSubprocess: boolean }[] | null, + tabs: null as unknown[] | null, +})); +vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({ + ...(await original()), + executeAtomQuery: async (_registry: unknown, atom: string) => { + resourceQueries.calls++; + const value = + atom === "terminal-metadata" ? resourceQueries.terminals : { sessions: resourceQueries.tabs }; + return (atom === "terminal-metadata" ? resourceQueries.terminals : resourceQueries.tabs) === + null + ? { _tag: "Failure", cause: {} } + : { _tag: "Success", value }; + }, +})); +vi.mock("../../connection/hub", () => ({ + hubConnection: { organizationPermissionAtom: (id: string) => `organization:${id}` }, +})); +vi.mock("../../connection/catalog", () => ({ + environmentCatalog: { stateAtom: (id: string) => `connection:${id}` }, +})); +vi.mock("../../state/terminal", () => ({ + terminalEnvironment: { metadataSnapshot: () => "terminal-metadata" }, +})); +vi.mock("../../state/preview", () => ({ previewEnvironment: { list: () => "preview-list" } })); + const state = vi.hoisted(() => ({ pendingOrder: null as object | null, dropBusy: false, scopes: new Map>(), + organization: new Set(), shells: [] as EnvironmentThreadShell[], requests: [] as { action: string; environmentId: string; input: { threadId: string; orderKey?: string }; }[], - dialogs: [] as { onConfirm: () => void }[], + autoConfirm: true, + dialogs: [] as { + title: string; + message?: string; + onConfirm: () => void; + onCancel?: () => void; + }[], alerts: [] as { title: string; + message?: string; buttons?: { text: string; onPress?: () => void }[]; }[], afterRequest: undefined as (() => void) | undefined, @@ -35,8 +71,12 @@ vi.mock("react", () => ({ })); vi.mock("react-native", () => ({ Alert: { - alert: (title: string, _message: string, buttons?: { text: string; onPress?: () => void }[]) => - state.alerts.push({ title, buttons }), + alert: (title: string, message: string, buttons?: { text: string; onPress?: () => void }[]) => { + state.alerts.push({ title, message, buttons }); + if (state.autoConfirm && title.startsWith("Settle ")) { + buttons?.find((button) => button.text === "Settle")?.onPress?.(); + } + }, }, })); vi.mock("expo-haptics", () => ({ @@ -44,17 +84,29 @@ vi.mock("expo-haptics", () => ({ ImpactFeedbackStyle: { Light: "light" }, })); vi.mock("../../components/ConfirmDialogHost", () => ({ - showConfirmDialog: (dialog: { onConfirm: () => void }) => state.dialogs.push(dialog), + showConfirmDialog: (dialog: { + title: string; + message?: string; + onConfirm: () => void; + onCancel?: () => void; + }) => { + state.dialogs.push(dialog); + if (state.autoConfirm && dialog.title.startsWith("Settle ")) dialog.onConfirm(); + }, })); vi.mock("../archive/useArchivedThreadSnapshots", () => ({ refreshArchivedThreadsForEnvironment: () => {}, })); vi.mock("../../state/session", () => ({ + readThreadOrganizationPermission: (environmentId: string) => + state.organization.has(environmentId) || + state.scopes.get(environmentId)?.has("orchestration:operate") === true, readEnvironmentScope: (environmentId: string, scope: string) => state.scopes.get(environmentId)?.has(scope) === true, })); vi.mock("../../state/server", () => ({ - environmentServerConfigsAtom: "server-configs", + environmentCapabilitiesAtom: "environment-capabilities", + threadListEnvironmentsAtom: "thread-list-environments", })); vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: { @@ -62,28 +114,35 @@ vi.mock("../../state/atom-registry", () => ({ state.dropBusy = value; }, get: (atom: string) => - atom === "thread-drop-busy" - ? state.dropBusy - : atom === "thread-shells" - ? state.shells - : atom === "queued-thread-keys" - ? new Set() - : new Map( - [...state.scopes.keys()].map((environmentId) => [ - environmentId, - { - environment: { - capabilities: { - threadSettlement: true, - threadSnooze: true, - threadPinning: true, - threadPinReorder: true, - threadTitleRegeneration: true, - }, - }, - }, - ]), - ), + atom.startsWith("organization:") + ? state.organization.has(atom.slice(13)) + : atom.startsWith("connection:") + ? AsyncResult.success({ phase: "offline" }) + : atom === "thread-drop-busy" + ? state.dropBusy + : atom === "thread-shells" + ? state.shells + : atom === "queued-thread-keys" + ? new Set() + : atom === "thread-list-environments" + ? { + settlementEnvironmentIds: new Set(state.scopes.keys()), + snoozeEnvironmentIds: new Set(state.scopes.keys()), + } + : new Map( + [...state.scopes.keys()].map((environmentId) => [ + environmentId, + { + threadSettlement: true, + threadSnooze: true, + threadPinning: true, + threadActiveReorder: true, + threadPinReorder: true, + threadTitleRegeneration: true, + threadAutoSettleOptOut: true, + }, + ]), + ), }, })); vi.mock("../../state/use-atom-command", () => ({ @@ -131,7 +190,22 @@ vi.mock("../../state/threads", () => ({ input: { threadId: string; orderKey?: string }; }) => { state.requests.push({ action, ...request }); - if (!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope)) { + if ( + !state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope) && + !( + state.organization.has(request.environmentId) && + [ + "settle", + "unsettle", + "snooze", + "unsnooze", + "pin", + "unpin", + "reorderPin", + "setAutoSettle", + ].includes(action) + ) + ) { return AsyncResult.failure(Cause.fail(new Error("Thread operation denied"))); } state.afterRequest?.(); @@ -169,6 +243,10 @@ const mutationCases = [ ] as const; beforeEach(() => { + resourceQueries.calls = 0; + state.organization.clear(); + resourceQueries.terminals = null; + resourceQueries.tabs = null; state.pendingOrder = null; state.dropBusy = false; state.scopes = new Map([ @@ -177,6 +255,7 @@ beforeEach(() => { ]); state.requests = []; state.shells = []; + state.autoConfirm = true; state.dialogs = []; state.alerts = []; state.afterRequest = undefined; @@ -184,6 +263,89 @@ beforeEach(() => { afterEach(() => vi.unstubAllEnvs()); +describe("settle resource warning", () => { + it.each([ + { terminals: [] }, + { terminals: [{ threadId: "thread", hasRunningSubprocess: false }] }, + ])("settles empty or idle-shell chats without prompting", async ({ terminals }) => { + resourceQueries.terminals = terminals; + resourceQueries.tabs = []; + await expect(useThreadListActions().settleThread(makeThread())).resolves.toBe(true); + expect(state.dialogs).toHaveLength(0); + expect(state.alerts).toHaveLength(0); + expect(state.requests.map((request) => request.action)).toEqual(["settle"]); + }); + + it.each([ + { + terminals: [{ threadId: "thread", hasRunningSubprocess: true }], + tabs: [], + text: "Undo cannot restart them.", + }, + { terminals: [], tabs: [{}], text: "Undo cannot restore page state." }, + { terminals: [], tabs: null, text: "could not be checked" }, + ])( + "warns for programs, browser tabs, or unavailable resource data: $text", + async ({ terminals, tabs, text }) => { + resourceQueries.terminals = terminals; + resourceQueries.tabs = tabs; + await useThreadListActions().settleThread(makeThread()); + expect(state.dialogs[0]?.message).toContain(text); + expect(state.requests.map((request) => request.action)).toEqual(["settle"]); + }, + ); + + it.each(["ios", "android"])("cancels %s settlement before dismissal or mutation", async (os) => { + vi.stubEnv("EXPO_OS", os); + state.autoConfirm = false; + const actions = useThreadListActions(); + const pending = actions.settleThread(makeThread()); + await vi.waitFor(() => expect(os === "ios" ? state.alerts : state.dialogs).toHaveLength(1)); + const warning = os === "ios" ? state.alerts[0] : state.dialogs[0]; + expect(warning?.message).toContain("stops any running commands"); + expect(warning?.message).toContain("Undo cannot restore commands or page state"); + expect(state.requests).toEqual([]); + + if (os === "ios") + state.alerts[0]?.buttons?.find((button) => button.text === "Cancel")?.onPress?.(); + else state.dialogs[0]?.onCancel?.(); + + await expect(pending).resolves.toBe(false); + expect(state.requests).toEqual([]); + }); + + it.each(["ios", "android"])("rechecks permission after %s confirmation", async (os) => { + vi.stubEnv("EXPO_OS", os); + state.autoConfirm = false; + const pending = useThreadListActions().settleThread(makeThread()); + await vi.waitFor(() => expect(os === "ios" ? state.alerts : state.dialogs).toHaveLength(1)); + state.scopes.get(primaryEnvironmentId)!.clear(); + if (os === "ios") + state.alerts[0]?.buttons?.find((button) => button.text === "Settle")?.onPress?.(); + else state.dialogs[0]?.onConfirm(); + + await expect(pending).resolves.toBe(false); + expect(state.requests).toEqual([]); + }); + + it("warns for a move to Settled before issuing its command", async () => { + state.autoConfirm = false; + const thread = makeThread(); + state.shells = [thread]; + const pending = useThreadListActions().moveThread(thread, { + section: "settled", + targetId: null, + placement: "after", + }); + expect(state.requests).toEqual([]); + await vi.waitFor(() => expect(state.dialogs).toHaveLength(1)); + state.dialogs[0]?.onConfirm(); + + await expect(pending).resolves.toBe(true); + expect(state.requests.map((request) => request.action)).toEqual(["settle"]); + }); +}); + describe("thread list operation permissions", () => { it.each(mutationCases)( "%s rejects a retained callback after its environment loses permission", @@ -324,3 +486,21 @@ describe("pinned thread operation permissions", () => { expect(state.requests).toHaveLength(1); }); }); + +it("organizes a chat through the central grant while source mutations remain denied", async () => { + resourceQueries.terminals = []; + resourceQueries.tabs = []; + state.organization.add(otherEnvironmentId); + const thread = makeThread({ environmentId: otherEnvironmentId }); + const actions = useThreadListActions(); + expect(await actions.settleThread(thread)).toBe(true); + expect(resourceQueries.calls).toBe(0); + expect(await actions.unsettleThread(thread)).toBe(true); + expect(await actions.snoozeThread(thread, "2027-01-01T00:00:00.000Z")).toBe(true); + expect(await actions.regenerateThreadTitle(thread)).toBe(false); + actions.confirmDeleteThread(thread); + expect(state.requests.map((request) => request.action)).toEqual(["settle", "unsettle", "snooze"]); + state.organization.clear(); + expect(await actions.pinThread(thread)).toBe(false); + expect(state.requests).toHaveLength(3); +}); diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 8e5db2faf99d839f10cd355dc3348f357e304690..7b48c24bfc29f7e4ef1c2c8fa34234de9f22fb76 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,6 +1,14 @@ +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; +import { environmentCatalog } from "../../connection/catalog"; +import { hubConnection } from "../../connection/hub"; import type { ThreadMoveDestination } from "../threads/threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { + canSnooze, + effectiveSnoozed, + resolveThreadSettlementWarning, +} from "@t3tools/client-runtime/state/thread-settled"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; @@ -13,8 +21,11 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; -import { environmentServerConfigsAtom } from "../../state/server"; -import { readEnvironmentScope } from "../../state/session"; +import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; +import { terminalEnvironment } from "../../state/terminal"; +import { previewEnvironment } from "../../state/preview"; +import { environmentCapabilitiesAtom, threadListEnvironmentsAtom } from "../../state/server"; +import { readEnvironmentScope, readThreadOrganizationPermission } from "../../state/session"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -36,29 +47,23 @@ import { resolveThreadTitleRename } from "../threads/thread-title-rename"; (capability defaults false on decode for older servers). */ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadSettlement === true + appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSettlement === true ); } function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["environmentId"]) { - return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadSnooze === true - ); + return appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSnooze === true; } function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) { return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadPinning === true + appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinning === true ); } function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) { return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadPinReorder === true + appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinReorder === true ); } @@ -66,8 +71,8 @@ function environmentSupportsAutoSettleOptOut( environmentId: EnvironmentThreadShell["environmentId"], ) { return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadAutoSettleOptOut === true + appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadAutoSettleOptOut === + true ); } @@ -75,8 +80,8 @@ function environmentSupportsTitleRegeneration( environmentId: EnvironmentThreadShell["environmentId"], ) { return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadTitleRegeneration === true + appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadTitleRegeneration === + true ); } @@ -110,8 +115,17 @@ function actionFailureTitle(action: ThreadListAction): string { return "Could not delete thread"; } -function checkThreadOperationPermission(thread: EnvironmentThreadShell, title: string): boolean { - if (readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return true; +function checkThreadOperationPermission( + thread: EnvironmentThreadShell, + title: string, + organization = false, +): boolean { + if ( + organization + ? readThreadOrganizationPermission(thread.environmentId) + : readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope) + ) + return true; Alert.alert(title, "This connection cannot change threads."); return false; } @@ -129,14 +143,20 @@ function useThreadActionExecutor( const executeAction = useCallback( async (action: ThreadListAction, thread: EnvironmentThreadShell) => { - if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false; + if ( + !checkThreadOperationPermission( + thread, + actionFailureTitle(action), + action === "settle" || action === "unsettle", + ) + ) + return false; const key = scopedThreadKey(thread.environmentId, thread.id); if (inFlightThreadKeys.current.has(key)) { return false; } inFlightThreadKeys.current.add(key); - selectionHaptic(); try { if ( (action === "settle" || action === "unsettle") && @@ -148,6 +168,85 @@ function useThreadActionExecutor( ); return false; } + if (action === "settle") { + if (thread.settledOverride === "settled") return false; + const queryOptions = { + refresh: true, + reportFailure: false, + signal: AbortSignal.timeout(3_000), + }; + const sourceOffline = + appAtomRegistry.get(hubConnection.organizationPermissionAtom(thread.environmentId)) && + Option.getOrNull( + AsyncResult.value( + appAtomRegistry.get(environmentCatalog.stateAtom(thread.environmentId)), + ), + )?.phase !== "connected"; + const [terminals, previews] = sourceOffline + ? [null, null] + : await Promise.all([ + executeAtomQuery( + appAtomRegistry, + terminalEnvironment.metadataSnapshot({ + environmentId: thread.environmentId, + input: null, + }), + queryOptions, + ), + executeAtomQuery( + appAtomRegistry, + previewEnvironment.list({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }), + queryOptions, + ), + ]); + const resourceWarning = resolveThreadSettlementWarning({ + runningCommands: + terminals?._tag === "Success" + ? terminals.value.some( + (terminal) => terminal.threadId === thread.id && terminal.hasRunningSubprocess, + ) + : null, + browserTabs: previews?._tag === "Success" ? previews.value.sessions.length > 0 : null, + }); + const warning = + resourceWarning !== null && sourceOffline + ? `${resourceWarning} Cleanup waits until the computer reconnects.` + : resourceWarning; + const confirmed = + warning === null || + (await new Promise((resolve) => { + const title = `Settle "${thread.title}"?`; + if (process.env.EXPO_OS === "ios") { + Alert.alert(title, warning, [ + { text: "Cancel", style: "cancel", onPress: () => resolve(false) }, + { text: "Settle", style: "destructive", onPress: () => resolve(true) }, + ]); + } else { + showConfirmDialog({ + title, + message: warning, + confirmText: "Settle", + destructive: true, + onConfirm: () => resolve(true), + onCancel: () => resolve(false), + }); + } + })); + if ( + !confirmed || + !checkThreadOperationPermission( + thread, + actionFailureTitle(action), + action === "settle" || action === "unsettle", + ) + ) { + return false; + } + } + selectionHaptic(); // Archive keeps its original, narrower guard: never interrupt a // thread mid-turn. if (action === "archive" && !threadCanArchive(thread.runtime)) { @@ -291,7 +390,7 @@ export function useThreadListActions(): { ); const snoozeThread = useCallback( async (thread: EnvironmentThreadShell, snoozedUntil: string) => { - if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false; + if (!checkThreadOperationPermission(thread, "Could not snooze thread", true)) return false; const key = scopedThreadKey(thread.environmentId, thread.id); if (snoozeInFlightThreadKeys.current.has(key)) { return false; @@ -349,7 +448,7 @@ export function useThreadListActions(): { ); const unsnoozeThread = useCallback( async (thread: EnvironmentThreadShell) => { - if (!checkThreadOperationPermission(thread, "Could not wake thread")) return false; + if (!checkThreadOperationPermission(thread, "Could not wake thread", true)) return false; const key = scopedThreadKey(thread.environmentId, thread.id); if (snoozeInFlightThreadKeys.current.has(key)) { return false; @@ -397,7 +496,7 @@ export function useThreadListActions(): { ); const pinThread = useCallback( async (thread: EnvironmentThreadShell) => { - if (!checkThreadOperationPermission(thread, "Could not pin thread")) return false; + if (!checkThreadOperationPermission(thread, "Could not pin thread", true)) return false; if (!environmentSupportsPinning(thread.environmentId)) { Alert.alert( "Could not pin thread", @@ -438,7 +537,7 @@ export function useThreadListActions(): { ); const unpinThread = useCallback( async (thread: EnvironmentThreadShell) => { - if (!checkThreadOperationPermission(thread, "Could not unpin thread")) return false; + if (!checkThreadOperationPermission(thread, "Could not unpin thread", true)) return false; if (!environmentSupportsPinning(thread.environmentId)) { Alert.alert( "Could not unpin thread", @@ -467,6 +566,8 @@ export function useThreadListActions(): { ); const setThreadAutoSettle = useCallback( async (thread: EnvironmentThreadShell, enabled: boolean) => { + if (!checkThreadOperationPermission(thread, "Could not update auto-settle", true)) + return false; if (!environmentSupportsAutoSettleOptOut(thread.environmentId)) { Alert.alert( "Could not update auto-settle", @@ -589,7 +690,7 @@ export function useThreadListActions(): { }); const moveThread = useCallback( async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { - if (!checkThreadOperationPermission(thread, "Could not move thread")) return false; + if (!checkThreadOperationPermission(thread, "Could not move thread", true)) return false; if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); const current = shells.find( @@ -612,9 +713,10 @@ export function useThreadListActions(): { appAtomRegistry.set(threadDropBusyAtom, false); } } - const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const capabilitiesByEnvironment = appAtomRegistry.get(environmentCapabilitiesAtom); + const listEnvironments = appAtomRegistry.get(threadListEnvironmentsAtom); const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { - const capabilities = configs.get(environmentId)?.environment.capabilities; + const capabilities = capabilitiesByEnvironment.get(environmentId); return section === "pinned" ? capabilities?.threadPinReorder === true : capabilities?.threadActiveReorder === true; @@ -631,22 +733,16 @@ export function useThreadListActions(): { section, now: new Date().toISOString(), queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), - settlementEnvironmentIds: new Set( - [...configs].flatMap(([id, config]) => - config.environment.capabilities.threadSettlement === true ? [id] : [], - ), - ), - snoozeEnvironmentIds: new Set( - [...configs].flatMap(([id, config]) => - config.environment.capabilities.threadSnooze === true ? [id] : [], - ), - ), + settlementEnvironmentIds: listEnvironments.settlementEnvironmentIds, + snoozeEnvironmentIds: listEnvironments.snoozeEnvironmentIds, }); const assignments = createThreadMovePlanner({ allThreads: shells, ordered, section, - reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)), + reorderableEnvironmentIds: new Set( + [...capabilitiesByEnvironment.keys()].filter(supportsReorder), + ), })(scopedThreadKey(thread.environmentId, thread.id), direction); if (assignments === null) return false; const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); @@ -668,7 +764,7 @@ export function useThreadListActions(): { ); for (const assignment of assignments) { const target = shellByKey.get(assignment.id); - if (target && !checkThreadOperationPermission(target, "Could not move thread")) + if (target && !checkThreadOperationPermission(target, "Could not move thread", true)) return false; } selectionHaptic(); @@ -717,7 +813,7 @@ export function useThreadListActions(): { if (pending !== null && !pending.isPending()) return false; const target = shellByKey.get(assignment.id); if (target === undefined) continue; - if (!checkThreadOperationPermission(target, "Could not move thread")) return false; + if (!checkThreadOperationPermission(target, "Could not move thread", true)) return false; const result = await reorder({ environmentId: target.environmentId, input: { threadId: target.id, orderKey: assignment.orderKey }, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f5fade2153e908b3e639a4996a502b216c502fbb..67b56c444b8812fb4714309f57a15178a3375265 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1011,7 +1011,7 @@ function renderFeedEntry( readonly markdownLinkHandlers: MarkdownLinkHandlers; readonly renderMarkdownImage: MarkdownImageRenderer; readonly renderViewedImage: MarkdownImageRenderer; - readonly renderReasoning: (text: string) => ReactNode; + readonly renderMarkdown: (text: string) => ReactNode; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly screenColor: string; readonly userBubbleColor: string | import("react-native").ColorValue; @@ -1481,7 +1481,7 @@ function renderFeedEntry( onCopyRow={props.onCopyWorkRow} onToggleRow={props.onToggleWorkRow} renderImage={props.renderViewedImage} - renderReasoning={props.renderReasoning} + renderMarkdown={props.renderMarkdown} onPressPreview={props.onPressPreview} /> ); @@ -2013,7 +2013,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [props.environmentId, props.threadId, props.workspaceRoot], ); const markdownStyles = useMarkdownStyles(); - const renderReasoning = useCallback( + const renderMarkdown = useCallback( (text: string) => ( void; readonly onToggleRow: (rowId: string, anchorKey: string) => void; readonly renderImage: MarkdownImageRenderer; - readonly renderReasoning: (text: string) => ReactNode; + readonly renderMarkdown: (text: string) => ReactNode; readonly onPressPreview: (source: FilePreviewSource) => void; } @@ -505,7 +505,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { onCopyRow={props.onCopyRow} onToggleRow={props.onToggleRow} renderImage={props.renderImage} - renderReasoning={props.renderReasoning} + renderMarkdown={props.renderMarkdown} onPressPreview={props.onPressPreview} themeAppearance={props.themeAppearance} /> @@ -519,7 +519,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { props.onCopyRow, props.onToggleRow, props.renderImage, - props.renderReasoning, + props.renderMarkdown, props.onPressPreview, props.themeAppearance, ], @@ -540,7 +540,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { return ( {props.activities.map((row) => ( - {props.renderReasoning(row.workEntry.detail ?? "")} + {props.renderMarkdown(row.workEntry.detail ?? "")} ))} ); @@ -907,6 +907,34 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( : null, ); const failureItem = row.projectedItem.item; + if (failureItem.type === "notification" && failureItem.source.kind === "system") { + const failed = failureItem.outcome === "failed"; + return ( + + props.onCopyRow(row.id, row.getCopyText())}> + + + + + {failureItem.summary} + + + {failureItem.detail ? ( + {props.renderMarkdown(failureItem.detail)} + ) : null} + + ); + } if (failureItem.type === "error" && failureItem.status === "failed") { const warning = failureItem.failure.class === "usage_limit"; const timestamp = new Date(row.createdAt); @@ -1224,7 +1252,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( contentContainerStyle={{ paddingRight: 8 }} > {reasoning ? ( - props.renderReasoning(reasoning.text) + props.renderMarkdown(reasoning.text) ) : call ? ( [ call.command, diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 7a6447093ab5f047f93f6528fd7157bf0e0df2ed..6de9dd8edd621a51cf1103f4eac5abac05d3c4da 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -85,7 +85,7 @@ it("shows only the structured path in expanded mobile read details", () => { expect(withoutPath?.canExpand).toBe(false); }); -it("expands a T3 Code notice to the prompt the agent got", () => { +it("keeps system notices prominent for inline Markdown details", () => { const item = { ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2), type: "notification", @@ -99,10 +99,40 @@ it("expands a T3 Code notice to the prompt the agent got", () => { ); expect(activity?.summary).toBe("T3 Code restarted and resumed this turn"); - expect(activity?.canExpand).toBe(true); + expect(activity?.prominent).toBe(true); + expect(activity?.canExpand).toBe(false); expect(activity?.getFullDetail()).toBe("Continue where you left off."); }); +it("keeps a runless CI failure and its Mergebot link visible outside completed work", () => { + const item: OrchestrationV2TurnItem = { + ...base("ci-failure", "2026-06-20T00:00:03.000Z", 2), + runId: null, + type: "notification", + source: { kind: "system" }, + outcome: "failed", + summary: "CI failed for PR #42", + detail: "[Open PR #42 in Mergebot](https://example.com/queue/pr/42)", + }; + const feed = buildThreadFeed([projected(command(), 0), projected(item, 1)]); + const presented = deriveThreadFeedPresentation( + feed, + { + runId, + status: "completed", + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: "2026-06-20T00:00:02.000Z", + }, + new Set(), + ); + const activity = presented + .flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])) + .find((entry) => entry.projectedItem.item.id === item.id); + expect(activity?.prominent).toBe(true); + expect(activity?.status).toBe("failure"); + expect(activity?.getFullDetail()).toBe(item.detail); +}); + it("labels file searches with the adapter title and its search target", () => { const item: OrchestrationV2TurnItem = { ...base("file-search", "2026-06-20T00:00:03.000Z", 2), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 316732dd46f3488c400d47214eb26978effce255..c9425289aec5bd328bcfb21f3760b57bece3c618 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -460,6 +460,7 @@ function itemIsProminent(item: OrchestrationV2TurnItem): boolean { item.type === "fork" || item.type === "thread_created" || item.type === "system_notice" || + (item.type === "notification" && item.source.kind === "system") || // An answerable card: it must stand alone and never fold away with the run. item.type === "secret_request" ); @@ -841,6 +842,7 @@ function toFeedActivity( detail, canExpand: !(item.type === "error" && item.status === "failed") && + !(item.type === "notification" && item.source.kind === "system") && (readPaths ? readPaths.length > 0 || turnItemNeedsDetailFetch(item) : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index ec3fc4bc531b21168461510cc75b40387d1361b7..c60b6bf02e613ae3b1d56a03cbcb230ddd5fb255 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = { ...CLIENT_GUARDED_RPC_SCOPES, [WS_METHODS.uiSettingsGet]: AuthOrchestrationReadScope, [WS_METHODS.uiSettingsSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.managedWorkspacesStatus]: AuthFilesystemReadScope, [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index b5fb7d6be3a68f1e8c1dcfbfa8499de064adb902..79e1ab334919fcd7a8eebcc5a01ca6d5ac596e86 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -30,7 +30,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; const ThreadOrganizeTool = Tool.make("t3_thread_organize", { description: - "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply. Settling this thread takes effect when your turn completes, returning settlesWhenTurnEnds=true; a turn that fails or is interrupted, or a queued message, leaves it active.", + "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Settling closes this chat's terminals and browser tabs, stops their running commands and media, and preserves conversation history. Unsettling cannot restore running processes or page state; tell the user before settling work with open resources. Settling this thread takes effect when your turn completes, returning settlesWhenTurnEnds=true; a turn that fails or is interrupted, or a queued message, leaves it active.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), action: Schema.Literals([ diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 4e13b92614ef17310726c71754c5e135add5e63b..ede4ade4f26aff4bee4b968fb2ce8119cd081489 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -142,6 +142,9 @@ const RPC_AGGREGATES = { [WS_METHODS.subscribeProjectClones]: "source-control", [WS_METHODS.projectsListEntries]: "workspace", [WS_METHODS.devServerStatus]: "workspace", + [WS_METHODS.managedWorkspacesStatus]: "workspace", + [WS_METHODS.managedWorkspacesCreate]: "workspace", + [WS_METHODS.managedWorkspacesArchiveCompleted]: "workspace", [WS_METHODS.devServerStart]: "workspace", [WS_METHODS.projectsWatch]: "workspace", [WS_METHODS.projectsReadFile]: "workspace", diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index de616556b4eb06fbbb63bbe9bffc443235ed7081..6009bfc79e8455a85ab7dd2d08572106b9bf2f9a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -23,6 +23,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as DelegatedTaskQuestionReactor from "./DelegatedTaskQuestionReactor.ts"; import * as EventSink from "./EventSink.ts"; @@ -44,6 +45,7 @@ const adapter = { const layerDatabase = SqlitePersistence.layerMemory; const layerTest = Layer.mergeAll( layerDatabase, + OrchestrationEventStore.layer.pipe(Layer.provide(layerDatabase)), ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), ProviderReplayHarness.layerWithRegistry( { name: "control-reads" }, @@ -52,6 +54,68 @@ const layerTest = Layer.mergeAll( ), ); +it.effect("cancels only the automatic settlement that observed newly opened resources", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + for (const action of ["none", "manual-settle", "revive"] as const) { + const threadId = ThreadId.make(`thread:auto-settle-resources:${action}`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create-auto-settle-resources:${action}`), + threadId, + projectId: ProjectId.make("project:auto-settle-resources"), + title: "Resource settlement race", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const before = yield* projections.getThread(threadId); + const automatic = yield* orchestrator.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`server:auto-settle:${threadId}:1`), + threadId, + snapshotAt: before.updatedAt, + }); + const settledAt = (yield* projections.getThread(threadId)).settledAt; + if (action !== "none") { + yield* orchestrator.dispatch( + action === "manual-settle" + ? { + type: "thread.settle", + commandId: CommandId.make(`manual-settle-resources:${action}`), + threadId, + } + : { + type: "thread.unsettle", + commandId: CommandId.make(`revive-resources:${action}`), + threadId, + reason: "user", + }, + ); + } + const result = yield* orchestrator + .dispatch({ + type: "thread.unsettle", + commandId: CommandId.make(`server:auto-settle-resources:${threadId}:1`), + threadId, + reason: "user", + snapshotSequence: automatic.sequence, + }) + .pipe(Effect.exit); + + assert.equal(result._tag, action === "none" ? "Success" : "Failure"); + const after = yield* projections.getThread(threadId); + assert.equal(after.settledOverride, action === "manual-settle" ? "settled" : "active"); + if (action === "manual-settle") assert.deepStrictEqual(after.settledAt, settledAt); + } + }).pipe(Effect.provide(layerTest)), +); + it.effect( "dispatches metadata, queue resume and request controls without hydrating unrelated history", () => @@ -960,3 +1024,212 @@ it.effect("wakes the parent once when its delegated task asks a question", () => }); }).pipe(Effect.provide(DelegatedTaskQuestionReactor.layer.pipe(Layer.provideMerge(layerTest)))), ); + +it.effect("guarded archive preserves a resumed thread and rejects an earlier settlement", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:archive-race"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-archive-race"), + threadId, + projectId: ProjectId.make("project:archive-race"), + title: "Work", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("settle-archive-race"), + threadId, + }); + const { snapshotSequence } = yield* orchestrator.getShellSnapshot(); + const originalSettledAt = (yield* projections.getThread(threadId)).settledAt; + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("duplicate-settle-archive-race"), + threadId, + }); + yield* orchestrator.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("resume-archive-race"), + threadId, + reason: "user", + }); + const resumed = yield* orchestrator + .dispatch({ + type: "thread.archive", + commandId: CommandId.make("archive-resumed"), + threadId, + snapshotSequence, + }) + .pipe(Effect.flip); + assert.include(resumed, { + _tag: "OrchestratorDispatchError", + reason: "archive-condition-changed", + }); + assert.isNull((yield* projections.getThread(threadId)).archivedAt); + + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("resettle-archive-race"), + threadId, + }); + assert.deepEqual((yield* projections.getThread(threadId)).settledAt, originalSettledAt); + const stale = yield* orchestrator + .dispatch({ + type: "thread.archive", + commandId: CommandId.make("archive-previous-settlement"), + threadId, + snapshotSequence, + }) + .pipe(Effect.flip); + assert.include(stale, { + _tag: "OrchestratorDispatchError", + reason: "archive-condition-changed", + }); + assert.isNull((yield* projections.getThread(threadId)).archivedAt); + + const beforeRename = yield* orchestrator.getShellSnapshot(); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("rename-archive-race"), + threadId, + title: "Changed", + }); + const renamed = yield* orchestrator + .dispatch({ + type: "thread.archive", + commandId: CommandId.make("archive-before-rename"), + threadId, + snapshotSequence: beforeRename.snapshotSequence, + }) + .pipe(Effect.flip); + assert.include(renamed, { + _tag: "OrchestratorDispatchError", + reason: "archive-condition-changed", + }); + const current = yield* orchestrator.getShellSnapshot(); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-unrelated-archive-race"), + threadId: ThreadId.make("thread:unrelated-archive-race"), + projectId: ProjectId.make("project:archive-race"), + title: "Unrelated", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make("archive-current-settlement"), + threadId, + snapshotSequence: current.snapshotSequence, + }); + assert.isNotNull((yield* projections.getThread(threadId)).archivedAt); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("guarded archive retains threads with active, queued, or pending request work", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + for (const status of [ + "queued", + "preparing", + "starting", + "running", + "waiting", + "request", + ] as const) { + const threadId = ThreadId.make(`thread:archive-blocked:${status}`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create-archive-blocked:${status}`), + threadId, + projectId: ProjectId.make("project:archive-blocked"), + title: "Work", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make(`settle-archive-blocked:${status}`), + threadId, + }); + if (status === "request") { + yield* projections.apply({ + id: EventId.make("archive-blocked:request"), + type: "runtime-request.updated", + threadId, + occurredAt: now, + payload: { + id: RuntimeRequestId.make("archive-blocked:request"), + nodeId: NodeId.make("archive-blocked:request"), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + createdAt: now, + resolvedAt: null, + }, + }); + } else { + yield* projections.apply({ + id: EventId.make(`archive-blocked:run:${status}`), + type: "run.created", + threadId, + occurredAt: now, + payload: { + id: RunId.make(`archive-blocked:run:${status}`), + threadId, + ordinal: 1, + providerInstanceId: instanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`archive-blocked:message:${status}`), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: now, + startedAt: null, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }); + } + const snapshot = yield* orchestrator.getShellSnapshot(); + const exit = yield* orchestrator + .dispatch({ + type: "thread.archive", + commandId: CommandId.make(`archive-blocked:${status}`), + threadId, + snapshotSequence: snapshot.snapshotSequence, + }) + .pipe(Effect.flip); + assert.include(exit, { + _tag: "OrchestratorDispatchError", + reason: "archive-condition-changed", + }); + assert.isNull((yield* projections.getThread(threadId)).archivedAt); + } + }).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 7e3f84c531bf9c049fe98d8c4a57d55320e6db79..80bd203fc5aae2856a4089bbde1bdfaa3e5b164e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1,3 +1,4 @@ +import { isManagedWorkspaceArchiveCandidate } from "../workspace/managedWorkspaceArchive.ts"; import type { OrchestrationV2SearchThreadInput, OrchestrationV2SearchThreadResult, @@ -142,6 +143,7 @@ export class OrchestratorDispatchError extends Schema.TaggedError command.snapshotSequence) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} changed before automatic settlement could be cancelled.`, + }); + } + } + if (command.type === "thread.archive" && command.snapshotSequence !== undefined) { + const shell = yield* projectionStore + .getThreadShell(command.threadId) + .pipe(mapDispatchError(command)); + const records = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "runtimeRequests"]) + .pipe(mapDispatchError(command)); + const sequence = yield* eventSink + .latestSequence({ threadId: command.threadId }) + .pipe(mapDispatchError(command)); + if ( + shell === null || + !isManagedWorkspaceArchiveCandidate(shell) || + sequence > command.snapshotSequence || + records.runs.some((run) => + ["queued", "preparing", "starting", "running", "waiting"].includes(run.status), + ) || + records.runtimeRequests.some((request) => request.status === "pending") + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + reason: "archive-condition-changed", + cause: `Thread ${command.threadId} changed before automatic archival.`, + }); + } + } if (command.type === "thread.archive" && thread.archivedAt !== null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -10863,6 +10908,82 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.visit": yield* dispatchThreadVisit(command, events); break; + case "thread.ci-failed": { + const thread = yield* projectionStore + .getThreadShell(command.threadId) + .pipe(mapDispatchError(command)); + const sequence = yield* eventSink + .latestSequence({ threadId: command.threadId }) + .pipe(mapDispatchError(command)); + const records = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "runtimeRequests"]) + .pipe(mapDispatchError(command)); + if ( + thread === null || + !isManagedWorkspaceArchiveCandidate(thread) || + sequence > command.snapshotSequence || + records.runs.some((run) => + ["queued", "preparing", "starting", "running", "waiting"].includes(run.status), + ) || + records.runtimeRequests.some((request) => request.status === "pending") + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The thread changed before the CI failure was recorded.", + }); + } + if ( + yield* projectionStore + .getTurnItem({ threadId: command.threadId, itemId: command.itemId }) + .pipe(mapDispatchError(command)) + ) + break; + const now = yield* DateTime.now; + yield* dispatchThreadMutation( + { + type: "thread.unsettle", + commandId: command.commandId, + threadId: command.threadId, + reason: "user", + }, + events, + effects, + ); + const ordinal = yield* projectionStore + .getNextTurnItemOrdinal(command.threadId) + .pipe(mapDispatchError(command)); + yield* emit( + events, + command, + )({ + type: "turn-item.updated", + threadId: command.threadId, + occurredAt: now, + payload: { + type: "notification", + source: { kind: "system" }, + outcome: "failed", + summary: `CI failed for PR #${command.pullRequestNumber}`, + detail: `[Open PR #${command.pullRequestNumber} in Mergebot](<${command.url}>)`, + id: command.itemId, + threadId: command.threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }); + break; + } case "thread.auto-settle": { // Automatic settlement (#8600): the sweep evaluated a shell snapshot, // so re-check against the live thread before settling. Any change @@ -11212,6 +11333,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio planned.events.length > 0 || command.type === "thread.background-work.settle" || command.type === "thread.stop" || + command.type === "thread.ci-failed" || command.type === "queued-message.promote-to-steer" || command.type === "subagent.stop" ? Effect.succeed(planned) @@ -11724,110 +11846,3 @@ export const layer: Layer.Layer< > = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe( Layer.provide(ThreadCommandExecutor.layer), ); - -const layerUnavailable: Layer.Layer = Layer.succeed( - OrchestratorV2, - OrchestratorV2.of({ - resumeQueuedRuns: Effect.fail( - new OrchestratorDispatchError({ - commandId: CommandId.make("command:system:resume-queued-runs"), - commandType: "message.dispatch", - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - recoverDelegatedTasks: Effect.void, - recoverDelegatedTask: () => Effect.void, - delegatedTaskResultPending: () => Effect.succeed(false), - restartContinuationPending: () => Effect.succeed(false), - awaitRestartContinuation: () => Effect.void, - dispatch: (command) => - Effect.fail( - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - searchThreadStream: (input) => - Stream.fail(new OrchestratorProjectionError({ threadId: input.threadId })), - searchThread: (input) => - Effect.fail(new OrchestratorProjectionError({ threadId: input.threadId })), - getThreadHistoryPage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), - getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), - getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), - getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })), - getThreadRecords: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), - getThreadProjection: (threadId) => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - getCheckpointContext: (threadId) => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - getThreadSnapshot: (threadId) => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - getThreadSnapshotWindow: (threadId) => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - getShellSnapshot: () => - Effect.fail( - new OrchestratorProjectionError({ - threadId: ThreadId.make("thread:shell"), - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - readShellSnapshot: () => - Effect.fail( - new OrchestratorProjectionError({ - threadId: ThreadId.make("thread:shell"), - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - getThreadShell: (threadId) => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - getThreadEventSequence: (threadId) => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - streamStoredEvents: Stream.fail( - new OrchestratorDomainEventStreamError({ - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - streamStoredEventsFrom: () => - Stream.fail( - new OrchestratorDomainEventStreamError({ - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - streamDomainEvents: Stream.fail( - new OrchestratorDomainEventStreamError({ - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - } satisfies OrchestratorV2Shape), -); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 1162daf5c6fec7d324e640bfa9e69c1db5a82588..30af6ddb1678c507815af8908aff233af192270f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -370,6 +370,7 @@ export interface ShellSnapshotOptions { * their run, item or session rows are read. */ readonly unsettledOnly?: boolean; + readonly settledOnly?: boolean; } export interface ProjectionStoreV2Shape { @@ -5308,7 +5309,12 @@ export const layer: Layer.Layer = const selectShellThreadRows = ( threadId?: ThreadId, - { location, projectId, unsettledOnly = false }: ShellSnapshotOptions = {}, + { + location, + projectId, + unsettledOnly = false, + settledOnly = false, + }: ShellSnapshotOptions = {}, ) => sql` SELECT @@ -5494,6 +5500,10 @@ export const layer: Layer.Layer = unsettledOnly ? sql` AND json_extract(t.payload_json, '$.settledAt') IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled'` : sql`` + }${ + settledOnly + ? sql` AND json_extract(t.payload_json, '$.settledOverride') = 'settled' AND json_extract(t.payload_json, '$.settledAt') IS NOT NULL` + : sql`` } ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -6196,6 +6206,12 @@ export const layerMemory: Layer.Layer = Layer.effect( ) { return false; } + if ( + options?.settledOnly && + (projection.thread.settledOverride !== "settled" || + projection.thread.settledAt === null) + ) + return false; if (options?.location === "active") return projection.thread.archivedAt === null; if (options?.location === "archive") return projection.thread.archivedAt !== null; return true; diff --git a/apps/server/src/orchestration-v2/ThreadAutoArchive.integration.test.ts b/apps/server/src/orchestration-v2/ThreadAutoArchive.integration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5bbcb5fec016ed482d4c64ae0fbc25c35125b64e --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadAutoArchive.integration.test.ts @@ -0,0 +1,279 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Exit from "effect/Exit"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; + +const instanceId = ProviderInstanceId.make("codex"); +const modelSelection = { instanceId, model: "gpt-5.1-codex" }; +const adapter = { + instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("A CI failure must not start a provider"), +} as ProviderAdapter.ProviderAdapterV2["Service"]; +const databaseLayer = SqlitePersistence.layerMemory; +const testLayer = Layer.mergeAll( + databaseLayer, + ProjectionStore.layer.pipe(Layer.provide(databaseLayer)), + ProviderReplayHarness.layerWithRegistry( + { name: "auto-archive" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer, runEffectWorker: false }, + ), +); + +it.effect( + "CI failure wakes atomically with a durable local notification, deduplicates across settlement, and never requests provider work", + () => + Effect.gen(function* () { + const engine = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("ci-notification"); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-ci"), + threadId, + projectId: ProjectId.make("project-ci"), + title: "CI notification", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("settle-ci"), + threadId, + }); + assert.deepStrictEqual( + (yield* projections.getShellSnapshot({ + location: "active", + settledOnly: true, + })).threads.map((thread) => thread.id), + [threadId], + ); + const itemId = TurnItemId.make("ci-head-notice"); + const dispatchFailure = (commandId: string, snapshotSequence: number) => + engine.dispatch({ + type: "thread.ci-failed", + commandId: CommandId.make(commandId), + threadId, + itemId, + snapshotSequence, + pullRequestNumber: 42, + url: "https://mergebot.example.test/owner/repository/pull/42", + }); + yield* dispatchFailure("wake-ci", (yield* projections.getShellSnapshot()).snapshotSequence); + const thread = yield* projections.getThread(threadId); + assert.equal(thread.settledOverride, "active"); + assert.equal(thread.settledAt, null); + assert.equal( + (yield* projections.getShellSnapshot({ location: "active", settledOnly: true })).threads + .length, + 0, + ); + const item = yield* projections.getTurnItem({ threadId, itemId }); + assert.ok(item?.type === "notification"); + assert.deepStrictEqual(item.source, { kind: "system" }); + assert.equal(item.summary, "CI failed for PR #42"); + assert.equal( + item.detail, + "[Open PR #42 in Mergebot]()", + ); + assert.equal(item.runId, null); + assert.equal( + (yield* projections.getThreadRecords(threadId, ["runs", "messages"])).runs.length, + 0, + ); + assert.equal( + (yield* projections.getThreadRecords(threadId, ["messages"])).messages.length, + 0, + ); + const effects = yield* sql<{ + type: string; + }>`SELECT effect_type AS type FROM orchestration_v2_effect_outbox`; + assert.ok( + effects.every( + (effect) => + ![ + "provider-runtime.continue", + "provider-turn.start", + "provider-turn.steer", + "provider-turn.restart", + ].includes(effect.type), + ), + ); + yield* engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("resettle-ci"), + threadId, + }); + yield* dispatchFailure( + "duplicate-ci", + (yield* projections.getShellSnapshot()).snapshotSequence, + ); + assert.equal((yield* projections.getThread(threadId)).settledOverride, "settled"); + assert.equal( + (yield* projections.getThreadRecords(threadId, ["turnItems"])).turnItems.length, + 1, + ); + }).pipe(Effect.provide(testLayer)), +); + +it.effect( + "a concurrent activation or edit invalidates pending automatic archival and CI wakeup", + () => + Effect.gen(function* () { + const engine = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("archive-race"); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-race"), + threadId, + projectId: ProjectId.make("project-ci"), + title: "Archive race", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("settle-race"), + threadId, + }); + const before = (yield* projections.getShellSnapshot()).snapshotSequence; + yield* engine.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("activate-race"), + threadId, + reason: "user", + }); + yield* engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("resettle-race"), + threadId, + }); + for (const command of [ + { + type: "thread.archive" as const, + commandId: CommandId.make("stale-archive"), + threadId, + snapshotSequence: before, + }, + { + type: "thread.ci-failed" as const, + commandId: CommandId.make("stale-ci"), + threadId, + snapshotSequence: before, + itemId: TurnItemId.make("stale-notice"), + pullRequestNumber: 42, + url: "https://mergebot.example.test/owner/repository/pull/42", + }, + ]) + assert.ok(Exit.isFailure(yield* Effect.exit(engine.dispatch(command)))); + assert.equal((yield* projections.getThread(threadId)).archivedAt, null); + assert.equal( + (yield* projections.getThreadRecords(threadId, ["turnItems"])).turnItems.length, + 0, + ); + yield* engine.dispatch({ + type: "thread.archive", + commandId: CommandId.make("fresh-archive"), + threadId, + snapshotSequence: (yield* projections.getShellSnapshot()).snapshotSequence, + }); + assert.notEqual((yield* projections.getThread(threadId)).archivedAt, null); + }).pipe(Effect.provide(testLayer)), +); + +it.effect( + "a new user turn prevents a pending archive or CI observation from changing the thread", + () => + Effect.gen(function* () { + const engine = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("new-run-race"); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-new-run"), + threadId, + projectId: ProjectId.make("project-ci"), + title: "New run race", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("settle-new-run"), + threadId, + }); + const snapshotSequence = (yield* projections.getShellSnapshot()).snapshotSequence; + yield* engine.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("new-user-turn"), + threadId, + messageId: MessageId.make("new-message"), + text: "Continue this work", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + for (const command of [ + { + type: "thread.archive" as const, + commandId: CommandId.make("archive-after-new-run"), + threadId, + snapshotSequence, + }, + { + type: "thread.ci-failed" as const, + commandId: CommandId.make("ci-after-new-run"), + threadId, + snapshotSequence, + itemId: TurnItemId.make("new-run-notice"), + pullRequestNumber: 42, + url: "https://mergebot.example.test/owner/repository/pull/42", + }, + ]) + assert.ok(Exit.isFailure(yield* Effect.exit(engine.dispatch(command)))); + assert.equal((yield* projections.getThread(threadId)).archivedAt, null); + assert.equal( + yield* projections.getTurnItem({ threadId, itemId: TurnItemId.make("new-run-notice") }), + null, + ); + assert.equal((yield* projections.getThreadRecords(threadId, ["runs"])).runs.length, 1); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 7016a17dcd8922e4a8a4f59f8c2e737dbb2c3840..ac482e96c6b3389cb0ebda7433f99f26904ec995 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -3,18 +3,26 @@ import { assert, describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import { DEFAULT_SERVER_SETTINGS, + CommandId, ProjectId, EventId, + RunId, + TurnItemId, + ManagedWorkspaceError, + DevServerError, ProviderInstanceId, ProviderSessionId, ThreadId, type OrchestrationProjectShell, type OrchestrationV2AppThread, - type OrchestrationV2Command, type OrchestrationV2DomainEvent, + type OrchestrationV2StoredEvent, + type TerminalSummary, type OrchestrationV2ShellSnapshot, type OrchestrationV2ThreadShell, type PullRequestSummary, + type MergebotPullRequestDetails, + type OrchestrationV2ServerCommand, type ServerSettings as ContractServerSettings, type ServerSettingsPatch, } from "@t3tools/contracts"; @@ -22,6 +30,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; @@ -36,10 +45,15 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerActivation from "../serverActivation.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; +import * as PreviewManager from "../preview/Manager.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ThreadSettlementService from "./ThreadSettlementService.ts"; +import * as ManagedWorkspaces from "../workspace/ManagedWorkspaces.ts"; +import * as DevServer from "../workspace/DevServer.ts"; +import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); const DAY_MS = 24 * 60 * 60 * 1_000; @@ -152,7 +166,7 @@ describe("isAutoSettlementCandidate", () => { ).toBe(false); }); - it("settles a thread whose only background work is a command left running", () => { + it("keeps a thread with a running background command active", () => { expect( ThreadSettlementService.isAutoSettlementCandidate( shell({ @@ -162,7 +176,7 @@ describe("isAutoSettlementCandidate", () => { }), NOW_MS, ), - ).toBe(true); + ).toBe(false); }); it("keeps snoozed threads parked until they wake early on error or completion", () => { @@ -251,6 +265,34 @@ describe("threadHasQueuedTurnStart", () => { }); describe("resolveAutoSettlementAt", () => { + it("keeps canonical work PR links while ignoring their cached lifecycle", () => { + const input = { + thread: shell({ + latestUserMessageAt: at(-4 * DAY_MS), + pullRequests: [link(1)], + }), + pullRequest: null, + usePullRequestSnapshots: false, + nowMs: NOW_MS, + autoSettleAfterDays: 3, + autoSettleOnMerge: true, + autoSettleKeepPullRequests: true, + }; + expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull(); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + ...input, + autoSettleKeepPullRequests: false, + }), + ).toEqual(at(-4 * DAY_MS)); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + ...input, + pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + }), + ).toEqual(at(-4 * DAY_MS)); + }); + it("keeps threads with a PR from inactivity settlement only when asked", () => { const input = { thread: shell({ @@ -416,6 +458,24 @@ const PROJECT_ID = ProjectId.make("settlement-project"); const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); describe("autoSettlementSettingsKey", () => { + it("tracks PR retention changes at environment and project scope", () => { + const base = ThreadSettlementService.autoSettlementSettingsKey(DEFAULT_SERVER_SETTINGS); + expect( + ThreadSettlementService.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleKeepPullRequests: true, + }), + ).not.toBe(base); + expect( + ThreadSettlementService.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [PROJECT_ID]: { sidebarAutoSettleKeepPullRequests: true }, + }, + }), + ).not.toBe(base); + }); + it("distinguishes a project that inherits the threshold from one that disables it", () => { const inherits = ThreadSettlementService.autoSettlementSettingsKey({ ...DEFAULT_SERVER_SETTINGS, @@ -446,7 +506,12 @@ describe("autoSettlementSettingsKey", () => { }); }); -type AutoSettleCommand = Extract; +type SettlementCommand = Extract< + OrchestrationV2ServerCommand, + { + readonly type: "thread.auto-settle" | "thread.archive" | "thread.ci-failed" | "thread.unsettle"; + } +>; const testCrypto = Crypto.make({ randomBytes: (size) => new Uint8Array(size).fill(1), @@ -468,16 +533,19 @@ function makeProject( }; } -function makeThread(id: string, overrides: Partial = {}): SettlementShell { - return shell({ - id: ThreadId.make(id), - projectId: PROJECT_ID, - title: id, - createdAt: DateTime.makeUnsafe("2026-08-01T00:00:00.000Z"), - updatedAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), - latestUserMessageAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), - ...overrides, - }); +function makeThread(id: string, overrides: Partial = {}) { + return { + ...shell({ + id: ThreadId.make(id), + projectId: PROJECT_ID, + title: id, + createdAt: DateTime.makeUnsafe("2026-08-01T00:00:00.000Z"), + updatedAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), + latestUserMessageAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), + ...overrides, + }), + lastVisitedAt: overrides.lastVisitedAt ?? null, + }; } type SettlementSnapshot = Omit & { @@ -533,19 +601,109 @@ function makeBranchPullRequest(state: "open" | "closed" | "merged") { } satisfies GitManager.GitBranchPullRequest; } +function terminal(threadId: ThreadId): TerminalSummary { + return { + threadId, + terminalId: "default", + cwd: "/workspace/project", + worktreePath: null, + status: "running", + pid: 123, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: true, + label: "command", + updatedAt: NOW, + }; +} + +function link(number: number) { + return { + host: "github.com", + repository: "owner/repository", + number, + url: `https://github.com/owner/repository/pull/${number}`, + source: "manual" as const, + linkedAt: NOW, + snapshot: null, + stack: null, + }; +} + +function mergebotDetail( + number: number, + overrides: Partial = {}, +): MergebotPullRequestDetails { + return { + number, + headSha: "current-head", + lifecycle: "open", + queueStatus: "not_queued", + position: null, + mergeEtaAt: null, + url: `https://mergebot.example.test/owner/repository/pull/${number}`, + title: "Pull request", + createdAt: NOW, + author: null, + additions: 1, + deletions: 0, + changedFilesCount: 1, + labels: [], + comments: [], + observedAt: Date.parse(NOW), + updatedAt: NOW, + mergedAt: null, + closedAt: null, + body: null, + draft: false, + baseBranch: "main", + headBranch: "feature", + ciStatus: "passed", + hasConflicts: false, + checks: [], + ...overrides, + }; +} + +function failedCheck( + overrides: Partial = {}, +): MergebotPullRequestDetails["checks"][number] { + return { + id: "check-1", + name: "build", + workflowName: null, + status: "completed", + conclusion: "failure", + url: null, + startedAt: null, + completedAt: NOW, + ...overrides, + }; +} + interface HarnessOptions { readonly snapshot: SettlementSnapshot; readonly settings?: ContractServerSettings; readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"]; readonly existingWorktreePaths?: ReadonlyArray; - readonly onDispatch?: (command: AutoSettleCommand) => Effect.Effect; + readonly managedThreadIds?: ReadonlyArray; + readonly managedLookup?: ManagedWorkspaces.ManagedWorkspaces["Service"]["isManagedThread"]; + readonly pullRequestDetail?: PullRequestService.PullRequestService["Service"]["detail"]; + readonly mergebotDetail?: MergebotQueue.MergebotQueue["Service"]["readPullRequest"]; + readonly mergebotFind?: MergebotQueue.MergebotQueue["Service"]["findPullRequest"]; + readonly previousCiNotice?: boolean; + readonly previousItemIds?: ReadonlySet; + readonly settlementTimes?: ReadonlyMap; + readonly onDispatch?: (command: SettlementCommand) => Effect.Effect; /** Runs before each settle script start; a failure fails that start. */ readonly onScriptRun?: ( input: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput, ) => Effect.Effect; - /** Runs inside each `closeIdle` call. */ - readonly onCloseIdle?: () => Effect.Effect; + /** Runs inside each `close` call. */ + readonly onCloseTerminals?: () => Effect.Effect; + readonly terminals?: ReadonlyArray; + readonly onStopUnused?: (threadId: ThreadId) => Effect.Effect; /** Threads `getThread` returns when a `thread.settled` event is handled. */ readonly currentThreads?: ReadonlyArray; } @@ -559,7 +717,13 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); const settingsChanges = yield* PubSub.unbounded(); const mergedPullRequests = yield* PubSub.unbounded(); - const commands = yield* Ref.make>([]); + const mergebotChanges = yield* PubSub.unbounded<{ + readonly repository: string; + readonly number?: number; + }>(); + const mergebotSubscribed = yield* Deferred.make(); + const providerDetailReads = yield* Ref.make(0); + const commands = yield* Ref.make>([]); const branchCalls = yield* Ref.make< ReadonlyArray<{ readonly cwd: string; readonly branch: string }> >([]); @@ -572,10 +736,17 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: >([]); const summaryRecovery = yield* Ref.make>([]); const invalidatedCwds = yield* Ref.make>([]); - const domainEvents = yield* PubSub.unbounded(); - const closedIdle = yield* Queue.unbounded<{ readonly threadId: string }>(); + const domainEvents = yield* PubSub.unbounded(); + const previews = yield* PreviewManager.make.pipe( + Effect.provideService(Crypto.Crypto, testCrypto), + ); + const closedTerminals = yield* Queue.unbounded<{ readonly threadId: string }>(); + const archiveReads = yield* Queue.unbounded(); + const fullArchiveReadCount = yield* Ref.make(0); + const singleArchiveReads = yield* Ref.make>([]); const scriptRuns = yield* Queue.unbounded(); + const devServerStops = yield* Ref.make>([]); const updateSettings = (patch: ServerSettingsPatch) => Effect.gen(function* () { @@ -611,7 +782,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: }); const dispatch: Orchestrator.OrchestratorV2Shape["dispatch"] = (command) => { - if (command.type !== "thread.auto-settle") { + if ( + command.type !== "thread.auto-settle" && + command.type !== "thread.archive" && + command.type !== "thread.ci-failed" && + command.type !== "thread.unsettle" + ) { return Effect.die(new Error(`Unexpected command: ${command.type}`)); } return Ref.update(commands, (recorded) => [...recorded, command]).pipe( @@ -634,10 +810,74 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: }); const layerDependencies = Layer.mergeAll( + Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({ + latestAgentSequence: () => Effect.succeed(1), + getThreadSettlementTime: (threadId) => + Ref.get(snapshots).pipe( + Effect.map( + (snapshot) => + options.settlementTimes?.get(threadId) ?? + snapshot.threads.find((thread) => thread.id === threadId)?.settledAt ?? + null, + ), + ), + }), + Layer.mock(ManagedWorkspaces.ManagedWorkspaces)({ + isManagedThread: + options.managedLookup ?? + ((threadId) => Effect.succeed(options.managedThreadIds?.includes(threadId) ?? false)), + }), Layer.mock(ProjectStore.ProjectStoreV2)({ listShells: () => Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)), }), Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: (threadId) => + Ref.update(singleArchiveReads, (reads) => [...reads, threadId]).pipe( + Effect.andThen(Queue.offer(archiveReads, undefined)), + Effect.andThen(Ref.get(snapshots)), + Effect.map( + (snapshot) => + [...snapshot.threads, ...snapshot.archivedThreads].find( + (thread) => thread.id === threadId, + ) ?? null, + ), + ), + getShellSnapshot: (input) => + Ref.update(fullArchiveReadCount, (count) => count + 1).pipe( + Effect.andThen(Queue.offer(archiveReads, undefined)), + Effect.andThen(Ref.get(snapshots)), + Effect.map((snapshot) => ({ + ...snapshot, + threads: snapshot.threads.filter( + (thread) => !input?.settledOnly || thread.settledOverride === "settled", + ), + })), + ), + getTurnItem: (input) => + Effect.succeed( + options.previousCiNotice || options.previousItemIds?.has(input.itemId) + ? { + ...input, + id: input.itemId, + type: "notification", + source: { kind: "system" }, + outcome: "failed", + summary: "CI failed", + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: null, + startedAt: DateTime.makeUnsafe(NOW), + completedAt: DateTime.makeUnsafe(NOW), + updatedAt: DateTime.makeUnsafe(NOW), + } + : null, + ), getSettlementCandidates: (threadId) => Ref.update(candidateReads, (reads) => [...reads, threadId]).pipe( Effect.andThen(Ref.updateAndGet(snapshotReadCount, (count) => count + 1)), @@ -657,12 +897,25 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: }, }), Layer.mock(Orchestrator.OrchestratorV2)({ - streamDomainEvents: Stream.fromPubSub(domainEvents), + streamStoredEventsFrom: () => Stream.fromPubSub(domainEvents), dispatch, }), Layer.mock(TerminalManager.TerminalManager)({ - closeIdle: (input) => - Queue.offer(closedIdle, input).pipe(Effect.andThen(options.onCloseIdle?.() ?? Effect.void)), + close: (input) => + Queue.offer(closedTerminals, input).pipe( + Effect.andThen(options.onCloseTerminals?.() ?? Effect.void), + ), + list: ({ threadId }) => + Effect.succeed( + options.terminals?.filter((terminal) => terminal.threadId === threadId) ?? [], + ), + }), + Layer.succeed(PreviewManager.PreviewManager, previews), + Layer.mock(DevServer.DevServer)({ + stopUnused: (threadId) => + Ref.update(devServerStops, (stops) => [...stops, threadId]).pipe( + Effect.andThen(options.onStopUnused?.(threadId) ?? Effect.void), + ), }), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ runForThread: (input) => @@ -676,11 +929,34 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]), }), Layer.mock(PullRequestService.PullRequestService)({ + invalidate: () => Effect.void, + detail: (input, ...rest) => + Ref.update(providerDetailReads, (count) => count + 1).pipe( + Effect.andThen( + options.pullRequestDetail?.(input, ...rest) ?? Effect.die("Unexpected detail read"), + ), + ), summary: pullRequestSummary, subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), }), + Layer.mock(MergebotQueue.MergebotQueue)({ + configuration: { + origin: "https://mergebot.example.test", + repositories: ["owner/repository"], + }, + read: () => Effect.succeed(null), + readPullRequest: options.mergebotDetail ?? (() => Effect.succeed(null)), + findPullRequest: options.mergebotFind ?? (() => Effect.succeed(null)), + streamChanges: Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(mergebotChanges); + yield* Deferred.succeed(mergebotSubscribed, undefined); + return Stream.fromSubscription(subscription); + }), + ), + }), Layer.succeed(ServerSettings.ServerSettingsService, serverSettings), Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), Layer.succeed(Crypto.Crypto, testCrypto), @@ -699,10 +975,23 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: branchCalls, summaryCalls, summaryRecovery, + providerDetailReads, + mergebotSubscribed, + publishMergebotChange: (number?: number) => + PubSub.publish(mergebotChanges, { + repository: "owner/repository", + ...(number === undefined ? {} : { number }), + }), invalidatedCwds, - closedIdle, + closedTerminals, + archiveReads, + fullArchiveReadCount, + singleArchiveReads, scriptRuns, - publishEvent: (event: OrchestrationV2DomainEvent) => PubSub.publish(domainEvents, event), + devServerStops, + previews, + publishEvent: (event: OrchestrationV2DomainEvent, commandId: CommandId | null = null) => + PubSub.publish(domainEvents, { sequence: 2, commandId, event }), updateSettings, publishMerge: PubSub.publish(mergedPullRequests, { projectId: PROJECT_ID, @@ -726,6 +1015,231 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( }); describe("ThreadSettlementServiceV2 worker", () => { + it.effect.each([true, false])( + "honors inactivity PR retention %s for canonical work links without cached snapshots", + (keepPullRequests) => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = makeThread("inactive-canonical-work-pr", { pullRequests: [link(1)] }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([thread]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: 3, + sidebarAutoSettleKeepPullRequests: keepPullRequests, + }, + }); + + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual( + keepPullRequests ? [] : [thread.id], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("uses only Mergebot for configured linked and branch pull requests", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const workProject = { + ...makeProject(), + repositoryIdentity: { + canonicalKey: "github.com/owner/repository", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/owner/repository", + }, + }, + }; + const legacy = makeThread("legacy-work-pr", { + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 1, + url: link(1).url, + }, + }); + const linked = makeThread("linked-work-pr", { + pullRequests: [ + { + ...link(1), + snapshot: { + state: "open", + title: "Old snapshot", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + }, + }, + ], + }); + const branch = makeThread("branch-work-pr", { branch: "feature" }); + const reused = makeThread("reused-work-branch", { + linkedPullRequest: legacy.linkedPullRequest, + branch: "reused-feature", + }); + const fixture = yield* makeHarness({ + settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null }, + snapshot: makeSnapshot([legacy, linked, branch, reused], [workProject]), + mergebotDetail: ({ number }) => + Effect.succeed(mergebotDetail(number, { lifecycle: "merged", mergedAt: NOW })), + mergebotFind: ({ headBranch }) => + Effect.succeed( + mergebotDetail(1, { + lifecycle: headBranch === "reused-feature" ? "open" : "merged", + mergedAt: NOW, + }), + ), + }); + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + expect( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId).toSorted(), + ).toEqual([legacy.id, linked.id, branch.id].toSorted()); + expect(yield* Ref.get(fixture.branchCalls)).toEqual([]); + expect(yield* Ref.get(fixture.summaryCalls)).toEqual([]); + expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect( + "ignores cached work PR snapshots and unknown merge times while inactivity stays independent", + () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = makeThread("unknown-work-pr", { + pullRequests: [ + { + ...link(1), + snapshot: { + state: "merged", + title: "Cached merge", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + mergedAt: NOW, + }, + }, + ], + }); + for (const state of ["disconnected", "unknown-time", "inactive"] as const) { + const fixture = yield* makeHarness({ + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: state === "inactive" ? 1 : null, + }, + snapshot: makeSnapshot([thread]), + mergebotDetail: () => + Effect.succeed( + state === "unknown-time" + ? mergebotDetail(1, { lifecycle: "merged", updatedAt: NOW, mergedAt: null }) + : null, + ), + }); + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + expect((yield* Ref.get(fixture.commands)).length).toBe(state === "inactive" ? 1 : 0); + expect(yield* Ref.get(fixture.branchCalls)).toEqual([]); + expect(yield* Ref.get(fixture.summaryCalls)).toEqual([]); + expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0); + }).pipe(Effect.provide(fixture.layer)); + } + }), + ), + ); + + it.effect("rechecks open resources after waiting for a merged pull request", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const lookupStarted = yield* Deferred.make(); + const lookupResult = yield* Deferred.make(); + const thread = makeThread("open-during-lookup", { branch: "feature" }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([thread]), + settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null }, + branchPullRequest: () => + Deferred.succeed(lookupStarted, undefined).pipe( + Effect.andThen(Deferred.await(lookupResult)), + ), + }); + + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + const startup = yield* startHarness( + service, + fixture.activation, + fixture.snapshotReads, + ).pipe(Effect.forkScoped); + yield* Deferred.await(lookupStarted); + const tab = yield* fixture.previews.open({ threadId: thread.id }); + yield* Deferred.succeed(lookupResult, makeBranchPullRequest("merged")); + yield* Fiber.join(startup); + expect(yield* Ref.get(fixture.commands)).toEqual([]); + expect((yield* fixture.previews.list({ threadId: thread.id })).sessions).toEqual([tab]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps unattended chats with terminals, browser tabs, or commands active", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const runningTerminal = makeThread("running-terminal"); + const idleTerminal = makeThread("idle-terminal"); + const exitedTerminal = makeThread("exited-terminal"); + const browser = makeThread("browser-tab"); + const background = makeThread("background-command", { + pendingBackgroundTasks: [{ taskId: "command", kind: "command" }], + }); + const quiet = makeThread("quiet-chat"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + runningTerminal, + idleTerminal, + exitedTerminal, + browser, + background, + quiet, + ]), + settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 1 }, + terminals: [ + terminal(runningTerminal.id), + { ...terminal(idleTerminal.id), hasRunningSubprocess: false }, + { ...terminal(exitedTerminal.id), status: "exited", pid: null }, + ], + }); + yield* fixture.previews.open({ threadId: browser.id }); + + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ + quiet.id, + ]); + expect((yield* fixture.previews.list({ threadId: browser.id })).sessions).toHaveLength(1); + expect(yield* Queue.size(fixture.closedTerminals)).toBe(0); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("settles a merged pull request stored only in the thread links", () => Effect.scoped( Effect.gen(function* () { @@ -769,6 +1283,54 @@ describe("ThreadSettlementServiceV2 worker", () => { ), ); + it.effect("reevaluates a merged link immediately after its compact update event", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const mergedLink = { + ...link(42), + host: "example.test", + url: "https://example.test/owner/repository/pull/42", + snapshot: { + state: "merged" as const, + title: "Pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + mergedAt: NOW, + }, + }; + const thread = makeThread("compact-merged-link", { pullRequests: [mergedLink] }); + const settled = yield* Deferred.make(); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null }, + onDispatch: () => Deferred.succeed(settled, undefined), + }); + + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + yield* Ref.set(fixture.snapshots, makeSnapshot([thread])); + yield* fixture.publishEvent({ + id: EventId.make("compact-merged-link"), + type: "thread.pull-request-link-updated", + threadId: thread.id, + occurredAt: DateTime.makeUnsafe(NOW), + payload: mergedLink, + }); + yield* Deferred.await(settled); + yield* service.drain; + expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ + thread.id, + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("settles only the project opted in while environment settlement is disabled", () => Effect.scoped( Effect.gen(function* () { @@ -814,6 +1376,8 @@ describe("ThreadSettlementServiceV2 worker", () => { yield* startHarness(service, fixture.activation, fixture.snapshotReads); const commands = yield* Ref.get(fixture.commands); expect(commands).toHaveLength(1); + assert.equal(commands[0]?.type, "thread.auto-settle"); + if (commands[0]?.type !== "thread.auto-settle") return; expect(commands[0]?.settledAt).toEqual(thread.latestRunCompletedAt); expect(commands[0]?.snapshotAt).toEqual(thread.updatedAt); }).pipe(Effect.provide(fixture.layer)); @@ -1077,6 +1641,185 @@ describe("ThreadSettlementServiceV2 worker", () => { ); }); +describe("ThreadSettlementServiceV2 development servers", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleOnMerge: false, + sidebarAutoSettleAfterDays: null, + }; + const settled = (id: string, overrides: Partial = {}) => + makeThread(id, { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(NOW), + ...overrides, + }); + const start = Effect.fn("startDevServerCleanupHarness")(function* ( + fixture: Effect.Success>, + ) { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* service.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.archiveReads); + yield* service.drain; + return service; + }); + + it.effect( + "reconciles settled and archived servers on startup with auto-settlement disabled", + () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const parked = settled("parked"); + const archived = makeThread("archived", { archivedAt: DateTime.makeUnsafe(NOW) }); + const fixture = yield* makeHarness({ + settings, + snapshot: { + ...makeSnapshot([parked, makeThread("active")]), + archivedThreads: [archived], + }, + managedThreadIds: [parked.id], + }); + yield* start(fixture).pipe(Effect.provide(fixture.layer)); + assert.deepStrictEqual((yield* Ref.get(fixture.devServerStops)).toSorted(), [ + archived.id, + parked.id, + ]); + assert.equal(yield* Queue.size(fixture.scriptRuns), 0); + }), + ), + ); + + it.effect( + "settling retains a managed workspace while stopping its separate development server", + () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = settled("managed", { worktreePath: null }); + const fixture = yield* makeHarness({ + settings, + snapshot: makeSnapshot([]), + currentThreads: [thread], + managedThreadIds: [thread.id], + }); + yield* Effect.gen(function* () { + const service = yield* start(fixture); + yield* Ref.set(fixture.snapshots, makeSnapshot([thread])); + yield* fixture.publishEvent({ + type: "thread.settled", + id: EventId.make("settled:managed"), + threadId: thread.id, + occurredAt: DateTime.makeUnsafe(NOW), + payload: thread, + }); + yield* Queue.take(fixture.archiveReads); + yield* service.drain; + assert.deepStrictEqual(yield* Ref.get(fixture.devServerStops), [thread.id]); + assert.equal(yield* Queue.size(fixture.scriptRuns), 0); + assert.equal(yield* Queue.size(fixture.closedTerminals), 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("retries a failed shutdown on the next minute without blocking healthy threads", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const failed = settled("failed-stop"); + const healthy = settled("healthy-stop"); + const attempts = yield* Ref.make(0); + const fixture = yield* makeHarness({ + settings, + snapshot: makeSnapshot([failed, healthy]), + onStopUnused: (threadId) => + threadId !== failed.id + ? Effect.void + : Ref.updateAndGet(attempts, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.fail(new DevServerError({ reason: "status" })) + : Effect.void, + ), + ), + }); + yield* Effect.gen(function* () { + const service = yield* start(fixture); + assert.deepStrictEqual((yield* Ref.get(fixture.devServerStops)).toSorted(), [ + failed.id, + healthy.id, + ]); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.archiveReads); + yield* service.drain; + assert.equal(yield* Ref.get(attempts), 2); + assert.equal((yield* Ref.get(fixture.devServerStops)).length, 4); + assert.equal(yield* Queue.size(fixture.scriptRuns), 0); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("committed archival stops an unsettled thread's server", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = makeThread("archiving", { archivedAt: DateTime.makeUnsafe(NOW) }); + const fixture = yield* makeHarness({ settings, snapshot: makeSnapshot([]) }); + yield* Effect.gen(function* () { + const service = yield* start(fixture); + yield* Ref.set(fixture.snapshots, { + ...makeSnapshot([]), + archivedThreads: [thread], + }); + yield* fixture.publishEvent({ + type: "thread.archived", + id: EventId.make("archived:archiving"), + threadId: thread.id, + occurredAt: DateTime.makeUnsafe(NOW), + payload: thread, + }); + yield* Queue.take(fixture.archiveReads); + yield* service.drain; + assert.deepStrictEqual(yield* Ref.get(fixture.devServerStops), [thread.id]); + assert.equal(yield* Queue.size(fixture.scriptRuns), 0); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("a settlement event already overtaken by revival does not stop its server", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const old = settled("revived"); + const active = makeThread("revived", { settledOverride: "active", status: "running" }); + const fixture = yield* makeHarness({ + settings, + snapshot: makeSnapshot([]), + currentThreads: [active], + }); + yield* Effect.gen(function* () { + const service = yield* start(fixture); + yield* Ref.set(fixture.snapshots, makeSnapshot([active])); + yield* fixture.publishEvent({ + type: "thread.settled", + id: EventId.make("settled:revived"), + threadId: old.id, + occurredAt: DateTime.makeUnsafe(NOW), + payload: old, + }); + yield* Queue.take(fixture.archiveReads); + yield* service.drain; + assert.deepStrictEqual(yield* Ref.get(fixture.devServerStops), []); + assert.equal(yield* Queue.size(fixture.closedTerminals), 0); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); +}); + describe("ThreadSettlementServiceV2 terminals", () => { const appThread = ( id: string, @@ -1116,7 +1859,7 @@ describe("ThreadSettlementServiceV2 terminals", () => { payload: thread, }); - it.effect("closes the thread's idle terminals when it settles", () => + it.effect("closes all of the thread's terminals and browser tabs when it settles", () => Effect.scoped( Effect.gen(function* () { const thread = appThread("settled-thread", "settled"); @@ -1124,12 +1867,88 @@ describe("ThreadSettlementServiceV2 terminals", () => { snapshot: makeSnapshot([]), currentThreads: [thread], }); + yield* fixture.previews.open({ threadId: thread.id }); + yield* fixture.previews.open({ threadId: thread.id, runtime: "server" }); + const peerId = ThreadId.make("other-chat"); + const peerTab = yield* fixture.previews.open({ threadId: peerId }); + const previewEvents = yield* fixture.previews.subscribeEvents; yield* Effect.gen(function* () { const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; yield* startHarness(service, fixture.activation, fixture.snapshotReads); yield* fixture.publishEvent(settledEvent(thread)); - assert.deepStrictEqual(yield* Queue.take(fixture.closedIdle), { threadId: thread.id }); + assert.deepStrictEqual(yield* Queue.take(fixture.closedTerminals), { + threadId: thread.id, + }); + expect((yield* PubSub.take(previewEvents)).type).toBe("closed"); + expect((yield* PubSub.take(previewEvents)).type).toBe("closed"); + expect((yield* fixture.previews.list({ threadId: thread.id })).sessions).toEqual([]); + expect((yield* fixture.previews.list({ threadId: peerId })).sessions).toEqual([peerTab]); + const active = appThread("settled-thread", "active"); + yield* fixture.publishEvent({ + type: "thread.unsettled", + id: EventId.make("event:unsettled:managed"), + threadId: active.id, + occurredAt: DateTime.makeUnsafe(NOW), + payload: active, + }); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("preserves resources opened during an automatic settlement", () => + Effect.scoped( + Effect.gen(function* () { + const thread = appThread("automatic-thread", "settled", "/worktrees/automatic"); + const marker = appThread("marker-thread", "settled", "/worktrees/marker"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + currentThreads: [thread, marker], + terminals: [terminal(thread.id)], + existingWorktreePaths: ["/worktrees/automatic", "/worktrees/marker"], + }); + const tab = yield* fixture.previews.open({ threadId: thread.id }); + + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + yield* fixture.publishEvent( + settledEvent(thread), + CommandId.make("server:auto-settle:automatic-thread:1"), + ); + yield* fixture.publishEvent(settledEvent(marker)); + expect((yield* Queue.take(fixture.scriptRuns)).threadId).toBe(marker.id); + expect(yield* Queue.take(fixture.closedTerminals)).toEqual({ threadId: marker.id }); + expect(yield* Queue.size(fixture.closedTerminals)).toBe(0); + expect((yield* fixture.previews.list({ threadId: thread.id })).sessions).toEqual([tab]); + expect(yield* Ref.get(fixture.commands)).toMatchObject([ + { type: "thread.unsettle", threadId: thread.id, snapshotSequence: 2 }, + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("ignores a stale settle event after the chat was revived and settled again", () => + Effect.scoped( + Effect.gen(function* () { + const previous = appThread("resettled-thread", "settled"); + const current = { ...previous, settledAt: DateTime.makeUnsafe(Date.parse(NOW) + 1) }; + const marker = appThread("marker-thread", "settled"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + currentThreads: [current, marker], + }); + const tab = yield* fixture.previews.open({ threadId: current.id }); + + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + yield* fixture.publishEvent(settledEvent(previous)); + yield* fixture.publishEvent(settledEvent(marker)); + expect(yield* Queue.take(fixture.closedTerminals)).toEqual({ threadId: marker.id }); + expect((yield* fixture.previews.list({ threadId: current.id })).sessions).toEqual([tab]); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -1153,7 +1972,9 @@ describe("ThreadSettlementServiceV2 terminals", () => { // Events run in order, so the first close belongs to the later // event only if the re-engaged thread was skipped. yield* fixture.publishEvent(settledEvent(marker)); - assert.deepStrictEqual(yield* Queue.take(fixture.closedIdle), { threadId: marker.id }); + assert.deepStrictEqual(yield* Queue.take(fixture.closedTerminals), { + threadId: marker.id, + }); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -1188,32 +2009,37 @@ describe("ThreadSettlementServiceV2 terminals", () => { ), ); - it.effect("skips the settle script for a thread re-engaged while its shells close", () => - Effect.scoped( - Effect.gen(function* () { - const settled = appThread("reengaging-thread", "settled", "/worktrees/reengaging"); - const marker = appThread("marker-thread", "settled", "/worktrees/marker"); - const currentThreads = [settled, marker]; - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([]), - currentThreads, - existingWorktreePaths: ["/worktrees/reengaging", "/worktrees/marker"], - // The user sends a message while the first close checks processes. - onCloseIdle: () => - Effect.sync(() => { - currentThreads[0] = { ...settled, settledOverride: "active" }; - }), - }); + it.effect( + "keeps browser tabs and skips the settle script when revived while terminals close", + () => + Effect.scoped( + Effect.gen(function* () { + const settled = appThread("reengaging-thread", "settled", "/worktrees/reengaging"); + const marker = appThread("marker-thread", "settled", "/worktrees/marker"); + const currentThreads = [settled, marker]; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + currentThreads, + existingWorktreePaths: ["/worktrees/reengaging", "/worktrees/marker"], + onCloseTerminals: () => + Effect.sync(() => { + currentThreads[0] = { ...settled, settledOverride: "active" }; + }), + }); + const tab = yield* fixture.previews.open({ threadId: settled.id }); - yield* Effect.gen(function* () { - const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; - yield* startHarness(service, fixture.activation, fixture.snapshotReads); - yield* fixture.publishEvent(settledEvent(settled)); - yield* fixture.publishEvent(settledEvent(marker)); - assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id); - }).pipe(Effect.provide(fixture.layer)); - }), - ), + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + yield* fixture.publishEvent(settledEvent(settled)); + yield* fixture.publishEvent(settledEvent(marker)); + assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id); + expect((yield* fixture.previews.list({ threadId: settled.id })).sessions).toEqual([ + tab, + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), ); it.effect("runs the settle script once when a settlement is re-emitted", () => @@ -1327,3 +2153,408 @@ describe("isSnoozed", () => { ).toBe(false); }); }); + +describe("settled thread archival", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleOnMerge: false, + sidebarAutoSettleAfterDays: null, + }; + + const start = Effect.fn("startArchiveHarness")(function* ( + fixture: Effect.Success>, + ) { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* service.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.archiveReads); + yield* service.drain; + }); + + it.effect( + "archives from a live Mergebot merge update without a minute timer or provider lookup", + () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = makeThread("live-merge", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(NOW), + pullRequests: [link(1)], + }); + const detail = yield* Ref.make(null); + const archived = yield* Deferred.make(); + const fixture = yield* makeHarness({ + settings, + snapshot: makeSnapshot([thread]), + mergebotDetail: () => Ref.get(detail), + onDispatch: (command) => + command.type === "thread.archive" + ? Deferred.succeed(archived, undefined).pipe(Effect.asVoid) + : Effect.void, + }); + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* start(fixture); + yield* Deferred.await(fixture.mergebotSubscribed); + expect(yield* Ref.get(fixture.commands)).toEqual([]); + yield* Ref.set(detail, mergebotDetail(1, { lifecycle: "merged", mergedAt: NOW })); + yield* fixture.publishMergebotChange(1); + yield* TestClock.adjust("100 millis"); + yield* Deferred.await(archived); + yield* service.drain; + expect((yield* Ref.get(fixture.commands)).map((command) => command.type)).toEqual([ + "thread.archive", + ]); + expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("Saturday archival stays available while Mergebot is disconnected", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const boundary = ThreadSettlementService.lastSaturdayArchiveBoundary(Date.parse(NOW)); + const fixture = yield* makeHarness({ + settings, + snapshot: makeSnapshot([ + makeThread("offline-saturday", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(boundary), + pullRequests: [link(1)], + }), + ]), + mergebotDetail: () => Effect.succeed(null), + }); + yield* start(fixture).pipe(Effect.provide(fixture.layer)); + expect((yield* Ref.get(fixture.commands)).map((command) => command.type)).toEqual([ + "thread.archive", + ]); + expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0); + }), + ), + ); + + it("uses Saturday at nine in the server timezone, including a DST week", () => { + const local = DateTime.zoneMakeLocal(); + const saturday = DateTime.makeZonedUnsafe( + { year: 2026, month: 11, day: 7, hour: 9 }, + { timeZone: local, adjustForTimeZone: true }, + ); + const friday = DateTime.subtract(saturday, { days: 1 }); + expect( + ThreadSettlementService.lastSaturdayArchiveBoundary(DateTime.toEpochMillis(saturday)), + ).toBe(DateTime.toEpochMillis(saturday)); + expect( + ThreadSettlementService.lastSaturdayArchiveBoundary(DateTime.toEpochMillis(friday)), + ).toBe(DateTime.toEpochMillis(DateTime.subtract(saturday, { weeks: 1 }))); + expect( + ThreadSettlementService.lastSaturdayArchiveBoundary( + DateTime.toEpochMillis(DateTime.subtract(saturday, { milliseconds: 1 })), + ), + ).toBe(DateTime.toEpochMillis(DateTime.subtract(saturday, { weeks: 1 }))); + }); + + it.effect( + "catches up after restart, excluding fresh settlements and busy threads with auto-settlement disabled", + () => + Effect.scoped( + Effect.gen(function* () { + const nowMs = Date.parse(NOW); + yield* TestClock.setTime(nowMs); + const boundary = ThreadSettlementService.lastSaturdayArchiveBoundary(nowMs); + const fixture = yield* makeHarness({ + settings, + snapshot: makeSnapshot([ + makeThread("old", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(boundary), + }), + makeThread("fresh", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(boundary + 1), + }), + makeThread("busy", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(boundary), + activeRunId: RunId.make("run"), + status: "running", + }), + makeThread("queued", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(boundary), + latestUserMessageAt: DateTime.makeUnsafe(nowMs), + }), + makeThread("active", { settledOverride: "active" }), + makeThread("backdated", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(boundary - DAY_MS), + }), + ]), + settlementTimes: new Map([[ThreadId.make("backdated"), DateTime.makeUnsafe(nowMs)]]), + }); + yield* start(fixture).pipe(Effect.provide(fixture.layer)); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => [command.type, command.threadId]), + [["thread.archive", ThreadId.make("old")]], + ); + }), + ), + ); + + it.effect( + "archives a settled managed thread only when every visible PR merged, reading shared PRs once", + () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const reads = yield* Ref.make>([]); + const threads = ["merged-a", "merged-b", "stack-open"].map((id) => + makeThread(id, { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(NOW), + pullRequests: id === "stack-open" ? [link(1), link(2)] : [link(1)], + }), + ); + const fixture = yield* makeHarness({ + settings, + snapshot: makeSnapshot(threads), + managedThreadIds: threads.map((thread) => thread.id), + mergebotDetail: (reference) => + Ref.update(reads, (values) => [...values, reference.number]).pipe( + Effect.as( + mergebotDetail(reference.number, { + lifecycle: reference.number === 1 ? "merged" : "open", + }), + ), + ), + }); + yield* start(fixture).pipe(Effect.provide(fixture.layer)); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId).toSorted(), + [ThreadId.make("merged-a"), ThreadId.make("merged-b")], + ); + assert.deepStrictEqual((yield* Ref.get(reads)).toSorted(), [1, 2]); + }), + ), + ); + + it.effect("a managed lookup failure leaves healthy archival and auto-settlement running", () => + Effect.scoped( + Effect.gen(function* () { + const nowMs = Date.parse(NOW); + yield* TestClock.setTime(nowMs); + const boundary = ThreadSettlementService.lastSaturdayArchiveBoundary(nowMs); + const failed = makeThread("failed-lookup", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(boundary), + pullRequests: [{ ...link(1), repository: "unconfigured/repository" }], + }); + const fixture = yield* makeHarness({ + settings: { ...settings, sidebarAutoSettleAfterDays: 1 }, + snapshot: makeSnapshot([ + failed, + makeThread("plain-archive", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(boundary), + }), + makeThread("inactive", { + latestUserMessageAt: DateTime.makeUnsafe(nowMs - 30 * DAY_MS), + }), + ]), + managedLookup: () => Effect.fail(new ManagedWorkspaceError({ reason: "workspace" })), + }); + yield* start(fixture).pipe(Effect.provide(fixture.layer)); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => [command.type, command.threadId]), + [ + ["thread.archive", ThreadId.make("plain-archive")], + ["thread.auto-settle", ThreadId.make("inactive")], + ], + ); + }), + ), + ); + + it.effect("observes configured work PRs from the repository's main checkout", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = makeThread("root-work-pr", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(NOW), + worktreePath: null, + pullRequests: [link(1)], + }); + const fixture = yield* makeHarness({ + settings, + snapshot: makeSnapshot([thread]), + managedLookup: () => Effect.fail(new ManagedWorkspaceError({ reason: "workspace" })), + mergebotDetail: () => + Effect.succeed(mergebotDetail(1, { lifecycle: "merged", mergedAt: NOW })), + }); + yield* start(fixture).pipe(Effect.provide(fixture.layer)); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.type), + ["thread.archive"], + ); + }), + ), + ); + + it.effect( + "settling after merge archives immediately and skips the managed workspace settle script", + () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = { + ...makeThread("settle-after-merge", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(NOW), + worktreePath: "/workspace/managed", + pullRequests: [link(1)], + }), + lastVisitedAt: null, + }; + const archived = yield* Deferred.make(); + const fixture = yield* makeHarness({ + settings, + snapshot: makeSnapshot([]), + currentThreads: [thread], + managedThreadIds: [thread.id], + existingWorktreePaths: ["/workspace/managed"], + mergebotDetail: () => + Effect.succeed(mergebotDetail(1, { lifecycle: "merged", mergedAt: NOW })), + onDispatch: (command) => + command.type === "thread.archive" + ? Deferred.succeed(archived, undefined).pipe(Effect.asVoid) + : Effect.void, + }); + yield* Effect.gen(function* () { + yield* start(fixture); + yield* Ref.set(fixture.snapshots, makeSnapshot([thread])); + yield* fixture.publishEvent({ + type: "thread.settled", + id: EventId.make("settled-after-merge"), + threadId: thread.id, + occurredAt: DateTime.makeUnsafe(NOW), + payload: thread, + }); + yield* Deferred.await(archived); + assert.equal(yield* Ref.get(fixture.fullArchiveReadCount), 1); + assert.deepStrictEqual(yield* Ref.get(fixture.singleArchiveReads), [thread.id]); + assert.equal(yield* Queue.size(fixture.scriptRuns), 0); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.type), + ["thread.archive"], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect( + "wakes failed current-head CI through a notification command without dispatching a message or starting a run", + () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = makeThread("ci-failed", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(NOW), + pullRequests: [link(1)], + }); + const fixture = yield* makeHarness({ + settings, + snapshot: makeSnapshot([thread]), + managedThreadIds: [thread.id], + mergebotDetail: () => + Effect.succeed( + mergebotDetail(1, { + checks: [failedCheck()], + }), + ), + }); + yield* start(fixture).pipe(Effect.provide(fixture.layer)); + const commands = yield* Ref.get(fixture.commands); + assert.equal(commands.length, 1); + assert.equal(commands[0]?.type, "thread.ci-failed"); + if (commands[0]?.type !== "thread.ci-failed") return; + assert.equal(commands[0].url, "https://mergebot.example.test/owner/repository/pull/1"); + assert.ok(commands[0].itemId.includes(":current-head:")); + }), + ), + ); + + it.effect("records each failed check attempt once and wakes again for a same-head rerun", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = makeThread("rerun-ci", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(NOW), + pullRequests: [link(1)], + }); + const original = failedCheck({ completedAt: "2026-08-28T11:00:00.000Z" }); + const first = yield* makeHarness({ + settings, + snapshot: makeSnapshot([thread]), + managedThreadIds: [thread.id], + mergebotDetail: () => Effect.succeed(mergebotDetail(1, { checks: [original] })), + }); + yield* start(first).pipe(Effect.provide(first.layer)); + const previous = (yield* Ref.get(first.commands))[0]; + assert.ok(previous?.type === "thread.ci-failed"); + for (const changed of [ + null, + { completedAt: "2026-08-28T11:30:00.000Z" }, + { id: "rerun-check-2" }, + ]) { + const fixture = yield* makeHarness({ + settings, + snapshot: makeSnapshot([thread]), + managedThreadIds: [thread.id], + previousItemIds: new Set([previous.itemId]), + mergebotDetail: () => + Effect.succeed(mergebotDetail(1, { checks: [{ ...original, ...changed }] })), + }); + yield* start(fixture).pipe(Effect.provide(fixture.layer)); + const commands = yield* Ref.get(fixture.commands); + assert.equal(commands.length, changed === null ? 0 : 1); + if (commands[0]?.type === "thread.ci-failed") + assert.notEqual(commands[0].itemId, previous.itemId); + } + }), + ), + ); + + it.effect("ignores stale snapshots and failures already recorded for the current head", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = makeThread("known-ci", { + settledOverride: "settled", + settledAt: DateTime.makeUnsafe(NOW), + pullRequests: [link(1)], + }); + for (const previousCiNotice of [false, true]) { + const fixture = yield* makeHarness({ + settings, + snapshot: makeSnapshot([thread]), + previousCiNotice, + mergebotDetail: () => + Effect.succeed( + previousCiNotice ? mergebotDetail(1, { checks: [failedCheck()] }) : null, + ), + }); + yield* start(fixture).pipe(Effect.provide(fixture.layer)); + expect(yield* Ref.get(fixture.commands)).toEqual([]); + expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0); + } + }), + ), + ); +}); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 4a3540ef02912486b57d0966dbf27bffbc4798bf..fd9e923684c59744b6cf20af032a71be1e0b87e3 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -1,10 +1,19 @@ -import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; +import { + threadPullRequestKeyOf, + normalizeThreadPullRequestKey, + legacyThreadPullRequestKey, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import { CommandId, + TurnItemId, + MergebotPullRequestCheck, + isMergebotRepository, type ThreadId, - type OrchestrationV2DomainEvent, + type PullRequestRef, + type OrchestrationV2AppThread, + type OrchestrationV2StoredEvent, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -15,6 +24,8 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Hex from "effect/encoding/Hex"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -23,11 +34,30 @@ import * as GitManager from "../git/GitManager.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; import { forkParked } from "../serverActivation.ts"; import * as TerminalManager from "../terminal/Manager.ts"; +import * as PreviewManager from "../preview/Manager.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ManagedWorkspaces from "../workspace/ManagedWorkspaces.ts"; +import * as DevServer from "../workspace/DevServer.ts"; +import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; +import { isManagedWorkspaceArchiveCandidate } from "../workspace/managedWorkspaceArchive.ts"; + +const encodeCiFailureIdentity = Schema.encodeSync( + Schema.fromJsonString( + Schema.Array( + MergebotPullRequestCheck.mapFields(({ id, status, conclusion, completedAt }) => ({ + id, + status, + conclusion, + completedAt, + })), + ), + ), +); export interface SettlementPullRequest { readonly state: "open" | "closed" | "merged"; @@ -35,6 +65,20 @@ export interface SettlementPullRequest { readonly mergedAt?: string | null; } +export function lastSaturdayArchiveBoundary(nowMs: number): number { + const now = DateTime.makeZonedUnsafe(nowMs, { timeZone: DateTime.zoneMakeLocal() }); + const days = (DateTime.toParts(now).weekDay + 1) % 7; + const boundary = DateTime.setParts(DateTime.subtract(now, { days }), { + hour: 9, + minute: 0, + second: 0, + millisecond: 0, + }); + return DateTime.toEpochMillis( + DateTime.toEpochMillis(boundary) > nowMs ? DateTime.subtract(boundary, { weeks: 1 }) : boundary, + ); +} + const DAY_MS = 24 * 60 * 60 * 1_000; export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; @@ -144,10 +188,8 @@ export function isAutoSettlementCandidate( if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false; // Blocked-on-you work must never park behind a settled override. if (thread.pendingRuntimeRequest !== null) return false; - // A live run, or background work that will wake the agent, is not - // staleness. A dev server left running is: the agent is done. if (thread.activityRunStatus != null) return false; - if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false; + if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) return false; if (threadHasQueuedTurnStart(thread, nowMs)) return false; const snoozedUntilMs = toMillis(thread.snoozedUntil); if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true; @@ -203,12 +245,17 @@ export function resolveAutoSettlementAt(input: { readonly autoSettleAfterDays: number | null; readonly autoSettleOnMerge: boolean; readonly autoSettleKeepPullRequests?: boolean; + readonly usePullRequestSnapshots?: boolean; }): DateTime.Utc | null { const { thread } = input; let pullRequest = input.pullRequest; const links = visibleThreadPullRequests(thread.pullRequests ?? []); - if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null; - if (links.length > 0) { + if ( + input.usePullRequestSnapshots !== false && + links.some((link) => link.snapshot === null || link.snapshot.state === "open") + ) + return null; + if (input.usePullRequestSnapshots !== false && links.length > 0) { const terminalAt = (link: (typeof links)[number]) => { const snapshot = link.snapshot; const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt; @@ -238,7 +285,10 @@ export function resolveAutoSettlementAt(input: { return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs); } const hasPullRequest = - pullRequest !== null || thread.linkedPullRequest != null || thread.branchPullRequest != null; + links.length > 0 || + pullRequest !== null || + thread.linkedPullRequest != null || + thread.branchPullRequest != null; if (input.autoSettleKeepPullRequests === true && hasPullRequest) return null; if (input.autoSettleAfterDays === null || activityAtMs === null) return null; // A passed wake timer restarts inactivity. An earlier wake can only delay settlement. @@ -312,15 +362,215 @@ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const terminals = yield* TerminalManager.TerminalManager; + const previews = yield* PreviewManager.PreviewManager; const projectScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const managedWorkspaces = yield* ManagedWorkspaces.ManagedWorkspaces; + const devServer = yield* DevServer.DevServer; + const mergebot = yield* MergebotQueue.MergebotQueue; + const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; // Settling a settled thread re-emits thread.settled with the same settledAt, // so this keeps the settle action to one run per settlement. const settleActionRunAt = new Map(); + const configuredRepository = (reference: { + readonly host?: string | undefined; + readonly repository: string; + }) => + reference.host?.toLowerCase() === "github.com" + ? mergebot.configuration?.repositories.find( + (repository) => repository.toLowerCase() === reference.repository.toLowerCase(), + ) + : undefined; + + const hasOpenResources = Effect.fn("ThreadSettlementServiceV2.hasOpenResources")(function* ( + threadId: ThreadId, + ) { + if ((yield* terminals.list({ threadId })).length > 0) return true; + return (yield* previews.list({ threadId })).sessions.length > 0; + }); + + const stopUnusedDevServer = Effect.fn("ThreadSettlementServiceV2.stopUnusedDevServer")( + (threadId: ThreadId) => devServer.stopUnused(threadId), + (effect, threadId) => + effect.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("stopping an unused development server failed", { + threadId, + cause: Cause.pretty(cause), + }), + ), + ), + ); + + const archiveSettledThreads = Effect.fn("ThreadSettlementServiceV2.archiveSettledThreads")( + function* (threadId?: ThreadId) { + const snapshot = + threadId === undefined + ? yield* projections.getShellSnapshot() + : yield* Effect.gen(function* () { + const snapshotSequence = yield* eventStore.latestAgentSequence(threadId); + const thread = yield* projections.getThreadShell(threadId); + return { + snapshotSequence, + threads: thread === null ? [] : [thread], + archivedThreads: [], + }; + }); + const parked = [...snapshot.threads, ...snapshot.archivedThreads].filter( + (thread) => thread.settledOverride === "settled" || thread.archivedAt !== null, + ); + yield* Effect.forEach(parked, (thread) => stopUnusedDevServer(thread.id), { + concurrency: 8, + discard: true, + }); + const nowMs = DateTime.toEpochMillis(yield* DateTime.now); + const boundary = lastSaturdayArchiveBoundary(nowMs); + const candidates = snapshot.threads + .filter(isManagedWorkspaceArchiveCandidate) + .filter( + (thread) => + (threadId === undefined || thread.id === threadId) && + !threadHasQueuedTurnStart(thread, nowMs), + ); + const readPullRequest = Effect.fn("ThreadSettlementServiceV2.observePullRequest")(function* ( + reference: PullRequestRef, + ) { + const repository = configuredRepository(reference); + if (repository === undefined) { + yield* pullRequests.invalidate({ reference }, { notifyReaders: false }); + const detail = yield* pullRequests.detail({ ...reference, allowStale: false }); + return { ...detail, failureIdentity: null }; + } + const detail = yield* mergebot.readPullRequest({ repository, number: reference.number }); + if (detail === null) return null; + const failedChecks = detail.checks.filter((check) => + ["failure", "action_required", "timed_out", "startup_failure"].includes( + check.conclusion ?? "", + ), + ); + const failed = + detail.lifecycle === "open" && + (failedChecks.length > 0 || + detail.ciStatus === "failed" || + detail.queueStatus === "ci_failed"); + const identity = encodeCiFailureIdentity( + failedChecks.toSorted((left, right) => left.id.localeCompare(right.id)), + ); + return { + ...detail, + repository, + state: detail.lifecycle, + failureIdentity: failed + ? Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(identity))) + : null, + }; + }); + const reads = new Map>(); + const targets = (yield* Effect.forEach( + candidates, + (thread) => + Effect.gen(function* () { + const links = visibleThreadPullRequests(thread.pullRequests ?? []); + const workPullRequest = links.some( + (link) => + normalizeThreadPullRequestKey(link).host === "github.com" && + isMergebotRepository(mergebot.configuration ?? undefined, link.repository), + ); + const work = + links.length > 0 && + (workPullRequest || (yield* managedWorkspaces.isManagedThread(thread.id))); + return { thread, links, work }; + }).pipe( + Effect.catchTags({ + ManagedWorkspaceError: () => + Effect.logWarning("automatic thread archival workspace lookup skipped", { + threadId: thread.id, + }).pipe(Effect.as(null)), + }), + ), + { concurrency: 8 }, + )).filter((target) => target !== null); + for (const { thread, links, work } of targets) { + if (!work) continue; + for (const link of links) { + const reference = { + projectId: thread.projectId, + host: link.host, + repository: link.repository, + number: link.number, + }; + const key = `${reference.projectId}:${threadPullRequestKeyOf(reference)}`; + if (!reads.has(key)) reads.set(key, yield* Effect.cached(readPullRequest(reference))); + } + } + yield* Effect.forEach( + targets, + ({ thread, links, work }) => + Effect.gen(function* () { + let merged = false; + if (work) { + const observed = yield* Effect.forEach( + links, + (link) => + reads.get(`${thread.projectId}:${threadPullRequestKeyOf(link)}`) ?? + Effect.die("Pull request observation was not prepared"), + ); + for (const failed of observed) { + if (failed === null || failed.failureIdentity === null) continue; + const itemId = TurnItemId.make( + `server:ci-failed:${thread.id}:${failed.repository}:${failed.number}:${failed.headSha}:${failed.failureIdentity}`, + ); + const previous = yield* projections.getTurnItem({ threadId: thread.id, itemId }); + if (previous === null) { + const uuid = yield* crypto.randomUUIDv4(); + yield* orchestrator.dispatch({ + type: "thread.ci-failed", + commandId: CommandId.make(`server:ci-failed:${thread.id}:${uuid}`), + threadId: thread.id, + snapshotSequence: snapshot.snapshotSequence, + itemId, + pullRequestNumber: failed.number, + url: failed.url, + }); + return; + } + } + merged = + observed.length > 0 && observed.every((detail) => detail?.state === "merged"); + } + if (!merged) { + const settledAt = yield* eventStore.getThreadSettlementTime(thread.id); + if (settledAt === null || DateTime.toEpochMillis(settledAt) > boundary) return; + } + const uuid = yield* crypto.randomUUIDv4(); + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make(`server:auto-archive:${thread.id}:${uuid}`), + threadId: thread.id, + snapshotSequence: snapshot.snapshotSequence, + }); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread archival skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + { concurrency: 8, discard: true }, + ); + }, + ); + const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* ( mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, threadId?: ThreadId, ) { + yield* archiveSettledThreads(threadId); const settings = yield* settingsService.getSettings; if (!autoSettlementConfigured(settings)) { return; @@ -331,6 +581,23 @@ export const make = Effect.gen(function* () { const projectShells = yield* projectStore.listShells(); const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const projects = new Map(projectShells.map((project) => [project.id, project])); + const workRepositoryFor = (thread: (typeof threads)[number]) => { + for (const link of visibleThreadPullRequests(thread.pullRequests ?? [])) { + const repository = configuredRepository(normalizeThreadPullRequestKey(link)); + if (repository !== undefined) return repository; + } + const reference = thread.linkedPullRequest ?? thread.branchPullRequest; + if (reference != null) { + const repository = configuredRepository(legacyThreadPullRequestKey(reference)); + if (repository !== undefined) return repository; + } + const canonical = projects + .get(thread.projectId) + ?.repositoryIdentity?.canonicalKey.toLowerCase(); + return mergebot.configuration?.repositories.find( + (repository) => canonical === `github.com/${repository.toLowerCase()}`, + ); + }; // A merge event re-sweeps every candidate, not just the threads linked to // the merged pull request: most threads carry no link and settle from // their branch lookup, which would otherwise wait for the next minute's @@ -339,6 +606,7 @@ export const make = Effect.gen(function* () { const settleThread = Effect.fn("ThreadSettlementServiceV2.settleThread")( function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { + if (yield* hasOpenResources(thread.id)) return null; const currentSettings = resolveProjectSettings( yield* settingsService.getSettings, thread.projectId, @@ -347,6 +615,7 @@ export const make = Effect.gen(function* () { const settledAt = resolveAutoSettlementAt({ thread, pullRequest, + usePullRequestSnapshots: workRepositoryFor(thread) === undefined, nowMs: DateTime.toEpochMillis(decisionNow), autoSettleAfterDays: currentSettings.sidebarAutoSettleAfterDays, autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge, @@ -384,7 +653,11 @@ export const make = Effect.gen(function* () { { concurrency: 8 }, )) .filter((thread) => thread !== null) - .filter((thread) => visibleThreadPullRequests(thread.pullRequests ?? []).length === 0); + .filter( + (thread) => + visibleThreadPullRequests(thread.pullRequests ?? []).length === 0 || + workRepositoryFor(thread) !== undefined, + ); // Use the same cwd as the sidebar so both paths share GitManager's PR cache. const lookupCwdByThreadId = new Map(); yield* Effect.forEach( @@ -412,7 +685,16 @@ export const make = Effect.gen(function* () { // its branch threads now instead of on a later sweep. Threads linked to // the merged pull request settle from the event itself below and need no // lookup, so they are absent from this map by construction. - const cwds = [...new Set(lookupCwdByThreadId.values())]; + const cwds = [ + ...new Set( + lookupCandidates + .filter((thread) => workRepositoryFor(thread) === undefined) + .flatMap((thread) => { + const cwd = lookupCwdByThreadId.get(thread.id); + return cwd === undefined ? [] : [cwd]; + }), + ), + ]; yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { concurrency: 8, discard: true, @@ -450,6 +732,7 @@ export const make = Effect.gen(function* () { resolveAutoSettlementAt({ thread, pullRequest, + usePullRequestSnapshots: workRepositoryFor(thread) === undefined, nowMs: DateTime.toEpochMillis(decisionNow), autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, @@ -464,6 +747,91 @@ export const make = Effect.gen(function* () { ) { const thread = group[0]!; const reference = thread.linkedPullRequest ?? thread.branchPullRequest; + const workRepository = workRepositoryFor(thread); + if (workRepository !== undefined) { + const links = visibleThreadPullRequests(thread.pullRequests ?? []); + const references = + links.length > 0 + ? links.map(normalizeThreadPullRequestKey) + : reference === null || reference === undefined + ? [] + : [legacyThreadPullRequestKey(reference)]; + const observed = + references.length > 0 + ? yield* Effect.forEach(references, (link) => + Effect.gen(function* () { + const repository = configuredRepository(link); + if (repository !== undefined) { + const detail = yield* mergebot.readPullRequest({ + repository, + number: link.number, + }); + return detail === null + ? null + : { + state: detail.lifecycle, + mergedAt: detail.mergedAt ?? null, + closedAt: detail.closedAt ?? null, + }; + } + const detail = yield* pullRequests.summary( + { projectId: thread.projectId, ...link }, + { recoverTransientFailure: false }, + ); + return { + state: detail.state, + mergedAt: detail.mergedAt ?? null, + closedAt: detail.closedAt ?? null, + }; + }), + ) + : thread.branch === null + ? [] + : [ + yield* mergebot + .findPullRequest({ repository: workRepository, headBranch: thread.branch }) + .pipe( + Effect.map((detail) => + detail === null + ? null + : { + state: detail.lifecycle, + mergedAt: detail.mergedAt ?? null, + closedAt: detail.closedAt ?? null, + }, + ), + ), + ]; + const terminal = observed.filter((detail) => detail !== null); + if ( + terminal.length === 0 || + terminal.length !== observed.length || + terminal.some( + (detail) => + detail.state === "open" || + !Number.isFinite( + Date.parse((detail.state === "merged" ? detail.mergedAt : detail.closedAt) ?? ""), + ), + ) + ) + return null; + if (references.length > 0 && thread.branch !== null) { + const current = yield* mergebot.findPullRequest({ + repository: workRepository, + headBranch: thread.branch, + }); + if (current?.lifecycle === "open") return null; + } + return terminal.reduce((latest, detail) => { + const latestAt = Date.parse( + (latest.state === "merged" ? latest.mergedAt : latest.closedAt) ?? "", + ); + const detailAt = Date.parse( + (detail.state === "merged" ? detail.mergedAt : detail.closedAt) ?? "", + ); + return detailAt > latestAt ? detail : latest; + }); + } if (reference != null) { // The event carries the merged state, so only the threads linked to // that exact pull request settle from it. Every other linked thread @@ -565,24 +933,41 @@ export const make = Effect.gen(function* () { runSweep(null, threadId), ); - // Settling closes the thread's shells that sit at an idle prompt, so they stop - // holding the worktree. A terminal running a command (a dev server, an - // editor) stays for the user to close. Then the project's settle script runs - // in the thread's own worktree; a thread in the shared checkout skips it, - // because other threads may still be working there. const cleanUpSettledThread = Effect.fn("ThreadSettlementServiceV2.cleanUpSettledThread")( - function* (threadId: ThreadId) { + function* (settlement: OrchestrationV2AppThread, automaticSequence: number | undefined) { + const threadId = settlement.id; + const settledAtMs = toMillis(settlement.settledAt); // A thread re-engaged before this event ran keeps its shells. const settled = yield* projections.getThread(threadId); - if (settled.settledOverride !== "settled") return; - yield* terminals.closeIdle({ threadId }); + if (settled.settledOverride !== "settled" || toMillis(settled.settledAt) !== settledAtMs) + return; + if (automaticSequence !== undefined) { + if (yield* hasOpenResources(threadId)) { + const uuid = yield* crypto.randomUUIDv4(); + yield* orchestrator.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make(`server:auto-settle-resources:${threadId}:${uuid}`), + threadId, + reason: "user", + snapshotSequence: automaticSequence, + }); + return; + } + } else { + yield* terminals.close({ threadId }); + const current = yield* projections.getThread(threadId); + if (current.settledOverride !== "settled" || toMillis(current.settledAt) !== settledAtMs) + return; + yield* previews.close({ threadId }); + } + if (yield* managedWorkspaces.isManagedThread(threadId)) return; const worktreePath = settled.worktreePath; if (worktreePath === null || !(yield* fileSystem.exists(worktreePath))) return; // Closing and the worktree check wait on I/O. A thread re-engaged // meanwhile is working again, so its worktree is no place for cleanup. const thread = yield* projections.getThread(threadId); - if (thread.settledOverride !== "settled") return; - const settledAtMs = toMillis(thread.settledAt); + if (thread.settledOverride !== "settled" || toMillis(thread.settledAt) !== settledAtMs) + return; if (settledAtMs === null || settleActionRunAt.get(threadId) === settledAtMs) return; const run = yield* projectScripts.runForThread({ threadId, @@ -598,48 +983,60 @@ export const make = Effect.gen(function* () { yield* run.completion.pipe(Effect.forkDetach); } }, - (effect, threadId) => + (effect, settlement) => effect.pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning("cleaning up a settled thread failed", { - threadId, + threadId: settlement.id, cause: Cause.pretty(cause), }), ), ), ); - const processEvent = (event: OrchestrationV2DomainEvent) => { + const processEvent = Effect.fn("ThreadSettlementServiceV2.processEvent")(function* ( + stored: OrchestrationV2StoredEvent, + ) { + const event = stored.event; switch (event.type) { case "thread.settled": - return cleanUpSettledThread(event.threadId); + return yield* cleanUpSettledThread( + event.payload, + stored.commandId?.startsWith("server:auto-settle:") ? stored.sequence : undefined, + ).pipe(Effect.andThen(worker.enqueue(event.threadId))); + case "thread.archived": case "thread.pull-request-link-updated": case "thread.pull-request-synced": case "provider-session.detached": - return worker.enqueue(event.threadId); + return yield* worker.enqueue(event.threadId); case "provider-session.updated": - return event.payload.status !== "starting" && event.payload.status !== "running" - ? worker.enqueue(event.threadId) - : Effect.void; + if (event.payload.status !== "starting" && event.payload.status !== "running") { + yield* worker.enqueue(event.threadId); + } + return; case "run.updated": - return ["completed", "interrupted", "failed", "cancelled", "rolled_back"].includes( - event.payload.status, - ) - ? worker.enqueue(event.threadId) - : Effect.void; + if ( + ["completed", "interrupted", "failed", "cancelled", "rolled_back"].includes( + event.payload.status, + ) + ) { + yield* worker.enqueue(event.threadId); + } + return; default: - return Effect.void; + return; } - }; + }); const start: ThreadSettlementServiceV2["Service"]["start"] = Effect.fn( "ThreadSettlementServiceV2.start", )(function* () { const settingsChanges = yield* settingsService.subscribeChanges; const mergedPullRequests = yield* pullRequests.subscribeMerges; - const events = orchestrator.streamDomainEvents; + const afterSequence = yield* eventStore.latestAgentSequence().pipe(Effect.orDie); + const events = orchestrator.streamStoredEventsFrom({ afterSequence }); const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); let lastSettlementSettings = autoSettlementSettingsKey(initialSettings); yield* forkParked( @@ -659,6 +1056,11 @@ export const make = Effect.gen(function* () { }), ); yield* forkParked(Stream.runForEach(mergedPullRequests, (event) => runSweep(event))); + yield* forkParked( + Stream.runForEach(mergebot.streamChanges.pipe(Stream.debounce("100 millis")), () => + worker.enqueue(undefined), + ), + ); yield* forkParked( Stream.runForEach(events, processEvent).pipe( Effect.catchCause((cause) => diff --git a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts index 9c7f87b3c9b88ebe9fac012057df9460a90c4f3a..1eac3bde5db1d321f821a2bf715278fda382043f 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts @@ -330,3 +330,51 @@ it.effect("replays a command's events through the command index, not the sequenc ), ), ); + +it.effect( + "dates a settlement from its first event, keeps duplicate settlements and imports stable, and resets on activation", + () => + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const threadId = ThreadId.make("weekly-settlement"); + const settled = { settledOverride: "settled", settledAt: "2026-08-01T00:00:00.000Z" }; + const first = "2026-09-04T17:00:00.000Z"; + const second = "2026-09-06T17:00:00.000Z"; + const event = (ordinal: number, eventType: string, at: string, payload: unknown) => ({ + ...eventRow(ordinal, "thread", 2, threadId), + event_type: eventType, + occurred_at: at, + payload_json: encodeJson(payload), + }); + assert.equal(yield* store.getThreadSettlementTime(threadId), null); + yield* seedEvents([ + event(1001, "thread.created", occurredAt, { settledOverride: null, settledAt: null }), + event(1002, "thread.settled", first, settled), + event(1003, "thread.settled", second, settled), + ]); + assert.deepStrictEqual( + yield* store.getThreadSettlementTime(threadId), + DateTime.makeUnsafe(first), + ); + yield* seedEvents([ + event(1004, "thread.unsettled", second, { settledOverride: "active", settledAt: null }), + event(1005, "thread.settled", second, settled), + ]); + assert.deepStrictEqual( + yield* store.getThreadSettlementTime(threadId), + DateTime.makeUnsafe(second), + ); + const imported = ThreadId.make("legacy-settlement"); + yield* seedEvents([ + { + ...eventRow(1006, "thread", 2, imported), + event_type: "thread.created", + payload_json: encodeJson(settled), + }, + ]); + assert.deepStrictEqual( + yield* store.getThreadSettlementTime(imported), + DateTime.makeUnsafe(settled.settledAt), + ); + }).pipe(Effect.provide(layerEventStore)), +); diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts index a11e9648b5b3abd68bc1a6813453d78a700848ae..df86697311cf6ad421df74deab717c34d12ce614 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.ts @@ -30,6 +30,7 @@ import * as Context from "effect/Context"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlSchema from "effect/sql/SqlSchema"; import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Option from "effect/Option"; @@ -123,6 +124,9 @@ export class OrchestrationEventStore extends Context.Service< ) => Effect.Effect; readonly latestApplicationSequence: Effect.Effect; + readonly getThreadSettlementTime: ( + threadId: ThreadId, + ) => Effect.Effect; /** * Read the finite retained application-event range `(afterSequence, throughSequence]`. @@ -703,6 +707,33 @@ const makeEventStore = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.latestAgentSequence:query")), ); + const getThreadSettlementTime: OrchestrationEventStore["Service"]["getThreadSettlementTime"] = ( + threadId, + ) => + sql<{ readonly settled_at: string | null }>` + SELECT CASE WHEN event_type = 'thread.settled' THEN occurred_at + ELSE json_extract(payload_json, '$.settledAt') END AS settled_at + FROM orchestration_events INDEXED BY idx_orchestration_events_agent_stream_sequence + WHERE application_event_version = 2 AND aggregate_kind = 'thread' AND stream_id = ${threadId} + AND (event_type = 'thread.settled' OR + (event_type = 'thread.created' AND json_extract(payload_json, '$.settledOverride') = 'settled')) + AND sequence > COALESCE(( + SELECT MAX(sequence) FROM orchestration_events INDEXED BY idx_orchestration_events_agent_stream_sequence + WHERE application_event_version = 2 AND aggregate_kind = 'thread' AND stream_id = ${threadId} + AND event_type LIKE 'thread.%' + AND json_type(payload_json, '$.settledOverride') IS NOT NULL + AND json_extract(payload_json, '$.settledOverride') IS NOT 'settled' + ), 0) + ORDER BY sequence ASC LIMIT 1 + `.pipe( + Effect.map((rows) => + rows[0]?.settled_at == null ? null : DateTime.makeUnsafe(rows[0].settled_at), + ), + Effect.mapError( + toPersistenceSqlError("OrchestrationEventStore.getThreadSettlementTime:query"), + ), + ); + // The OR planner otherwise scans every V2 event instead of seeking the final sequence. const latestApplicationSequence = sql<{ readonly sequence: number | null }>` SELECT MAX(sequence) AS sequence @@ -786,6 +817,7 @@ const makeEventStore = Effect.gen(function* () { getReplayStats, latestAgentSequence, latestApplicationSequence, + getThreadSettlementTime, readApplicationEvents: catchUpApplicationEvents, publishCommitted: (events) => PubSub.publishAll(committedEvents, events).pipe(Effect.asVoid), streamApplicationEvents, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 841fdfcb47be5d252d8f835f900a470d28c6a33b..ed65039d9d74645522e7cfb1c2b39fcf2a15d8f6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -119,6 +119,7 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; import * as DevServer from "./workspace/DevServer.ts"; +import * as ManagedWorkspaces from "./workspace/ManagedWorkspaces.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -646,6 +647,12 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.effectDiscard(ProjectSettingsOverrideSweep.sweep), ).pipe( // Core Services + Layer.provideMerge( + ManagedWorkspaces.layer.pipe( + Layer.provide(ProcessRunner.layer), + Layer.provide(layerPullRequestService), + ), + ), Layer.provideMerge(layerOrchestrationApplication), Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 77dd6cd082dd069131f3b662f7b17c4918067911..a0c46370fcee4ab2de21e4e8523e285daee37ad4 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1827,6 +1827,40 @@ it.layer( }), ); + it.effect( + "lists and closes one chat's sessions without touching a peer in the same directory", + () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput({ terminalId: "default" })); + yield* manager.open(openInput({ terminalId: "sidecar" })); + yield* manager.open(openInput({ threadId: "peer-chat", terminalId: "default" })); + const outputProcessed = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" && event.threadId === "thread-1" + ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + ptyAdapter.processes[0]!.emitData("valuable history\n"); + yield* Deferred.await(outputProcessed); + + const listed = yield* manager.list({ threadId: "thread-1" }); + expect(listed.map((session) => session.terminalId).sort()).toEqual(["default", "sidecar"]); + expect(listed.every((session) => session.cwd === listed[0]?.cwd)).toBe(true); + yield* manager.close({ threadId: "thread-1" }); + + expect(yield* manager.list({ threadId: "thread-1" })).toEqual([]); + expect( + (yield* manager.list({ threadId: "peer-chat" })).map((session) => session.terminalId), + ).toEqual(["default"]); + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([true, true, false]); + expect((yield* manager.open(openInput({ terminalId: "default" }))).history).toBe( + "valuable history\n", + ); + }), + ); + it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 722f8d6238371d99ba48720ef4065b07143b0efe..5140ac1cdc5613547ab271be1c4215ff7aa97f57 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -211,11 +211,14 @@ export class TerminalManager extends Context.Service< */ readonly close: (input: TerminalCloseInput) => Effect.Effect; + readonly list: (input: { + readonly threadId: string; + }) => Effect.Effect>; + /** * Close a thread's terminals that wait at an idle shell prompt. A terminal * that runs a command stays open. When `terminalId` is set, only that - * terminal is considered. Used when a thread settles and when a setup - * script finishes. + * terminal is considered. */ readonly closeIdle: (input: { readonly threadId: string; @@ -3214,6 +3217,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func clear, restart, close, + list: ({ threadId }) => + sessionsForThread(threadId).pipe(Effect.map((sessions) => sessions.map(summary))), closeIdle, subscribe, subscribeMetadata, diff --git a/apps/server/src/workspace/ManagedWorkspaces.archive.test.ts b/apps/server/src/workspace/ManagedWorkspaces.archive.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4692160bc4565fce346fb498b193babd34f3f4dc --- /dev/null +++ b/apps/server/src/workspace/ManagedWorkspaces.archive.test.ts @@ -0,0 +1,340 @@ +// @effect-diagnostics nodeBuiltinImport:off - Owns isolated filesystem fixtures. +import { afterEach, beforeEach, describe, expect, vi } from "vite-plus/test"; +import { it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + ProjectId, + ThreadId, + ProviderInstanceId, + SourceControlProviderKind, + type OrchestrationV2ThreadShell, + type ThreadPullRequestLink, + type PullRequestSummary, + PullRequestOperationError, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import * as ManagedWorkspaces from "./ManagedWorkspaces.ts"; + +const now = DateTime.makeUnsafe("2026-10-13T12:00:00.000Z"); +const projectId = ProjectId.make("managed-project"); +const threadId = ThreadId.make("managed-thread"); +let home: string; +let workspacePath: string; +let threads: OrchestrationV2ThreadShell[]; +const launches = vi.fn(); +const dispatch = vi.fn(); +const summary = vi.fn(); +const invalidate = vi.fn(); +const link = (number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository: "work/app", + number, + url: `https://github.com/work/app/pull/${number}`, + source: "manual", + linkedAt: DateTime.formatIso(now), + snapshot: null, + stack: null, +}); +const pullRequest = (state: PullRequestSummary["state"], number = 1): PullRequestSummary => ({ + provider: SourceControlProviderKind.make("github"), + projectId, + repository: "work/app", + number, + title: "Work", + url: link(number).url, + state, + headBranch: "feature", + baseBranch: "main", + mergedAt: state === "merged" ? DateTime.formatIso(now) : null, + updatedAt: DateTime.formatIso(now), +}); +const thread = (): OrchestrationV2ThreadShell => ({ + id: threadId, + projectId, + title: "Managed workspace", + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + activeRunId: null, + latestRunId: null, + status: "idle", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: "settled", + settledAt: now, + deletedAt: null, + pullRequests: [link(1)], +}); + +beforeEach(async () => { + home = await NodeFSP.realpath( + await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-managed-archive-")), + ); + workspacePath = NodePath.join(home, "workspaces/1"); + await NodeFSP.mkdir(workspacePath, { recursive: true }); + const records = NodePath.join(home, ".local/state/workspaces/workspaces"); + await NodeFSP.mkdir(records, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(records, "1.json"), JSON.stringify({ threadId })); + await NodeFSP.mkdir(NodePath.join(home, ".config")); + await NodeFSP.mkdir(NodePath.join(home, "source/client"), { recursive: true }); + await NodeFSP.mkdir(NodePath.join(home, "bin")); + await NodeFSP.writeFile( + NodePath.join(home, ".config/workspaces.json"), + JSON.stringify({ repository: NodePath.join(home, "source") }), + ); + await NodeFSP.writeFile( + NodePath.join(home, "source/client/package.json"), + '{"name":"@work/client"}', + ); + await NodeFSP.writeFile(NodePath.join(home, "bin/ws"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + threads = [thread()]; + launches.mockReset(); + launches.mockImplementation(() => + Effect.succeed({ + stdout: '{"abandoned":false}', + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + ); + dispatch.mockReset(); + summary.mockReset(); + invalidate.mockReset(); + dispatch.mockImplementation(() => Effect.succeed({ sequence: 1, storedEvents: [] })); + summary.mockImplementation((ref) => Effect.succeed(pullRequest("merged", ref.number))); + invalidate.mockImplementation(() => Effect.void); +}); +afterEach(async () => { + await NodeFSP.rm(home, { recursive: true, force: true }); +}); + +const layer = () => + ManagedWorkspaces.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcess.HomeDirectory, home), + Layer.succeed(HostProcess.Environment, { PATH: NodePath.join(home, "bin") }), + ServerConfig.layerTest(home, NodePath.join(home, "isolated-t3")).pipe( + Layer.provide(NodeServices.layer), + ), + Layer.succeed(ProcessRunner.ProcessRunner, { run: launches }), + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadShell: (id) => + Effect.succeed(threads.find((thread) => thread.id === id) ?? null), + getShellSnapshot: () => + Effect.succeed({ schemaVersion: 1, snapshotSequence: 1, threads, archivedThreads: [] }), + dispatch, + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + getShell: () => + Effect.succeed( + Option.some({ + id: projectId, + title: "Workspace", + workspaceRoot: workspacePath, + defaultModelSelection: null, + scripts: [], + createdAt: DateTime.formatIso(now), + updatedAt: DateTime.formatIso(now), + }), + ), + listShells: () => + Effect.succeed([ + { + id: projectId, + title: "Workspace", + workspaceRoot: workspacePath, + repositoryIdentity: null, + defaultModelSelection: null, + defaultThreadEnvMode: "local", + autoPull: false, + faviconPath: null, + projectIcon: null, + scripts: [], + createdAt: DateTime.formatIso(now), + updatedAt: DateTime.formatIso(now), + }, + ]), + }), + Layer.mock(PullRequestService.PullRequestService)({ summary, invalidate }), + ), + ), + ); +const use = (effect: Effect.Effect) => + effect.pipe(Effect.provide(layer())); + +describe("managed workspace archival", () => { + it.effect("archives JJ project-root threads only after every linked PR is freshly verified", () => + use( + Effect.gen(function* () { + threads[0] = { ...threads[0]!, pullRequests: [link(1), link(2)] }; + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ + archivedThreadIds: [threadId], + }); + expect(invalidate).toHaveBeenCalledTimes(2); + expect(summary).toHaveBeenCalledTimes(2); + expect(summary.mock.calls[0]).toEqual([ + { + host: "github.com", + repository: "work/app", + number: 1, + projectId, + allowStale: false, + }, + { recoverTransientFailure: false }, + ]); + expect(dispatch.mock.calls[0]?.[0]).toMatchObject({ + type: "thread.archive", + threadId, + snapshotSequence: 1, + }); + }), + ), + ); + it.effect.each(["open", "closed"] as const)("retains a workspace with any %s PR", (state) => + use( + Effect.gen(function* () { + threads[0] = { ...threads[0]!, pullRequests: [link(1), link(2)] }; + summary.mockImplementation((ref) => + Effect.succeed(pullRequest(ref.number === 1 ? "merged" : state, ref.number)), + ); + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ + archivedThreadIds: [], + }); + expect(dispatch).not.toHaveBeenCalled(); + }), + ), + ); + it.effect.each(["active", "unlinked", "shared active"] as const)( + "retains %s work without host reads", + (kind) => + use( + Effect.gen(function* () { + if (kind === "active") threads[0] = { ...threads[0]!, settledOverride: "active" }; + if (kind === "unlinked") threads[0] = { ...threads[0]!, pullRequests: [] }; + if (kind === "shared active") + threads.push({ ...thread(), id: ThreadId.make("active-peer"), settledOverride: null }); + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ + archivedThreadIds: [], + }); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary).not.toHaveBeenCalled(); + }), + ), + ); + it.effect("fails closed when a host cannot confirm the merge", () => + use( + Effect.gen(function* () { + summary.mockImplementation( + () => new PullRequestOperationError({ operation: "summary", detail: "Host unavailable" }), + ); + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + expect((yield* service.archiveCompleted({ workspacePath }).pipe(Effect.flip)).reason).toBe( + "archive", + ); + expect(dispatch).not.toHaveBeenCalled(); + }), + ), + ); + it.effect("retains an unknown merge timestamp", () => + use( + Effect.gen(function* () { + summary.mockImplementation(() => + Effect.succeed({ ...pullRequest("merged"), mergedAt: null }), + ); + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ + archivedThreadIds: [], + }); + expect(dispatch).not.toHaveBeenCalled(); + }), + ), + ); + it.effect("refuses an arbitrary project checkout", () => + use( + Effect.gen(function* () { + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + expect( + (yield* service.archiveCompleted({ workspacePath: home }).pipe(Effect.flip)).reason, + ).toBe("workspace"); + expect(dispatch).not.toHaveBeenCalled(); + }), + ), + ); +}); + +it.effect("retains an abandoned settled workspace without a merged PR", () => + use( + Effect.gen(function* () { + threads[0] = { ...threads[0]!, pullRequests: [] }; + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ archivedThreadIds: [] }); + expect(launches).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }), + ), +); + +it.effect("recognizes managed project roots and managed thread worktrees", () => + use( + Effect.gen(function* () { + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + expect(yield* service.isManagedThread(threadId)).toBe(true); + threads[0] = { ...threads[0]!, worktreePath: home }; + expect(yield* service.isManagedThread(threadId)).toBe(false); + threads[0] = { ...threads[0]!, worktreePath: workspacePath }; + expect(yield* service.isManagedThread(threadId)).toBe(true); + expect(yield* service.isManagedThread(ThreadId.make("missing-thread"))).toBe(false); + expect(launches).not.toHaveBeenCalled(); + }), + ), +); + +it.effect("keeps merged workspaces available while another chat references them", () => + use( + Effect.gen(function* () { + threads.push({ ...thread(), id: ThreadId.make("active-peer"), settledOverride: "active" }); + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ archivedThreadIds: [] }); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary).not.toHaveBeenCalled(); + }), + ), +); diff --git a/apps/server/src/workspace/ManagedWorkspaces.test.ts b/apps/server/src/workspace/ManagedWorkspaces.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ac00447d0346e33ad05d64f954e634570592fca --- /dev/null +++ b/apps/server/src/workspace/ManagedWorkspaces.test.ts @@ -0,0 +1,173 @@ +// @effect-diagnostics nodeBuiltinImport:off - Owns isolated filesystem fixtures. +import { afterEach, beforeEach, describe, expect, vi } from "vite-plus/test"; +import { it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProjectId } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ManagedWorkspaces from "./ManagedWorkspaces.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; + +let home: string; +let repository: string; +let platform: NodeJS.Platform; +let sourceRegistered: boolean; +const launches = vi.fn(); +const created = { + workspacePath: "/test/workspaces/1", + projectId: "new-project", + threadId: "new-thread", + environmentId: "current-host", +}; +const output = (stdout = JSON.stringify(created), code = 0) => ({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(code), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, +}); + +beforeEach(async () => { + home = await NodeFSP.realpath( + await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-managed-ws-")), + ); + repository = NodePath.join(home, "source"); + platform = "darwin"; + sourceRegistered = true; + await NodeFSP.mkdir(repository, { recursive: true }); + await NodeFSP.mkdir(NodePath.join(home, ".config")); + await NodeFSP.mkdir(NodePath.join(home, "bin")); + await NodeFSP.writeFile( + NodePath.join(home, ".config/workspaces.json"), + JSON.stringify({ repository }), + ); + await NodeFSP.writeFile(NodePath.join(home, "bin/ws"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + launches.mockReset(); + launches.mockImplementation(() => Effect.succeed(output())); +}); + +afterEach(async () => { + await NodeFSP.rm(home, { recursive: true, force: true }); +}); + +const layer = () => + ManagedWorkspaces.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcess.HomeDirectory, home), + Layer.succeed(HostProcess.Platform, platform), + Layer.succeed(HostProcess.Environment, { PATH: NodePath.join(home, "bin") }), + ServerConfig.layerTest(repository, NodePath.join(home, "isolated-t3")).pipe( + Layer.provide(NodeServices.layer), + ), + Layer.succeed(ProcessRunner.ProcessRunner, { run: launches }), + Layer.mock(Orchestrator.OrchestratorV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => + Effect.succeed( + sourceRegistered + ? [ + { + id: ProjectId.make("source-project"), + title: "my work", + workspaceRoot: repository, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-13T12:00:00.000Z", + updatedAt: "2026-10-13T12:00:00.000Z", + }, + ] + : [], + ), + }), + Layer.mock(PullRequestService.PullRequestService)({}), + ), + ), + ); + +const use = (effect: Effect.Effect) => + effect.pipe(Effect.provide(layer())); + +describe("managed workspace creation", () => { + it.effect( + "reuses the installed workflow and targets this server without opening a host window", + () => + use( + Effect.gen(function* () { + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + expect(yield* service.status()).toEqual({ projectId: ProjectId.make("source-project") }); + expect(launches).not.toHaveBeenCalled(); + expect(yield* service.create()).toEqual(created); + expect(launches).toHaveBeenCalledOnce(); + expect(launches.mock.calls[0]?.[0]).toMatchObject({ + command: NodePath.join(home, "bin/ws"), + args: ["new", "--background", "--no-open", "--json"], + cwd: repository, + env: { T3CODE_HOME: NodePath.join(home, "isolated-t3") }, + }); + }), + ), + ); + + it.effect.each([ + "missing tool", + "missing configuration", + "missing repository", + "unregistered source", + "Windows", + ] as const)("does not launch when %s makes creation unavailable", (reason) => { + if (reason === "Windows") platform = "win32"; + if (reason === "unregistered source") sourceRegistered = false; + return use( + Effect.gen(function* () { + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + if (reason === "missing tool") + yield* Effect.promise(() => NodeFSP.rm(NodePath.join(home, "bin/ws"))); + if (reason === "missing configuration") + yield* Effect.promise(() => NodeFSP.rm(NodePath.join(home, ".config/workspaces.json"))); + if (reason === "missing repository") + yield* Effect.promise(() => NodeFSP.rm(repository, { recursive: true })); + expect(yield* service.status()).toEqual({ projectId: null }); + const error = yield* service.create().pipe(Effect.flip); + expect(error.reason).toBe("unavailable"); + expect(launches).not.toHaveBeenCalled(); + }), + ); + }); + + it.effect("rejects unreadable and incomplete CLI replies", () => + use( + Effect.gen(function* () { + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + for (const stdout of ["not JSON", JSON.stringify({ workspacePath: "/created" })]) { + launches.mockImplementation(() => Effect.succeed(output(stdout))); + expect((yield* service.create().pipe(Effect.flip)).reason).toBe("reply"); + } + }), + ), + ); + + it.effect("propagates a stopped creation instead of navigating to an incomplete workspace", () => + use( + Effect.gen(function* () { + launches.mockImplementation(() => Effect.succeed(output("", 1))); + const service = yield* ManagedWorkspaces.ManagedWorkspaces; + expect((yield* service.create().pipe(Effect.flip)).reason).toBe("create"); + }), + ), + ); +}); diff --git a/apps/server/src/workspace/ManagedWorkspaces.ts b/apps/server/src/workspace/ManagedWorkspaces.ts new file mode 100644 index 0000000000000000000000000000000000000000..b99a1651ac5829434fec3539cb83c311c005db98 --- /dev/null +++ b/apps/server/src/workspace/ManagedWorkspaces.ts @@ -0,0 +1,223 @@ +import { + CommandId, + type ProjectId, + type ThreadId, + type ManagedWorkspaceArchived, + ManagedWorkspaceCreated, + ManagedWorkspaceError, +} from "@t3tools/contracts"; +import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Option from "effect/Option"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import { + normalizeThreadPullRequestKey, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import { isManagedWorkspaceArchiveCandidate } from "./managedWorkspaceArchive.ts"; + +const decodeConfiguration = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ repository: Schema.String })), +); +const decodeCreated = Schema.decodeUnknownEffect(Schema.fromJsonString(ManagedWorkspaceCreated)); + +export class ManagedWorkspaces extends Context.Service< + ManagedWorkspaces, + { + readonly isManagedThread: (threadId: ThreadId) => Effect.Effect; + readonly status: () => Effect.Effect<{ readonly projectId: ProjectId | null }>; + readonly create: () => Effect.Effect; + readonly archiveCompleted: (input: { + readonly workspacePath: string; + }) => Effect.Effect; + } +>()("t3/workspace/ManagedWorkspaces") {} + +const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; + const platform = yield* HostProcess.Platform; + const environment = yield* HostProcess.Environment; + const config = yield* ServerConfig.ServerConfig; + const runner = yield* ProcessRunner.ProcessRunner; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projects = yield* ProjectStore.ProjectStoreV2; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + + const configuration = Effect.gen(function* () { + if (platform === "win32") return null; + const value = yield* fs.readFileString(path.join(home, ".config/workspaces.json")); + const configured = yield* decodeConfiguration(value); + const repository = yield* fs.realPath(configured.repository); + const executable = yield* resolveCommandPath("ws", { env: environment }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(HostProcess.Platform, platform), + ); + if (executable === null) return null; + return { repository, executable }; + }).pipe(Effect.orElseSucceed(() => null)); + + const status = () => + Effect.gen(function* () { + const configured = yield* configuration; + if (configured === null) return { projectId: null }; + const sources = yield* projects.listShells(); + for (const project of sources) { + const root = yield* fs + .realPath(project.workspaceRoot) + .pipe(Effect.orElseSucceed(() => null)); + if (root === configured.repository) return { projectId: project.id }; + } + return { projectId: null }; + }).pipe(Effect.orElseSucceed(() => ({ projectId: null }))); + + const create = Effect.fn("ManagedWorkspaces.create")(function* () { + const configured = yield* configuration; + if (configured === null || (yield* status()).projectId === null) + return yield* new ManagedWorkspaceError({ reason: "unavailable" }); + const result = yield* runner + .run({ + command: configured.executable, + args: ["new", "--background", "--no-open", "--json"], + cwd: configured.repository, + env: { T3CODE_HOME: config.baseDir }, + timeout: "30 seconds", + maxOutputBytes: 16 * 1024, + }) + .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "create", cause }))); + if (result.code !== 0 || result.timedOut) { + return yield* new ManagedWorkspaceError({ reason: "create" }); + } + return yield* decodeCreated(result.stdout).pipe( + Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "reply", cause })), + ); + }); + + const managedDirectory = Effect.fn("ManagedWorkspaces.managedDirectory")(function* ( + directory: string, + ) { + const root = path.join(home, "workspaces"); + const id = path.relative(root, directory); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id)) return null; + const workspacePath = yield* fs + .realPath(directory) + .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause }))); + if ( + workspacePath !== path.join(root, id) || + !(yield* fs + .exists(path.join(home, ".local/state/workspaces/workspaces", `${id}.json`)) + .pipe( + Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause })), + )) + ) { + return null; + } + return { id, workspacePath }; + }); + + const isManagedThread = Effect.fn("ManagedWorkspaces.isManagedThread")(function* ( + threadId: ThreadId, + ) { + const thread = yield* orchestrator + .getThreadShell(threadId) + .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause }))); + if (thread === null) return false; + const project = yield* projects + .getShell(thread.projectId) + .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause }))); + if (Option.isNone(project)) return false; + return (yield* managedDirectory(thread.worktreePath ?? project.value.workspaceRoot)) !== null; + }); + + const archiveCompleted = Effect.fn("ManagedWorkspaces.archiveCompleted")(function* (input: { + readonly workspacePath: string; + }) { + const managed = yield* managedDirectory(input.workspacePath); + if (managed === null) return yield* new ManagedWorkspaceError({ reason: "workspace" }); + const { workspacePath } = managed; + const configured = yield* configuration; + if (configured === null) return yield* new ManagedWorkspaceError({ reason: "unavailable" }); + const [snapshot, projectShells] = yield* Effect.all([ + orchestrator.getShellSnapshot({ location: "active", unsettledOnly: false }), + projects.listShells(), + ]).pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause }))); + const roots = new Map(projectShells.map((project) => [project.id, project.workspaceRoot])); + const threads = []; + for (const thread of snapshot.threads) { + const cwd = thread.worktreePath ?? roots.get(thread.projectId); + if (cwd === undefined) return yield* new ManagedWorkspaceError({ reason: "workspace" }); + const relative = path.relative(workspacePath, cwd); + if ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ) + threads.push(thread); + } + const candidates = threads.filter(isManagedWorkspaceArchiveCandidate); + if (candidates.length === 0 || candidates.length !== threads.length) + return { archivedThreadIds: [] }; + for (const thread of candidates) { + const links = visibleThreadPullRequests(thread.pullRequests ?? []); + if (links.length === 0) return { archivedThreadIds: [] }; + for (const link of links) { + const ref = { + ...normalizeThreadPullRequestKey(link), + projectId: thread.projectId, + allowStale: false, + }; + yield* pullRequests + .invalidate({ reference: ref }) + .pipe( + Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })), + ); + const summary = yield* pullRequests + .summary(ref, { recoverTransientFailure: false }) + .pipe( + Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })), + ); + if (summary.state !== "merged" || !Number.isFinite(Date.parse(summary.mergedAt ?? ""))) + return { archivedThreadIds: [] }; + } + } + const archivedThreadIds: ThreadId[] = []; + for (const thread of candidates) { + const uuid = yield* crypto + .randomUUIDv4() + .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause }))); + yield* orchestrator + .dispatch({ + type: "thread.archive", + commandId: CommandId.make(`managed-workspace:${uuid}`), + threadId: thread.id, + snapshotSequence: snapshot.snapshotSequence, + }) + .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause }))); + archivedThreadIds.push(thread.id); + } + return { archivedThreadIds }; + }); + + return { + status, + create, + archiveCompleted, + isManagedThread, + } satisfies ManagedWorkspaces["Service"]; +}); + +export const layer = Layer.effect(ManagedWorkspaces, make); diff --git a/apps/server/src/workspace/managedWorkspaceArchive.ts b/apps/server/src/workspace/managedWorkspaceArchive.ts new file mode 100644 index 0000000000000000000000000000000000000000..7090f678de60628a3751beb25eb5e9fd63ff5c9d --- /dev/null +++ b/apps/server/src/workspace/managedWorkspaceArchive.ts @@ -0,0 +1,20 @@ +import type { OrchestrationV2ThreadShell } from "@t3tools/contracts"; + +export function isManagedWorkspaceArchiveCandidate( + thread: OrchestrationV2ThreadShell, +): thread is OrchestrationV2ThreadShell & { + readonly settledAt: NonNullable; +} { + return ( + thread.settledOverride === "settled" && + thread.settledAt !== null && + thread.archivedAt === null && + thread.deletedAt === null && + thread.activeRunId === null && + ["idle", "completed", "interrupted", "failed", "cancelled", "rolled_back"].includes( + thread.status, + ) && + thread.pendingRuntimeRequest === null && + (thread.pendingBackgroundTasks?.length ?? 0) === 0 + ); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3bd45e6c8916ccbf10db91d720ee9a2b021e45c7..2dd0c3faecea0c7888dbf56c921ed5be33329e22 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -8,14 +8,12 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Base64 from "effect/encoding/Base64"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as 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 Schema from "effect/Schema"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; @@ -191,6 +189,7 @@ import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; import * as DevServer from "./workspace/DevServer.ts"; +import * as ManagedWorkspaces from "./workspace/ManagedWorkspaces.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; @@ -204,7 +203,6 @@ import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts import * as ProjectService from "./project/ProjectService.ts"; import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; import { projectMutationOperation } from "./project/ProjectMutation.ts"; -import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; @@ -1197,7 +1195,6 @@ const layerWsRpc = ( ); const usage = yield* UsageService.UsageService; const usageLimitSources = yield* UsageLimitSources.UsageLimitSources; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker; const repositoryIdentityResolver = @@ -1236,6 +1233,7 @@ const layerWsRpc = ( const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const devServer = yield* DevServer.DevServer; + const managedWorkspaces = yield* ManagedWorkspaces.ManagedWorkspaces; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; @@ -1678,6 +1676,7 @@ const layerWsRpc = ( }, settings, devServerStatus: true, + managedWorkspaces: true, ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}), shellResumeCompletionMarker: true, threadResumeCompletionMarker: true, @@ -1809,6 +1808,9 @@ const layerWsRpc = ( return new OrchestrationV2DispatchCommandError({ commandId: command.commandId, commandType: command.type, + ...(cause._tag === "OrchestratorDispatchError" && cause.reason !== undefined + ? { reason: cause.reason } + : {}), message: detail ?? "Failed to dispatch orchestration V2 command", ...(detail === undefined ? {} : { detail }), cause, @@ -2604,6 +2606,10 @@ const layerWsRpc = ( }), ), ), + [WS_METHODS.managedWorkspacesStatus]: () => managedWorkspaces.status(), + [WS_METHODS.managedWorkspacesCreate]: () => managedWorkspaces.create(), + [WS_METHODS.managedWorkspacesArchiveCompleted]: (input) => + managedWorkspaces.archiveCompleted(input), [WS_METHODS.devServerStatus]: (input) => devServer.status(input), [WS_METHODS.devServerStart]: (input) => devServer.start(input), [WS_METHODS.projectsReadFile]: (input) => diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 9472500ea78f5b0fc766e2aee89ce1bbcdebf0db..4a5946c85f67e232ea7ea58a0f0d1c8cf0d35327 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -35,6 +35,7 @@ import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { NewWorkWorkspaceButton } from "./sidebar/NewWorkWorkspaceButton"; import { AppMenu } from "./sidebar/AppMenu"; import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; @@ -155,6 +156,11 @@ function SidebarControl() { Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} + {isSidebarVisible ? ( +
+ +
+ ) : null} ); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 43416cff3a0e29c8ed844b49ed7aa13b0da86030..57423e8fd684cdb9fc22071b66386b8e93ff7a7e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -119,6 +119,7 @@ import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { useNewWorkWorkspace } from "../hooks/useNewWorkWorkspace"; import { useScratchProject } from "../hooks/useScratchProject"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; @@ -621,6 +622,7 @@ function OpenCommandPaletteDialog(props: { const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const newWorkWorkspace = useNewWorkWorkspace(); const availableSettingsSearchItems = useAvailableSettingsSearchItems(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); @@ -1774,6 +1776,17 @@ function OpenCommandPaletteDialog(props: { }, [clearOpenIntent, browseNavigation, openIntent, projectThreadItems, pushPaletteView]); const actionItems: Array = []; + if (newWorkWorkspace.project) { + actionItems.push({ + kind: "action", + value: "action:new-work-workspace", + searchTerms: ["my work", "workspace", "new", "create"], + title: newWorkWorkspace.label, + icon: , + disabled: newWorkWorkspace.disabled, + run: newWorkWorkspace.run, + }); + } if (projects.length > 0) { const activeProjectTitle = diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5522c10ff0e8e5057bbc23d9febe3c2719ed22c1..815a377e2b7d9228ac64cd70ea1b71d2a81abba8 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -60,7 +60,15 @@ import { resolveSidebarDropVerb, } from "./Sidebar.logic"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; -import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + OrchestrationV2DispatchCommandError, + ProjectId, + ProviderInstanceId, + RunId, + ThreadId, +} from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, @@ -242,8 +250,8 @@ describe("deleteSelectedThreadEntries", () => { describe("archiveSelectedThreadEntries", () => { const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; - const success = { _tag: "Success" } as const; - const failure = { _tag: "Failure" } as const; + const success = AsyncResult.success(undefined); + const failure = AsyncResult.failure(Cause.fail(new Error("Archive failed"))); it("records every entry after full success", async () => { const outcome = await archiveSelectedThreadEntries({ @@ -291,6 +299,73 @@ describe("archiveSelectedThreadEntries", () => { followupFailures: [failure], }); }); + + it("archives a full shelf beyond the visible tail and omits a revived entry", async () => { + const entries = Array.from({ length: 137 }, (_, index) => ({ threadKey: `settled-${index}` })); + const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { + if (entry.threadKey === "settled-92") return null; + onArchived(); + return success; + }); + const outcome = await archiveSelectedThreadEntries({ entries, archive }); + + expect(archive).toHaveBeenCalledTimes(137); + expect(outcome.archivedThreadKeys).toHaveLength(136); + expect(outcome.archivedThreadKeys).toContain("settled-136"); + expect(outcome.archivedThreadKeys).not.toContain("settled-92"); + expect(outcome.mutationFailure).toBeNull(); + expect(outcome.followupFailures).toEqual([]); + }); + it("skips a typed condition rejection in the middle and archives later candidates", async () => { + const changed = AsyncResult.failure( + Cause.fail( + new OrchestrationV2DispatchCommandError({ + commandId: CommandId.make("archive-two"), + commandType: "thread.archive", + reason: "archive-condition-changed", + message: "The thread changed before automatic archival.", + }), + ), + ); + const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { + if (entry.threadKey === "two") return changed; + onArchived(); + return success; + }); + const outcome = await archiveSelectedThreadEntries({ entries, archive }); + + expect(archive).toHaveBeenCalledTimes(3); + expect(outcome).toEqual({ + archivedThreadKeys: ["one", "three"], + mutationFailure: null, + followupFailures: [], + }); + }); + + it("does not skip a genuine failure with the condition error's text", async () => { + const rejected = AsyncResult.failure( + Cause.fail( + new OrchestrationV2DispatchCommandError({ + commandId: CommandId.make("archive-two"), + commandType: "thread.archive", + message: "The thread changed before automatic archival.", + }), + ), + ); + const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { + if (entry.threadKey === "two") return rejected; + onArchived(); + return success; + }); + const outcome = await archiveSelectedThreadEntries({ entries, archive }); + + expect(archive).toHaveBeenCalledTimes(2); + expect(outcome).toEqual({ + archivedThreadKeys: ["one"], + mutationFailure: rejected, + followupFailures: [], + }); + }); }); describe("buildBulkUnpinContextMenuItem", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index bc86619108305dc87550d4e81e37b6e411f99bc3..56cbd8cd4896b87e810fb0101d4318d530b1b47a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -8,7 +8,14 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; -import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + OrchestrationV2DispatchCommandError, + type ContextMenuItem, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/reactivity"; import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; @@ -516,33 +523,41 @@ export async function deleteSelectedThreadEntries< return { deletedThreadKeys, firstFailure }; } +const isDispatchCommandError = Schema.is(OrchestrationV2DispatchCommandError); + export async function archiveSelectedThreadEntries< TEntry extends { readonly threadKey: string }, - TResult extends { readonly _tag: "Success" | "Failure" }, >(input: { entries: readonly TEntry[]; - archive: (entry: TEntry, onArchived: () => void) => Promise; + archive: ( + entry: TEntry, + onArchived: () => void, + ) => Promise | null>; }): Promise<{ archivedThreadKeys: readonly string[]; - mutationFailure: Extract | null; - followupFailures: readonly Extract[]; + mutationFailure: AsyncResult.Failure | null; + followupFailures: readonly AsyncResult.Failure[]; }> { const archivedThreadKeys: string[] = []; - const followupFailures: Extract[] = []; + const followupFailures: AsyncResult.Failure[] = []; for (const entry of input.entries) { let didArchive = false; const result = await input.archive(entry, () => { didArchive = true; }); + if (result === null) continue; if (didArchive || result._tag === "Success") archivedThreadKeys.push(entry.threadKey); if (result._tag === "Success") continue; - const failure = result as Extract; if (didArchive) { - followupFailures.push(failure); + followupFailures.push(result); continue; } - return { archivedThreadKeys, mutationFailure: failure, followupFailures }; + const error = Cause.squash(result.cause); + if (isDispatchCommandError(error) && error.reason === "archive-condition-changed") { + continue; + } + return { archivedThreadKeys, mutationFailure: result, followupFailures }; } return { archivedThreadKeys, mutationFailure: null, followupFailures }; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 50f4213691bccec74221ae62b3360b89e76e603d..d9c836d7d98ae2dc853403342b5028af9c2d6bce 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -172,6 +172,8 @@ import { import { environmentProjects } from "../state/projects"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentSnapshotAtom } from "../state/shell"; import { useEnvironmentQuery } from "../state/query"; import { useThreadSearch } from "../state/queries"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; @@ -201,10 +203,12 @@ import { } from "./threadActionMenu.logic"; import { animateSidebarLayoutChanges, + archiveSelectedThreadEntries, applySidebarThreadDrop, filterSidebarV2VisibleThreads, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, + buildMultiSelectThreadContextMenuItems, deleteSelectedThreadEntries, filterSidebarProjectScopeItems, formatWorkingDurationLabel, @@ -305,15 +309,12 @@ import { type DraftSessionState, } from "../composerDraftStore"; -// Settled-tail paging: recent history is the common lookup; the deep tail -// stays behind an explicit Show more. const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); // Collapsed shelves share one empty list so a route change alone does not // give the sidebar list a new identity. const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; const SETTLED_TAIL_INITIAL_COUNT = 10; -const SETTLED_TAIL_PAGE_COUNT = 25; // Fresh keys deliberately reset both shelves to collapsed for existing users. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; @@ -818,6 +819,7 @@ function SidebarSectionHeader(props: { dragging?: boolean; isDropTarget?: boolean; toggle: { expanded: boolean; onToggle: () => void }; + onContextMenu?: (position: { x: number; y: number }) => void; }) { const shelf = props.marker === "working-header" @@ -834,6 +836,22 @@ function SidebarSectionHeader(props: { > { + if (!props.onContextMenu) return; + event.preventDefault(); + props.onContextMenu({ x: event.clientX, y: event.clientY }); + }} + onKeyDown={(event) => { + if ( + !props.onContextMenu || + (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) + ) { + return; + } + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + props.onContextMenu({ x: rect.left, y: rect.bottom }); + }} expanded={props.toggle.expanded} tone={ props.isDropTarget ? "accent" : props.dragging ? "emphasized" : snoozed ? "info" : "muted" @@ -2454,6 +2472,7 @@ export default function Sidebar() { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { settleThread, + confirmThreadSettlement, unsettleThread, snoozeThread, unsnoozeThread, @@ -2467,6 +2486,7 @@ export default function Sidebar() { archiveThread, deleteThread, } = useThreadActions(); + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -3009,10 +3029,7 @@ export default function Sidebar() { return () => window.clearTimeout(id); }, [snoozedThreads]); - // The settled tail renders in pages: history shouldn't dominate the - // sidebar, and the common lookups are recent. Expansion resets when the - // filter context changes so a scope/search flip never inherits a deep - // page state. + // Changing project scope restores the recent tail; the open thread stays visible. const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); const settledResetKey = projectScopeKey ?? "all"; const lastSettledResetKeyRef = useRef(settledResetKey); @@ -3039,9 +3056,90 @@ export default function Sidebar() { }, [routeThreadKey, settledThreads, settledVisibleCount]); const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length; const showMoreSettled = useCallback( - () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), - [], + () => setSettledVisibleCount(settledThreads.length), + [settledThreads.length], ); + const [archivingThreads, setArchivingThreads] = useState(false); + const archiveThreads = useCallback( + async (targets: readonly EnvironmentThreadShell[], onlySettled = false) => { + if (archivingThreads || targets.length === 0 || !checkThreadOperations(targets)) return; + const entries = targets.map((thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + return { threadRef, threadKey: scopedThreadKey(threadRef) }; + }); + setArchivingThreads(true); + try { + if (confirmThreadArchive) { + const api = readLocalApi(); + if (!api) return; + const confirmation = await settlePromise(() => + api.dialogs.confirm( + `Archive ${entries.length}${onlySettled ? " settled" : ""} thread${entries.length === 1 ? "" : "s"}?`, + ), + ); + if (confirmation._tag === "Failure" || !confirmation.value) return; + } + if (!checkThreadOperations(targets)) return; + const outcome = await archiveSelectedThreadEntries({ + entries, + archive: ({ threadRef }, onArchived) => { + const snapshot = appAtomRegistry.get(environmentSnapshotAtom(threadRef.environmentId)); + const current = snapshot?.threads.find((thread) => thread.id === threadRef.threadId); + if ( + snapshot === null || + current === undefined || + current.archivedAt !== null || + (onlySettled && current.settledOverride !== "settled") + ) { + return Promise.resolve(null); + } + return archiveThread(threadRef, { + onArchived, + ...(onlySettled ? { snapshotSequence: snapshot.snapshotSequence } : {}), + }); + }, + }); + removeFromSelection(outcome.archivedThreadKeys); + for (const failure of [outcome.mutationFailure, ...outcome.followupFailures]) { + if (failure === null || isAtomCommandInterrupted(failure)) continue; + const error = squashAtomCommandFailure(failure); + toastManager.add( + stackedThreadToast({ + type: "error", + title: + failure === outcome.mutationFailure + ? "Failed to archive threads" + : "Thread archived, but navigation failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } finally { + setArchivingThreads(false); + } + }, + [archiveThread, archivingThreads, confirmThreadArchive, removeFromSelection], + ); + const handleSettledContextMenu = async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + { + id: "archive-all", + label: "Archive all", + disabled: + settledThreads.length === 0 || archivingThreads || !canOperateThreads(settledThreads), + }, + ], + position, + ), + ); + if (clicked._tag === "Success" && clicked.value === "archive-all") { + await archiveThreads(settledThreads, true); + } + }; const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( SETTLED_SHELF_EXPANDED_KEY, false, @@ -3401,14 +3499,17 @@ export default function Sidebar() { ); const attemptSettle = useCallback( - (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { + ( + threadRef: ScopedThreadRef, + opts: { coSettlingKeys?: ReadonlySet; confirmed?: true } = {}, + ) => { void (async () => { const threadKey = scopedThreadKey(threadRef); if (settlingThreadKeysRef.current.has(threadKey)) return; settlingThreadKeysRef.current.add(threadKey); try { const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); - const result = await settleThread(threadRef); + const result = await settleThread(threadRef, opts.confirmed ? { confirmed: true } : {}); if (result._tag === "Failure") { // Never navigate away from a thread that did not settle. if (!isAtomCommandInterrupted(result)) { @@ -3451,15 +3552,35 @@ export default function Sidebar() { // Pinned rows ARE included: the decider clears the pin as part of settling, // so they park like the rest. const settleThreads = useCallback( - (threadKeys: readonly string[]) => { - const coSettlingKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { + async (threadKeys: readonly string[]) => { + const targets = threadKeys.flatMap((threadKey) => { const thread = threadByKeyRef.current.get(threadKey); - if (!thread || thread.settledOverride === "settled") continue; - attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); + return thread && thread.settledOverride !== "settled" + ? [scopeThreadRef(thread.environmentId, thread.id)] + : []; + }); + const confirmation = await confirmThreadSettlement(targets); + if (confirmation._tag === "Failure") { + if (!isAtomCommandInterrupted(confirmation)) { + const error = squashAtomCommandFailure(confirmation); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle chats", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return false; } + if (!confirmation.value) return false; + const coSettlingKeys = new Set(targets.map(scopedThreadKey)); + for (const target of targets) { + attemptSettle(target, { coSettlingKeys, confirmed: true }); + } + return true; }, - [attemptSettle], + [attemptSettle, confirmThreadSettlement], ); const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { @@ -4293,28 +4414,28 @@ export default function Sidebar() { [performSnooze], ); - const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( async (position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; - // One exact actionable set: keys whose rows are actually rendered - // right now. Selections can outlive their rows (settled-tail paging, - // thread deletion elsewhere) and the menu labels must count only what - // the actions will touch. + const readSelectedThread = (threadKey: string) => { + const threadRef = parseScopedThreadKey(threadKey); + const thread = threadRef ? readThreadShell(threadRef) : null; + return thread?.archivedAt === null ? thread : null; + }; const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; - const threadKeys = selectedThreadKeys.filter((threadKey) => - threadByKeyRef.current.has(threadKey), + const selectedThreads = selectedThreadKeys.flatMap((threadKey) => { + const thread = readSelectedThread(threadKey); + return thread ? [thread] : []; + }); + const threadKeys = selectedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); if (threadKeys.length === 0) return; const count = threadKeys.length; // Snooze (N) is offered when every selected thread can actually take // it — a mixed selection with blocked-on-you work would half-apply. const selectionNow = new Date(); - const selectedThreads = threadKeys.flatMap((threadKey) => { - const thread = threadByKeyRef.current.get(threadKey); - return thread ? [thread] : []; - }); const settlingThreads = selectedThreads.filter( (thread) => thread.settledOverride !== "settled", ); @@ -4389,13 +4510,22 @@ export default function Sidebar() { }, ] : []), - { id: "mark-unread", label: `Mark unread (${count})` }, - { - id: "delete", - label: `Delete (${count})`, - destructive: true, - disabled: !canOperateThreads(selectedThreads), - }, + ...buildMultiSelectThreadContextMenuItems({ + count, + hasRunningThread: selectedThreads.some( + (thread) => !threadRuntimeCanArchive(thread.runtime), + ), + }).map((item) => + item.id === "mark-unread" + ? item + : { + ...item, + disabled: + item.disabled || + !canOperateThreads(selectedThreads) || + (item.id === "archive" && archivingThreads), + }, + ), ], position, ), @@ -4408,7 +4538,7 @@ export default function Sidebar() { ? regeneratableTitleThreads : clicked.value === "settle" ? settlingThreads.flatMap((thread) => { - const current = threadByKeyRef.current.get( + const current = readSelectedThread( scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); return current && current.settledOverride !== "settled" ? [current] : []; @@ -4497,22 +4627,28 @@ export default function Sidebar() { return; } if (clicked.value === "settle") { - settleThreads( - actionTargets.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ); - clearSelection(); + if ( + await settleThreads( + actionTargets.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ) + ) + clearSelection(); return; } if (clicked.value === "mark-unread") { for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); + const thread = readSelectedThread(threadKey); if (thread) markThreadUnread(scopeThreadRef(thread.environmentId, thread.id)); } clearSelection(); return; } + if (clicked.value === "archive") { + await archiveThreads(selectedThreads); + return; + } if (clicked.value !== "delete") return; if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -4529,7 +4665,7 @@ export default function Sidebar() { if ( !checkThreadOperations( threadKeys.flatMap((threadKey) => { - const thread = threadByKeyRef.current.get(threadKey); + const thread = readSelectedThread(threadKey); return thread ? [thread] : []; }), ) @@ -4538,7 +4674,7 @@ export default function Sidebar() { const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ entries: threadKeys.map((threadKey) => ({ threadKey })), delete: async ({ threadKey }, deletedThreadKeys) => { - const thread = threadByKeyRef.current.get(threadKey); + const thread = readSelectedThread(threadKey); if (!thread) return null; return deleteThread(scopeThreadRef(thread.environmentId, thread.id), { deletedThreadKeys, @@ -4563,6 +4699,8 @@ export default function Sidebar() { ); }, [ + archiveThreads, + archivingThreads, attemptUnpin, clearSelection, confirmThreadDelete, @@ -5577,6 +5715,9 @@ export default function Sidebar() { expanded: settledShelfExpanded, onToggle: toggleSettledShelf, }} + onContextMenu={(position) => + void handleSettledContextMenu(position) + } />, ); break; @@ -5607,10 +5748,10 @@ export default function Sidebar() { ) : null} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index e6634ead3ac110326993f54fbe1491f2865ec5ce..2a834bddcee7a252272429f8a70a45909a53d5e6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -8,7 +8,10 @@ import { ProjectId, RunId, ThreadId, + TurnItemId, + type OrchestrationV2TurnItem, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import { act, createRef, @@ -553,6 +556,89 @@ describe("MessagesTimeline", () => { } }); + it("shows a system notification's actionable link without opening its inspector", async () => { + vi.unstubAllGlobals(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + Object.defineProperty(window, "matchMedia", { value: matchMedia, configurable: true }); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + const item = { + id: TurnItemId.make("ci-failure"), + threadId: ThreadId.make("thread-1"), + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: null, + startedAt: null, + completedAt: null, + updatedAt: DateTime.makeUnsafe(MESSAGE_CREATED_AT), + type: "notification", + source: { kind: "system" }, + outcome: "failed", + summary: "CI failed for PR #42", + detail: "[Open PR #42 in Mergebot](https://example.com/queue/pr/42)", + } satisfies OrchestrationV2TurnItem; + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(() => { + root.render( + , + ); + }); + const notice = container.querySelector('[data-v2-item-type="notification"]')!; + expect(notice.textContent).toContain("CI failed for PR #42"); + expect(notice.querySelector("[aria-expanded], button")).toBeNull(); + const link = notice.querySelector("a")!; + expect(link.textContent).toBe("Open PR #42 in Mergebot"); + expect(link.href).toBe("https://example.com/queue/pr/42"); + expect(link.closest('[hidden], [aria-hidden="true"]')).toBeNull(); + await act(() => link.focus()); + expect(document.activeElement).toBe(link); + const follow = vi.fn((event: Event) => event.preventDefault()); + link.addEventListener("click", follow); + await act(() => link.click()); + expect(follow).toHaveBeenCalledOnce(); + } finally { + await act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + } + }); + it("shows dynamic tool input without cached output when the row is expanded", async () => { activityTestState.expanded = true; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8469675a4f5c4c18ff55d35903bf986d0c277e08..eb02dcce6da5b9f13ba17992151200eaaf1ea38f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -5529,6 +5529,46 @@ type WorkEntryRowProps = { const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowProps) { const ctx = use(TimelineRowCtx); const item = props.workEntry.projectedItem?.item; + if (item?.type === "notification" && item.source.kind === "system") { + const failed = item.outcome === "failed"; + return ( + + } + label={ + + {item.summary} + + } + trailing={ + + } + > + {item.detail ? ( +
+ +
+ ) : null} +
+ ); + } const childThreadId = item?.type === "notification" ? notificationChildThreadId(item.source) : undefined; if (item?.type !== "notification" || childThreadId === undefined) { diff --git a/apps/web/src/components/sidebar/NewWorkWorkspaceButton.tsx b/apps/web/src/components/sidebar/NewWorkWorkspaceButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b7ae2363b49fee7abf6bf6b3682bb569e1929f63 --- /dev/null +++ b/apps/web/src/components/sidebar/NewWorkWorkspaceButton.tsx @@ -0,0 +1,40 @@ +import { createLucideIcon } from "lucide-react"; + +import { useNewWorkWorkspace } from "../../hooks/useNewWorkWorkspace"; +import { Button } from "../ui/button"; +import { useSidebar } from "../ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +const BriefcasePlusIcon = createLucideIcon("briefcase-plus", [ + ["path", { d: "M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2", key: "handle" }], + ["path", { d: "M12 20H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4", key: "case" }], + ["path", { d: "M16 18h6M19 15v6", key: "plus" }], +]); + +export function NewWorkWorkspaceButton() { + const { setOpenMobile } = useSidebar(); + const { project, pending, label, disabled, run } = useNewWorkWorkspace(() => + setOpenMobile(false), + ); + if (!project) return null; + + return ( + + void run()} + > + + ); +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ce4659b8a28ca04ed27cbcbe517e98bfa6068b38..5d681bc5c31a91cba6b5e4936a9d6011ddfa9375 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -19,6 +19,7 @@ import { } from "../ui/sidebar"; import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; +import { NewWorkWorkspaceButton } from "./NewWorkWorkspaceButton"; import { AppMenu } from "./AppMenu"; import { SidebarMergebotButton } from "./SidebarMergebotButton"; import { SidebarUsageButton } from "./SidebarUsageButton"; @@ -49,9 +50,12 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ +
+ +
{pillLabel ? ( -
+
{pillLabel} diff --git a/apps/web/src/hooks/useNewWorkWorkspace.test.tsx b/apps/web/src/hooks/useNewWorkWorkspace.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..83c4770c16ff5ae44b49aa3adafbd598b69e6b55 --- /dev/null +++ b/apps/web/src/hooks/useNewWorkWorkspace.test.tsx @@ -0,0 +1,202 @@ +import { RegistryContext } from "@effect/atom-react"; +import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; +import * as Cause from "effect/Cause"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + create: vi.fn(), + navigate: vi.fn(), + toast: vi.fn(), + sidebarOpen: true, + onNavigated: vi.fn(() => { + state.sidebarOpen = false; + }), + router: { state: { location: { href: "/existing-chat" } } }, + target: null as object | null, + draftEnvironmentId: null as string | null, + allowed: true, + connected: true, +})); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => state.navigate, + useRouter: () => state.router, + useParams: () => state.target, +})); +vi.mock("../state/environments", () => ({ + usePrimaryEnvironmentId: () => "local", + useEnvironment: () => ({ + connection: { phase: state.connected ? "connected" : "offline" }, + serverConfig: { managedWorkspaces: true }, + }), +})); +vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => state.draftEnvironmentId })); +vi.mock("../state/entities", () => ({ useProject: () => ({ id: "source" }) })); +vi.mock("../state/query", () => ({ + useEnvironmentQuery: () => ({ isSuccess: true, data: { projectId: "source" } }), +})); +vi.mock("../state/session", () => ({ useEnvironmentScope: () => true })); +vi.mock("../state/managedWorkspaces", () => ({ + managedWorkspaceStatus: () => null, + managedWorkspacePending: Atom.family(() => Atom.make(false)), + createManagedWorkspace: { permissionAtom: () => Atom.make(state.allowed) }, +})); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => state.create })); +vi.mock("../components/ui/toast", () => ({ toastManager: { add: state.toast } })); + +import { useNewWorkWorkspace } from "./useNewWorkWorkspace"; + +let renderer: ReactTestRenderer; +let registry: ReturnType; +let actions: ReturnType[]; +function Probe({ onNavigated }: { onNavigated?: () => void }) { + actions.push(useNewWorkWorkspace(onNavigated)); + return null; +} +const created = { + environmentId: "local", + projectId: "new-project", + threadId: "new-thread", + workspacePath: "/work/new", +}; +async function mount() { + await act(() => { + renderer = create( + + + + , + ); + }); +} +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + registry = AtomRegistry.make(); + actions = []; + state.target = null; + state.draftEnvironmentId = null; + state.allowed = true; + state.connected = true; + state.router.state.location.href = "/existing-chat"; + state.create.mockReset().mockResolvedValue(AsyncResult.success(created)); + state.navigate.mockReset(); + state.toast.mockReset(); + state.sidebarOpen = true; + state.onNavigated.mockClear(); +}); +afterEach(async () => { + await act(() => renderer?.unmount()); + registry.dispose(); + vi.unstubAllGlobals(); +}); + +it("shares the in-flight guard between header and command palette, then opens the returned thread", async () => { + let finish!: (value: unknown) => void; + state.create.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + await mount(); + let first!: Promise; + await act(async () => { + first = actions[0]!.run(); + await actions[1]!.run(); + }); + expect(state.create).toHaveBeenCalledTimes(1); + await act(async () => { + finish(AsyncResult.success(created)); + await first; + }); + expect(state.navigate).toHaveBeenCalledExactlyOnceWith({ + to: "/$environmentId/$threadId", + params: { environmentId: "local", threadId: "new-thread" }, + }); +}); + +it("releases the guard after failure so retry creates a workspace", async () => { + state.create.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("ws failed")))); + await mount(); + await act(() => actions[0]!.run()); + expect(state.navigate).not.toHaveBeenCalled(); + expect(state.sidebarOpen).toBe(true); + expect(state.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", description: "ws failed" }), + ); + await act(() => actions.at(-1)!.run()); + expect(state.create).toHaveBeenCalledTimes(2); + expect(state.navigate).toHaveBeenCalledTimes(1); +}); + +it("keeps a newly selected chat open while creation finishes and offers an explicit Open action", async () => { + let finish!: (value: unknown) => void; + state.create.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + await mount(); + let pending!: Promise; + await act(() => { + pending = actions[0]!.run(); + }); + state.router.state.location.href = "/another-chat"; + await act(async () => { + finish(AsyncResult.success(created)); + await pending; + }); + expect(state.navigate).not.toHaveBeenCalled(); + expect(state.sidebarOpen).toBe(true); + const toast = state.toast.mock.calls[0]![0]; + expect(toast).toMatchObject({ type: "success", description: "/work/new" }); + await toast.actionProps.onClick(); + expect(state.navigate).toHaveBeenCalledTimes(1); +}); + +it.each(["offline", "denied"])("does not create when %s", async (unavailable) => { + state.connected = unavailable !== "offline"; + state.allowed = unavailable !== "denied"; + await mount(); + await act(() => actions[0]!.run()); + expect(state.create).not.toHaveBeenCalled(); +}); + +it("creates on the draft's environment instead of the primary environment", async () => { + state.target = { kind: "draft", draftId: "remote-draft" }; + state.draftEnvironmentId = "remote"; + await mount(); + await act(() => actions[0]!.run()); + expect(state.create).toHaveBeenCalledExactlyOnceWith({ environmentId: "remote", input: {} }); +}); + +it("reveals the created workspace only after its navigation succeeds", async () => { + let finishNavigation!: () => void; + state.navigate.mockReturnValue( + new Promise((resolve) => { + finishNavigation = resolve; + }), + ); + await mount(); + let pending!: Promise; + await act(async () => { + pending = actions[0]!.run(); + }); + expect(state.navigate).toHaveBeenCalledTimes(1); + expect(state.sidebarOpen).toBe(true); + await act(async () => { + finishNavigation(); + await pending; + }); + expect(state.sidebarOpen).toBe(false); +}); + +it("keeps the mobile sidebar open if navigation to the created workspace fails", async () => { + state.navigate.mockRejectedValue(new Error("navigation failed")); + await mount(); + await act(() => actions[0]!.run()); + expect(state.sidebarOpen).toBe(true); + expect(state.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", description: "navigation failed" }), + ); +}); diff --git a/apps/web/src/hooks/useNewWorkWorkspace.ts b/apps/web/src/hooks/useNewWorkWorkspace.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb77659ede4fd3c5e20f03b48e08691be824035b --- /dev/null +++ b/apps/web/src/hooks/useNewWorkWorkspace.ts @@ -0,0 +1,111 @@ +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { useCallback, useContext } from "react"; + +import { toastManager } from "../components/ui/toast"; +import { resolveThreadRouteTarget } from "../threadRoutes"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; +import { useProject } from "../state/entities"; +import { + createManagedWorkspace, + managedWorkspacePending, + managedWorkspaceStatus, +} from "../state/managedWorkspaces"; +import { useEnvironmentQuery } from "../state/query"; +import { useEnvironmentScope } from "../state/session"; +import { useAtomCommand } from "../state/use-atom-command"; + +export function useNewWorkWorkspace(onNavigated?: () => void) { + const target = useParams({ strict: false, select: resolveThreadRouteTarget }); + const draftEnvironmentId = useComposerDraftStore((store) => + target?.kind === "draft" ? store.getDraftSession(target.draftId)?.environmentId : null, + ); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const environmentId = + target?.kind === "server" + ? target.threadRef.environmentId + : (draftEnvironmentId ?? primaryEnvironmentId); + const environment = useEnvironment(environmentId); + const connected = environment?.connection.phase === "connected"; + const supported = environment?.serverConfig?.managedWorkspaces === true; + const canRead = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const allowed = useAtomValue(createManagedWorkspace.permissionAtom(environmentId)); + const status = useEnvironmentQuery( + environmentId && connected && supported && canRead + ? managedWorkspaceStatus({ environmentId, input: {} }) + : null, + ); + const project = useProject( + environmentId && status.isSuccess && status.data?.projectId + ? scopeProjectRef(environmentId, status.data.projectId) + : null, + ); + const pendingAtom = managedWorkspacePending(environmentId); + const pending = useAtomValue(pendingAtom); + const registry = useContext(RegistryContext); + const create = useAtomCommand(createManagedWorkspace, { reportFailure: false }); + const navigate = useNavigate(); + const router = useRouter(); + const run = useCallback(async () => { + if (!environmentId || !connected || !allowed || !project || registry.get(pendingAtom)) return; + registry.set(pendingAtom, true); + const origin = router.state.location.href; + try { + const result = await create({ environmentId, input: {} }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) throw squashAtomCommandFailure(result); + return; + } + const open = async () => { + await navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: result.value.environmentId, threadId: result.value.threadId }, + }); + onNavigated?.(); + }; + if (router.state.location.href === origin) await open(); + else + toastManager.add({ + type: "success", + title: "Workspace for my work ready", + description: result.value.workspacePath, + actionProps: { children: "Open", onClick: () => void open() }, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not create workspace", + description: + error instanceof Error ? error.message : "Try again when the environment is connected.", + }); + } finally { + registry.set(pendingAtom, false); + } + }, [ + environmentId, + connected, + allowed, + project, + registry, + pendingAtom, + router, + create, + navigate, + onNavigated, + ]); + + return { + project, + pending, + label: pending ? "Creating workspace for my work…" : "New workspace for my work", + disabled: !connected || !allowed || pending, + run, + }; +} diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 3170af2c1eadcf8330f0d27be696a07d8a9e6978..ed150986995787c11cd2b89ecda88e659bfbe091 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -8,7 +8,7 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/reactivity"; +import { AsyncResult, Atom } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; function deferred() { @@ -19,8 +19,42 @@ function deferred() { return { promise, resolve }; } +const resourceQueries = vi.hoisted(() => ({ + calls: 0, + terminals: null as { threadId: string; hasRunningSubprocess: boolean }[] | null, + tabs: null as unknown[] | null, +})); +vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({ + ...(await original()), + executeAtomQuery: async (_registry: unknown, atom: string) => { + resourceQueries.calls++; + const value = + atom === "terminal-metadata" ? resourceQueries.terminals : { sessions: resourceQueries.tabs }; + return (atom === "terminal-metadata" ? resourceQueries.terminals : resourceQueries.tabs) === + null + ? { _tag: "Failure", cause: {} } + : { _tag: "Success", value }; + }, +})); +vi.mock("../connection/hub", () => ({ + hubConnection: { + organizationPermissionAtom: (id: EnvironmentId) => Atom.make(state.organization.has(id)), + }, +})); +vi.mock("../connection/catalog", () => ({ + environmentCatalog: { + stateAtom: (_id: EnvironmentId) => Atom.make(AsyncResult.success({ phase: "offline" })), + catalogValueAtom: Atom.make({ isReady: true, entries: new Map() }), + }, +})); +vi.mock("../state/terminal", () => ({ + terminalEnvironment: { metadataSnapshot: () => "terminal-metadata" }, +})); +vi.mock("../state/preview", () => ({ previewEnvironment: { list: () => "preview-list" } })); + const state = vi.hoisted(() => ({ scopes: new Map>(), + organization: new Set(), threads: [] as { environmentId: EnvironmentId; id: ThreadId; @@ -47,6 +81,8 @@ vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({ state: { matches: [] }, navigate: async () => {} }), })); vi.mock("../state/session", () => ({ + readThreadOrganizationPermission: (id: string) => + state.organization.has(id) || state.scopes.get(id)?.has("orchestration:operate") === true, environmentSession: { sessionStateAtom: "session" }, readEnvironmentScope: (environmentId: string, scope: string) => state.scopes.get(environmentId)?.has(scope) === true, @@ -58,7 +94,23 @@ vi.mock("../state/use-atom-command", () => ({ state.requests.push({ action, ...request }); const scope = action === "removeWorktree" ? AuthSourceControlWriteScope : AuthOrchestrationOperateScope; - if (!state.scopes.get(request.environmentId)?.has(scope)) { + if ( + !state.scopes.get(request.environmentId)?.has(scope) && + !( + state.organization.has(request.environmentId) && + [ + "settle", + "unsettle", + "snooze", + "unsnooze", + "pin", + "unpin", + "reorderPin", + "reorderActive", + "setAutoSettle", + ].includes(action) + ) + ) { return AsyncResult.failure(Cause.fail(new Error("Server denied the request"))); } state.afterRequest?.(action); @@ -111,7 +163,8 @@ vi.mock("../state/entities", () => ({ .map((thread) => ({ environmentId, threadId: thread.id })), readProject: () => ({ workspaceRoot: "/project" }), })); -vi.mock("../components/Sidebar.logic", () => ({ +vi.mock("../components/Sidebar.logic", async (original) => ({ + ...(await original()), getFallbackThreadIdAfterDelete: () => null, pinOrderKeyBetween: () => "a", })); @@ -166,6 +219,7 @@ vi.mock("./useSettings", () => ({ })); import { useThreadActions } from "./useThreadActions"; +import { archiveSelectedThreadEntries } from "../components/Sidebar.logic"; const primary = EnvironmentId.make("primary"); const secondary = EnvironmentId.make("secondary"); @@ -209,6 +263,9 @@ const operations = [ ] as const; beforeEach(() => { + resourceQueries.calls = 0; + resourceQueries.terminals = null; + resourceQueries.tabs = null; state.scopes = new Map([ [primary, new Set([AuthOrchestrationOperateScope])], [secondary, new Set()], @@ -230,9 +287,44 @@ beforeEach(() => { state.toasts = []; state.afterRequest = undefined; state.sessionLookupFails = false; + state.organization.clear(); }); describe("thread action permissions", () => { + it("stops a bulk archive when its environment loses permission between entries", async () => { + state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + const second = { ...target, threadId: ThreadId.make("second") }; + state.threads.push({ ...state.threads[0]!, id: second.threadId }); + state.afterRequest = (action) => { + if (action === "archive") state.scopes.get(secondary)!.clear(); + }; + const actions = useThreadActions(); + const entries = [target, second].map((ref) => ({ threadKey: ref.threadId, ref })); + const result = await archiveSelectedThreadEntries({ + entries, + archive: (entry, onArchived) => actions.archiveThread(entry.ref, { onArchived }), + }); + expect(state.requests.map((request) => request.input.threadId)).toEqual([target.threadId]); + expect(result.archivedThreadKeys).toEqual([target.threadId]); + expect(result.mutationFailure?._tag).toBe("Failure"); + }); + + it("rechecks permission after a settle confirmation without dispatching", async () => { + state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + const confirmed = deferred(); + state.confirm.mockReturnValue(confirmed.promise); + const pending = useThreadActions().settleThread(target); + expect(state.requests).toEqual([]); + state.scopes.get(secondary)!.clear(); + + confirmed.resolve(true); + const result = await pending; + + expect(result._tag).toBe("Failure"); + expect(state.requests).toEqual([]); + expect(state.localEffects).toEqual([]); + }); + it.each(operations)("$name requires the target environment's grant", async ({ run }) => { const result = await run(useThreadActions(), target); expect(result._tag).toBe("Failure"); @@ -277,7 +369,7 @@ describe("thread action permissions", () => { const confirmation = deferred(); state.confirm.mockReturnValue(confirmation.promise); const result = useThreadActions()[action](target); - expect(state.confirm).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(state.confirm).toHaveBeenCalledOnce()); state.scopes.get(secondary)!.clear(); confirmation.resolve(true); await result; @@ -353,3 +445,17 @@ describe("thread action permissions", () => { } }); }); + +it("uses the central grant for organization without authorizing source execution", async () => { + resourceQueries.terminals = []; + resourceQueries.tabs = []; + state.organization.add(secondary); + const actions = useThreadActions(); + expect((await actions.settleThread(target))._tag).toBe("Success"); + expect(resourceQueries.calls).toBe(0); + expect((await actions.deleteThread(target))._tag).toBe("Failure"); + expect(state.requests.map((request) => request.action)).toEqual(["settle"]); + state.organization.clear(); + expect((await actions.unsettleThread(target))._tag).toBe("Failure"); + expect(state.requests).toHaveLength(1); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index b0093f4a0a128a5abcb367b7e162209a957b7432..27d0063faedbfbc5449305e745040f4dcd0effe4 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -1,11 +1,21 @@ +import { environmentCatalog } from "../connection/catalog"; +import { hubConnection } from "../connection/hub"; import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; +import { + executeAtomQuery, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + canSnooze, + threadWokeAt, + resolveThreadSettlementWarning, +} from "@t3tools/client-runtime/state/thread-settled"; import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; import { AuthOrchestrationOperateScope, @@ -18,6 +28,7 @@ import { } from "@t3tools/contracts"; import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/reactivity"; import { useRouter } from "@tanstack/react-router"; @@ -25,13 +36,18 @@ import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; -import { environmentSession, readEnvironmentScope } from "../state/session"; -import { terminalEnvironment } from "../state/terminal"; +import { + environmentSession, + readEnvironmentScope, + readThreadOrganizationPermission, +} from "../state/session"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../state/server"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; +import { terminalEnvironment } from "../state/terminal"; +import { previewEnvironment } from "../state/preview"; import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; @@ -255,8 +271,11 @@ export function useAcknowledgeThreadWoke() { ); } -function threadOperationFailure(target: ScopedThreadRef) { - return readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope) +function threadOperationFailure(target: ScopedThreadRef, organization = false) { + const permitted = organization + ? readThreadOrganizationPermission(target.environmentId) + : readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope); + return permitted ? null : AsyncResult.failure( Cause.fail( @@ -279,30 +298,39 @@ export function useThreadActions() { reportFailure: false, }); const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, { + organization: true, reportFailure: false, }); const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { + organization: true, reportFailure: false, }); const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, { + organization: true, reportFailure: false, }); const unpinThreadMutation = useOrchestrationCommand(threadEnvironment.unpin, { + organization: true, reportFailure: false, }); const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, { + organization: true, reportFailure: false, }); const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, { + organization: true, reportFailure: false, }); const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, { + organization: true, reportFailure: false, }); const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, { + organization: true, reportFailure: false, }); const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { + organization: true, reportFailure: false, }); const markThreadUnread = useMarkThreadUnread(); @@ -374,7 +402,10 @@ export function useThreadActions() { ); const archiveThread = useCallback( - async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => { + async ( + target: ScopedThreadRef, + opts: { onArchived?: () => void; snapshotSequence?: number } = {}, + ) => { const resolved = resolveThreadTarget(target); if (!resolved) return AsyncResult.success(undefined); const { thread, threadRef } = resolved; @@ -389,14 +420,15 @@ export function useThreadActions() { ); } - const currentRouteThreadRef = getCurrentRouteThreadRef(); - const shouldNavigateToDraft = - currentRouteThreadRef?.threadId === threadRef.threadId && - currentRouteThreadRef.environmentId === threadRef.environmentId; const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef)); const archiveResult = await archiveThreadMutation({ environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId }, + input: { + threadId: threadRef.threadId, + ...(opts.snapshotSequence === undefined + ? {} + : { snapshotSequence: opts.snapshotSequence }), + }, }); if (archiveResult._tag === "Failure") { action.finish(); @@ -408,25 +440,27 @@ export function useThreadActions() { } refreshArchivedThreadsForEnvironment(threadRef.environmentId); opts.onArchived?.(); + const currentRouteThreadRef = getCurrentRouteThreadRef(); + const shouldNavigateToDraft = + currentRouteThreadRef?.threadId === threadRef.threadId && + currentRouteThreadRef.environmentId === threadRef.environmentId; + const navigationResult = shouldNavigateToDraft + ? await settlePromise(() => + handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), + ) + : null; showThreadUndoNotice({ action: "Archived", claim: action, // Undo also brings the reader back when archiving moved them to a draft. - undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }), + undo: () => + unarchiveThread(threadRef, { + navigate: navigationResult?._tag === "Success" && navigationResult.value !== null, + }), failureTitle: "Failed to undo archive", }); - if (shouldNavigateToDraft) { - const navigationResult = await settlePromise(() => - handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), - ); - if (navigationResult._tag === "Failure") { - return navigationResult; - } - return archiveResult; - } - - return archiveResult; + return navigationResult?._tag === "Failure" ? navigationResult : archiveResult; }, [ archiveThreadMutation, @@ -767,8 +801,98 @@ export function useThreadActions() { [pinThread, unpinThreadMutation], ); + const confirmThreadSettlement = useCallback(async (targets: readonly ScopedThreadRef[]) => { + for (const target of targets) { + const permissionFailure = threadOperationFailure(target, true); + if (permissionFailure) return permissionFailure; + if (!readEnvironmentSupportsSettlement(target.environmentId)) { + return AsyncResult.failure(Cause.fail(new ThreadSettlementUnsupportedError(target))); + } + } + if (targets.length === 0) return AsyncResult.success(false); + const offline = new Set( + targets.flatMap((target) => + appAtomRegistry.get(hubConnection.organizationPermissionAtom(target.environmentId)) && + Option.getOrNull( + AsyncResult.value( + appAtomRegistry.get(environmentCatalog.stateAtom(target.environmentId)), + ), + )?.phase !== "connected" + ? [target.environmentId] + : [], + ), + ); + const metadata = new Map( + await Promise.all( + [...new Set(targets.map((target) => target.environmentId))].map( + async (environmentId) => + [ + environmentId, + offline.has(environmentId) + ? null + : await executeAtomQuery( + appAtomRegistry, + terminalEnvironment.metadataSnapshot({ environmentId, input: null }), + { refresh: true, reportFailure: false, signal: AbortSignal.timeout(3_000) }, + ), + ] as const, + ), + ), + ); + const resources = await Promise.all( + targets.map(async (target) => { + const terminals = metadata.get(target.environmentId)!; + const previews = offline.has(target.environmentId) + ? null + : await executeAtomQuery( + appAtomRegistry, + previewEnvironment.list({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }), + { refresh: true, reportFailure: false, signal: AbortSignal.timeout(3_000) }, + ); + return { + runningCommands: + terminals?._tag === "Success" + ? terminals.value.some( + (terminal) => + terminal.threadId === target.threadId && terminal.hasRunningSubprocess, + ) + : null, + browserTabs: previews?._tag === "Success" ? previews.value.sessions.length > 0 : null, + }; + }), + ); + const warning = resolveThreadSettlementWarning({ + runningCommands: resources.some((resource) => resource.runningCommands === null) + ? null + : resources.some((resource) => resource.runningCommands), + browserTabs: resources.some((resource) => resource.browserTabs === null) + ? null + : resources.some((resource) => resource.browserTabs), + }); + if (warning === null) return AsyncResult.success(true); + const title = + targets.length === 1 + ? `Settle "${readThreadShell(targets[0]!)?.title ?? "this chat"}"?` + : `Settle ${targets.length} chats?`; + const localApi = readLocalApi(); + if (!localApi) return AsyncResult.failure(Cause.interrupt()); + return settlePromise(() => + localApi.dialogs.confirm( + `${title}\n${warning}${offline.size > 0 ? " Cleanup waits until the computer reconnects." : ""}`, + { + variant: "destructive", + }, + ), + ); + }, []); + const settleThread = useCallback( - async (target: ScopedThreadRef) => { + async (target: ScopedThreadRef, options: { confirmed?: true } = {}) => { + const permissionFailure = threadOperationFailure(target, true); + if (permissionFailure) return permissionFailure; // Version skew: never send the command to a server that predates it — // the raw protocol rejection would read as a random failure. if (!readEnvironmentSupportsSettlement(target.environmentId)) { @@ -781,6 +905,16 @@ export function useThreadActions() { ), ); } + if (readThreadShell(target)?.settledOverride === "settled") { + return AsyncResult.success(undefined); + } + if (!options.confirmed) { + const confirmation = await confirmThreadSettlement([target]); + if (confirmation._tag === "Failure") return confirmation; + if (!confirmation.value) return AsyncResult.failure(Cause.interrupt()); + } + const currentPermissionFailure = threadOperationFailure(target, true); + if (currentPermissionFailure) return currentPermissionFailure; const resolved = resolveThreadTarget(target); const wokeAt = resolved ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) @@ -832,6 +966,7 @@ export function useThreadActions() { return result; }, [ + confirmThreadSettlement, markThreadVisited, pinThread, resolveThreadTarget, @@ -843,7 +978,7 @@ export function useThreadActions() { const confirmAndUnpinThread = useCallback( async (target: ScopedThreadRef) => { - const permissionFailure = threadOperationFailure(target); + const permissionFailure = threadOperationFailure(target, true); if (permissionFailure) return permissionFailure; const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); @@ -1013,6 +1148,7 @@ export function useThreadActions() { unarchiveThread, deleteThread, confirmAndDeleteThread, + confirmThreadSettlement, settleThread, unsettleThread, snoozeThread, @@ -1029,6 +1165,7 @@ export function useThreadActions() { archiveThread, confirmAndDeleteThread, confirmAndUnpinThread, + confirmThreadSettlement, deleteThread, markThreadUnread, pinThread, diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index da65bf2ef931feb8619739b395068cd18af03d8a..7866dc929be236595b6d307efa4e59c1acf19f19 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -1,11 +1,41 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + OrchestrationV2DispatchCommandError, + ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { useThreadActions } from "./useThreadActions"; import { threadEnvironment } from "../state/threads"; import { toastManager } from "../components/ui/toast"; import { useThreadUndoNotice } from "./showThreadUndoNotice"; +import { archiveSelectedThreadEntries } from "../components/Sidebar.logic"; +const resourceQueries = vi.hoisted(() => ({ + terminals: null as { threadId: string; hasRunningSubprocess: boolean }[] | null, + tabs: null as unknown[] | null, +})); +vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({ + ...(await original()), + executeAtomQuery: async (_registry: unknown, atom: string) => { + const value = + atom === "terminal-metadata" ? resourceQueries.terminals : { sessions: resourceQueries.tabs }; + return (atom === "terminal-metadata" ? resourceQueries.terminals : resourceQueries.tabs) === + null + ? { _tag: "Failure", cause: {} } + : { _tag: "Success", value }; + }, +})); +vi.mock("../state/terminal", () => ({ + terminalEnvironment: { metadataSnapshot: () => "terminal-metadata" }, +})); +vi.mock("../state/preview", () => ({ previewEnvironment: { list: () => "preview-list" } })); + +const confirmation = vi.hoisted(() => vi.fn(async (_message: string, _options?: unknown) => true)); +vi.mock("../localApi", () => ({ readLocalApi: () => ({ dialogs: { confirm: confirmation } }) })); const commands = vi.hoisted(() => ({ pin: vi.fn(), unpin: vi.fn(), @@ -20,6 +50,12 @@ const router = vi.hoisted(() => ({ navigate: vi.fn(async () => {}), state: { matches: [{ params: {} as Record }] }, })); +const newThread = vi.hoisted(() => + vi.fn<() => Promise<{ draftId: string; threadId: string } | null>>(async () => ({ + draftId: "draft", + threadId: "draft-thread", + })), +); vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); vi.mock("../state/session", async (original) => ({ ...(await original()), @@ -33,7 +69,7 @@ vi.mock("react", async (original) => ({ })); vi.mock("@tanstack/react-router", () => ({ useRouter: () => router })); vi.mock("./useSettings", () => ({ useClientSettings: () => false })); -vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => newThread })); vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() })); vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() })); @@ -43,6 +79,7 @@ const threadShell = vi.hoisted(() => ({ pinOrderKey: "a0", pinnedAt: null as string | null, snoozedUntil: null as string | null, + settledOverride: null as "settled" | null, projectId: "project", environmentId: "undo-env", session: null, @@ -91,14 +128,19 @@ function currentUndo() { } beforeEach(() => { + resourceQueries.terminals = null; + resourceQueries.tabs = null; vi.useFakeTimers(); + confirmation.mockReset().mockResolvedValue(true); for (const command of Object.values(commands)) { command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); } router.navigate.mockClear(); + newThread.mockReset().mockResolvedValue({ draftId: "draft", threadId: "draft-thread" }); router.state.matches[0]!.params = {}; threadShell.pinnedAt = null; threadShell.snoozedUntil = null; + threadShell.settledOverride = null; }); afterEach(() => { vi.runAllTimers(); @@ -129,6 +171,134 @@ describe("unpin Undo", () => { }); describe("archive Undo", () => { + it("keeps a rejected current thread in place and groups Undo only for successful candidates", async () => { + const entries = ["first", target.threadId, "last"].map((id) => ({ + threadRef: { ...target, threadId: ThreadId.make(id) }, + threadKey: `${target.environmentId}:${id}`, + })); + const changed = AsyncResult.failure( + Cause.fail( + new OrchestrationV2DispatchCommandError({ + commandId: CommandId.make("archive-condition-rejected"), + commandType: "thread.archive", + reason: "archive-condition-changed", + message: "The thread changed before automatic archival.", + }), + ), + ); + commands.archive.mockResolvedValueOnce(AsyncResult.success(undefined)); + commands.archive.mockResolvedValueOnce(changed); + commands.archive.mockResolvedValueOnce(AsyncResult.success(undefined)); + router.state.matches[0]!.params = { + environmentId: target.environmentId, + threadId: target.threadId, + }; + const { archiveThread } = useThreadActions(); + const outcome = await archiveSelectedThreadEntries({ + entries, + archive: ({ threadRef }, onArchived) => + archiveThread(threadRef, { onArchived, snapshotSequence: 7 }), + }); + + expect(outcome.archivedThreadKeys).toEqual([entries[0]!.threadKey, entries[2]!.threadKey]); + expect(newThread).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 2 }); + + await currentUndo()(); + + expect(commands.unarchive.mock.calls.map(([request]) => request.input.threadId)).toEqual([ + "last", + "first", + ]); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it("preserves navigation and Undo stays put when the reader leaves during archive", async () => { + let completeArchive!: () => void; + const pending = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => { + completeArchive = () => resolve({ _tag: "Success", value: undefined }); + }); + commands.archive.mockReturnValueOnce(pending); + router.state.matches[0]!.params = { + environmentId: target.environmentId, + threadId: target.threadId, + }; + const archiving = useThreadActions().archiveThread(target); + expect(commands.archive).toHaveBeenCalledOnce(); + router.state.matches[0]!.params = { + environmentId: target.environmentId, + threadId: "another-thread", + }; + completeArchive(); + await archiving; + + expect(newThread).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + await currentUndo()(); + expect(commands.unarchive).toHaveBeenCalledOnce(); + expect(router.navigate).not.toHaveBeenCalled(); + expect(router.state.matches[0]!.params.threadId).toBe("another-thread"); + }); + + it("keeps archive Undo without return navigation when leaving the archived thread failed", async () => { + router.state.matches[0]!.params = { + environmentId: target.environmentId, + threadId: target.threadId, + }; + newThread.mockRejectedValueOnce(new Error("Navigation failed")); + const result = await useThreadActions().archiveThread(target); + + expect(result._tag).toBe("Failure"); + expect(newThread).toHaveBeenCalledOnce(); + expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 1 }); + await currentUndo()(); + expect(commands.unarchive).toHaveBeenCalledOnce(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it("does not return on Undo when draft creation declined navigation", async () => { + router.state.matches[0]!.params = { + environmentId: target.environmentId, + threadId: target.threadId, + }; + newThread.mockResolvedValueOnce(null); + const result = await useThreadActions().archiveThread(target); + + expect(result._tag).toBe("Success"); + expect(newThread).toHaveBeenCalledOnce(); + await currentUndo()(); + expect(commands.unarchive).toHaveBeenCalledOnce(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it("preserves each archive snapshot guard and restores a bulk notice together", async () => { + const actions = useThreadActions(); + const second = { ...target, threadId: ThreadId.make("second-thread") }; + await actions.archiveThread(target, { snapshotSequence: 17 }); + await actions.archiveThread(second, { snapshotSequence: 42 }); + + expect(commands.archive).toHaveBeenNthCalledWith(1, { + environmentId: target.environmentId, + input: { threadId: target.threadId, snapshotSequence: 17 }, + }); + expect(commands.archive).toHaveBeenNthCalledWith(2, { + environmentId: second.environmentId, + input: { threadId: second.threadId, snapshotSequence: 42 }, + }); + expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 2 }); + await currentUndo()(); + expect(commands.unarchive).toHaveBeenCalledTimes(2); + expect(commands.unarchive).toHaveBeenCalledWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + expect(commands.unarchive).toHaveBeenCalledWith({ + environmentId: second.environmentId, + input: { threadId: second.threadId }, + }); + }); + it("unarchives and returns to the thread when archiving left it", async () => { const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); router.state.matches[0]!.params = { @@ -169,6 +339,105 @@ describe("archive Undo", () => { }); describe("settle and snooze Undo", () => { + it.each([ + { terminals: [] }, + { terminals: [{ threadId: "thread", hasRunningSubprocess: false }] }, + ])("settles empty or shell-only terminals without confirmation", async ({ terminals }) => { + resourceQueries.terminals = terminals; + resourceQueries.tabs = []; + await useThreadActions().settleThread(target); + expect(confirmation).not.toHaveBeenCalled(); + expect(commands.settle).toHaveBeenCalledOnce(); + }); + + it.each([ + { + terminals: [{ threadId: "thread", hasRunningSubprocess: true }], + tabs: [], + text: "Undo cannot restart them.", + }, + { terminals: [], tabs: [{}], text: "Undo cannot restore page state." }, + { terminals: null, tabs: [], text: "could not be checked" }, + ])("warns accurately before settlement: $text", async ({ terminals, tabs, text }) => { + resourceQueries.terminals = terminals; + resourceQueries.tabs = tabs; + confirmation.mockResolvedValue(false); + await useThreadActions().settleThread(target); + expect(confirmation).toHaveBeenCalledExactlyOnceWith(expect.stringContaining(text), { + variant: "destructive", + }); + expect(commands.settle).not.toHaveBeenCalled(); + }); + + it("ignores commands belonging to a different chat", async () => { + resourceQueries.terminals = [{ threadId: "other-thread", hasRunningSubprocess: true }]; + resourceQueries.tabs = []; + await useThreadActions().settleThread(target); + expect(confirmation).not.toHaveBeenCalled(); + expect(commands.settle).toHaveBeenCalledOnce(); + }); + + it("cancels without changing the chat or invalidating its existing Undo", async () => { + const actions = useThreadActions(); + await actions.unpinThread(target); + const undo = currentUndo(); + confirmation.mockResolvedValue(false); + + const result = await actions.settleThread(target); + + expect(result._tag).toBe("Failure"); + expect(commands.settle).not.toHaveBeenCalled(); + await undo(); + expect(commands.pin).toHaveBeenCalledOnce(); + expect(confirmation).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("Undo cannot restore commands or page state"), + { variant: "destructive" }, + ); + }); + + it("waits for confirmation before dispatching settlement", async () => { + let confirm!: (allowed: boolean) => void; + confirmation.mockImplementation( + () => + new Promise((resolve) => { + confirm = resolve; + }), + ); + const pending = useThreadActions().settleThread(target); + expect(commands.settle).not.toHaveBeenCalled(); + + await vi.waitFor(() => expect(confirmation).toHaveBeenCalledOnce()); + confirm(true); + await pending; + + expect(commands.settle).toHaveBeenCalledOnce(); + }); + + it("does not warn or settle again when the chat is already settled", async () => { + threadShell.settledOverride = "settled"; + + await useThreadActions().settleThread(target); + + expect(confirmation).not.toHaveBeenCalled(); + expect(commands.settle).not.toHaveBeenCalled(); + }); + + it("warns once before a confirmed batch and never again during Undo", async () => { + const actions = useThreadActions(); + const second = { ...target, threadId: ThreadId.make("second-thread") }; + const result = await actions.confirmThreadSettlement([target, second]); + expect(result).toMatchObject({ _tag: "Success", value: true }); + expect(commands.settle).not.toHaveBeenCalled(); + + await actions.settleThread(target, { confirmed: true }); + await actions.settleThread(second, { confirmed: true }); + await currentUndo()(); + + expect(confirmation).toHaveBeenCalledOnce(); + expect(confirmation.mock.calls[0]?.[0]).toContain("Settle 2 chats?"); + expect(commands.settle).toHaveBeenCalledTimes(2); + }); + it("un-settles from the notice and expires the Undo after a manual un-settle", async () => { const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); const actions = useThreadActions(); diff --git a/apps/web/src/state/managedWorkspaces.ts b/apps/web/src/state/managedWorkspaces.ts new file mode 100644 index 0000000000000000000000000000000000000000..08b8e8bc935c4d8820332c650be4ae2ae680301c --- /dev/null +++ b/apps/web/src/state/managedWorkspaces.ts @@ -0,0 +1,25 @@ +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "@t3tools/client-runtime/state/runtime"; +import { WS_METHODS, type EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const managedWorkspaceStatus = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "environment-data:managed-workspaces:status", + tag: WS_METHODS.managedWorkspacesStatus, + staleTimeMs: 30_000, + idleTtlMs: 60_000, +}); + +export const createManagedWorkspace = createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:managed-workspaces:create", + tag: WS_METHODS.managedWorkspacesCreate, +}); + +export const managedWorkspacePending = Atom.family< + EnvironmentId | null, + Atom.Writable +>(() => Atom.make(false)); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 2a9735f61c6da18b96f3aeaefcb1c8fa811ae021..ba9ea7389d80a5828cfae5ef0a678ec68b46c447 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -130,17 +130,39 @@ Choose **Settle thread** from its menu to move finished work out of the active l without deleting the conversation. **Un-settle thread** restores it to active work and prevents automatic settlement until new activity resumes the usual rules. Manually settling an idle thread dismisses unanswered async questions without -sending an answer or restarting the agent. Settling also closes the thread's -terminals that wait at an idle prompt, and keeps their output. A terminal that -runs a command, such as a dev server, stays open. +sending an answer or restarting the agent. Manual settlement closes all of the +thread's terminals and browser tabs, including running commands and playing media. +Running programs or open browser tabs ask for confirmation; empty chats and idle +shells settle directly. Terminal output stays in the conversation. Undo or +unsettling restores the chat, but cannot restart those commands or restore its tabs. +Other chats keep their own terminals and tabs, including in a shared workspace. +The workspace's detached dev server stops once no +active chat or pending work needs it; shared workspaces keep their server while +another chat is active. To reclaim disk space from settled work, turn on **Run in the thread's worktree when the thread settles** for one of the project's actions, or set `"runOnSettle": true` on a `t3.json` script, for example `cargo clean`. It runs each time a thread in its own -worktree settles, manually or automatically, even if a terminal there still runs a -command such as a dev server. Threads in the project's main checkout skip it. Its terminal +worktree settles, manually or automatically. Threads in the project's main checkout skip it. Its terminal closes when the command succeeds and stays open when it fails. +Settled threads archive on Saturday at 9:00 AM in the server's local time. If the +server was offline, it catches up when it starts. Threads with running work or +pending requests wait until that work finishes. + +On web and desktop, right-click **Settled** and choose **Archive all** to archive +the settled chats in the current project filter, including hidden chats. To archive +a selection, choose **Archive** from its context menu. Undo restores each archived +group together. Chats that resume work during Archive all stay available. + +Workspaces for my work stay available while their chats are settled; settlement +does not run a cleanup script there. A settled work chat archives once all its +linked pull requests merge, including when you settle it after the merge. +Workspace cleanup waits for archival and for other chats or external tools +to release the folder. Mergebot supplies live PR and check updates. A new CI failure returns the chat to Active and adds a +Mergebot link to the transcript without starting an agent. Settling it again +acknowledges that failure; a failed rerun can wake it again. + On web and desktop, press a thread's **Settle** button and drag up or down to settle every thread in that section between it and the one you release on. The **Un-settle** and **Wake** buttons work the same way in their sections. @@ -148,12 +170,15 @@ Press `Escape` while dragging to cancel. By default, environments settle inactive threads after three days and settle threads whose pull request merged. A closed pull request can also settle an idle -thread. Work in progress, pending questions or approvals, and live background work -prevent automatic settlement. An open pull request does not prevent inactivity -settlement unless you turn on **Keep threads with a PR** in **Settings → General**, -but an old closed or merged pull request does not settle work you +thread. Work in progress, pending questions or approvals, live background work, +and open terminals or browser tabs prevent automatic settlement. An open pull +request does not prevent inactivity settlement unless you turn on **Keep threads +with a PR** in **Settings → General**, but an old closed or merged pull request +does not settle work you resumed after it closed. Only your own messages count as resuming. A turn that finished background work or a pull request watch starts on its own does not. +Disconnected or unverified Mergebot data cannot trigger merge archival, PR-based +settlement, or a CI wakeup. Scheduled archival and inactivity rules still apply. To keep one thread out of the settled shelf no matter how long it sits idle, open its menu, choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 2c38e18f3e5c8f29924db461f7987bdf82dccc88..cb23ed078f482ef268b42f6f8aa86b69aefce793 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -234,6 +234,26 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(layerTestCrypto)), ); + it.effect("preserves the settled shelf snapshot guard when archiving", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const supervisor = yield* makeSupervisor({ commands, projects: [] }); + yield* archiveThread({ + commandId: CommandId.make("guarded-archive"), + threadId: ThreadId.make("settled-thread"), + snapshotSequence: 137, + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + expect(commands).toEqual([ + { + type: "thread.archive", + commandId: "guarded-archive", + threadId: "settled-thread", + snapshotSequence: 137, + }, + ]); + }).pipe(Effect.provide(layerTestCrypto)), + ); + it.effect("resolves run ordinal zero to the persisted thread-start checkpoint", () => Effect.gen(function* () { const scopeId = CheckpointScopeId.make("checkpoint-scope-root"); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index bce6b98c421c4af2efc68e7ecbd632427e21b85f..ade8d0486354cdc3dcce374b8efbe0f5960e5b94 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -90,7 +90,9 @@ export interface ThreadCommandInput extends CommandMetadata { } export type DeleteThreadInput = ThreadCommandInput; -export type ArchiveThreadInput = ThreadCommandInput; +export interface ArchiveThreadInput extends ThreadCommandInput { + readonly snapshotSequence?: number; +} export type UnarchiveThreadInput = ThreadCommandInput; export type SettleThreadInput = ThreadCommandInput; @@ -458,7 +460,12 @@ export const deleteThread = Effect.fn("EnvironmentCommands.deleteThread")(functi export const archiveThread = Effect.fn("EnvironmentCommands.archiveThread")(function* ( input: ArchiveThreadInput, ) { - return yield* simpleThreadCommand("thread.archive", input); + return yield* dispatch({ + type: "thread.archive", + commandId: yield* allocateCommandId(input), + threadId: input.threadId, + ...(input.snapshotSequence === undefined ? {} : { snapshotSequence: input.snapshotSequence }), + }); }); export const unarchiveThread = Effect.fn("EnvironmentCommands.unarchiveThread")(function* ( diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index cd3053c4bb2598b9d1bf86924ae9649b421e05ee..fd0cd22af2200e90a02517d4ecbdc70a037b06e1 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -33,6 +33,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { EnvironmentRpcRequestObserver, request, + requestGuarded, runStream, subscribe, subscribeDynamicWithSession, @@ -89,6 +90,33 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { }); describe("environment RPC", () => { + it.effect("reads managed workspace status without a command boundary and denies creation", () => + Effect.gen(function* () { + let creates = 0; + const client = { + [WS_METHODS.managedWorkspacesStatus]: () => Effect.succeed({ projectId: null }), + [WS_METHODS.managedWorkspacesCreate]: () => { + creates += 1; + return Effect.die("Protected workspace creation reached the transport"); + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + + const status = yield* request(WS_METHODS.managedWorkspacesStatus, {}).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + expect(status).toEqual({ projectId: null }); + + const create = yield* requestGuarded(WS_METHODS.managedWorkspacesCreate, {}).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.flip, + ); + expect(create._tag).toBe("EnvironmentAuthorizationError"); + expect(creates).toBe(0); + }), + ); + it.effect("reuses the session config stream instead of opening a duplicate subscription", () => Effect.gen(function* () { const event: ServerConfigStreamEvent = { diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index cb074149c744fe4c9b098b550c33a33e110afdab..ff4b046a296a55a5587b44e6d33a27eca0872386 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -86,6 +86,24 @@ describe("command permissions", () => { ), ); + it.effect("checks destination grants before creating a managed workspace", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const create = createCommandPermissions(runtime, WS_METHODS.managedWorkspacesCreate); + registry.set(sessions(env), AsyncResult.success(grant(false))); + expect(registry.get(create.permissionAtom(env))).toBe(false); + expect((yield* create.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set(sessions(env), AsyncResult.success(grant(true))); + yield* create.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(create.permissionAtom(other))).toBe(false); + }), + ), + ); + it.effect("requires destination read grants for dev status and terminal authority to start", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/terminal.test.ts b/packages/client-runtime/src/state/terminal.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d5d2da690c2359225113142102b0158e70095f8 --- /dev/null +++ b/packages/client-runtime/src/state/terminal.test.ts @@ -0,0 +1,108 @@ +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, type TerminalMetadataStreamEvent, WS_METHODS } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { vi } from "vite-plus/test"; + +import { EnvironmentRegistry } from "../connection/registry.ts"; +import { createTerminalEnvironmentAtoms } from "./terminal.ts"; +import { executeAtomQuery } from "./runtime.ts"; + +const source = vi.hoisted(() => ({ stream: undefined as unknown })); +vi.mock("../rpc/client.ts", async (original) => ({ + ...(await original()), + subscribe: (tag: string) => { + expect(tag).toBe(WS_METHODS.subscribeTerminalMetadata); + return source.stream; + }, +})); + +it.effect("waits for the server metadata snapshot before reporting an empty terminal list", () => + Effect.scoped( + Effect.gen(function* () { + const subscribed = yield* Deferred.make(); + const snapshot = yield* Deferred.make(); + source.stream = Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe( + Stream.flatMap(() => Stream.fromEffect(Deferred.await(snapshot))), + ); + const runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry, { + followStream: (_id, stream) => Stream.concat(stream, Stream.never), + } as EnvironmentRegistry["Service"]), + ); + const terminals = createTerminalEnvironmentAtoms(runtime); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const atom = terminals.metadata({ + environmentId: EnvironmentId.make("metadata-proof"), + input: null, + }); + const read = yield* Effect.forkChild(AtomRegistry.getResult(registry, atom)); + yield* Deferred.await(subscribed); + expect(AsyncResult.isSuccess(registry.get(atom))).toBe(false); + yield* Deferred.succeed(snapshot, { type: "snapshot", terminals: [] }); + expect(yield* Fiber.join(read)).toEqual([]); + }), + ), +); + +it.effect("refreshes terminal metadata while an existing subscriber keeps the stream mounted", () => + Effect.scoped( + Effect.gen(function* () { + let subscriptions = 0; + let current: TerminalMetadataStreamEvent = { type: "snapshot", terminals: [] }; + source.stream = Stream.suspend(() => { + subscriptions++; + return Stream.concat(Stream.succeed(current), Stream.never); + }); + const runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry, { + followStream: (_id, stream) => Stream.concat(stream, Stream.never), + } as EnvironmentRegistry["Service"]), + ); + const terminals = createTerminalEnvironmentAtoms(runtime); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const target = { environmentId: EnvironmentId.make("metadata-refresh"), input: null }; + const live = terminals.metadata(target); + yield* AtomRegistry.mount(registry, live); + expect(yield* AtomRegistry.getResult(registry, live)).toEqual([]); + const atom = terminals.metadataSnapshot(target); + const initial = yield* Effect.promise(() => + executeAtomQuery(registry, atom, { refresh: true }), + ); + expect(initial._tag).toBe("Success"); + if (initial._tag === "Success") expect(initial.value).toEqual([]); + const subscriptionsBeforeRefresh = subscriptions; + current = { + type: "snapshot", + terminals: [ + { + threadId: "thread", + terminalId: "term", + cwd: "/tmp", + worktreePath: null, + status: "running", + pid: 42, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: true, + label: "sleep", + updatedAt: "2026-10-15T00:00:00.000Z", + }, + ], + }; + const result = yield* Effect.promise(() => + executeAtomQuery(registry, atom, { refresh: true }), + ); + expect(result._tag).toBe("Success"); + if (result._tag === "Success") expect(result.value).toEqual(current.terminals); + expect(subscriptions).toBeGreaterThan(subscriptionsBeforeRefresh); + expect(registry.get(live)).toMatchObject({ _tag: "Success", waiting: true, value: [] }); + }), + ), +); diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index 8a3e3e7490302f75ec4026eec5e7f1c479066167..7688196d24a997ca4731f88a0410f9c28a81177b 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -64,6 +64,16 @@ export function createTerminalEnvironmentAtoms( subscribe: (_input: null) => subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe( Stream.scan((): ReadonlyArray => [], applyTerminalMetadataStreamEvent), + Stream.drop(1), + ), + }), + metadataSnapshot: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:metadata-snapshot", + completeWhen: () => true, + subscribe: (_input: null) => + subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe( + Stream.filter((event) => event.type === "snapshot"), + Stream.map((event) => event.terminals), ), }), open: createEnvironmentRpcCommand(runtime, { diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..efaa229d5e2133c553abaefc2fce43c8d05b2911 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadSettlementWarning } from "./threadSettled.ts"; + +describe("settlement resource warning", () => { + it("skips confirmation when no commands or browser tabs need closing", () => { + expect( + resolveThreadSettlementWarning({ runningCommands: false, browserTabs: false }), + ).toBeNull(); + }); + + it.each([ + { runningCommands: true, browserTabs: false, text: "Undo cannot restart them." }, + { runningCommands: false, browserTabs: true, text: "Undo cannot restore page state." }, + { + runningCommands: true, + browserTabs: true, + text: "Undo cannot restore commands or page state.", + }, + ])( + "describes only the resources being closed: $runningCommands/$browserTabs", + ({ text, ...resources }) => { + expect(resolveThreadSettlementWarning(resources)).toContain(text); + }, + ); + + it.each([ + { runningCommands: null, browserTabs: false }, + { runningCommands: false, browserTabs: null }, + { runningCommands: null, browserTabs: null }, + ])("never treats an unavailable resource read as empty", (resources) => { + expect(resolveThreadSettlementWarning(resources)).toContain("could not be checked"); + }); +}); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 887b1b79b19a592f6c03889996cf450e032036a3..1c1abcfb093a9a9363e13492ca7f9a1914535986 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -3,6 +3,23 @@ import * as DateTime from "effect/DateTime"; import type { PendingSubagentRequest } from "./models.ts"; +export function resolveThreadSettlementWarning(resources: { + readonly runningCommands: boolean | null; + readonly browserTabs: boolean | null; +}): string | null { + const { runningCommands, browserTabs } = resources; + if (runningCommands === false && browserTabs === false) return null; + if (runningCommands === null || browserTabs === null) { + return "Some terminals or browser tabs could not be checked. Settling closes them and stops any running commands. Undo cannot restore commands or page state."; + } + if (runningCommands && browserTabs) { + return "Settling stops running commands and closes browser tabs. Undo cannot restore commands or page state."; + } + return runningCommands + ? "Settling stops running commands. Undo cannot restart them." + : "Settling closes browser tabs. Undo cannot restore page state."; +} + interface SettlementRunLike { readonly turnId?: unknown; readonly assistantMessageId?: unknown; diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 9b9727c4475a5db1c74db328b7ac886b6f7abaf1..bb920df91216fe40f4c5cc4946a4bc8efeab1bb2 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -17,6 +17,8 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.uiSettingsPatch]: AuthSettingsWriteScope, [WS_METHODS.devServerStatus]: AuthFilesystemReadScope, [WS_METHODS.devServerStart]: AuthTerminalOperateScope, + [WS_METHODS.managedWorkspacesCreate]: AuthOrchestrationOperateScope, + [WS_METHODS.managedWorkspacesArchiveCompleted]: AuthOrchestrationOperateScope, [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, // Endpoint checks exist to configure exports, so they need the same grant as saving them. [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f01963184112b54af75e4c839da9bc8c1ef62928..78ebcfeced6158c5b2b64e93cecb497eadf60d2b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -66,6 +66,7 @@ export * from "./worktreeSetup.ts"; export * from "./secretRequest.ts"; export * from "./clientRpcPermissions.ts"; export * from "./devServer.ts"; +export * from "./managedWorkspaces.ts"; export * from "./mergebot.ts"; export * from "./scriptProgress.ts"; export * from "./desktopTerminal.ts"; diff --git a/packages/contracts/src/managedWorkspaces.ts b/packages/contracts/src/managedWorkspaces.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bf7013003e9f71aff4dccc0494fe34f7bc6cc87 --- /dev/null +++ b/packages/contracts/src/managedWorkspaces.ts @@ -0,0 +1,40 @@ +import * as Schema from "effect/Schema"; +import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const ManagedWorkspaceStatus = Schema.Struct({ projectId: Schema.NullOr(ProjectId) }); +export const ManagedWorkspaceCreated = Schema.Struct({ + workspacePath: TrimmedNonEmptyString, + projectId: ProjectId, + threadId: ThreadId, + environmentId: EnvironmentId, +}); +export type ManagedWorkspaceCreated = typeof ManagedWorkspaceCreated.Type; + +export const ManagedWorkspaceArchiveInput = Schema.Struct({ workspacePath: TrimmedNonEmptyString }); +export const ManagedWorkspaceArchived = Schema.Struct({ + archivedThreadIds: Schema.Array(ThreadId), +}); +export type ManagedWorkspaceArchived = typeof ManagedWorkspaceArchived.Type; + +export class ManagedWorkspaceError extends Schema.TaggedError()( + "ManagedWorkspaceError", + { + reason: Schema.Literals(["unavailable", "create", "reply", "workspace", "archive"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.reason) { + case "workspace": + return "Workspace cleanup is paused. Check the managed workspace on this host."; + case "archive": + return "Workspace archival is paused. Check its linked pull requests and retry."; + case "unavailable": + return "Workspace creation is unavailable. Install ws, configure the source for my work, and add that source to T3 Code on this host."; + case "create": + return "Workspace creation stopped. Run ws new on this host for details."; + case "reply": + return "The workspace tool returned an unreadable reply. Update ws on this host before trying again."; + } + } +} diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index bca9ff656ebec5f4147d02ab90d0c181ec6f5426..044913ceb18ab2f69fa7c210eeec94f7ec2aacb9 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -29,6 +29,7 @@ import { OrchestrationV2Command, OrchestrationV2LimitRecoveryUpdate, OrchestrationV2DomainEvent, + OrchestrationV2DispatchCommandError, OrchestrationV2ProviderCapabilities, OrchestrationV2ProviderThread, OrchestrationV2ProviderThreadJson, @@ -46,6 +47,22 @@ import { } from "./orchestrationV2.ts"; const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z"); +const encodeDispatchCommandError = Schema.encodeSync(OrchestrationV2DispatchCommandError); +const decodeDispatchCommandError = Schema.decodeUnknownSync(OrchestrationV2DispatchCommandError); + +it("preserves conditional archive rejection across the dispatch error wire schema", () => { + const error = new OrchestrationV2DispatchCommandError({ + commandId: CommandId.make("archive-condition-wire"), + commandType: "thread.archive", + reason: "archive-condition-changed", + message: "The thread changed before archival.", + }); + const encoded = encodeDispatchCommandError(error); + const decoded = decodeDispatchCommandError(JSON.parse(JSON.stringify(encoded))); + + expect(decoded.reason).toBe("archive-condition-changed"); +}); + function emptyThreadProjection() { return { thread: { diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index e4179a201be66c88b74fb74556f3137d38917bda..196687bfc05e8bc8dbc2fdf86be41d43b7a1cf7d 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2746,6 +2746,7 @@ export const OrchestrationV2Command = Schema.Union([ type: Schema.Literal("thread.archive"), commandId: CommandId, threadId: ThreadId, + snapshotSequence: Schema.optional(NonNegativeInt), }), Schema.Struct({ type: Schema.Literal("thread.unarchive"), @@ -2781,6 +2782,7 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, reason: Schema.Literal("user"), + snapshotSequence: Schema.optional(NonNegativeInt), }), Schema.Struct({ type: Schema.Literal("thread.snooze"), @@ -3179,6 +3181,15 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; * send them. */ const OrchestrationV2InternalCommand = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("thread.ci-failed"), + commandId: CommandId, + threadId: ThreadId, + snapshotSequence: NonNegativeInt, + itemId: TurnItemId, + pullRequestNumber: PositiveInt, + url: TrimmedNonEmptyString, + }), /** * Records what a pull request watch saw, and wakes the agent in the same transaction when * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is @@ -3535,6 +3546,7 @@ export class OrchestrationV2DispatchCommandError extends Schema.TaggedError { expect(detail.checksState).toBe("pending"); }); + it("preserves a failed rerun's job URL and completion time", () => { + const raw = JSON.parse(detailJson) as Record; + const previous = { + __typename: "CheckRun", + name: "build", + workflowName: "Checks", + status: "COMPLETED", + conclusion: "FAILURE", + startedAt: "2026-08-11T16:00:00Z", + completedAt: "2026-08-11T16:01:00Z", + detailsUrl: "https://github.com/owner/repository/actions/runs/1/job/2", + }; + const rerun = { + ...previous, + startedAt: "2026-08-11T17:00:00Z", + completedAt: "2026-08-11T17:01:00Z", + detailsUrl: "https://github.com/owner/repository/actions/runs/1/job/3", + }; + const detail = expectSuccess( + decodePullRequestDetailJson(JSON.stringify({ ...raw, statusCheckRollup: [previous, rerun] })), + ); + expect(detail.checks).toEqual([ + { + name: "build", + status: "failure", + description: null, + url: rerun.detailsUrl, + completedAt: rerun.completedAt, + }, + ]); + }); + it("keeps a check pending while another run of it on the same commit is still going", () => { // A workflow on both `push` and `pull_request` runs the same check twice on one commit. The // `pull_request` run finished after the `push` run started, and the `push` run is still going. diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 8423ea9a203d345854da496276c7349498f6c080..db7a6f4ead52eb7f68dd9a92171befbd72bdabf3 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -1651,6 +1651,7 @@ function toCheckEntries( const name = trimmed(check.name) ?? trimmed(check.context); if (name === null) return []; const status = toCheckStatus(check); + const completedAt = trimmed(check.completedAt); return [ { check: { @@ -1658,6 +1659,7 @@ function toCheckEntries( status, description: trimmed(check.description), url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), + ...(completedAt === null || completedAt.startsWith("0001-") ? {} : { completedAt }), ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}), }, workflowName: trimmed(check.workflowName), -- 2.54.0